diff --git a/.github/workflows/chart-drift.yml b/.github/workflows/chart-drift.yml new file mode 100644 index 0000000..78a41f0 --- /dev/null +++ b/.github/workflows/chart-drift.yml @@ -0,0 +1,102 @@ +name: Chart drift (cross-repo) + +# The CLI hardcodes 8 invariants about the tracebloc/client chart — discovery +# labels + Deployment name forms, the jobs-manager Service on 8080, the +# client-pvc mount at /data/shared, the ingestion-authz ConfigMap + "ingestor" +# SA fallback, the INGESTOR_IMAGE_DIGEST env, the requests-proxy Deployment, +# and the /internal/submit-ingestion-run port. A chart rename ships green in +# BOTH repos and breaks discovery/doctor/ingest in the field (cli#290). +# +# This gate renders the chart at the PINNED ref in scripts/.client-ref (same +# pattern as build.yml's schema-drift job with scripts/.data-ingestors-ref: +# pin, don't float, so an unrelated upstream commit can't red every open CLI +# PR) and asserts every invariant via scripts/chart-invariants. The client +# repo guards the same contract at the source on its own PRs +# (scripts/tests/check-drift.sh) — this side catches a CLI-side constant +# change, and the weekly run catches a pin gone stale enough to matter. +# +# tracebloc/client is public, so the checkout needs no token (same as +# install-path-persist's cross-repo checkout, PR #62). If the client repo +# ever goes private, add a read token to the checkout below. + +on: + schedule: + - cron: "0 5 * * 1" # weekly Monday, offset from e2e (03:00) + path-persist (04:00) + workflow_dispatch: + pull_request: + branches: [develop, main] + paths: + - "internal/cluster/**" + - "internal/doctor/**" + - "scripts/.client-ref" + - "scripts/chart-invariants/**" + - ".github/workflows/chart-drift.yml" + +permissions: + contents: read + +jobs: + chart-invariants: + timeout-minutes: 10 + name: Chart-invariant gate (pinned client ref) + runs-on: ubuntu-latest + steps: + - name: Checkout this CLI ref + uses: actions/checkout@v4 + with: + path: cli + + - name: Resolve the pinned client ref + id: pin + # First non-comment, non-blank line of scripts/.client-ref — the same + # convention scripts/sync-schema.sh uses for .data-ingestors-ref. Shape + # is validated (SHA/branch/tag characters only, no "..") before it is + # handed to the checkout action. + run: | + ref="$(grep -vE '^[[:space:]]*(#|$)' cli/scripts/.client-ref | head -1 | tr -d '[:space:]')" + if [ -z "$ref" ]; then + echo "::error file=scripts/.client-ref::no ref found — the first non-comment line must be a commit SHA" + exit 1 + fi + if ! printf '%s' "$ref" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._/-]*$' || printf '%s' "$ref" | grep -q '\.\.'; then + echo "::error file=scripts/.client-ref::invalid ref shape: $ref" + exit 1 + fi + echo "ref=$ref" >> "$GITHUB_OUTPUT" + + - name: Checkout tracebloc/client @ pinned ref + uses: actions/checkout@v4 + with: + repository: tracebloc/client + ref: ${{ steps.pin.outputs.ref }} + path: client + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache: true + cache-dependency-path: cli/go.sum + + - name: Set up Helm + # Pinned to the same version the client repo's own drift-checks job + # uses, so both sides assert against an identical render. + uses: azure/setup-helm@v4 + with: + version: v3.15.4 + + - name: Render the chart + # The chart's values.schema.json rejects a bare-defaults render on + # purpose (clientId/clientPassword are required), so render with the + # same CI values file the client repo's check-drift.sh uses — dummy + # creds + platform bits only; every invariant object is unaffected. + env: + CLIENT_REF: ${{ steps.pin.outputs.ref }} + run: | + helm template tracebloc client/client -n tracebloc \ + -f client/client/ci/bm-values.yaml > rendered.yaml + echo "rendered $(grep -c '^kind:' rendered.yaml) objects @ $CLIENT_REF" + + - name: Assert the 8 CLI-assumed chart invariants + working-directory: cli + run: go run ./scripts/chart-invariants -release tracebloc -namespace tracebloc ../rendered.yaml diff --git a/scripts/.client-ref b/scripts/.client-ref new file mode 100644 index 0000000..6523fe0 --- /dev/null +++ b/scripts/.client-ref @@ -0,0 +1,20 @@ +# Pinned tracebloc/client commit the chart-invariant drift gate renders +# (.github/workflows/chart-drift.yml). CI renders the chart at THIS ref, not a +# floating branch — so an unrelated chart commit can't red every open CLI PR; +# adopting upstream chart changes is a deliberate SHA bump, confronted together +# with the CLI-side constants they may break (same pattern as +# scripts/.data-ingestors-ref, backend#1009; gate: cli#290). +# +# To adopt upstream: bump this SHA, then re-run the gate locally — +# helm template tracebloc /client -n tracebloc \ +# -f /client/ci/bm-values.yaml \ +# | go run ./scripts/chart-invariants +# — and fix any CLI-side constant (internal/cluster, internal/doctor, +# internal/submit) the render breaks, in the same PR as the bump. +# +# Format: the first non-comment, non-blank line is the ref (a full commit SHA +# preferred; a branch name works but reintroduces floating drift). +# +# CURRENT PIN: client develop HEAD at gate introduction (cli#290) — +# "feat(installer): first-time install run-through (#341)". +ca9a3352f3b1a1a9135c196dcbb694ce239be1fe diff --git a/scripts/chart-invariants/chart-invariants b/scripts/chart-invariants/chart-invariants new file mode 100755 index 0000000..79ae907 Binary files /dev/null and b/scripts/chart-invariants/chart-invariants differ diff --git a/scripts/chart-invariants/main.go b/scripts/chart-invariants/main.go new file mode 100644 index 0000000..254dab8 --- /dev/null +++ b/scripts/chart-invariants/main.go @@ -0,0 +1,462 @@ +// Command chart-invariants asserts the 8 tracebloc/client chart invariants the +// CLI hardcodes for discovery, doctor, and dataset ingest (cli#290). It reads a +// `helm template` render (multi-doc YAML) from a file argument or stdin and +// exits non-zero when any invariant no longer holds — so a chart rename that +// would ship green in both repos and break in the field fails CI instead. +// +// The CLI-side sources of truth for each invariant: +// +// 1. Discovery labels — internal/cluster/discover.go DiscoverParentRelease +// lists Deployments by `app.kubernetes.io/name=client, +// app.kubernetes.io/managed-by=Helm` and reads the release name, chart +// version ("client-"), and app version off the labels. +// 2. jobs-manager Deployment name — the same discovery tolerates exactly two +// forms: "-jobs-manager" or bare "jobs-manager". +// 3. jobs-manager Service — pickJobsManagerService probes "jobs-manager" then +// "-jobs-manager"; the CLI port-forwards to port 8080 +// (jobsManagerPort). +// 4. Shared PVC — internal/cluster/pvc.go pins the claim name "client-pvc" +// (SharedPVCClaimName) mounted at "/data/shared" (SharedPVCMountPath). +// 5. Ingestion authz — discoverIngestorSAName reads ConfigMap +// "-ingestion-authz", key "ingestion-authz.yaml", shaped as +// allowed[]{service_account, namespace}, and needs exactly one distinct SA +// for the release namespace; the CLI's fallback default is the SA name +// "ingestor" (release.IngestorSAName), which the chart must keep rendering. +// 6. Ingestor image pin — DiscoverParentRelease reads INGESTOR_IMAGE_DIGEST +// from the FIRST container of the jobs-manager Deployment. +// 7. requests-proxy Deployment — internal/doctor findDeployment accepts +// "-requests-proxy", or bare "requests-proxy" tied to the release +// by its app.kubernetes.io/instance label. +// 8. Submit path — internal/submit/client.go POSTs SubmitPath +// ("/internal/submit-ingestion-run") to port 8080; the jobs-manager +// container must expose that port and the chart must still document the +// path (the rendered manifests carry it in comments — a client-runtime +// endpoint move updates those in the same breath). +// +// Usage: +// +// helm template tracebloc /client -n tracebloc \ +// -f /client/ci/bm-values.yaml | go run ./scripts/chart-invariants +// go run ./scripts/chart-invariants -release tracebloc -namespace tracebloc rendered.yaml +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout)) +} + +func run(args []string, stdin io.Reader, out io.Writer) int { + fs := flag.NewFlagSet("chart-invariants", flag.ContinueOnError) + fs.SetOutput(out) + release := fs.String("release", "tracebloc", "helm release name the chart was rendered with") + namespace := fs.String("namespace", "tracebloc", "namespace the chart was rendered into (-n)") + if err := fs.Parse(args); err != nil { + return 2 + } + + var raw []byte + var err error + if fs.NArg() > 0 { + raw, err = os.ReadFile(fs.Arg(0)) + } else { + raw, err = io.ReadAll(stdin) + } + if err != nil { + printf(out, "error: reading the rendered chart: %v\n", err) + return 2 + } + + docs, err := parseDocs(raw) + if err != nil { + printf(out, "error: the render is not parseable YAML (helm template failed?): %v\n", err) + return 2 + } + if len(docs) == 0 { + printf(out, "error: the render contains no objects — pass the `helm template` output as a file or on stdin\n") + return 2 + } + + c := &checker{release: *release, namespace: *namespace, raw: string(raw), docs: docs, out: out} + printf(out, "── CLI-assumed chart invariants (tracebloc/cli#290) ─────────\n") + c.checkDiscoveryLabels() + c.checkDeploymentName() + c.checkService() + c.checkPVC() + c.checkIngestionAuthz() + c.checkIngestorDigestEnv() + c.checkRequestsProxy() + c.checkSubmitPath() + printf(out, "──────────────────────────────────────────────────────────────\n") + if c.failed > 0 { + printf(out, "DRIFT: %d invariant(s) broken. Fix the chart, or bump the CLI-side constants (internal/cluster, internal/doctor, internal/submit) together with scripts/.client-ref in one deliberate PR.\n", c.failed) + return 1 + } + printf(out, "all 8 invariants hold.\n") + return 0 +} + +// printf writes formatted output, deliberately discarding the writer error: +// this is CI log output, and a late write failure must not mask the exit code. +func printf(w io.Writer, format string, a ...any) { + _, _ = fmt.Fprintf(w, format, a...) +} + +type doc = map[string]any + +type checker struct { + release string + namespace string + raw string // the raw render, for comment-carried markers (invariant 8) + docs []doc + out io.Writer + failed int +} + +func (c *checker) ok(n int, format string, a ...any) { + printf(c.out, " ✔ %d. %s\n", n, fmt.Sprintf(format, a...)) +} + +func (c *checker) fail(n int, format string, a ...any) { + c.failed++ + printf(c.out, " ✖ %d. %s\n", n, fmt.Sprintf(format, a...)) +} + +// parseDocs decodes a multi-document YAML stream into generic maps, skipping +// empty documents (helm renders plenty of them between templates). +func parseDocs(raw []byte) ([]doc, error) { + dec := yaml.NewDecoder(bytes.NewReader(raw)) + var docs []doc + for { + var d doc + err := dec.Decode(&d) + if errors.Is(err, io.EOF) { + return docs, nil + } + if err != nil { + return nil, err + } + if d != nil { + docs = append(docs, d) + } + } +} + +// dig walks nested map[string]any keys; nil when any hop is missing. +func dig(v any, path ...string) any { + cur := v + for _, k := range path { + m, ok := cur.(map[string]any) + if !ok { + return nil + } + cur = m[k] + } + return cur +} + +func digStr(v any, path ...string) string { + s, _ := dig(v, path...).(string) + return s +} + +func digList(v any, path ...string) []any { + l, _ := dig(v, path...).([]any) + return l +} + +func kind(d doc) string { return digStr(d, "kind") } +func name(d doc) string { return digStr(d, "metadata", "name") } + +// find returns every object of the given kind whose name matches any of names. +func (c *checker) find(k string, names ...string) []doc { + var out []doc + for _, d := range c.docs { + if kind(d) != k { + continue + } + for _, n := range names { + if name(d) == n { + out = append(out, d) + break + } + } + } + return out +} + +// jobsManagerDeployments mirrors DiscoverParentRelease exactly: Deployments +// carrying the discovery labels, name-filtered to the two tolerated forms. +func (c *checker) jobsManagerDeployments() []doc { + var out []doc + for _, d := range c.docs { + if kind(d) != "Deployment" { + continue + } + if digStr(d, "metadata", "labels", "app.kubernetes.io/name") != "client" || + digStr(d, "metadata", "labels", "app.kubernetes.io/managed-by") != "Helm" { + continue + } + if n := name(d); n == "jobs-manager" || strings.HasSuffix(n, "-jobs-manager") { + out = append(out, d) + } + } + return out +} + +// 1. Discovery labels: exactly one jobs-manager Deployment is reachable via +// the selector DiscoverParentRelease lists with, and the labels it reads the +// release identity from are all present and well-formed. +func (c *checker) checkDiscoveryLabels() { + const n = 1 + jms := c.jobsManagerDeployments() + if len(jms) != 1 { + c.fail(n, "want exactly one jobs-manager Deployment under app.kubernetes.io/name=client + app.kubernetes.io/managed-by=Helm, got %d — DiscoverParentRelease can no longer find the release", len(jms)) + return + } + labels, _ := dig(jms[0], "metadata", "labels").(map[string]any) + var broken []string + if s, _ := labels["app.kubernetes.io/instance"].(string); s != c.release { + broken = append(broken, fmt.Sprintf("app.kubernetes.io/instance=%q (want the release name %q)", s, c.release)) + } + if s, _ := labels["helm.sh/chart"].(string); !strings.HasPrefix(s, "client-") { + broken = append(broken, fmt.Sprintf("helm.sh/chart=%q (chartVersionFromLabel expects the \"client-\" prefix)", s)) + } + if s, _ := labels["app.kubernetes.io/version"].(string); s == "" { + broken = append(broken, "app.kubernetes.io/version is empty (AppVersion read)") + } + if len(broken) > 0 { + c.fail(n, "jobs-manager Deployment labels drifted: %s", strings.Join(broken, "; ")) + return + } + c.ok(n, "jobs-manager Deployment discoverable via the chart labels (name=client, managed-by=Helm, instance, helm.sh/chart, version)") +} + +// 2. Deployment name: one of the two forms DiscoverParentRelease tolerates. +func (c *checker) checkDeploymentName() { + const n = 2 + jms := c.jobsManagerDeployments() + if len(jms) != 1 { + c.fail(n, "cannot check the jobs-manager Deployment name — discovery found %d candidates", len(jms)) + return + } + got := name(jms[0]) + if got == "jobs-manager" || got == c.release+"-jobs-manager" { + c.ok(n, "jobs-manager Deployment named %q (a tolerated form: \"-jobs-manager\" or \"jobs-manager\")", got) + return + } + c.fail(n, "jobs-manager Deployment named %q — the CLI only tolerates %q or \"jobs-manager\"", got, c.release+"-jobs-manager") +} + +// 3. Service: the one pickJobsManagerService actually selects, with port 8080. +// +// pickJobsManagerService (internal/cluster/discover.go) probes candidate names +// in precedence order — unprefixed "jobs-manager" first, then +// "-jobs-manager" — and returns the first that EXISTS, regardless of +// its ports. So the gate must assert that *the Service the CLI selects* exposes +// 8080, not merely that some matching Service does: with both Services rendered +// but only the non-selected one listening on 8080, an "any Service" check would +// pass while the CLI port-forwards to the wrong target on 8080. +func (c *checker) checkService() { + const n = 3 + svcs := c.find("Service", "jobs-manager", c.release+"-jobs-manager") + if len(svcs) == 0 { + c.fail(n, "no Service named \"jobs-manager\" or %q — pickJobsManagerService finds nothing to port-forward to", c.release+"-jobs-manager") + return + } + // Mirror pickJobsManagerService's precedence: unprefixed name wins if present. + selected := svcs[0] + for _, want := range []string{"jobs-manager", c.release + "-jobs-manager"} { + found := false + for _, s := range svcs { + if name(s) == want { + selected, found = s, true + break + } + } + if found { + break + } + } + for _, p := range digList(selected, "spec", "ports") { + if port, _ := dig(p, "port").(int); port == 8080 { + c.ok(n, "Service %q (the one pickJobsManagerService selects) exposes port 8080 (jobsManagerPort — the CLI's port-forward + POST target)", name(selected)) + return + } + } + c.fail(n, "Service %q — the one the CLI selects (unprefixed-first) — has no port 8080; the CLI hardcodes jobsManagerPort=8080 and would port-forward to the wrong target", name(selected)) +} + +// 4. PVC "client-pvc" rendered and mounted at /data/shared by jobs-manager. +func (c *checker) checkPVC() { + const n = 4 + if len(c.find("PersistentVolumeClaim", "client-pvc")) == 0 { + c.fail(n, "no PersistentVolumeClaim named \"client-pvc\" — SharedPVCClaimName no longer resolves") + return + } + jms := c.jobsManagerDeployments() + if len(jms) != 1 { + c.fail(n, "PVC \"client-pvc\" rendered, but cannot verify the /data/shared mount — jobs-manager Deployment not uniquely discoverable") + return + } + jm := jms[0] + // Find the volumeMount at /data/shared, then the pod volume backing it. + volName := "" + for _, ct := range digList(jm, "spec", "template", "spec", "containers") { + for _, vm := range digList(ct, "volumeMounts") { + if digStr(vm, "mountPath") == "/data/shared" { + volName = digStr(vm, "name") + } + } + } + if volName == "" { + c.fail(n, "jobs-manager has no volumeMount at \"/data/shared\" (SharedPVCMountPath) — the stage-Pod mount convention breaks") + return + } + for _, v := range digList(jm, "spec", "template", "spec", "volumes") { + if digStr(v, "name") == volName { + if claim := digStr(v, "persistentVolumeClaim", "claimName"); claim != "client-pvc" { + c.fail(n, "the /data/shared volume %q is backed by claim %q, not \"client-pvc\"", volName, claim) + return + } + c.ok(n, "PVC \"client-pvc\" rendered and mounted at \"/data/shared\" in jobs-manager (SharedPVCClaimName/SharedPVCMountPath)") + return + } + } + c.fail(n, "the /data/shared volumeMount references volume %q, which the pod spec does not define", volName) +} + +// 5. ingestion-authz ConfigMap shape + the "ingestor" SA fallback. +func (c *checker) checkIngestionAuthz() { + const n = 5 + cms := c.find("ConfigMap", c.release+"-ingestion-authz") + if len(cms) == 0 { + c.fail(n, "no ConfigMap named %q — discoverIngestorSAName silently degrades to the fallback", c.release+"-ingestion-authz") + return + } + rawPolicy := digStr(cms[0], "data", "ingestion-authz.yaml") + if rawPolicy == "" { + c.fail(n, "ConfigMap %q has no \"ingestion-authz.yaml\" key — the key discoverIngestorSAName reads", c.release+"-ingestion-authz") + return + } + var policy struct { + Allowed []struct { + ServiceAccount string `yaml:"service_account"` + Namespace string `yaml:"namespace"` + } `yaml:"allowed"` + } + if err := yaml.Unmarshal([]byte(rawPolicy), &policy); err != nil { + c.fail(n, "the ingestion-authz policy no longer parses as allowed[]{service_account, namespace}: %v", err) + return + } + // Mirror discoverIngestorSAName: entries scoped to the release namespace, + // exactly one distinct non-empty SA — anything else degrades to the fallback. + sa := "" + for _, e := range policy.Allowed { + if e.Namespace != c.namespace || e.ServiceAccount == "" { + continue + } + if sa != "" && sa != e.ServiceAccount { + c.fail(n, "the default policy is ambiguous (SAs %q and %q for namespace %q) — the CLI would fall back to guessing", sa, e.ServiceAccount, c.namespace) + return + } + sa = e.ServiceAccount + } + if sa == "" { + c.fail(n, "the default policy has no entry for namespace %q with a service_account — discoverIngestorSAName finds nothing", c.namespace) + return + } + // The fallback contract: the CLI hardcodes "ingestor" when the ConfigMap is + // absent/unreadable (older charts). The chart's default SA must stay in + // lock-step, and the SA object itself must render. + if sa != "ingestor" { + c.fail(n, "the chart's default ingestor SA is %q, but the CLI's hardcoded fallback is \"ingestor\" (discover.go IngestorSAName) — update both sides together", sa) + return + } + if len(c.find("ServiceAccount", "ingestor")) == 0 { + c.fail(n, "the policy references SA \"ingestor\" but no ServiceAccount \"ingestor\" is rendered — token minting would target a ghost SA") + return + } + c.ok(n, "ConfigMap %q key \"ingestion-authz.yaml\" parses, and the default SA is the CLI's \"ingestor\" fallback (SA rendered)", c.release+"-ingestion-authz") +} + +// 6. INGESTOR_IMAGE_DIGEST env on the FIRST jobs-manager container — the exact +// read DiscoverParentRelease does (Containers[0] only). +func (c *checker) checkIngestorDigestEnv() { + const n = 6 + jms := c.jobsManagerDeployments() + if len(jms) != 1 { + c.fail(n, "cannot check INGESTOR_IMAGE_DIGEST — jobs-manager Deployment not uniquely discoverable") + return + } + cts := digList(jms[0], "spec", "template", "spec", "containers") + if len(cts) == 0 { + c.fail(n, "jobs-manager renders no containers") + return + } + for _, e := range digList(cts[0], "env") { + if digStr(e, "name") == "INGESTOR_IMAGE_DIGEST" { + c.ok(n, "INGESTOR_IMAGE_DIGEST env present on jobs-manager's first container (digest-pin discovery)") + return + } + } + c.fail(n, "no INGESTOR_IMAGE_DIGEST env on jobs-manager's FIRST container — the CLI reads Containers[0] only") +} + +// 7. requests-proxy Deployment under a name doctor's findDeployment accepts. +func (c *checker) checkRequestsProxy() { + const n = 7 + if len(c.find("Deployment", c.release+"-requests-proxy")) > 0 { + c.ok(n, "requests-proxy Deployment named %q (doctor's Service Bus egress check)", c.release+"-requests-proxy") + return + } + for _, d := range c.find("Deployment", "requests-proxy") { + if digStr(d, "metadata", "labels", "app.kubernetes.io/instance") == c.release { + c.ok(n, "requests-proxy Deployment named \"requests-proxy\", tied to the release by its instance label") + return + } + } + c.fail(n, "no Deployment named %q (or bare \"requests-proxy\" with instance=%q) — doctor's checkRequestsProxy reports the egress broker missing", c.release+"-requests-proxy", c.release) +} + +// 8. Submit path: the jobs-manager container exposes 8080 (the port behind +// POST /internal/submit-ingestion-run), and the chart still references the +// path. The path lives in rendered comments, not a structured field — a weak +// but deliberate tripwire: a client-runtime endpoint move updates the chart's +// comments in the same change, and this makes that update confront the CLI's +// SubmitPath constant too. +func (c *checker) checkSubmitPath() { + const n = 8 + jms := c.jobsManagerDeployments() + if len(jms) != 1 { + c.fail(n, "cannot check the submit port — jobs-manager Deployment not uniquely discoverable") + return + } + port8080 := false + for _, ct := range digList(jms[0], "spec", "template", "spec", "containers") { + for _, p := range digList(ct, "ports") { + if cp, _ := dig(p, "containerPort").(int); cp == 8080 { + port8080 = true + } + } + } + if !port8080 { + c.fail(n, "no jobs-manager container exposes containerPort 8080 — the POST %s target", "/internal/submit-ingestion-run") + return + } + if !strings.Contains(c.raw, "/internal/submit-ingestion-run") { + c.fail(n, "the render no longer references \"/internal/submit-ingestion-run\" — if the endpoint moved, update internal/submit SubmitPath and this gate together") + return + } + c.ok(n, "jobs-manager exposes containerPort 8080 and the chart still references POST /internal/submit-ingestion-run (SubmitPath)") +} diff --git a/scripts/chart-invariants/main_test.go b/scripts/chart-invariants/main_test.go new file mode 100644 index 0000000..42edd5b --- /dev/null +++ b/scripts/chart-invariants/main_test.go @@ -0,0 +1,278 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// goodRender is a minimal render carrying every shape the 8 invariants assert. +// It tests the CHECKER's logic — the real chart is exercised in CI at the +// pinned ref (scripts/.client-ref) by .github/workflows/chart-drift.yml. +const goodRender = `--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ingestor + namespace: tracebloc +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: tracebloc-ingestion-authz + namespace: tracebloc +data: + ingestion-authz.yaml: | + allowed: + - service_account: "ingestor" + namespace: "tracebloc" + table_prefixes: + - "*" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: client-pvc + namespace: tracebloc +--- +apiVersion: v1 +kind: Service +metadata: + # (POST /internal/submit-ingestion-run) served here + name: jobs-manager + namespace: tracebloc +spec: + ports: + - name: http + port: 8080 + targetPort: 8080 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tracebloc-jobs-manager + namespace: tracebloc + labels: + app.kubernetes.io/name: client + app.kubernetes.io/instance: tracebloc + app.kubernetes.io/version: "1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: client-1.9.4 +spec: + template: + spec: + containers: + - name: api + ports: + - name: http + containerPort: 8080 + volumeMounts: + - name: shared-volume + mountPath: "/data/shared" + env: + - name: INGESTOR_IMAGE_DIGEST + value: "" + volumes: + - name: shared-volume + persistentVolumeClaim: + claimName: client-pvc +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tracebloc-requests-proxy + namespace: tracebloc + labels: + app.kubernetes.io/name: client + app.kubernetes.io/instance: tracebloc + app.kubernetes.io/managed-by: Helm +` + +// runOn feeds a render through run() on stdin and returns (exitCode, output). +func runOn(t *testing.T, render string) (int, string) { + t.Helper() + var out bytes.Buffer + code := run(nil, strings.NewReader(render), &out) + return code, out.String() +} + +func TestAllInvariantsHold(t *testing.T) { + code, out := runOn(t, goodRender) + if code != 0 { + t.Fatalf("want exit 0 on the good render, got %d:\n%s", code, out) + } + if !strings.Contains(out, "all 8 invariants hold") { + t.Fatalf("missing success line:\n%s", out) + } +} + +func TestFileArgument(t *testing.T) { + p := filepath.Join(t.TempDir(), "render.yaml") + if err := os.WriteFile(p, []byte(goodRender), 0o600); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if code := run([]string{p}, strings.NewReader(""), &out); code != 0 { + t.Fatalf("want exit 0 reading from a file, got %d:\n%s", code, out.String()) + } +} + +func TestBrokenInvariants(t *testing.T) { + tests := []struct { + name string + // mutate the good render via string replacement + old, new string + wantMark string // "✖ ." line that must appear + }{ + { + name: "discovery label selector broken", + old: "app.kubernetes.io/name: client\n app.kubernetes.io/instance: tracebloc\n app.kubernetes.io/version", + new: "app.kubernetes.io/name: renamed\n app.kubernetes.io/instance: tracebloc\n app.kubernetes.io/version", + wantMark: "✖ 1.", + }, + { + name: "chart label lost its client- prefix", + old: "helm.sh/chart: client-1.9.4", + new: "helm.sh/chart: renamed-1.9.4", + wantMark: "✖ 1.", + }, + { + name: "deployment renamed to an untolerated form", + old: "name: tracebloc-jobs-manager", + new: "name: tracebloc-manager-of-jobs", + wantMark: "✖ 1.", // no longer matches the discovery name filter at all + }, + { + name: "service renamed", + old: " name: jobs-manager\n namespace: tracebloc\nspec:\n ports:", + new: " name: manager-svc\n namespace: tracebloc\nspec:\n ports:", + wantMark: "✖ 3.", + }, + { + name: "service port moved off 8080", + old: " port: 8080", + new: " port: 9090", + wantMark: "✖ 3.", + }, + { + // The CLI's pickJobsManagerService selects the unprefixed + // "jobs-manager" Service first (by existence, ignoring ports). + // Here that selected Service listens on 9090 while a sibling + // "-jobs-manager" Service exposes 8080. An "any Service + // exposes 8080" gate would wrongly pass; the gate must fail + // because the CLI would port-forward to the wrong target on 8080. + name: "selected service lacks 8080 though a sibling has it", + old: " name: jobs-manager\n namespace: tracebloc\nspec:\n ports:\n - name: http\n port: 8080\n targetPort: 8080", + new: " name: jobs-manager\n namespace: tracebloc\nspec:\n ports:\n - name: http\n port: 9090\n targetPort: 9090\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: tracebloc-jobs-manager\n namespace: tracebloc\nspec:\n ports:\n - name: http\n port: 8080\n targetPort: 8080", + wantMark: "✖ 3.", + }, + { + name: "pvc renamed", + old: "name: client-pvc\n namespace: tracebloc", + new: "name: client-data\n namespace: tracebloc", + wantMark: "✖ 4.", + }, + { + name: "mount path moved", + old: `mountPath: "/data/shared"`, + new: `mountPath: "/data/datasets"`, + wantMark: "✖ 4.", + }, + { + name: "mount backed by a different claim", + old: "claimName: client-pvc", + new: "claimName: other-pvc", + wantMark: "✖ 4.", + }, + { + name: "authz configmap renamed", + old: "name: tracebloc-ingestion-authz", + new: "name: tracebloc-ingest-policy", + wantMark: "✖ 5.", + }, + { + name: "authz policy key renamed", + old: "ingestion-authz.yaml: |", + new: "policy.yaml: |", + wantMark: "✖ 5.", + }, + { + name: "default SA no longer the CLI fallback", + old: `service_account: "ingestor"`, + new: `service_account: "pusher"`, + wantMark: "✖ 5.", + }, + { + name: "SA object not rendered", + old: "kind: ServiceAccount\nmetadata:\n name: ingestor", + new: "kind: ServiceAccount\nmetadata:\n name: other-sa", + wantMark: "✖ 5.", + }, + { + name: "digest env dropped", + old: "- name: INGESTOR_IMAGE_DIGEST", + new: "- name: INGESTOR_IMAGE_SHA", + wantMark: "✖ 6.", + }, + { + name: "requests-proxy renamed", + old: "name: tracebloc-requests-proxy", + new: "name: tracebloc-egress-broker", + wantMark: "✖ 7.", + }, + { + name: "container port moved off 8080", + old: "containerPort: 8080", + new: "containerPort: 9090", + wantMark: "✖ 8.", + }, + { + name: "submit path marker gone from the render", + old: "(POST /internal/submit-ingestion-run) served here", + new: "(POST /internal/run-ingestion) served here", + wantMark: "✖ 8.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mutated := strings.Replace(goodRender, tt.old, tt.new, 1) + if mutated == goodRender { + t.Fatalf("mutation %q did not apply — fixture drifted", tt.old) + } + code, out := runOn(t, mutated) + if code != 1 { + t.Fatalf("want exit 1, got %d:\n%s", code, out) + } + if !strings.Contains(out, tt.wantMark) { + t.Fatalf("want a %q failure, got:\n%s", tt.wantMark, out) + } + }) + } +} + +func TestBareRequestsProxyTiedByInstanceLabel(t *testing.T) { + // Older unprefixed charts: doctor accepts a bare "requests-proxy" ONLY when + // its instance label ties it to the release. + render := strings.Replace(goodRender, "name: tracebloc-requests-proxy", "name: requests-proxy", 1) + if code, out := runOn(t, render); code != 0 { + t.Fatalf("bare requests-proxy with instance label must pass, got %d:\n%s", code, out) + } + render = strings.Replace(render, + "name: requests-proxy\n namespace: tracebloc\n labels:\n app.kubernetes.io/name: client\n app.kubernetes.io/instance: tracebloc", + "name: requests-proxy\n namespace: tracebloc\n labels:\n app.kubernetes.io/name: client\n app.kubernetes.io/instance: someone-else", 1) + code, out := runOn(t, render) + if code != 1 || !strings.Contains(out, "✖ 7.") { + t.Fatalf("bare requests-proxy owned by ANOTHER release must fail invariant 7, got %d:\n%s", code, out) + } +} + +func TestUnparseableRenderIsAnError(t *testing.T) { + if code, _ := runOn(t, "kind: [unclosed"); code != 2 { + t.Fatalf("want exit 2 on unparseable YAML, got %d", code) + } + if code, _ := runOn(t, ""); code != 2 { + t.Fatalf("want exit 2 on an empty render, got %d", code) + } +}