Files
repospawner/internal/server/server.go
T
unkin-agent f1bcb8cd3a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add the initial repospawner service
repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
2026-08-30 14:33:31 +10:00

223 lines
7.1 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"
"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
}
// 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,
}
}
// 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
}
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})
}