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.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user