8786636f7c
Four issues from the review of the initial repospawner service, none of which change the shape of a request or the file terraform-git receives. - Encode status checks as one --check flag per context on the server-to-job hop, so a separator inside a context can no longer turn one context into several; ban commas (and cap lengths) in Validate as well, since a real context never holds one. - Fail a merged request that has waited five minutes for a Woodpecker token that vanished after acceptance, surfacing "woodpecker token unavailable" through the API, instead of warning in the log forever from enabling-ci. Advance now leaves a terminal request alone so the failure sticks. - Hold a per-name lock from the duplicate checks through the store write, so two concurrent submissions of one name cannot both be accepted. - Cap the description at 500 characters and the status checks at 20 contexts of 100 characters each, and mirror the first two caps in the form.
643 lines
21 KiB
Go
643 lines
21 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// mergedWoodpeckerCluster holds a request whose terraform-git PR merged and
|
|
// which asked for Woodpecker enablement.
|
|
func mergedWoodpeckerCluster() *fakeCluster {
|
|
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}`)
|
|
return &fakeCluster{jobs: []batchv1.Job{watchJob}, pods: []corev1.Pod{watchPod}}
|
|
}
|
|
|
|
func TestReconcileWaitsForWoodpeckerToken(t *testing.T) {
|
|
cluster := mergedWoodpeckerCluster()
|
|
srv, st := newTestServer(t, testCfg(t), cluster)
|
|
|
|
// Short of the bound the request keeps waiting; the token may still arrive.
|
|
for i := range maxWoodpeckerTokenWaits - 1 {
|
|
if err := srv.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("Reconcile %d: %v", i, err)
|
|
}
|
|
got, _ := st.Get("abc123")
|
|
if got.State != store.StateEnablingCI {
|
|
t.Fatalf("state after %d passes = %q, want enabling-ci", i+1, got.State)
|
|
}
|
|
}
|
|
if len(cluster.createdNames()) != 0 {
|
|
t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames())
|
|
}
|
|
}
|
|
|
|
func TestReconcileFailsRequestWhenWoodpeckerTokenNeverArrives(t *testing.T) {
|
|
cluster := mergedWoodpeckerCluster()
|
|
srv, st := newTestServer(t, testCfg(t), cluster)
|
|
|
|
for i := range maxWoodpeckerTokenWaits {
|
|
if err := srv.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("Reconcile %d: %v", i, err)
|
|
}
|
|
}
|
|
got, _ := st.Get("abc123")
|
|
if got.State != store.StateFailed {
|
|
t.Fatalf("state = %q, want failed", got.State)
|
|
}
|
|
if got.Error != woodpeckerTokenUnavailable {
|
|
t.Errorf("error = %q, want %q", got.Error, woodpeckerTokenUnavailable)
|
|
}
|
|
if len(cluster.createdNames()) != 0 {
|
|
t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames())
|
|
}
|
|
|
|
// The failure sticks across later passes, and the API surfaces the reason.
|
|
if err := srv.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
r := httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil)
|
|
r.Header.Set("X-Forwarded-Groups", allowed)
|
|
w := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET = %d", w.Code)
|
|
}
|
|
var served store.Request
|
|
if err := json.Unmarshal(w.Body.Bytes(), &served); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if served.State != store.StateFailed || served.Error != woodpeckerTokenUnavailable {
|
|
t.Errorf("served = %+v, want a failed request naming the missing token", served)
|
|
}
|
|
}
|
|
|
|
func TestReconcileEnablesWoodpeckerOnceTheTokenReturns(t *testing.T) {
|
|
cfg := testCfg(t)
|
|
cluster := mergedWoodpeckerCluster()
|
|
srv, st := newTestServer(t, cfg, cluster)
|
|
|
|
if err := srv.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp\n"), 0o600); err != nil {
|
|
t.Fatalf("write token: %v", err)
|
|
}
|
|
if err := srv.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-woodpecker-abc123" {
|
|
t.Fatalf("created jobs = %v, want the woodpecker job", names)
|
|
}
|
|
if got, _ := st.Get("abc123"); got.State != store.StateEnablingCI {
|
|
t.Errorf("state = %q, want enabling-ci", got.State)
|
|
}
|
|
}
|
|
|
|
func TestCreateSerialisesConcurrentSubmissionsOfOneName(t *testing.T) {
|
|
srv, st := newTestServer(t, testCfg(t), &fakeCluster{})
|
|
h := srv.Handler()
|
|
body := `{"name":"widget","description":"d","status_checks":["x"]}`
|
|
|
|
const submissions = 8
|
|
codes := make([]int, submissions)
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for i := range submissions {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
r := httptest.NewRequest(http.MethodPost, "/api/requests", strings.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("X-Forwarded-Groups", allowed)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
codes[i] = w.Code
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
accepted, conflicted := 0, 0
|
|
for _, c := range codes {
|
|
switch c {
|
|
case http.StatusAccepted:
|
|
accepted++
|
|
case http.StatusConflict:
|
|
conflicted++
|
|
}
|
|
}
|
|
if accepted != 1 || conflicted != submissions-1 {
|
|
t.Fatalf("codes = %v, want exactly one 202 and %d 409s", codes, submissions-1)
|
|
}
|
|
if got := len(st.List()); got != 1 {
|
|
t.Errorf("stored requests = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
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}])
|
|
}
|
|
}
|