f1bcb8cd3a
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.
223 lines
7.1 KiB
Go
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})
|
|
}
|