Files
repospawner/internal/server/server.go
T
unkin-agent 8786636f7c
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Address review findings on the initial service
Four issues from the review of the initial repospawner service, none of
which change the shape of a request or the file terraform-git receives.

- Encode status checks as one --check flag per context on the
  server-to-job hop, so a separator inside a context can no longer turn
  one context into several; ban commas (and cap lengths) in Validate as
  well, since a real context never holds one.
- Fail a merged request that has waited five minutes for a Woodpecker
  token that vanished after acceptance, surfacing "woodpecker token
  unavailable" through the API, instead of warning in the log forever
  from enabling-ci. Advance now leaves a terminal request alone so the
  failure sticks.
- Hold a per-name lock from the duplicate checks through the store write,
  so two concurrent submissions of one name cannot both be accepted.
- Cap the description at 500 characters and the status checks at 20
  contexts of 100 characters each, and mirror the first two caps in the
  form.
2026-08-30 14:53:27 +10:00

276 lines
8.3 KiB
Go

// Package server wires repospawner's HTTP surface: the request API, the health
// probes and the embedded UI, plus the reconcile loop that drives requests
// forward.
package server
import (
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"git.unkin.net/unkin/repospawner/internal/auth"
"git.unkin.net/unkin/repospawner/internal/config"
"git.unkin.net/unkin/repospawner/internal/gitea"
"git.unkin.net/unkin/repospawner/internal/jobs"
"git.unkin.net/unkin/repospawner/internal/repospec"
"git.unkin.net/unkin/repospawner/internal/store"
)
// maxBodyBytes caps a submitted request body; the payload is four small fields.
const maxBodyBytes = 64 << 10
// Server holds the resolved dependencies of the app.
type Server struct {
cfg *config.Config
store *store.Store
forge *gitea.Client
cluster Cluster
gate *auth.Middleware
assets fs.FS
log *slog.Logger
// names serialises the name claim so two concurrent submissions of one name
// cannot both pass the duplicate checks.
names keyedMutex
waitMu sync.Mutex
// woodpeckerWaits counts, per request id, the reconcile passes spent
// waiting for a Woodpecker token that vanished after acceptance.
woodpeckerWaits map[string]int
}
// New constructs a Server.
func New(cfg *config.Config, st *store.Store, forge *gitea.Client, cluster Cluster, assets fs.FS, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
return &Server{
cfg: cfg,
store: st,
forge: forge,
cluster: cluster,
gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups),
assets: assets,
log: log,
woodpeckerWaits: map[string]int{},
}
}
// keyedMutex serialises work per key and forgets a key once nothing holds it.
type keyedMutex struct {
mu sync.Mutex
held map[string]*keyedEntry
}
type keyedEntry struct {
mu sync.Mutex
refs int
}
// lock blocks until key is free and returns the function that releases it.
func (k *keyedMutex) lock(key string) func() {
k.mu.Lock()
if k.held == nil {
k.held = map[string]*keyedEntry{}
}
e, ok := k.held[key]
if !ok {
e = &keyedEntry{}
k.held[key] = e
}
e.refs++
k.mu.Unlock()
e.mu.Lock()
return func() {
e.mu.Unlock()
k.mu.Lock()
defer k.mu.Unlock()
e.refs--
if e.refs == 0 {
delete(k.held, key)
}
}
}
// Handler returns the root handler. Health probes are ungated (kubelet sends no
// identity header); everything else — API and UI alike — sits behind the group
// gate, so an unauthorized user cannot even load the page shell.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("GET /readyz", s.readyz)
gated := http.NewServeMux()
gated.HandleFunc("POST /api/requests", s.handleCreate)
gated.HandleFunc("GET /api/requests", s.handleList)
gated.HandleFunc("GET /api/requests/{id}", s.handleGet)
gated.HandleFunc("GET /api/capabilities", s.handleCapabilities)
gated.HandleFunc("/", s.handleUI)
mux.Handle("/", s.gate.Wrap(gated))
return secureHeaders(mux)
}
// cspPolicy locks the page to same-origin code. The UI carries no inline script
// or style, so no unsafe-inline escape hatch is needed; data: is in img-src
// solely for the inline SVG favicon.
const cspPolicy = "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'"
// secureHeaders stamps the browser-facing hardening headers onto every
// response — API, UI and probes alike — before the handler writes.
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", cspPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := s.cluster.Ping(ctx); err != nil {
s.log.Warn("readyz: kubernetes api unreachable", "err", err)
http.Error(w, "kubernetes api unreachable", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("ok"))
}
// woodpeckerAvailable reports whether a Woodpecker API token is mounted. It is
// read per call so a later secret mount needs no restart.
func (s *Server) woodpeckerAvailable() bool {
if s.cfg.WoodpeckerTokenFile == "" {
return false
}
b, err := os.ReadFile(s.cfg.WoodpeckerTokenFile)
return err == nil && strings.TrimSpace(string(b)) != ""
}
func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"woodpecker": s.woodpeckerAvailable(),
"tfgit_repo": s.cfg.TFGitRepo,
})
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
var spec repospec.Request
dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes))
dec.DisallowUnknownFields()
if err := dec.Decode(&spec); err != nil {
writeErr(w, http.StatusBadRequest, "request body is not the expected JSON object")
return
}
spec = spec.Normalize()
if err := spec.Validate(); err != nil {
var fe repospec.FieldErrors
if errors.As(err, &fe) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid request", "fields": fe})
return
}
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if spec.Woodpecker && !s.woodpeckerAvailable() {
writeErr(w, http.StatusServiceUnavailable,
"woodpecker enablement is unavailable: no woodpecker API token is mounted; resubmit with woodpecker disabled")
return
}
// Everything from here to the store write claims the name; holding it per
// name keeps two concurrent submissions from both finding it free.
unlock := s.names.lock(spec.Name)
defer unlock()
if s.store.HasActiveName(spec.Name) {
writeErr(w, http.StatusConflict, "a request for that repository name is already in flight")
return
}
exists, err := s.forge.FileExists(r.Context(), s.cfg.TFGitRepo, spec.ConfigPath(), "main")
if err != nil {
s.log.Error("terraform-git name check failed", "name", spec.Name, "err", err)
writeErr(w, http.StatusBadGateway, "cannot check the repository name against terraform-git")
return
}
if exists {
writeErr(w, http.StatusConflict, "that repository is already defined in terraform-git")
return
}
req := store.NewRequest(store.NewID(), spec, s.store.Now())
if err := s.cluster.CreateJob(r.Context(), jobs.PR(s.cfg, req)); err != nil {
s.log.Error("create pull request job", "request", req.ID, "err", err)
writeErr(w, http.StatusInternalServerError, "cannot start the pull request job")
return
}
s.store.Put(req)
s.log.Info("accepted repo request", "request", req.ID, "name", req.Name, "woodpecker", req.Woodpecker)
w.Header().Set("Location", "/api/requests/"+req.ID)
writeJSON(w, http.StatusAccepted, map[string]any{
"id": req.ID,
"status_url": "/api/requests/" + req.ID,
"state": req.State,
})
}
func (s *Server) handleList(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"requests": s.store.List()})
}
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
req, ok := s.store.Get(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusNotFound, "no such request")
return
}
writeJSON(w, http.StatusOK, req)
}
// handleUI serves the embedded assets, falling back to index.html so a reload
// on any path lands on the app.
func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if strings.HasPrefix(p, "api/") {
writeErr(w, http.StatusNotFound, "not found")
return
}
if p != "" {
if st, err := fs.Stat(s.assets, p); err == nil && !st.IsDir() {
http.FileServerFS(s.assets).ServeHTTP(w, r)
return
}
}
b, err := fs.ReadFile(s.assets, "index.html")
if err != nil {
http.Error(w, "index missing", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}