diff --git a/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh b/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh index 9e3855b..065ba82 100755 --- a/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh +++ b/conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh @@ -26,7 +26,10 @@ # KNOWN_NON_CATALOG_PUBLISHERS — shrink-only, stale entries FAIL; # (7) the check is LOAD-BEARING: whole-repo scope declared in chainsaw-test.yaml, honoured by # ci/l0.sh's --include-scope union, and ci/gate.sh blocks on an l0 failure. NO hand-written -# ci/l0.sh stage (whole-repo-invariants-wired fails the gate if one appears). +# ci/l0.sh stage (whole-repo-invariants-wired fails the gate if one appears); +# (8) the external-dns HelmRelease declares `annotationPrefix` explicitly and it matches the +# prefix of EVERY external-dns annotation in the tree and in catalog/render.py — added after +# the 2026-11-07 v0.23.0 default-prefix change silently voided (1)-(3) at runtime. # # WHAT THIS DOES *NOT* PROVE (honest gaps — it is STATIC, offline, cluster-free): # - That the record is gone from Cloudflare. Live read; belongs beside @@ -90,9 +93,18 @@ CHECKS = 0 # ControllerValue = "dns-controller" -> the value that CLAIMS an object # source/annotations/processors.go IsControllerMismatch skips a source object when the key is # PRESENT and the value is NOT ControllerValue. So "disowned" == key present, value != claimed. -CONTROLLER_KEY = "external-dns.alpha.kubernetes.io/controller" +# +# THE PREFIX IS NOT A CONSTANT OF NATURE. Every key external-dns reads is ``, +# and the prefix is a runtime flag whose DEFAULT moved in v0.22.0 (#6424: alpha -> GA, "with no +# fallback"). On 2026-11-07 the chart bump to v0.23.0 made every key below invisible to the running +# controller while this check — which only asserted the keys were PRESENT — stayed green. (8) now +# ties ANNOTATION_PREFIX to the HelmRelease's explicit `annotationPrefix`. +ANNOTATION_PREFIX = "external-dns.alpha.kubernetes.io/" +CONTROLLER_KEY = ANNOTATION_PREFIX + "controller" CONTROLLER_CLAIMED = "dns-controller" -HOSTNAME_KEY = "external-dns.alpha.kubernetes.io/hostname" +HOSTNAME_KEY = ANNOTATION_PREFIX + "hostname" +# Any key that looks like an external-dns annotation, under ANY prefix variant (alpha, GA, typos). +EXTERNAL_DNS_KEY_RE = re.compile(r"^external-dns(\.[a-z0-9-]+)*\.kubernetes\.io/") def check(cond, msg): @@ -231,9 +243,7 @@ for sub in SCAN_DIRS: ann = md.get("annotations") or {} if kind == "HTTPRoute": routes.append((rel, d)) - elif kind == "Service" and any( - a.startswith("external-dns.alpha.kubernetes.io/") for a in ann - ): + elif kind == "Service" and any(EXTERNAL_DNS_KEY_RE.match(a) for a in ann): dns_services.append((rel, d)) check(len(routes) > 0, "inventory: no HTTPRoute found under apps/ platform/ runbooks/ — parse " @@ -444,6 +454,67 @@ check( "and the whole-repo-invariants-wired meta-test fails the gate on a hand-written stage.", ) +# ============================================================================================= +# (8) THE RUNNING CONTROLLER READS THE PREFIX THIS TREE WRITES. +# (a) the external-dns HelmRelease sets `values.annotationPrefix` EXPLICITLY (an upstream +# default is exactly what moved under us) and it equals ANNOTATION_PREFIX; +# (b) it does not ALSO enable `--enable-legacy-annotation-prefix` (dual-read hides a +# half-migration; upstream says the flag will be removed); +# (c) every external-dns-looking annotation key on any object under apps/ platform/ runbooks/ +# uses ANNOTATION_PREFIX — a key under another prefix is silently ignored at runtime; +# (d) catalog/render.py emits no external-dns key under another prefix. +# Migrating to the GA prefix = change ANNOTATION_PREFIX, the HelmRelease value, the renderer +# and every manifest in ONE commit; this assertion fails on any partial state. +# ============================================================================================= +HR_PATH = ROOT / "platform/externaldns/helmrelease.yaml" +hr = next((d for d in docs_of(HR_PATH) if d.get("kind") == "HelmRelease" + and (d.get("metadata") or {}).get("name") == "external-dns"), None) +check(hr is not None, f"(8) {HR_PATH.relative_to(ROOT)} holds no HelmRelease `external-dns` — " + f"cannot prove which annotation prefix the controller reads") +values = ((hr or {}).get("spec") or {}).get("values") or {} +hr_prefix = values.get("annotationPrefix") +check( + hr_prefix == ANNOTATION_PREFIX, + f"(8a) external-dns HelmRelease `values.annotationPrefix` is {hr_prefix!r}, but every " + f"external-dns annotation in this tree uses {ANNOTATION_PREFIX!r}. A mismatch (or relying on " + f"the upstream default, which changed in v0.22.0) makes the controller IGNORE `controller: " + f"none`, the Gateway `target` and Service `hostname` — internal names go public, public names " + f"answer RFC1918 (incident 2026-11-07).", +) +extra = values.get("extraArgs") or [] +extra_s = " ".join(f"{k}={v}" for k, v in extra.items()) if isinstance(extra, dict) \ + else " ".join(map(str, extra)) +check("enable-legacy-annotation-prefix" not in extra_s and "annotation-prefix" not in extra_s, + "(8b) external-dns extraArgs must not set --annotation-prefix or " + "--enable-legacy-annotation-prefix; the prefix is declared ONLY via values.annotationPrefix " + "so this check can see it") + +foreign: list[str] = [] +seen_keys = 0 +for sub in SCAN_DIRS: + base = ROOT / sub + if not base.is_dir(): + continue + for p in sorted(base.rglob("*.yaml")): + rel = p.relative_to(ROOT).as_posix() + for d in docs_of(p): + for k in annotations_of(d): + if isinstance(k, str) and EXTERNAL_DNS_KEY_RE.match(k): + seen_keys += 1 + if not k.startswith(ANNOTATION_PREFIX): + foreign.append(f"{rel}:{'/'.join(ident(d))} {k}") +check(seen_keys > 0, "(8c) no external-dns annotation found anywhere — parse contract broken " + "(the Gateway target alone guarantees at least one)") +check(not foreign, + f"(8c) external-dns annotations under a prefix the controller does NOT read " + f"({ANNOTATION_PREFIX!r} is configured): {foreign}") +render_src = read(CATALOG / "render.py") +bad_render = sorted({m for m in re.findall(r"external-dns(?:\.[a-z0-9-]+)*\.kubernetes\.io/", + render_src) if m != ANNOTATION_PREFIX}) +check(not bad_render, + f"(8d) catalog/render.py emits external-dns keys under {bad_render}, but the controller " + f"reads {ANNOTATION_PREFIX!r}") + # ============================================================================================= if FAILURES: for f in FAILURES: diff --git a/decisions/2026-11-07-l0-externaldns-annotation-prefix.md b/decisions/2026-11-07-l0-externaldns-annotation-prefix.md new file mode 100644 index 0000000..9017819 --- /dev/null +++ b/decisions/2026-11-07-l0-externaldns-annotation-prefix.md @@ -0,0 +1,51 @@ +--- +date: 2026-11-07 +branch: feat/l0-externaldns-annotation-prefix +title: L0 guard — external-dns annotationPrefix must match every external-dns annotation in the tree +--- + +## What changed + +`conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh` gains assertion +(8). The check is already whole-repo-scoped, so it runs on every gate. + +- **(8a)** `platform/externaldns/helmrelease.yaml` must set `values.annotationPrefix` explicitly, + and it must equal the check's `ANNOTATION_PREFIX` (`external-dns.alpha.kubernetes.io/`). The + check does not trust the upstream default, because that default is what changed. +- **(8b)** `extraArgs` must not carry `--annotation-prefix` or `--enable-legacy-annotation-prefix`, + so the prefix has one declared place. +- **(8c)** Every external-dns-looking annotation key under apps/, platform/ and runbooks/ must use + that prefix. The match is any `external-dns(.x)*.kubernetes.io/` key. +- **(8d)** catalog/render.py must emit no external-dns key under any other prefix. + +`CONTROLLER_KEY` and `HOSTNAME_KEY` are now derived from `ANNOTATION_PREFIX`. The Service +inventory matches any external-dns prefix variant, not only the alpha one. + +Why: on 2026-11-07, external-dns v0.23.0 (whose default prefix is GA) ignored every alpha +annotation. Assertions (1) to (3) stayed green, because they prove the annotations are present, +not that the controller reads them. See `decisions/2026-11-07-public-dns-wan-a-record.md`. + +This branch is stacked on `fix/public-dns-wan-a-record` (PR #140). Without that fix, (8a) fails, +which is correct. Merge #140 first. + +## What I verified + +Run on the host (PyYAML): + +- the check passes on this tree: `check OK: 62 assertions passed`. +- it **fails (8a)** with origin/main's HelmRelease, which has no `annotationPrefix`. +- it **fails (8c)** when `platform/gateway/gateway.yaml`'s `target` key is changed to the GA + prefix. + +Toolbox gate: see the PR description. + +## What I did NOT verify + +- Whether a future chart could read the prefix from somewhere other than `values.annotationPrefix`, + for example a renamed chart value. (8a) would then pass while the flag silently vanished from the + rendered args. Rendering the chart in the gate would close that gap. Not done: it needs network + access or a vendored chart in L0. +- `conformance/**` is a **sensitive path**. This PR needs the operator's approving review or + `/approve-override`. The agent did not post one. +- The header of the sibling chainsaw-test.yaml still says "external-dns v0.21.0". That is cosmetic + and I left it alone.