8786636f7c
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.
276 lines
8.3 KiB
Go
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})
|
|
}
|