6aec05deae
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.
227 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|