Add the initial repospawner service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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:
2026-08-30 14:33:31 +10:00
parent c104212dda
commit f1bcb8cd3a
41 changed files with 5388 additions and 1 deletions
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"context"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"git.unkin.net/unkin/repospawner/internal/jobs"
)
// Cluster is the slice of the Kubernetes API the server needs. Keeping it an
// interface lets the reconciler and handlers be tested without a cluster.
type Cluster interface {
// CreateJob creates a Job, reporting AlreadyExists as nil so a repeated
// reconcile is harmless.
CreateJob(ctx context.Context, job *batchv1.Job) error
// ListJobs returns every repospawner-owned Job in the namespace.
ListJobs(ctx context.Context) ([]batchv1.Job, error)
// ListPods returns every repospawner-owned Job pod in the namespace.
ListPods(ctx context.Context) ([]corev1.Pod, error)
// Ping reports whether the API server is reachable.
Ping(ctx context.Context) error
}
// KubeCluster is the in-cluster Cluster implementation.
type KubeCluster struct {
client kubernetes.Interface
namespace string
}
// NewKubeCluster builds a Cluster from the pod's in-cluster credentials.
func NewKubeCluster(namespace string) (*KubeCluster, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
return &KubeCluster{client: client, namespace: namespace}, nil
}
// ownedSelector matches everything repospawner creates.
const ownedSelector = jobs.LabelApp + "=" + jobs.AppName + "," + jobs.LabelRequest
func (k *KubeCluster) CreateJob(ctx context.Context, job *batchv1.Job) error {
_, err := k.client.BatchV1().Jobs(k.namespace).Create(ctx, job, metav1.CreateOptions{})
if apierrors.IsAlreadyExists(err) {
return nil
}
return err
}
func (k *KubeCluster) ListJobs(ctx context.Context) ([]batchv1.Job, error) {
list, err := k.client.BatchV1().Jobs(k.namespace).List(ctx, metav1.ListOptions{LabelSelector: ownedSelector})
if err != nil {
return nil, err
}
return list.Items, nil
}
func (k *KubeCluster) ListPods(ctx context.Context) ([]corev1.Pod, error) {
list, err := k.client.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{LabelSelector: ownedSelector})
if err != nil {
return nil, err
}
return list.Items, nil
}
func (k *KubeCluster) Ping(ctx context.Context) error {
limit := int64(1)
_, err := k.client.BatchV1().Jobs(k.namespace).List(ctx, metav1.ListOptions{Limit: limit})
return err
}
+140
View File
@@ -0,0 +1,140 @@
package server
import (
"context"
"time"
corev1 "k8s.io/api/core/v1"
"git.unkin.net/unkin/repospawner/internal/jobs"
"git.unkin.net/unkin/repospawner/internal/store"
)
// reconcileInterval is how often the server folds Job state into the store. The
// UI polls on the same cadence, so a change surfaces within two ticks.
const reconcileInterval = 10 * time.Second
// Run reconciles until ctx is cancelled, starting with an immediate pass so a
// restarted server rebuilds its state before serving its first request.
func (s *Server) Run(ctx context.Context) {
if err := s.Reconcile(ctx); err != nil {
s.log.Error("initial reconcile failed", "err", err)
}
t := time.NewTicker(reconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := s.Reconcile(ctx); err != nil {
s.log.Error("reconcile failed", "err", err)
}
}
}
}
// Reconcile reads every repospawner Job, advances each request's state and
// creates whatever Job comes next. It is also the startup recovery path: a
// request the store has never seen is rebuilt from its Job annotations.
func (s *Server) Reconcile(ctx context.Context) error {
jobList, err := s.cluster.ListJobs(ctx)
if err != nil {
return err
}
podList, err := s.cluster.ListPods(ctx)
if err != nil {
return err
}
results := terminationMessages(podList)
byRequest := map[string]map[jobs.Type]jobs.View{}
for _, j := range jobList {
id := j.Labels[jobs.LabelRequest]
t := jobs.Type(j.Labels[jobs.LabelType])
if id == "" || t == "" {
continue
}
v := jobs.ViewOf(j, results[resultKey{id, t}])
if byRequest[id] == nil {
byRequest[id] = map[jobs.Type]jobs.View{}
}
byRequest[id][t] = v
}
for id, views := range byRequest {
current, ok := s.store.Get(id)
if !ok {
current = rebuild(views)
}
next, action := jobs.Advance(current, views)
s.store.Put(next)
if err := s.act(ctx, next, action); err != nil {
s.log.Error("create follow-up job", "request", id, "action", string(action), "err", err)
}
}
return nil
}
// rebuild reconstructs a request the store lost, preferring the newest job's
// annotations because those carry the pull request coordinates.
func rebuild(views map[jobs.Type]jobs.View) store.Request {
for _, t := range []jobs.Type{jobs.TypeWoodpecker, jobs.TypeWatch, jobs.TypePR} {
if v, ok := views[t]; ok {
return jobs.RequestFrom(v)
}
}
return store.Request{}
}
func (s *Server) act(ctx context.Context, r store.Request, action jobs.Action) error {
switch action {
case jobs.ActionCreateWatch:
s.log.Info("following terraform-git pull request", "request", r.ID, "pr", r.PRNumber)
return s.cluster.CreateJob(ctx, jobs.Watch(s.cfg, r))
case jobs.ActionCreateWoodpecker:
if !s.woodpeckerAvailable() {
s.log.Warn("woodpecker enablement requested but no token is mounted", "request", r.ID)
return nil
}
s.log.Info("enabling repository in woodpecker", "request", r.ID, "name", r.Name)
return s.cluster.CreateJob(ctx, jobs.Woodpecker(s.cfg, r))
case jobs.ActionNone:
return nil
default:
return nil
}
}
type resultKey struct {
request string
jobType jobs.Type
}
// terminationMessages collects each job pod's termination message, preferring a
// terminated container over one that is merely waiting to restart.
func terminationMessages(pods []corev1.Pod) map[resultKey][]byte {
out := map[resultKey][]byte{}
for _, p := range pods {
id := p.Labels[jobs.LabelRequest]
t := jobs.Type(p.Labels[jobs.LabelType])
if id == "" || t == "" {
continue
}
for _, cs := range p.Status.ContainerStatuses {
term := cs.State.Terminated
if term == nil && cs.LastTerminationState.Terminated != nil {
term = cs.LastTerminationState.Terminated
}
if term == nil || term.Message == "" {
continue
}
key := resultKey{id, t}
// A retried pod leaves several messages; the successful one wins.
if _, seen := out[key]; !seen || term.ExitCode == 0 {
out[key] = []byte(term.Message)
}
}
}
return out
}
+222
View File
@@ -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})
}
+529
View File
@@ -0,0 +1,529 @@
package server
import (
"context"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"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/store"
)
// fakeCluster records what the server would have created.
type fakeCluster struct {
mu sync.Mutex
created []*batchv1.Job
jobs []batchv1.Job
pods []corev1.Pod
createErr error
pingErr error
}
func (f *fakeCluster) CreateJob(_ context.Context, job *batchv1.Job) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.createErr != nil {
return f.createErr
}
f.created = append(f.created, job)
f.jobs = append(f.jobs, *job)
return nil
}
func (f *fakeCluster) ListJobs(context.Context) ([]batchv1.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]batchv1.Job(nil), f.jobs...), nil
}
func (f *fakeCluster) ListPods(context.Context) ([]corev1.Pod, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]corev1.Pod(nil), f.pods...), nil
}
func (f *fakeCluster) Ping(context.Context) error { return f.pingErr }
func (f *fakeCluster) createdNames() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, 0, len(f.created))
for _, j := range f.created {
out = append(out, j.Name)
}
return out
}
func testAssets() fs.FS {
return fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<html>repospawner</html>")},
"app.css": &fstest.MapFile{Data: []byte("body{}")},
}
}
func testCfg(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
Listen: ":0",
Namespace: "repospawner",
Image: "repospawner:test",
JobServiceAccount: "repospawner",
GiteaURL: "https://git.unkin.net",
TFGitRepo: "unkin/terraform-git",
VaultAddr: "https://vault.invalid",
VaultK8sMount: "k8s/au/syd1",
VaultK8sRole: "repospawner",
VaultSATokenPath: "/var/run/secrets/vault/token",
GiteaCredsPath: "gitea/creds/repospawner",
WoodpeckerServer: "https://ci.invalid",
WoodpeckerTokenFile: filepath.Join(t.TempDir(), "absent"),
WoodpeckerSecret: "repospawner-woodpecker",
GroupsHeader: "X-Forwarded-Groups",
AllowedGroups: []string{"akP-repospawner-user"},
}
}
// newTestServer wires a Server against a stub forge whose contents endpoint
// answers exists for every name in taken.
func newTestServer(t *testing.T, cfg *config.Config, cluster Cluster, taken ...string) (*Server, *store.Store) {
t.Helper()
forgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, name := range taken {
if strings.HasSuffix(r.URL.Path, "/"+name+".yaml") {
_, _ = w.Write([]byte(`{"name":"` + name + `.yaml"}`))
return
}
}
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"object does not exist"}`))
}))
t.Cleanup(forgeSrv.Close)
forge := gitea.New(forgeSrv.URL, func(context.Context, bool) (string, error) { return "tok", nil })
st := store.New()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
return New(cfg, st, forge, cluster, testAssets(), log), st
}
func post(t *testing.T, h http.Handler, body string, groups string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "/api/requests", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if groups != "" {
r.Header.Set("X-Forwarded-Groups", groups)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
const allowed = "akP-repospawner-user"
func TestCreateAcceptsAndLaunchesPRJob(t *testing.T) {
cluster := &fakeCluster{}
srv, st := newTestServer(t, testCfg(t), cluster)
h := srv.Handler()
w := post(t, h, `{"name":"widget","description":"does widgets","woodpecker":false,"status_checks":["ci/woodpecker/pr/test"]}`, allowed)
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var res struct {
ID string `json:"id"`
StatusURL string `json:"status_url"`
State string `json:"state"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("decode: %v", err)
}
if res.ID == "" || res.StatusURL != "/api/requests/"+res.ID || res.State != string(store.StateOpeningPR) {
t.Fatalf("response = %+v", res)
}
if got := w.Header().Get("Location"); got != res.StatusURL {
t.Errorf("Location = %q, want %q", got, res.StatusURL)
}
if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-pr-"+res.ID {
t.Errorf("created jobs = %v", names)
}
if _, ok := st.Get(res.ID); !ok {
t.Error("the accepted request was not recorded")
}
// The status URL the caller was handed must resolve.
getReq := httptest.NewRequest(http.MethodGet, res.StatusURL, nil)
getReq.Header.Set("X-Forwarded-Groups", allowed)
getRec := httptest.NewRecorder()
h.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("GET %s = %d", res.StatusURL, getRec.Code)
}
var stored store.Request
if err := json.Unmarshal(getRec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decode: %v", err)
}
if stored.Name != "widget" || stored.State != store.StateOpeningPR || stored.PRURL != "" {
t.Errorf("stored = %+v (pr_url is empty until the job reports it)", stored)
}
}
func TestCreateValidationErrors(t *testing.T) {
srv, _ := newTestServer(t, testCfg(t), &fakeCluster{})
h := srv.Handler()
cases := []struct {
name string
body string
status int
fields []string
}{
{name: "not json", body: `nope`, status: http.StatusBadRequest},
{name: "unknown field", body: `{"name":"a","description":"d","status_checks":["x"],"private":true}`, status: http.StatusBadRequest},
{
name: "missing everything",
body: `{}`,
status: http.StatusBadRequest,
fields: []string{"name", "description", "status_checks"},
},
{
name: "bad name",
body: `{"name":"Widget","description":"d","status_checks":["x"]}`,
status: http.StatusBadRequest,
fields: []string{"name"},
},
{
name: "blank status checks",
body: `{"name":"widget","description":"d","status_checks":[" ",""]}`,
status: http.StatusBadRequest,
fields: []string{"status_checks"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := post(t, h, tc.body, allowed)
if w.Code != tc.status {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if len(tc.fields) == 0 {
return
}
var res struct {
Fields map[string]string `json:"fields"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("decode: %v", err)
}
if len(res.Fields) != len(tc.fields) {
t.Fatalf("fields = %v, want %v", res.Fields, tc.fields)
}
for _, f := range tc.fields {
if _, ok := res.Fields[f]; !ok {
t.Errorf("missing field error %q in %v", f, res.Fields)
}
}
})
}
}
func TestCreateRejectsNameAlreadyInTerraformGit(t *testing.T) {
cluster := &fakeCluster{}
srv, _ := newTestServer(t, testCfg(t), cluster, "widget")
w := post(t, srv.Handler(), `{"name":"widget","description":"d","status_checks":["x"]}`, allowed)
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if len(cluster.createdNames()) != 0 {
t.Error("a rejected request must not launch a job")
}
}
func TestCreateRejectsDuplicateInFlightName(t *testing.T) {
srv, _ := newTestServer(t, testCfg(t), &fakeCluster{})
h := srv.Handler()
body := `{"name":"widget","description":"d","status_checks":["x"]}`
if w := post(t, h, body, allowed); w.Code != http.StatusAccepted {
t.Fatalf("first request status = %d", w.Code)
}
if w := post(t, h, body, allowed); w.Code != http.StatusConflict {
t.Fatalf("second request status = %d, want 409", w.Code)
}
}
func TestCreateRejectsWoodpeckerWithoutToken(t *testing.T) {
srv, _ := newTestServer(t, testCfg(t), &fakeCluster{})
w := post(t, srv.Handler(), `{"name":"widget","description":"d","woodpecker":true,"status_checks":["x"]}`, allowed)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "woodpecker") {
t.Errorf("body = %s", w.Body.String())
}
}
func TestCreateAcceptsWoodpeckerWhenTokenMounted(t *testing.T) {
cfg := testCfg(t)
cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp-token\n"), 0o600); err != nil {
t.Fatalf("write token: %v", err)
}
srv, _ := newTestServer(t, cfg, &fakeCluster{})
w := post(t, srv.Handler(), `{"name":"widget","description":"d","woodpecker":true,"status_checks":["x"]}`, allowed)
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
}
func TestCreateSurfacesJobFailure(t *testing.T) {
cluster := &fakeCluster{createErr: errors.New("forbidden")}
srv, st := newTestServer(t, testCfg(t), cluster)
w := post(t, srv.Handler(), `{"name":"widget","description":"d","status_checks":["x"]}`, allowed)
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d", w.Code)
}
if len(st.List()) != 0 {
t.Error("a request whose job could not be created must not be recorded")
}
}
func TestGroupGateAndProbes(t *testing.T) {
cluster := &fakeCluster{}
srv, _ := newTestServer(t, testCfg(t), cluster)
h := srv.Handler()
for _, path := range []string{"/", "/api/requests"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusForbidden {
t.Errorf("GET %s without a group = %d, want 403", path, w.Code)
}
}
if w := post(t, h, `{"name":"widget","description":"d","status_checks":["x"]}`, ""); w.Code != http.StatusForbidden {
t.Errorf("POST without a group = %d, want 403", w.Code)
}
// Probes are ungated: the kubelet sends no identity header.
for _, path := range []string{"/livez", "/readyz"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", path, w.Code)
}
}
cluster.pingErr = errors.New("connection refused")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if w.Code != http.StatusServiceUnavailable {
t.Errorf("readyz with an unreachable API = %d, want 503", w.Code)
}
}
func TestSecureHeadersAndUI(t *testing.T) {
srv, _ := newTestServer(t, testCfg(t), &fakeCluster{})
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Forwarded-Groups", allowed)
w := httptest.NewRecorder()
srv.Handler().ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "repospawner") {
t.Fatalf("status = %d body = %q", w.Code, w.Body.String())
}
if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "default-src 'self'") {
t.Errorf("CSP = %q", got)
}
if w.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("X-Content-Type-Options = %q", w.Header().Get("X-Content-Type-Options"))
}
// An unknown /api path must 404 rather than fall through to index.html.
r2 := httptest.NewRequest(http.MethodGet, "/api/nope", nil)
r2.Header.Set("X-Forwarded-Groups", allowed)
w2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(w2, r2)
if w2.Code != http.StatusNotFound {
t.Errorf("GET /api/nope = %d, want 404", w2.Code)
}
}
func TestListNewestFirst(t *testing.T) {
srv, st := newTestServer(t, testCfg(t), &fakeCluster{})
base := time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC)
now := base
st.SetClock(func() time.Time { return now })
for _, name := range []string{"one", "two"} {
if w := post(t, srv.Handler(), `{"name":"`+name+`","description":"d","status_checks":["x"]}`, allowed); w.Code != http.StatusAccepted {
t.Fatalf("post %s = %d", name, w.Code)
}
now = now.Add(time.Minute)
}
r := httptest.NewRequest(http.MethodGet, "/api/requests", nil)
r.Header.Set("X-Forwarded-Groups", allowed)
w := httptest.NewRecorder()
srv.Handler().ServeHTTP(w, r)
var res struct {
Requests []store.Request `json:"requests"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("decode: %v", err)
}
if len(res.Requests) != 2 || res.Requests[0].Name != "two" {
t.Errorf("requests = %+v, want newest first", res.Requests)
}
}
func TestGetUnknownRequest(t *testing.T) {
srv, _ := newTestServer(t, testCfg(t), &fakeCluster{})
r := httptest.NewRequest(http.MethodGet, "/api/requests/nope", nil)
r.Header.Set("X-Forwarded-Groups", allowed)
w := httptest.NewRecorder()
srv.Handler().ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestCapabilitiesReportsWoodpecker(t *testing.T) {
cfg := testCfg(t)
srv, _ := newTestServer(t, cfg, &fakeCluster{})
read := func() bool {
r := httptest.NewRequest(http.MethodGet, "/api/capabilities", nil)
r.Header.Set("X-Forwarded-Groups", allowed)
w := httptest.NewRecorder()
srv.Handler().ServeHTTP(w, r)
var res struct {
Woodpecker bool `json:"woodpecker"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("decode: %v", err)
}
return res.Woodpecker
}
if read() {
t.Error("woodpecker must be unavailable with no token file")
}
if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp\n"), 0o600); err != nil {
t.Fatalf("write token: %v", err)
}
if !read() {
t.Error("woodpecker must become available once the token is mounted")
}
}
// jobFor builds a finished Job with its pod, as the reconciler would observe it.
func jobFor(id string, t jobs.Type, anno map[string]string, succeeded bool, message string) (batchv1.Job, corev1.Pod) {
labels := map[string]string{jobs.LabelApp: jobs.AppName, jobs.LabelRequest: id, jobs.LabelType: string(t)}
job := batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: jobs.Name(t, id), Labels: labels, Annotations: anno},
}
if succeeded {
job.Status.Succeeded = 1
}
pod := corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: jobs.Name(t, id) + "-xyz", Labels: labels},
Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{
State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: message}},
}}},
}
return job, pod
}
func TestReconcileRebuildsStateFromJobs(t *testing.T) {
anno := map[string]string{
jobs.AnnoName: "widget",
jobs.AnnoDescription: "does widgets",
jobs.AnnoWoodpecker: "false",
jobs.AnnoStatusChecks: "ci/woodpecker/pr/test",
jobs.AnnoCreated: "2026-08-30T01:02:03Z",
}
prJob, prPod := jobFor("abc123", jobs.TypePR, anno,
true, `{"pr_number":42,"pr_url":"https://git.unkin.net/unkin/terraform-git/pulls/42"}`)
cluster := &fakeCluster{jobs: []batchv1.Job{prJob}, pods: []corev1.Pod{prPod}}
// A fresh store, as after a restart: everything comes from the cluster.
srv, st := newTestServer(t, testCfg(t), cluster)
if err := srv.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
got, ok := st.Get("abc123")
if !ok {
t.Fatal("the request was not rebuilt from its job")
}
if got.Name != "widget" || got.Description != "does widgets" {
t.Errorf("rebuilt request = %+v", got)
}
if got.State != store.StatePROpen || got.PRNumber != 42 || got.PRURL == "" {
t.Errorf("state = %q pr = %d %q", got.State, got.PRNumber, got.PRURL)
}
if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-watch-abc123" {
t.Fatalf("created jobs = %v, want the watch job", names)
}
// A second pass must not create the watch job again.
if err := srv.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
if names := cluster.createdNames(); len(names) != 1 {
t.Errorf("created jobs after a second reconcile = %v", names)
}
}
func TestReconcileSkipsWoodpeckerWithoutToken(t *testing.T) {
anno := map[string]string{
jobs.AnnoName: "widget",
jobs.AnnoWoodpecker: "true",
jobs.AnnoCreated: "2026-08-30T01:02:03Z",
jobs.AnnoPullRequest: "https://git.unkin.net/unkin/terraform-git/pulls/42",
jobs.AnnoPullRequestNo: "42",
}
watchJob, watchPod := jobFor("abc123", jobs.TypeWatch, anno, true, `{"merged":true}`)
cluster := &fakeCluster{jobs: []batchv1.Job{watchJob}, pods: []corev1.Pod{watchPod}}
srv, st := newTestServer(t, testCfg(t), cluster)
if err := srv.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
got, _ := st.Get("abc123")
if got.State != store.StateEnablingCI {
t.Errorf("state = %q, want enabling-ci", got.State)
}
if len(cluster.createdNames()) != 0 {
t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames())
}
}
func TestTerminationMessagesPrefersSuccessfulAttempt(t *testing.T) {
labels := map[string]string{jobs.LabelRequest: "abc123", jobs.LabelType: string(jobs.TypePR)}
pods := []corev1.Pod{{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{
LastTerminationState: corev1.ContainerState{
Terminated: &corev1.ContainerStateTerminated{ExitCode: 1, Message: `{"error":"first try"}`},
},
State: corev1.ContainerState{
Terminated: &corev1.ContainerStateTerminated{ExitCode: 0, Message: `{"pr_number":7}`},
},
}}},
}}
got := terminationMessages(pods)
if string(got[resultKey{"abc123", jobs.TypePR}]) != `{"pr_number":7}` {
t.Errorf("message = %q", got[resultKey{"abc123", jobs.TypePR}])
}
}