f1bcb8cd3a
repospawner turns JSON new-repo requests into terraform-git pull requests via kubernetes Jobs, follows those PRs to merge and optionally activates the repository in Woodpecker.
530 lines
17 KiB
Go
530 lines
17 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)
|
|
}
|
|
}
|
|
|
|
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}])
|
|
}
|
|
}
|