package ciconfig import ( "os" "path/filepath" "strings" "testing" "time" "github.com/sblinch/kdl-go/document" ) func TestRepositoryCIConfig(t *testing.T) { repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) if err != nil { t.Fatal(err) } cfg, err := Load(repoRoot) if err != nil { t.Fatalf("Load() error = %v", err) } want := map[string]bool{ "go-test": false, "go-race": false, "go-staticcheck": false, "go-vuln": false, "rust-fmt": false, "rust-clippy": false, "rust-test": false, "shell": false, "infra-tofu-route": false, "infra-quadlets": false, } for _, job := range cfg.Jobs { if _, ok := want[job.Name]; !ok { continue } if job.Name == "shell" && job.SourceFile == "shell.kdl" || (job.Name == "infra-tofu-route" || job.Name == "infra-quadlets") && job.SourceFile == "infra.kdl" || job.Name != "shell" && job.Name != "infra-tofu-route" && job.Name != "infra-quadlets" && job.SourceFile == "go.kdl" { want[job.Name] = true } } for name, found := range want { if !found { t.Errorf("repository CI job %q missing", name) } } for _, job := range cfg.Jobs { if job.SourceFile != "go.kdl" && job.SourceFile != "shell.kdl" && job.SourceFile != "infra.kdl" { continue } switch { case strings.HasPrefix(job.Name, "go-"): if job.Name != "go-vuln" && (len(job.Run) != 2 || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "scripts/local/go.sh") || !contains(job.Trigger.Push.Include, "scripts/local/check-staged.sh")) { t.Errorf("repository CI job %q is missing Go automation coverage: %#v", job.Name, job) } for _, command := range job.Run { if strings.Contains(command, "cd ") || !strings.Contains(command, "scripts/local/go.sh") || !strings.Contains(command, "GOTOOLCHAIN=local") || !strings.Contains(command, "GOMODCACHE=$PWD/.go-modcache") { t.Errorf("repository CI command %q must run bounded Go tooling from the workspace root", command) } } case strings.HasPrefix(job.Name, "rust-"): if job.Image != "docker.io/library/rust:1.98.0-bookworm" || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "services/luigit/**") || !contains(job.Trigger.Push.Include, "scripts/local/rust.sh") { t.Errorf("repository CI job %q is missing Rust automation coverage: %#v", job.Name, job) } if job.Name == "rust-fmt" { if len(job.Caches) != 0 || len(job.Run) != 1 || !strings.Contains(job.Run[0], "rustup component add rustfmt") { t.Errorf("repository CI fmt job must install rustfmt without a cache: %#v", job) } } else if len(job.Caches) != 1 || job.Caches[0].Path != ".cargo-home" { t.Errorf("repository CI job %q must cache Cargo state: %#v", job.Name, job) } for _, command := range job.Run { if strings.Contains(command, "cd ") || strings.Contains(command, "apk ") || !strings.Contains(command, "scripts/local/rust.sh") { t.Errorf("repository CI command %q must use Rust tooling from the workspace root", command) } } case strings.HasPrefix(job.Name, "infra-"): if job.SourceFile != "infra.kdl" || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "scripts/local/verify-infra.sh") || !contains(job.Trigger.Push.Include, "scripts/local/test-verify-infra.sh") || job.Name == "infra-tofu-route" && !contains(job.Trigger.Push.Include, "modules/klops/**") { t.Errorf("repository infrastructure CI job is incomplete: %#v", job) } if job.Name == "infra-tofu-route" && (len(job.Run) != 2 || strings.Contains(job.Run[1], "tofu version") || !strings.Contains(job.Run[1], "just verify infra tofu-route")) { t.Errorf("repository OpenTofu CI job must delegate diagnostics to verification: %#v", job) } if job.Name == "infra-quadlets" && (len(job.Run) != 2 || strings.Contains(job.Run[0], "dnf") || !strings.Contains(job.Run[0], "install-just.sh") || !contains(job.Trigger.Push.Include, "scripts/local/install-just.sh")) { t.Errorf("repository Quadlet CI job must bootstrap pinned Just without dnf: %#v", job) } case job.Name == "shell": if job.SourceFile != "shell.kdl" || job.Image != "docker.io/library/alpine:3.22.0" || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "**/*.sh") || !contains(job.Trigger.Push.Include, "*.sh") || len(job.Run) != 3 || !strings.Contains(job.Run[0], "apk --root $PWD/.apk --initdb") || !strings.Contains(job.Run[0], "add --no-cache bash git shfmt shellcheck") || !strings.Contains(job.Run[1], "shellcheck --version") || !strings.Contains(job.Run[2], "scripts/local/shell-check.sh read") { t.Errorf("repository shell CI job is incomplete: %#v", job) } for _, command := range job.Run { if strings.Contains(command, "go.sh") || strings.Contains(command, "tofu") { t.Errorf("repository shell CI command %q must not run Go or OpenTofu", command) } } } } } func TestRepositoryCIPathFilters(t *testing.T) { repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) if err != nil { t.Fatal(err) } cfg, err := Load(repoRoot) if err != nil { t.Fatal(err) } assertMatches := func(path string, want ...string) { t.Helper() got := map[string]bool{} for _, job := range cfg.Jobs { if job.MatchesPush("refs/heads/trunk", []string{path}) { got[job.Name] = true } } for _, name := range want { if !got[name] { t.Errorf("change %q did not select %q: %#v", path, name, got) } delete(got, name) } if len(got) != 0 { t.Errorf("change %q selected unexpected jobs: %#v", path, got) } } assertMatches("services/luci/cmd/luci/main.go", "go-test", "go-race", "go-staticcheck") assertMatches("services/toad/cmd/toad/main.go", "go-test", "go-race", "go-staticcheck", "infra-tofu-route") assertMatches("services/luigit/src/main.rs", "rust-fmt", "rust-clippy", "rust-test", "infra-tofu-route") assertMatches("scripts/local/go.sh", "go-test", "go-race", "go-staticcheck", "shell") assertMatches("scripts/local/rust.sh", "rust-fmt", "rust-clippy", "rust-test", "shell") assertMatches("scripts/local/shell-check.sh", "shell") assertMatches("scripts/local/with space.sh", "shell") assertMatches(".ci/go.kdl", "go-test", "go-race", "go-staticcheck", "rust-fmt", "rust-clippy", "rust-test") assertMatches(".ci/shell.kdl", "go-test", "go-race", "go-staticcheck", "rust-fmt", "rust-clippy", "rust-test", "shell") assertMatches(".ci/infra.kdl", "go-test", "go-race", "go-staticcheck", "rust-fmt", "rust-clippy", "rust-test", "infra-tofu-route", "infra-quadlets") assertMatches("modules/klops/quadlets/caddy.container", "infra-tofu-route", "infra-quadlets") assertMatches("services/toad/deploy/gateway.Caddyfile", "go-test", "go-race", "go-staticcheck", "infra-tofu-route") assertMatches("scripts/local/install-just.sh", "infra-quadlets", "shell") assertMatches("README.md") } func TestParseDurableMounts(t *testing.T) { cfg, err := ParseFiles(map[string]string{"ci.kdl": `job "deploy" { image "alpine" run "true" mount "auth" "registry" "/root/.config/containers" mount "cache" "build" "/var/cache/build" }`}) if err != nil || len(cfg.Jobs[0].Mounts) != 2 { t.Fatalf("mounts=%#v err=%v", cfg, err) } for _, source := range []string{ `mount "host" "name" "/target"`, `mount "auth" "../name" "/target"`, `mount "auth" "name" "target"`, `mount "cache" "one" "/same" mount "auth" "two" "/same/path"`, `mount "cache" "one" "/work/cache"`, } { _, err := ParseFiles(map[string]string{"ci.kdl": `job "bad" { image "alpine" run "true" ` + source + ` }`}) if err == nil { t.Fatalf("accepted %s", source) } } } func contains(values []string, want string) bool { for _, value := range values { if value == want { return true } } return false } func TestLoadReadsCiFilesLexicographically(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "20-second.kdl"), ` job "second" { image "alpine" run "echo second" } `) mustWrite(t, filepath.Join(repo, ".ci", "10-first.kdl"), ` job "first" { image "alpine" run "echo first" } `) cfg, err := Load(repo) if err != nil { t.Fatalf("Load() error = %v", err) } if got := []string{cfg.Jobs[0].Name, cfg.Jobs[1].Name}; got[0] != "first" || got[1] != "second" { t.Fatalf("job order = %#v", got) } } func TestParseFilesTracksDeterministicJobSource(t *testing.T) { cfg, err := ParseFiles(map[string]string{ "20-second.kdl": "job \"second\" {\n image \"alpine\"\n run \"echo second\"\n}", "10-first.kdl": "job \"first\" {\n image \"alpine\"\n run \"echo first\"\n}", }) if err != nil { t.Fatal(err) } if got, want := cfg.Jobs[0].Name, "first"; got != want { t.Fatalf("first job=%q", got) } if got, want := cfg.Jobs[0].SourceFile, "10-first.kdl"; got != want { t.Fatalf("source file=%q", got) } if got := cfg.Jobs[0].Source; !strings.Contains(got, `job "first"`) || !strings.Contains(got, `run "echo first"`) { t.Fatalf("source=%q", got) } } func TestLoadRejectsDuplicateJobs(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "a.kdl"), ` job "check" { image "alpine" run "echo a" } `) mustWrite(t, filepath.Join(repo, ".ci", "b.kdl"), ` job "check" { image "alpine" run "echo b" } `) _, err := Load(repo) if err == nil { t.Fatal("duplicate job accepted") } if got := err.Error(); got != "duplicate job \"check\"" { t.Fatalf("unexpected error: %s", got) } } func TestLoadParsesTriggerAndMatrix(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), ` job "test" { image "alpine:${version}" run "echo ${version}" trigger { push { branch "main" tag "v*" } schedule "0 3 * * *" } matrix { version "3.20" "3.21" } } `) cfg, err := Load(repo) if err != nil { t.Fatalf("Load() error = %v", err) } job := cfg.Jobs[0] if job.Trigger.Push == nil || len(job.Trigger.Push.Branches) != 1 || job.Trigger.Push.Branches[0] != "main" || len(job.Trigger.Push.Tags) != 1 || job.Trigger.Push.Tags[0] != "v*" || job.Trigger.Schedule == nil { t.Fatalf("trigger = %#v", job.Trigger) } if len(job.Matrix.Axes) != 1 || job.Matrix.Axes[0].Name != "version" || len(job.Matrix.Axes[0].Values) != 2 || job.Matrix.Axes[0].Values[1] != "3.21" { t.Fatalf("matrix = %#v", job.Matrix) } } func TestLoadParsesOptInPushPathsAndSchedule(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `job "test" { image "alpine" run "echo ok" trigger { push { branch "trunk" include "src/**" exclude "src/generated/**" } schedule "*/15 * * * *" } } job "manual" { image "alpine" run "echo manual" } `) cfg, err := Load(repo) if err != nil { t.Fatal(err) } push := cfg.Jobs[0].Trigger.Push if push == nil || len(push.Include) != 1 || len(push.Exclude) != 1 || cfg.Jobs[0].Trigger.Schedule == nil { t.Fatalf("trigger=%#v", cfg.Jobs[0].Trigger) } if cfg.Jobs[1].Trigger.Push != nil || cfg.Jobs[1].Trigger.Schedule != nil { t.Fatalf("manual trigger=%#v", cfg.Jobs[1].Trigger) } } func TestScheduleMatchesUTCMinute(t *testing.T) { minute := time.Date(2026, 3, 4, 3, 0, 45, 0, time.FixedZone("local", 3600)) if !ScheduleMatches("0 2 * * *", minute) { t.Fatal("UTC minute schedule did not match") } } func TestParseScheduleBoundsMalformedInput(t *testing.T) { _, err := ParseSchedule(strings.Repeat("1", 70000) + " 0 * * *") if err == nil || len(err.Error()) > 100 { t.Fatalf("err=%v", err) } } func TestLoadRejectsOversizedMatrix(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `job "large" { image "alpine" run "echo ok" matrix { a "1" "2" "3" "4" "5" "6" "7" "8" "9" b "1" "2" "3" "4" "5" "6" "7" "8" } } `) if _, err := Load(repo); err == nil { t.Fatal("oversized matrix accepted") } } func TestLoadRejectsBadTriggerAndMatrix(t *testing.T) { cases := map[string]string{ "bad-schedule": ` job "test" { image "alpine" run "echo ok" trigger { schedule "@daily" } } `, "timezone-schedule": ` job "test" { image "alpine" run "echo ok" trigger { schedule "TZ=UTC 0 0 * *" } } `, "push-property": ` job "test" { image "alpine" run "echo ok" trigger { push branch="trunk" } } `, "duplicate-schedule": ` job "test" { image "alpine" run "echo ok" trigger { schedule "0 3 * * *" schedule "0 4 * * *" } } `, "bad-path-glob": ` job "test" { image "alpine" run "echo ok" trigger { push { include "[" } } } `, "bad-trigger-child": ` job "test" { image "alpine" run "echo ok" trigger { timer true } } `, "bad-push-type": ` job "test" { image "alpine" run "echo ok" trigger { push "yes" } } `, "bad-push-args": ` job "test" { image "alpine" run "echo ok" trigger { push true false } } `, "bad-branch-args": ` job "test" { image "alpine" run "echo ok" trigger { branch "main" "dev" } } `, "bad-tag-args": ` job "test" { image "alpine" run "echo ok" trigger { tag "v*" "x*" } } `, "numeric-matrix-value": ` job "test" { image "alpine" run "echo ok" matrix { node 22 } } `, "duplicate-axis": ` job "test" { image "alpine" run "echo ok" matrix { arch "amd64" arch "arm64" } } `, "empty-axis": ` job "test" { image "alpine" run "echo ok" matrix { arch } } `, } for name, content := range cases { t.Run(name, func(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), content) if _, err := Load(repo); err == nil { t.Fatal("bad config accepted") } }) } } func TestLoadRejectsBadFilesAndJobShape(t *testing.T) { if _, err := Load("["); err == nil { t.Fatal("bad glob accepted") } repo := t.TempDir() if err := os.MkdirAll(filepath.Join(repo, ".ci"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink("missing", filepath.Join(repo, ".ci", "missing.kdl")); err != nil { t.Fatal(err) } if _, err := Load(repo); err == nil { t.Fatal("broken symlink accepted") } cases := map[string]string{ "invalid-kdl": `job "x" {`, "top-level": `pipeline "x" {}`, "job-no-arg": `job { image "alpine" run "echo ok" }`, "unknown-job-node": `job "x" { image "alpine" run "echo ok" artifact "x" }`, "bad-image-args": `job "x" { image "alpine" "edge" run "echo ok" }`, "bad-run-args": `job "x" { image "alpine" run }`, "missing-image": `job "x" { run "echo ok" }`, "missing-run": `job "x" { image "alpine" }`, "secret-no-arg": `job "x" { image "alpine" run "echo ok" secret env="TOKEN" }`, "secret-both": `job "x" { image "alpine" run "echo ok" secret "TOKEN" env="TOKEN" file="token" }`, "secret-neither": `job "x" { image "alpine" run "echo ok" secret "TOKEN" }`, } for name, content := range cases { t.Run(name, func(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), content) if _, err := Load(repo); err == nil { t.Fatal("bad config accepted") } }) } } func TestLoadRejectsMultilineBadJobShapes(t *testing.T) { cases := map[string]string{ "job-no-arg": `job { image "alpine" run "echo ok" } `, "unknown-job-node": `job "x" { image "alpine" run "echo ok" artifact "x" } `, "bad-image-args": `job "x" { image "alpine" "edge" run "echo ok" } `, "bad-run-args": `job "x" { image "alpine" run } `, "missing-image": `job "x" { run "echo ok" } `, "missing-run": `job "x" { image "alpine" } `, "secret-no-arg": `job "x" { image "alpine" run "echo ok" secret env="TOKEN" } `, "secret-both": `job "x" { image "alpine" run "echo ok" secret "TOKEN" env="TOKEN" file="token" } `, "secret-neither": `job "x" { image "alpine" run "echo ok" secret "TOKEN" } `, "bad-trigger-branch": `job "x" { image "alpine" run "echo ok" trigger { branch "main" "dev" } } `, "bad-trigger-tag": `job "x" { image "alpine" run "echo ok" trigger { tag "v*" "x*" } } `, "bad-trigger-push": `job "x" { image "alpine" run "echo ok" trigger { push true false } } `, "bad-trigger-push-type": `job "x" { image "alpine" run "echo ok" trigger { push "yes" } } `, "bad-trigger-child": `job "x" { image "alpine" run "echo ok" trigger { timer true } } `, "bad-matrix-duplicate": `job "x" { image "alpine" run "echo ok" matrix { arch "amd64" arch "arm64" } } `, "bad-matrix-empty": `job "x" { image "alpine" run "echo ok" matrix { arch } } `, "unsafe-secret-file": `job "x" { image "alpine" run "echo ok" secret "KEY" file="../key" } `, "bad-cache": `job "x" { image "alpine" run "echo ok" cache "deps" { path "../deps" key { file "lock" } } } `, "bad-publish": `job "x" { image "alpine" run "echo ok" publish "site" { from "dist" } } `, "registry-host-image": `job "x" { image "alpine" run "echo ok" publish "registry" { image "app:latest" to "registry.invalid/app:latest" } } `, "registry-missing-archive": `job "x" { image "alpine" run "echo ok" publish "registry" { to "registry.invalid/app:latest" } } `, } for name, content := range cases { t.Run(name, func(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), content) if _, err := Load(repo); err == nil { t.Fatal("bad config accepted") } }) } } func TestRegistryImagePublishHasMigrationError(t *testing.T) { _, err := ParseFiles(map[string]string{"ci.kdl": `job "x" { image "alpine" run "true" publish "registry" { image "app:latest" to "registry.invalid/app:latest" } }`}) if err == nil || !strings.Contains(err.Error(), "write an OCI archive") { t.Fatalf("err=%v", err) } } func TestLowLevelParserHelpers(t *testing.T) { if nodeName(nil) != "" || nodeName(&document.Node{}) != "" { t.Fatal("nil node name not empty") } if _, err := oneStringArg(&document.Node{}, "x"); err == nil { t.Fatal("oneStringArg accepted missing arg") } if _, err := oneBoolArg(&document.Node{}, "x"); err == nil { t.Fatal("oneBoolArg accepted missing arg") } if stringValue(nil) != "" { t.Fatal("nil stringValue not empty") } } func TestLoadParsesNumericMatrixValue(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `job "x" { image "alpine" run "echo ok" matrix { node 22 } } `) cfg, err := Load(repo) if err != nil { t.Fatal(err) } if cfg.Jobs[0].Matrix.Axes[0].Values[0] != "22" { t.Fatalf("matrix = %#v", cfg.Jobs[0].Matrix) } } func TestLoadParsesCoreJobFields(t *testing.T) { repo := t.TempDir() mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), ` job "test" { image "docker.io/library/node:${node}-alpine" run "npm ci" run "npm test" secret "HCLOUD_TOKEN" env="HCLOUD_TOKEN" secret "DEPLOY_KEY" file=".secrets/deploy_key" cache "npm" { path ".npm" key { file "package-lock.json" } } publish "site" { from "dist" to "docs/" } } `) cfg, err := Load(repo) if err != nil { t.Fatalf("Load() error = %v", err) } job := cfg.Jobs[0] if job.Name != "test" || job.Image != "docker.io/library/node:${node}-alpine" { t.Fatalf("job = %#v", job) } if len(job.Run) != 2 || job.Run[0] != "npm ci" || job.Run[1] != "npm test" { t.Fatalf("run = %#v", job.Run) } if len(job.Secrets) != 2 || job.Secrets[0].Name != "HCLOUD_TOKEN" || job.Secrets[0].Env != "HCLOUD_TOKEN" || job.Secrets[1].File != ".secrets/deploy_key" { t.Fatalf("secrets = %#v", job.Secrets) } if len(job.Caches) != 1 || job.Caches[0].Name != "npm" || job.Caches[0].Path != ".npm" || len(job.Caches[0].KeyFiles) != 1 { t.Fatalf("caches = %#v", job.Caches) } if len(job.Publishes) != 1 || job.Publishes[0].Adapter != "site" || job.Publishes[0].From != "dist" || job.Publishes[0].To != "docs/" { t.Fatalf("publishes = %#v", job.Publishes) } } func mustWrite(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestConfigJobMissing(t *testing.T) { cfg := Config{Jobs: []Job{{Name: "check"}}} if job, ok := cfg.Job("missing"); ok || job.Name != "" { t.Fatalf("missing job = %#v %v", job, ok) } } func TestTriggerMatching(t *testing.T) { all := &Push{} if !(Job{Name: "check", Trigger: Trigger{Push: all}}).MatchesPush("refs/heads/anything", nil) { t.Fatal("empty branch patterns did not match") } if !(Job{Name: "check", Trigger: Trigger{Push: all}}).MatchesPush("refs/tags/v1", nil) { t.Fatal("empty tag patterns did not match") } if (Job{Name: "check", Trigger: Trigger{Push: &Push{Branches: []string{"main"}}}}).MatchesPush("refs/tags/v1", nil) { t.Fatal("branch-only trigger matched tag") } if (Job{Name: "check", Trigger: Trigger{Push: &Push{Tags: []string{"v*"}}}}).MatchesPush("refs/heads/main", nil) { t.Fatal("tag-only trigger matched branch") } job := Job{Name: "check", Trigger: Trigger{Push: &Push{Branches: []string{"main", "release/*"}, Tags: []string{"v*"}, Include: []string{"src/**"}, Exclude: []string{"src/generated/**"}}}} if !job.MatchesPush("refs/heads/main", []string{"src/main/app.go"}) { t.Fatal("included nested path did not match") } if job.MatchesPush("refs/heads/main", []string{"src/generated/app.go"}) { t.Fatal("excluded path matched") } if !job.MatchesPush("refs/tags/v1.0.0", []string{"src/app.go"}) { t.Fatal("tag did not match") } if job.MatchesPush("refs/heads/feature/x", []string{"src/app.go"}) { t.Fatal("feature branch matched") } if (Job{Name: "manual"}).MatchesPush("refs/heads/main", nil) { t.Fatal("manual-only job matched push") } } func TestManualLookupIgnoresTriggers(t *testing.T) { cfg := Config{Jobs: []Job{{Name: "deploy"}}} job, ok := cfg.Job("deploy") if !ok || job.Name != "deploy" { t.Fatalf("manual lookup failed: %#v %v", job, ok) } } func TestMatrixExpansionSingleJob(t *testing.T) { expanded := (Job{Name: "check"}).ExpandMatrix() if len(expanded) != 1 || expanded[0].Name != "check" || len(expanded[0].Values) != 0 { t.Fatalf("expanded = %#v", expanded) } } func TestMatrixExpansionOrder(t *testing.T) { job := Job{ Name: "test", Matrix: Matrix{Axes: []Axis{ {Name: "node", Values: []string{"22", "24"}}, {Name: "distro", Values: []string{"fedora43", "debian13"}}, }}, } expanded := job.ExpandMatrix() wantNames := []string{ "test[node=22,distro=fedora43]", "test[node=22,distro=debian13]", "test[node=24,distro=fedora43]", "test[node=24,distro=debian13]", } for i, want := range wantNames { if expanded[i].Name != want { t.Fatalf("expanded[%d].Name = %q, want %q", i, expanded[i].Name, want) } } if expanded[2].Values["node"] != "24" || expanded[2].Values["distro"] != "fedora43" { t.Fatalf("expanded[2].Values = %#v", expanded[2].Values) } } func TestValidateSecretPaths(t *testing.T) { job := Job{Name: "deploy", Secrets: []Secret{{Name: "HCLOUD_TOKEN", Env: "HCLOUD_TOKEN"}}} paths, err := job.SecretPaths("/data/ci", "bugabinga.net") if err != nil { t.Fatalf("SecretPaths() error = %v", err) } want := filepath.Join("/data/ci", "secrets", "bugabinga.net", "deploy", "HCLOUD_TOKEN") if len(paths) != 1 || paths["HCLOUD_TOKEN"] != want { t.Fatalf("paths = %#v", paths) } bad := Job{Name: "../deploy", Secrets: []Secret{{Name: "TOKEN", Env: "TOKEN"}}} if _, err := bad.SecretPaths("/data/ci", "bugabinga.net"); err == nil { t.Fatal("unsafe job name accepted") } } func TestRejectsSecretEnvironmentCollisions(t *testing.T) { for name, source := range map[string]string{ "reserved": `job "check" { image "alpine" run "true" secret "token" env="CI_RUN_ID" }`, "duplicate": `job "check" { image "alpine" run "true" secret "first" env="TOKEN" secret "second" env="TOKEN" }`, "matrix": `job "check" { image "alpine" run "true" matrix { foo-bar "x" foo_bar "y" } }`, } { t.Run(name, func(t *testing.T) { if _, err := ParseFiles(map[string]string{"ci.kdl": source}); err == nil { t.Fatal("invalid environment accepted") } }) } }