#!/usr/bin/env python3 """Fail when the compose stack and the Helm chart stop describing the same stack. `infra/docker-compose.yml` is CI-canonical; `infra/helm/big-reference` is a transcription of it (ADR-0033), and until now nothing kept the two in step — an upstream image bump or a new service applied to only one of them landed unnoticed. This compares what each side actually *deploys*, not the two files: the rendered chart against `docker compose config`. Both tools are already prerequisites of the `k8s-*` make targets. Run it with `make k8s-drift`. No cluster needed. ponytail: names and images only, as sets — no per-workload env/ports/volumes. Those differ by design in four documented places (ADR-0033), so comparing them would mean re-encoding every deviation field by field; a tag bump and a missing service are the drift that actually bites. """ import json import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] COMPOSE = ROOT / "infra/docker-compose.yml" CHART = ROOT / "infra/helm/big-reference" # The busybox init container that every `waitFor` workload gets exists only in # the chart (compose has `depends_on`). Rendering it under a sentinel makes it # filterable without teaching the check what busybox is. BUSYBOX = "drift-check-ignored-init-image" # Differences that Kubernetes forces, not drift (ADR-0033). A name listed here is # expected to be on exactly one side; anything else fails. DEVIATIONS = { # The four Django services apply their own setup_configuration in the web pod # (`args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]`) rather than in a # separate init Job. Both that script and /start.sh run `manage.py migrate`, and # Kubernetes has no `depends_on: service_completed_successfully` to serialise them, # so the Job and its web pod migrated the same database concurrently. "oz-init": "folded into the openzaak pod", "nrc-init": "folded into the nrc-web pod", "objecttypen-init": "folded into the objecttypen pod", "objecten-init": "folded into the objecten pod", # Compose seeds these from the host — the verify scripts `docker cp` the two # scripts into a running container, and docker-compose.local.yml carries # `local-seed` + `nrc-subscribe` for `make local`. A cluster has no host to seed # from, so both became Jobs in the chart. "seed-zaaktype": "compose seeds the catalogus from the host (infra/openzaak/seed_catalogus.py)", "nrc-subscribe": "compose registers the abonnement from the host (infra/local/register-abonnement.py)", } # Workloads the observability backplane adds. Off by default in both stacks' # defaults, so they are rendered on purpose here — otherwise their images drift # unwatched. OBSERVABILITY = ["tempo", "prometheus", "grafana"] def compose_services() -> dict[str, str]: """Service name -> image, with ${TAG:-default} interpolation already applied.""" out = run(["docker", "compose", "-f", str(COMPOSE), "config", "--format", "json"]) return {name: svc.get("image", "") for name, svc in json.loads(out)["services"].items()} def chart_workloads() -> dict[str, str]: """Workload name -> image, read back out of the rendered manifests.""" out = run( ["helm", "template", "big", str(CHART), "-n", "big", "--set", f"images.busybox={BUSYBOX}"] + [f"--set=workloads.{w}.enabled=true" for w in OBSERVABILITY] ) workloads = {} for doc in out.split("\n---"): if not re.search(r"^kind: (Deployment|Job)$", doc, re.M): continue name = re.search(r"^ name: (\S+)$", doc, re.M)[1] images = [i for i in re.findall(r"^\s+image: (\S+)$", doc, re.M) if i != BUSYBOX] workloads[name] = images[0] return workloads def run(argv: list[str]) -> str: proc = subprocess.run(argv, capture_output=True, text=True) if proc.returncode != 0: sys.exit(f"{argv[0]} failed:\n{proc.stderr}") return proc.stdout def main() -> int: compose, chart = compose_services(), chart_workloads() problems = [] for name in sorted(set(compose) - set(chart) - set(DEVIATIONS)): problems.append(f" {name}: in docker-compose.yml, not in the chart") for name in sorted(set(chart) - set(compose) - set(DEVIATIONS)): problems.append(f" {name}: in the chart, not in docker-compose.yml") for name in sorted(set(compose) & set(chart)): if compose[name] != chart[name]: problems.append(f" {name}: compose runs {compose[name]}, the chart runs {chart[name]}") if problems: print("compose and the Helm chart describe different stacks:\n" + "\n".join(problems)) print( "\nPort the change to the other stack, or — if the difference is forced by\n" "Kubernetes — declare it in DEVIATIONS in this file, with the reason." ) return 1 print(f"no drift: {len(chart)} workloads, images identical on both stacks") for name, why in sorted(DEVIATIONS.items()): print(f" deviation (declared): {name} — {why}") return 0 if __name__ == "__main__": sys.exit(main())