Files
repospawner/internal/jobs/jobs_test.go
T
unkin-agent 6aec05deae
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Disable API token automount on spawned job pods
Job pods only need the vault-audience projected token to log in; the
default automounted ServiceAccount token hands them the repospawner
Role's k8s API access that no job subcommand uses.
2026-08-30 15:32:49 +10:00

227 lines
7.7 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)
}
// The job only needs the vault-audience token, not a k8s API token.
if pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken {
t.Errorf("automountServiceAccountToken = %v, want false", pod.AutomountServiceAccountToken)
}
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)
}
}