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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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}])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user