CI / k8s (push) Successful in 5s
CI / lint (push) Successful in 1m27s
CI / build (push) Successful in 1m22s
CI / unit (push) Successful in 1m12s
CI / frontend (push) Successful in 2m7s
CI / mutation (push) Successful in 3m9s
CI / verify-stack (push) Successful in 6m20s
## What & why The Helm chart landed in #167 with two gaps written into ADR-0033: `make k8s-lint` existed but no CI job ran it, and *"a second deployment description to keep in step with compose — nothing enforces that today; a drift check belongs in CI (follow-up)"*. Both are closed here. **`make k8s-drift`** (`infra/helm/check-drift.py`, stdlib only) compares what each stack actually deploys rather than diffing two files that differ by design: workload names and resolved container images, taken from `docker compose config --format json` and a rendered chart. The six differences that exist today are declared in `DEVIATIONS` with the reason each was forced — the four `*-init` Django services folded into their web pods, and the two bootstrap Jobs compose runs from the host — so only a *new* difference fails. **A `k8s` CI job** runs `k8s-lint` then `k8s-drift` on every push and PR. No cluster, no marketplace action: helm is fetched as the pinned static binary the Talos runbook already gives developers. Closes #168 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation — the red commit reports all six real differences; the green commit declares them. - [x] Implementation makes the test pass. - [x] Conventional Commits referencing the issue (`refs #168`). - [x] CI green — awaiting the run on this PR (`make k8s-lint` and `make k8s-drift` pass locally). - [x] `docker compose up` unaffected — no service, image or compose file is touched. - [x] Docs updated — `docs/runbooks/ci.md` (job table + the one place local and CI now differ), `docs/runbooks/kubernetes-talos.md` §7/§"not ported", and ADR-0033's cost note. - [x] No ADR needed: no new dependency (python stdlib, and helm/docker were already prerequisites of the `k8s-*` targets), no boundary moved, no §8 rule bent. - [x] Not user-visible, so no demo note. ## Notes for reviewers Verified by hand that both drift classes fail the check, not just that it passes today: - bumping `OPENZAAK_TAG` in compose alone → reports `openzaak` and `oz-celery` with both image strings; - adding a workload to `values.yaml` alone → reports it by name. Deliberate limits (there is a `ponytail:` note in the script): - **Names and images only**, as sets — no per-workload env, ports or volumes. Those differ by design in four documented places, so comparing them would mean re-encoding every deviation field by field for very little more signal. - **The three observability workloads are rendered with `enabled=true`** by the check, even though both stacks default them off, so their images can't drift unwatched. - **`k8s-lint`/`k8s-drift` are not in `make ci`**, to avoid making `helm` a hard prerequisite for everyone. That is now the only local/CI difference; it's called out in `docs/runbooks/ci.md`. Follow-ups filed while reviewing the chart, not addressed here: #169 (the published docs omit every ADR after 0010 and all runbooks but `ci.md`) and #170 (the production-posture ADR #25 asked for — secrets are still plain text in `values.yaml`).Reviewed-on: #171
117 lines
5.1 KiB
Python
Executable File
117 lines
5.1 KiB
Python
Executable File
#!/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())
|