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.
223 lines
7.5 KiB
Go
223 lines
7.5 KiB
Go
package jobs
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"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/store"
|
|
)
|
|
|
|
func testConfig() *config.Config {
|
|
return &config.Config{
|
|
Namespace: "repospawner",
|
|
Image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/repospawner:v1.2.3",
|
|
JobServiceAccount: "repospawner",
|
|
GiteaURL: "https://git.unkin.net",
|
|
TFGitRepo: "unkin/terraform-git",
|
|
VaultAddr: "https://vault.service.consul:8200",
|
|
VaultK8sMount: "k8s/au/syd1",
|
|
VaultK8sRole: "repospawner",
|
|
VaultSATokenPath: "/var/run/secrets/vault/token",
|
|
GiteaCredsPath: "gitea/creds/repospawner",
|
|
WoodpeckerServer: "https://ci.k8s.syd1.au.unkin.net",
|
|
WoodpeckerTokenFile: "/etc/repospawner/woodpecker/token",
|
|
WoodpeckerSecret: "repospawner-woodpecker",
|
|
AllowedGroups: []string{"akP-repospawner-user"},
|
|
}
|
|
}
|
|
|
|
func testRequest() store.Request {
|
|
return store.Request{
|
|
ID: "abc123",
|
|
Name: "widget",
|
|
Description: "does widgets",
|
|
Woodpecker: true,
|
|
StatusChecks: []string{"ci/woodpecker/pr/build", "ci/woodpecker/pr/test"},
|
|
PRNumber: 42,
|
|
PRURL: "https://git.unkin.net/unkin/terraform-git/pulls/42",
|
|
Created: time.Date(2026, 8, 30, 1, 2, 3, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
func TestPRJobSpec(t *testing.T) {
|
|
cfg, req := testConfig(), testRequest()
|
|
j := PR(cfg, req)
|
|
|
|
if j.Name != "repospawner-pr-abc123" || j.Namespace != "repospawner" {
|
|
t.Errorf("object meta = %s/%s", j.Namespace, j.Name)
|
|
}
|
|
if j.Labels[LabelRequest] != "abc123" || j.Labels[LabelType] != string(TypePR) || j.Labels[LabelApp] != AppName {
|
|
t.Errorf("labels = %v", j.Labels)
|
|
}
|
|
pod := j.Spec.Template.Spec
|
|
if pod.ServiceAccountName != "repospawner" {
|
|
t.Errorf("serviceAccountName = %q", pod.ServiceAccountName)
|
|
}
|
|
if len(pod.Containers) != 1 || pod.Containers[0].Image != cfg.Image {
|
|
t.Fatalf("containers = %+v", pod.Containers)
|
|
}
|
|
args := strings.Join(pod.Containers[0].Args, " ")
|
|
want := "job pr --request abc123 --name widget --description does widgets " +
|
|
"--check ci/woodpecker/pr/build --check ci/woodpecker/pr/test"
|
|
if args != want {
|
|
t.Errorf("args = %q, want %q", args, want)
|
|
}
|
|
if pod.RestartPolicy != corev1.RestartPolicyNever {
|
|
t.Errorf("restartPolicy = %q", pod.RestartPolicy)
|
|
}
|
|
if j.Spec.TTLSecondsAfterFinished == nil || *j.Spec.TTLSecondsAfterFinished != 3600 {
|
|
t.Errorf("ttlSecondsAfterFinished = %v", j.Spec.TTLSecondsAfterFinished)
|
|
}
|
|
if j.Spec.BackoffLimit == nil || *j.Spec.BackoffLimit != 2 {
|
|
t.Errorf("backoffLimit = %v", j.Spec.BackoffLimit)
|
|
}
|
|
|
|
// The vault-audience projected token is what makes the native login work.
|
|
if len(pod.Volumes) != 1 || pod.Volumes[0].Projected == nil {
|
|
t.Fatalf("volumes = %+v", pod.Volumes)
|
|
}
|
|
sat := pod.Volumes[0].Projected.Sources[0].ServiceAccountToken
|
|
if sat == nil || sat.Audience != "vault" || sat.Path != "token" {
|
|
t.Errorf("projected token = %+v", sat)
|
|
}
|
|
if pod.Containers[0].VolumeMounts[0].MountPath != "/var/run/secrets/vault" {
|
|
t.Errorf("mountPath = %q", pod.Containers[0].VolumeMounts[0].MountPath)
|
|
}
|
|
|
|
env := map[string]string{}
|
|
for _, e := range pod.Containers[0].Env {
|
|
env[e.Name] = e.Value
|
|
}
|
|
for k, want := range map[string]string{
|
|
"GITEA_URL": "https://git.unkin.net",
|
|
"VAULT_ADDR": "https://vault.service.consul:8200",
|
|
"REPOSPAWNER_VAULT_K8S_MOUNT": "k8s/au/syd1",
|
|
"REPOSPAWNER_GITEA_CREDS_PATH": "gitea/creds/repospawner",
|
|
"REPOSPAWNER_TFGIT_REPO": "unkin/terraform-git",
|
|
} {
|
|
if env[k] != want {
|
|
t.Errorf("env %s = %q, want %q", k, env[k], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWatchJobSpec(t *testing.T) {
|
|
j := Watch(testConfig(), testRequest())
|
|
if j.Name != "repospawner-watch-abc123" {
|
|
t.Errorf("name = %q", j.Name)
|
|
}
|
|
args := strings.Join(j.Spec.Template.Spec.Containers[0].Args, " ")
|
|
if args != "job watch --repo unkin/terraform-git --pr 42" {
|
|
t.Errorf("args = %q", args)
|
|
}
|
|
if j.Annotations[AnnoPullRequestNo] != "42" || j.Annotations[AnnoPullRequest] == "" {
|
|
t.Errorf("annotations = %v", j.Annotations)
|
|
}
|
|
// A terraform-git PR waits on a human, so the deadline is days not minutes.
|
|
if j.Spec.ActiveDeadlineSeconds == nil || *j.Spec.ActiveDeadlineSeconds != 7*24*60*60 {
|
|
t.Errorf("activeDeadlineSeconds = %v", j.Spec.ActiveDeadlineSeconds)
|
|
}
|
|
}
|
|
|
|
func TestWoodpeckerJobMountsTokenSecret(t *testing.T) {
|
|
cfg := testConfig()
|
|
j := Woodpecker(cfg, testRequest())
|
|
if strings.Join(j.Spec.Template.Spec.Containers[0].Args, " ") != "job woodpecker-enable --name widget" {
|
|
t.Errorf("args = %v", j.Spec.Template.Spec.Containers[0].Args)
|
|
}
|
|
var secret *corev1.SecretVolumeSource
|
|
for _, v := range j.Spec.Template.Spec.Volumes {
|
|
if v.Name == "woodpecker-token" {
|
|
secret = v.Secret
|
|
}
|
|
}
|
|
if secret == nil || secret.SecretName != "repospawner-woodpecker" {
|
|
t.Fatalf("woodpecker volume = %+v", secret)
|
|
}
|
|
if secret.Items[0].Key != "token" || secret.Items[0].Path != "token" {
|
|
t.Errorf("secret items = %+v", secret.Items)
|
|
}
|
|
var mounted bool
|
|
for _, m := range j.Spec.Template.Spec.Containers[0].VolumeMounts {
|
|
if m.Name == "woodpecker-token" && m.MountPath == "/etc/repospawner/woodpecker" {
|
|
mounted = true
|
|
}
|
|
}
|
|
if !mounted {
|
|
t.Errorf("volumeMounts = %+v", j.Spec.Template.Spec.Containers[0].VolumeMounts)
|
|
}
|
|
}
|
|
|
|
func TestDecodeResult(t *testing.T) {
|
|
var res PRResult
|
|
if !DecodeResult([]byte(`{"pr_number":7,"pr_url":"https://example/7"}`), &res) {
|
|
t.Fatal("DecodeResult returned false for a valid message")
|
|
}
|
|
if res.PRNumber != 7 || res.PRURL != "https://example/7" {
|
|
t.Errorf("res = %+v", res)
|
|
}
|
|
if DecodeResult(nil, &res) {
|
|
t.Error("an empty termination message must not decode")
|
|
}
|
|
if DecodeResult([]byte(" \n"), &res) {
|
|
t.Error("a whitespace termination message must not decode")
|
|
}
|
|
if DecodeResult([]byte("panic: boom"), &res) {
|
|
t.Error("a non-JSON termination message must not decode")
|
|
}
|
|
}
|
|
|
|
func TestViewOfAndRequestFrom(t *testing.T) {
|
|
j := batchv1.Job{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Labels: map[string]string{LabelRequest: "abc123", LabelType: string(TypeWatch)},
|
|
Annotations: map[string]string{
|
|
AnnoName: "widget",
|
|
AnnoDescription: "does widgets",
|
|
AnnoWoodpecker: "true",
|
|
AnnoStatusChecks: "a\nb",
|
|
AnnoCreated: "2026-08-30T01:02:03Z",
|
|
AnnoPullRequestNo: "42",
|
|
AnnoPullRequest: "https://git.unkin.net/unkin/terraform-git/pulls/42",
|
|
},
|
|
},
|
|
Status: batchv1.JobStatus{Succeeded: 1},
|
|
}
|
|
v := ViewOf(j, []byte(`{"merged":true}`))
|
|
if v.Type != TypeWatch || v.Request != "abc123" || !v.Succeeded || v.Failed {
|
|
t.Fatalf("view = %+v", v)
|
|
}
|
|
|
|
r := RequestFrom(v)
|
|
if r.Name != "widget" || r.Description != "does widgets" || !r.Woodpecker {
|
|
t.Errorf("request = %+v", r)
|
|
}
|
|
if len(r.StatusChecks) != 2 || r.StatusChecks[0] != "a" {
|
|
t.Errorf("statusChecks = %v", r.StatusChecks)
|
|
}
|
|
if r.PRNumber != 42 || r.PRURL == "" {
|
|
t.Errorf("pull request = %d %q", r.PRNumber, r.PRURL)
|
|
}
|
|
if !r.Created.Equal(time.Date(2026, 8, 30, 1, 2, 3, 0, time.UTC)) {
|
|
t.Errorf("created = %v", r.Created)
|
|
}
|
|
}
|
|
|
|
func TestViewOfFailedCondition(t *testing.T) {
|
|
j := batchv1.Job{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{LabelRequest: "x", LabelType: string(TypePR)}},
|
|
Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{
|
|
{Type: batchv1.JobFailed, Status: corev1.ConditionTrue},
|
|
}},
|
|
}
|
|
if v := ViewOf(j, nil); !v.Failed {
|
|
t.Errorf("view = %+v, want Failed", v)
|
|
}
|
|
}
|