Files
repospawner/internal/server/server_test.go
T
unkin-agent 8786636f7c
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Address review findings on the initial service
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.
2026-08-30 14:53:27 +10:00

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}])
}
}