diff --git a/Makefile b/Makefile index 56141f6..ca65950 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help +.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help ## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions) ## `verify` is the live-stack stage (full stack up once → ACL + notification checks). @@ -350,6 +350,13 @@ k8s-lint: helm lint $(K8S_CHART) helm template big $(K8S_CHART) -n $(K8S_NS) --set images.registry=registry.invalid:5000 >/dev/null +## k8s-drift: fail if compose and the Helm chart describe different stacks +# Compose is CI-canonical (ADR-0033) and the chart is a transcription of it; this +# compares what each one deploys — workload names and resolved images. Needs +# `docker compose` and `helm`, no cluster. +k8s-drift: + python3 infra/helm/check-drift.py + ## k8s-registry: deploy the in-cluster image registry (NodePort 30500) k8s-registry: kubectl apply -f infra/helm/registry.yaml diff --git a/infra/helm/check-drift.py b/infra/helm/check-drift.py new file mode 100755 index 0000000..a69a474 --- /dev/null +++ b/infra/helm/check-drift.py @@ -0,0 +1,94 @@ +#!/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" + +# 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)): + problems.append(f" {name}: in docker-compose.yml, not in the chart") + for name in sorted(set(chart) - set(compose)): + 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") + return 0 + + +if __name__ == "__main__": + sys.exit(main())