feat(conformance): L0 guard that the external-dns annotationPrefix matches the tree
gate / gate (pull_request) Successful in 2m58s
gate / conformance (pull_request) Skipped

Add assertion (8) to internal-services-publish-no-public-dns. The external-dns
HelmRelease must declare annotationPrefix explicitly, and every
external-dns annotation in apps/, platform/, runbooks/ and catalog/render.py
must use that prefix. The v0.23.0 default-prefix change on 2026-11-07
ignored the controller: none, target and hostname annotations while
assertions (1)-(3) stayed green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5.5 committed 2026-11-07 17:56:12 -06:00
1 parent 267446c68f
commit 9c51f60a35
2 files changed
+128 -6

No files matched your search

@@ -26,7 +26,10 @@
# KNOWN_NON_CATALOG_PUBLISHERS — shrink-only, stale entries FAIL; # 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 # (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'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): # WHAT THIS DOES *NOT* PROVE (honest gaps — it is STATIC, offline, cluster-free):
# - That the record is gone from Cloudflare. Live read; belongs beside # - 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 # ControllerValue = "dns-controller" -> the value that CLAIMS an object
# source/annotations/processors.go IsControllerMismatch skips a source object when the key is # 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. # 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 `<annotationPrefix><name>`,
# 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" 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): def check(cond, msg):
@@ -231,9 +243,7 @@ for sub in SCAN_DIRS:
ann = md.get("annotations") or {} ann = md.get("annotations") or {}
if kind == "HTTPRoute": if kind == "HTTPRoute":
routes.append((rel, d)) routes.append((rel, d))
elif kind == "Service" and any( elif kind == "Service" and any(EXTERNAL_DNS_KEY_RE.match(a) for a in ann):
a.startswith("external-dns.alpha.kubernetes.io/") for a in ann
):
dns_services.append((rel, d)) dns_services.append((rel, d))
check(len(routes) > 0, "inventory: no HTTPRoute found under apps/ platform/ runbooks/ — parse " 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.", "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: if FAILURES:
for f in FAILURES: for f in FAILURES:
@@ -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.