#!/usr/bin/env bash # ============================================================================= # L0 STATIC CHECK — no service declared `internal` can produce a PUBLIC DNS record. # See chainsaw-test.yaml's header for the full WHY. In one paragraph: # # docs/09 R-DNS-03 and schemas/renderer-contract.md §exposure both say an `internal` service # renders no public DNS record. Nothing enforced it: ExternalDNS watched every HTTPRoute on the # shared Gateway, so six internal services published `.home.keithjackson.dev` into the # public Cloudflare zone answering with the RFC1918 Gateway VIP (verified at the authority # 2026-09-14; pre-state committed at .standup-dns/dns-20260914-pre-externaldns-scope.txt). The fix # derives ExternalDNS's controller annotation from `exposure` in catalog/render.py; this check is # what keeps it derived, present, and scoped. # # ASSERTIONS # (0) the inventory is non-empty and the parse contract holds (anti-vacuous-pass); # (1) every rendered route of an `internal` service carries the disown annotation; # (2) NO rendered route of a `public` service carries it — a renderer stamping everything would # satisfy (1) and silently delete the five public records under policy: sync; # (3) the `service` source half: an internal service's Service may not carry an external-dns # hostname annotation without the disown annotation beside it; # (4) catalog/render.py DERIVES the annotation from `exposure` — the emitting function reads that # field, names no catalog service, and cannot emit external-dns's own `dns-controller`; # (5) the SHIPPED renderer is proven end-to-end on synthetic ServiceDefinitions (internal, # public, and `exposure` OMITTED — the contract's default posture is internal); # (6) every HTTPRoute / annotated Service the catalog does NOT render is declared in # 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); # (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; # (9) THE CLOUDFLARE TUNNEL WIRING (2026-11-08) — every public name is a PROXIED CNAME to the tunnel: # (a) the shared Gateway's `target` is EXACTLY `.cfargotunnel.com`, the UUID read from # platform/cloudflared/config.yaml, and the HelmRelease sets `--cloudflare-proxied`; # (b) every `service`-source publisher carries `cloudflare-proxied: "false"` (L4 A records stay # DNS-only), and no HTTPRoute opts out of proxying (a DNS-only tunnel CNAME is dead); # (c) no hostname the tunnel maps to `http_status:404` has a public record — no undisowned route # names it, and the only `service`-source publishers of one are the declared L4 DNS-only # names (unifi, crafty-mc); conversely every disowned route's hostname IS 404'd by the # tunnel, so the "unpublished" and "refused at the tunnel" lists cannot drift apart; # (d) the operator's LAN/VPN-only names (plex, headlamp) are disowned everywhere they appear. # # WHAT THIS DOES *NOT* PROVE (honest gaps — it is STATIC, offline, cluster-free): # - That the record is gone from Cloudflare. Live read; belongs beside # conformance/tests/l2-behavioral/dns-exposure, which owns the positive direction. The negative # direction was proven manually on prod against the deployed v0.21.0 (a route carrying the # annotation produced no record; adding it to a route that had one produced action=DELETE for # both the A and its `a-` TXT within ~60s) — but a manual proof is not a gate. # - That an internal name is UNREACHABLE. A port-forward plus a literal IP needs no DNS record. # This check owns the NAME axis only. # - That the tunnel actually SERVES the public names, or that Cloudflare honoured `proxied`. (9) # proves the tree asks for a proxied CNAME to the right tunnel; the live answer (`dig @1.1.1.1` # returning Cloudflare anycast) is a runtime read. Nor does (8)/(9) prove the RUNNING controller # honours the prefix — a future chart bump that drops it would publish PROXIED A records at the # Gateway VIP; (8) only keeps the tree and the HelmRelease value consistent. # - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06). # # Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns # into rc 3 = UNWIRED, never green, because this test declares the whole-repo scope) only if # catalog/ or apps/ is absent — i.e. the subject tree has vanished, which is not a pass. # ============================================================================= set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root PYTHON="${PYTHON:-python3}" # Honest SKIP (never green for a scope-declaring test) if the subject trees are not there at all. if [ ! -d "${ROOT}/catalog" ] || [ ! -d "${ROOT}/apps" ] || [ ! -f "${ROOT}/catalog/render.py" ]; then echo "check SKIPPED: catalog/, apps/ or catalog/render.py absent — nothing to audit" >&2 exit 3 fi cd "${ROOT}" "${PYTHON}" - "${ROOT}" <<'PY' """Static audit: an `internal` ServiceDefinition cannot produce a public DNS record. PyYAML (the same dependency as catalog/render.py and the sibling l0-static checks) rather than line-based parsing: an HTTPRoute's annotations and a ServiceDefinition's exposure are nested structures, and a line-based reader of them is precisely the approximate parser that silently stops matching — the failure class this check exists to prevent. """ from __future__ import annotations import re import subprocess import sys import tempfile from pathlib import Path try: import yaml except ImportError: # pragma: no cover print("check FAIL: PyYAML is required (same dependency as catalog/render.py)", file=sys.stderr) raise SystemExit(1) ROOT = Path(sys.argv[1]) sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree FAILURES: list[str] = [] CHECKS = 0 # external-dns's own constants (v0.21.0 source/annotations/annotations.go): # ControllerKey = annotationPrefix + "controller" -> the key below # 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. # # 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 = 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): global CHECKS CHECKS += 1 if not cond: FAILURES.append(msg) def read(p: Path) -> str: try: return p.read_text() except (OSError, UnicodeDecodeError): return "" def docs_of(p: Path): """Every YAML document in a file, tolerating the non-manifest files in the tree.""" try: return [d for d in yaml.safe_load_all(read(p)) if isinstance(d, dict)] except yaml.YAMLError: return [] # ============================================================================================= # REGISTER — KNOWN_NON_CATALOG_PUBLISHERS. Shrink-only. # # These are HAND-AUTHORED HTTPRoutes and Services that put a name into the same public Cloudflare # zone but that catalog/render.py does not render, so there is no `exposure` field for the renderer # to derive anything from and this check cannot adjudicate them. That is a real limit, not a pass — # so each one is DECLARED, with the name it publishes and why it is out of this check's scope, and # the register can only shrink. An entry naming an object that no longer exists FAILS as stale, so # a deleted publisher cannot leave a lie behind; a NEW publisher fails until it is declared, so the # unadjudicated set cannot grow without a review event. Same ratchet reasoning as # KNOWN_EXTERNAL_MONITORS in servicemonitor-ports-resolve. # # WHAT THE ENTRIES PUBLISH NOW (2026-11-08). Every HTTPRoute entry that is not disowned publishes a # PROXIED CNAME to the Cloudflare Tunnel (the shared Gateway's `target`, assertion 9a), so no origin # address of ours is in the public zone for it. Headlamp is disowned (operator: LAN/VPN-only, 9d). # The two `service`-source entries still publish their RFC1918 LB VIP as a DNS-only A record — # deliberate (L4 ports cannot ride Cloudflare's HTTP proxy), and pinned DNS-only by 9b. Whether each # remaining name should be public at all stays an operator decision per name, not a renderer change, # and a cluster-wide ExternalDNS opt-in filter — the available hardening, see # platform/externaldns/helmrelease.yaml — would DELETE all of them at once the moment it is set. # # Keyed by ":/". # ============================================================================================= KNOWN_NON_CATALOG_PUBLISHERS: dict[str, str] = { "platform/oauth2-proxy/httproute.yaml:oauth2-proxy/oauth2-proxy": "publishes auth.${BASE_DOMAIN} -> the tunnel (proxied CNAME). The oauth2-proxy login endpoint is a " "platform component, not a catalog service; it must be reachable by every browser that " "any forward-auth app redirects, so whether it is PUBLICLY resolvable is coupled to " "whether the five public services are — an operator decision, not a renderer one.", "platform/keycloak/httproute.yaml:keycloak/keycloak": "publishes keycloak.${BASE_DOMAIN} -> the tunnel (proxied CNAME), and it is the route that actually " "OWNS that record (the TXT registry entry reads resource=httproute/keycloak/keycloak, not " "the catalog-rendered keycloak-8080 route, because external-dns merges duplicate " "hostnames and this one won). Keycloak is `exposure: public` in the catalog, so the name " "is meant to be public either way; it is listed because the PUBLISHING object is the " "hand-authored platform route, which carries no exposure field.", "platform/monitoring/grafana-httproute.yaml:monitoring/grafana": "publishes grafana.${BASE_DOMAIN} -> the tunnel (proxied CNAME). An admin dashboard " "behind Keycloak; it is not a catalog service, so withdrawing it is an operator call per " "name (it stayed public in the 2026-11-08 tunnel decision).", "platform/headlamp/httproute.yaml:headlamp/headlamp": "publishes NOTHING since 2026-11-08: the route carries `controller: none` (operator " "decision: Headlamp is LAN/VPN-only) and the tunnel 404s the name. A Kubernetes web UI " "with native Keycloak OIDC; listed because it is a hand-authored route, and pinned " "disowned by (9d).", "platform/vuln-scanner/httproute.yaml:vuln-scanner/vuln-scanner": "publishes NOTHING: the route carries `controller: none` (docs/17: the CVE page is internal " "only) and the tunnel 404s the name. Hand-authored because the scanner is not a catalog " "service (its cluster-wide read role cannot be expressed in a ServiceDefinition, ADR-0027).", "platform/homepage/httproute.yaml:homepage/homepage": "publishes the APEX ${BASE_DOMAIN} itself -> the tunnel (proxied CNAME), via the https-apex " "listener. The apex is not a per-service name and cannot be withdrawn by an exposure " "declaration.", "runbooks/serving/httproute.yaml:runbooks/runbooks": "declares NO hostnames and inherits the https-apex listener's ${BASE_DOMAIN}, so it " "contributes to the apex record rather than a name of its own (the apex TXT registry " "entry names the homepage route). Deliberately un-authenticated — see the manifest's own " "header for why gating runbooks on Keycloak is a loop with no exit.", "platform/crafty/httproute.yaml:crafty/crafty": "publishes crafty.${BASE_DOMAIN} -> the tunnel (proxied CNAME) (the Minecraft admin UI, gated by an " "oauth2-proxy sidecar). Platform component, no exposure field.", "platform/crafty/service-l4.yaml:crafty/crafty-l4-tcp": "the `service` SOURCE, not a route: publishes crafty-mc.${BASE_DOMAIN} -> " "${CRAFTY_L4_VIP} (192.168.2.22) for the raw Minecraft TCP/UDP game ports the HTTPS " "Gateway cannot express. A game server is the one case where a public name may be " "genuinely wanted; the address it answers with is still RFC1918, DNS-only (9b).", "apps/vms/unifi/50-service-l4.yaml:unifi/unifi-l4-tcp": "the `service` source: publishes unifi.${BASE_DOMAIN} -> ${UNIFI_L4_VIP} " "(192.168.2.21), DNS-only (9b), for the UniFi controller VM's inform/STUN/adoption ports. Hand-authored " "under apps/vms (a VM, not a rendered container service); the catalog's only VM entry is " "vm-demo, which declares no ingressRoutes and publishes nothing.", } # ============================================================================================= # (0) INVENTORY — the catalog, and every HTTPRoute / annotated Service in the tree. # ============================================================================================= CATALOG = ROOT / "catalog" sds: dict[str, dict] = {} for p in sorted(CATALOG.glob("*.yaml")): for d in docs_of(p): if "name" in d and "source" in d: sds[d["name"]] = d check(len(sds) > 0, "inventory: catalog/ yielded no ServiceDefinition — parse contract broken") # The default posture is `internal` (renderer-contract "Default posture"), so an ABSENT field is # internal, never "unclassified". Getting this wrong in the check would be the same bug as in the # renderer: a new service that omits exposure would go unaudited. exposure_of = {n: (sd.get("exposure") or "internal") for n, sd in sds.items()} # (namespace, routename) -> service, for the routes the renderer emits: one per ingressRoute, # named "-" in namespace "" (catalog/render.py _route_meta). expected_routes: dict[tuple[str, str], str] = {} for n, sd in sds.items(): for r in sd.get("ingressRoutes") or []: expected_routes[(n, f"{n}-{r['port']}")] = n internal_with_routes = sorted({s for (_, _), s in expected_routes.items() if exposure_of[s] == "internal"}) public_with_routes = sorted({s for (_, _), s in expected_routes.items() if exposure_of[s] == "public"}) check(len(internal_with_routes) > 0, "inventory: no `internal` catalog service declares an ingressRoute — the subject set of " "this check is empty, which means either the catalog changed shape or the parse is wrong") check(len(public_with_routes) > 0, "inventory: no `public` catalog service declares an ingressRoute — assertion (2) would be " "vacuous, so the parse is presumed wrong rather than the repo") SCAN_DIRS = ["apps", "platform", "runbooks"] routes: list[tuple[str, dict]] = [] # (relpath, HTTPRoute doc) dns_services: list[tuple[str, dict]] = [] # (relpath, Service doc carrying an external-dns ann) 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): kind = d.get("kind") md = d.get("metadata") or {} ann = md.get("annotations") or {} if kind == "HTTPRoute": routes.append((rel, d)) 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 " "contract broken (the tree is known to author several)") def ident(d: dict) -> tuple[str, str]: md = d.get("metadata") or {} return (md.get("namespace") or "", md.get("name") or "") def annotations_of(d: dict) -> dict: return (d.get("metadata") or {}).get("annotations") or {} def is_disowned(d: dict) -> bool: """True iff external-dns v0.21.0 would SKIP this object: the controller annotation is present and its value is not external-dns's own. Absent == published (the default), which is the whole point — the annotation is the only thing standing between a route and the public zone.""" v = annotations_of(d).get(CONTROLLER_KEY) return v is not None and v != CONTROLLER_CLAIMED # ============================================================================================= # (1) + (2) every internal service's rendered route is disowned; no public one is. # ============================================================================================= by_ident: dict[tuple[str, str], list[tuple[str, dict]]] = {} for rel, d in routes: by_ident.setdefault(ident(d), []).append((rel, d)) for (ns, rname), svc in sorted(expected_routes.items()): found = by_ident.get((ns, rname), []) check(len(found) >= 1, f"({svc}) the catalog declares an ingressRoute rendering HTTPRoute {ns}/{rname}, but no " f"such HTTPRoute exists in the applied tree — apps/ is out of sync with catalog/ " f"(ci/render-golden.sh owns the byte-level half of this)") for rel, d in found: if exposure_of[svc] == "internal": v = annotations_of(d).get(CONTROLLER_KEY) check( v is not None, f"({svc}) R-DNS-03 VIOLATED: {rel} HTTPRoute {ns}/{rname} belongs to an " f"`exposure: internal` service but carries NO `{CONTROLLER_KEY}` annotation, so " f"external-dns publishes {ns}. into the PUBLIC Cloudflare zone answering " f"with the RFC1918 Gateway VIP. An internal service must render no public record.", ) check( v != CONTROLLER_CLAIMED, f"({svc}) {rel} HTTPRoute {ns}/{rname} sets `{CONTROLLER_KEY}: " f"{CONTROLLER_CLAIMED}` — that is external-dns's OWN value, which CLAIMS the route " f"rather than releasing it. It reads like the guard and is its exact inverse.", ) else: check( CONTROLLER_KEY not in annotations_of(d), f"({svc}) {rel} HTTPRoute {ns}/{rname} belongs to an `exposure: " f"{exposure_of[svc]}` service yet carries `{CONTROLLER_KEY}`. Under " f"`policy: sync` that DELETES its live public record — the five public services " f"must keep publishing.", ) # ============================================================================================= # (3) the `service` source half — an internal service's Service may not publish either. # ============================================================================================= for rel, d in dns_services: ns, name = ident(d) svc = ns if ns in sds else None if svc and exposure_of[svc] == "internal": check( is_disowned(d), f"({svc}) {rel} Service {ns}/{name} carries an external-dns annotation " f"({sorted(a for a in annotations_of(d) if a.startswith('external-dns'))}) for an " f"`exposure: internal` service without a disowning `{CONTROLLER_KEY}`. Both " f"`gateway-httproute` AND `service` are configured sources, so closing only the route " f"half is half a guarantee.", ) # ============================================================================================= # (4) the GENERATOR derives it from `exposure` — not from a list of service names. # ============================================================================================= render_py = ROOT / "catalog" / "render.py" src = read(render_py) check(bool(src), "catalog/render.py unreadable") emitter = re.search( r"def _externaldns_route_annotations\(.*?\n(?=\n?def |\n?# =====)", src, re.S ) check(emitter is not None, "catalog/render.py must define `_externaldns_route_annotations` — the single place the " "ExternalDNS scoping is derived. If it was renamed, rename it here too: this check has to " "read the DERIVATION, not just the output, or a hardcoded per-service list would pass.") if emitter: body = emitter.group(0) # The derivation must read `exposure`. A body that does not mention it cannot be deriving # anything from the operator's declaration. check('"exposure"' in body or "'exposure'" in body, "`_externaldns_route_annotations` must read the `exposure` field — the guarantee is " "that the OPERATOR'S DECLARATION decides, so `exposure` has to appear in the decision") # and it must not name any catalog service: a per-service list is how twelve ServiceMonitors # acquired one identical wrong port name in a single edit. Only the executable statements are # inspected — the docstring legitimately names the affected services as evidence. code = re.sub(r'"""(?:.|\n)*?"""', "", body, count=1) named = sorted(n for n in sds if re.search(rf"""['"]{re.escape(n)}['"]""", code)) check(not named, f"`_externaldns_route_annotations` names catalog service(s) {named} in its CODE. The " f"annotation must be derived from `exposure` alone; a per-service list silently omits " f"the next service anyone adds.") check(CONTROLLER_CLAIMED not in code, f"`_externaldns_route_annotations` must never be able to emit `{CONTROLLER_CLAIMED}` — " f"external-dns's own value CLAIMS the object instead of releasing it") # ============================================================================================= # (5) THE SHIPPED RENDERER IS PROVEN on synthetic ServiceDefinitions. Three fixtures, because # "internal is annotated" alone would also pass if the renderer annotated everything, and # because the contract's DEFAULT posture is internal — the case a new service hits by # omitting the field entirely, and the case a per-service list would miss. # ============================================================================================= FIXTURES = [ ("fixture-internal", {"exposure": "internal"}, True, "an `internal` fixture must render the disown annotation"), ("fixture-public", {"exposure": "public"}, False, "a `public` fixture must render NO disown annotation"), ("fixture-default", {}, True, "a fixture OMITTING `exposure` defaults to internal (renderer-contract default posture) and " "must also render the disown annotation"), ] with tempfile.TemporaryDirectory() as td: for fname, extra, want, msg in FIXTURES: sd = { "name": fname, "kind": "container", "source": {"image": "example/app:1.0.0"}, "egress": "internet", "dependencies": [], "ingressRoutes": [{"host": f"{fname}.home.keithjackson.dev", "port": 8080}], # `native` avoids the oauth2-proxy sidecar path and the schema's groups requirement, # keeping the fixture about DNS scoping and nothing else. A public service may not use # auth.mode none (schema invariant since 1.1.0), so both fixtures use the same mode. "auth": {"mode": "native"}, "update": {"channel": "stable", "cadence": "0 4 * * 0", "autoRollbackOnFailedHealth": True}, "observability": {"metrics": True, "slis": ["up"]}, "backup": {"policy": "reacquirable"}, } sd.update(extra) f = Path(td) / f"{fname}.yaml" f.write_text(yaml.safe_dump(sd, sort_keys=False)) proc = subprocess.run( [sys.executable, str(render_py), str(f), "--no-image-automation"], capture_output=True, text=True, cwd=str(ROOT), ) if proc.returncode != 0: check(False, f"fixture {fname}: the shipped renderer failed: " f"{proc.stderr.strip()[:400]}") continue rendered = [d for d in yaml.safe_load_all(proc.stdout) if isinstance(d, dict) and d.get("kind") == "HTTPRoute"] check(len(rendered) == 1, f"fixture {fname}: expected exactly one rendered HTTPRoute") if rendered: got = is_disowned(rendered[0]) check(got is want, f"fixture {fname}: {msg} (rendered annotations: " f"{annotations_of(rendered[0])})") # ============================================================================================= # (6) REGISTER RATCHET — every publisher the catalog does not render is declared, and every # declaration still names a real object. # ============================================================================================= catalog_rendered = set(expected_routes) seen_register_keys: set[str] = set() for rel, d in routes + dns_services: ns, name = ident(d) if (ns, name) in catalog_rendered: continue key = f"{rel}:{ns}/{name}" seen_register_keys.add(key) check( key in KNOWN_NON_CATALOG_PUBLISHERS, f"UNDECLARED publisher {key}: a {d.get('kind')} the catalog does not render, so it has no " f"`exposure` field this check can adjudicate, and it can put a name into the public " f"Cloudflare zone. Add it to KNOWN_NON_CATALOG_PUBLISHERS with what it publishes and why " f"it is out of scope — or render it from a ServiceDefinition instead.", ) stale = sorted(set(KNOWN_NON_CATALOG_PUBLISHERS) - seen_register_keys) check(not stale, f"STALE register entries {stale}: KNOWN_NON_CATALOG_PUBLISHERS is shrink-only, and these " f"name no object in the tree any more. Delete them — a register that keeps entries for " f"deleted objects stops being evidence of anything.") # ============================================================================================= # (7) LOAD-BEARING — the scope is DECLARED here (so ci/l0.sh Stage 3b's --include-scope union # always selects this test) and NO stage is hand-written into ci/l0.sh (the # whole-repo-invariants-wired meta-test fails the gate if one is). # ============================================================================================= TEST_DIR = ROOT / "conformance/tests/l0-static/internal-services-publish-no-public-dns" test_yaml = read(TEST_DIR / "chainsaw-test.yaml") check(bool(test_yaml), "this test's own chainsaw-test.yaml is unreadable") check( re.search(r'^\s*conformance\.home\.k3s/scope:\s*"?whole-repo"?\s*$', test_yaml, re.M) is not None, "chainsaw-test.yaml must DECLARE `conformance.home.k3s/scope: \"whole-repo\"` on a real " "mapping line — that declaration is the only thing that unions this check into every gate " "run (ci/l0.sh Stage 3b). A scope named in prose is documentation, not a declaration.", ) l0_sh = read(ROOT / "ci/l0.sh") check( "internal-services-publish-no-public-dns" not in l0_sh, "ci/l0.sh must NOT name this test: a whole-repo invariant is selected by its DECLARED scope, " "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}") # ============================================================================================= # (9) THE CLOUDFLARE TUNNEL WIRING (2026-11-08). The public names are PROXIED CNAMEs to the tunnel. # ============================================================================================= CF_CONFIG = ROOT / "platform/cloudflared/config.yaml" GATEWAY_FILE = ROOT / "platform/gateway/gateway.yaml" TARGET_KEY = ANNOTATION_PREFIX + "target" PROXIED_KEY = ANNOTATION_PREFIX + "cloudflare-proxied" UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") # The L4 names the tunnel 404s that are NONETHELESS published — as DNS-only A records from the # `service` source, at their own LB VIP, because they are not HTTP and never touch the tunnel # (operator decision 2026-11-08). Shrink-only, and must agree with the tunnel's 404 list (9c). L4_DNS_ONLY_NAMES = {"unifi.${BASE_DOMAIN}", "crafty-mc.${BASE_DOMAIN}"} # The operator's LAN/VPN-only HTTP names (2026-11-08). Pinned here as well as in the tunnel config, so # deleting one from config.yaml cannot quietly also release it from (9c). OPERATOR_LAN_ONLY_NAMES = {"plex.${BASE_DOMAIN}", "headlamp.${BASE_DOMAIN}"} cf = next(iter(docs_of(CF_CONFIG)), None) check(cf is not None and isinstance(cf.get("ingress"), list), f"(9) {CF_CONFIG.relative_to(ROOT)} unreadable or has no `ingress` list — cannot prove what " f"the tunnel refuses or which tunnel the Gateway must target") cf = cf or {} tunnel = str(cf.get("tunnel") or "") check(bool(UUID_RE.match(tunnel)), f"(9a) platform/cloudflared/config.yaml `tunnel:` is {tunnel!r}, not a literal tunnel UUID — " f"the Gateway target is derived from it") gw = next((d for d in docs_of(GATEWAY_FILE) if d.get("kind") == "Gateway" and ident(d) == ("gateway", "shared")), None) check(gw is not None, f"(9a) {GATEWAY_FILE.relative_to(ROOT)} holds no Gateway gateway/shared") want_target = f"{tunnel}.cfargotunnel.com" got_target = annotations_of(gw or {}).get(TARGET_KEY) check(got_target == want_target, f"(9a) Gateway gateway/shared `{TARGET_KEY}` is {got_target!r}; it must be EXACTLY " f"{want_target!r} (the tunnel in platform/cloudflared/config.yaml). Any other value publishes " f"every public name at the wrong place — a dead cfargotunnel.com name (Cloudflare 1016), or " f"an origin address of ours.") check("--cloudflare-proxied" in [str(a) for a in (extra if isinstance(extra, list) else [])] or (isinstance(extra, dict) and str(extra.get("cloudflare-proxied", "")).lower() == "true"), "(9a) external-dns HelmRelease extraArgs must include `--cloudflare-proxied`: a DNS-only " "CNAME to .cfargotunnel.com resolves to nothing routable, so every public name is dead") for rel, d in dns_services: if is_disowned(d): continue if HOSTNAME_KEY not in annotations_of(d): continue v = annotations_of(d).get(PROXIED_KEY) check(str(v).lower() == "false", f"(9b) {rel} Service {'/'.join(ident(d))} publishes via the `service` source but carries " f"`{PROXIED_KEY}: {v!r}`, not \"false\". With --cloudflare-proxied global it would become " f"an orange-clouded A record, which cannot carry L4 traffic and hides nothing (the LB VIP " f"is RFC1918). Every `service`-source publisher must opt out explicitly.") for rel, d in routes: v = annotations_of(d).get(PROXIED_KEY) check(v is None or str(v).lower() == "true", f"(9b) {rel} HTTPRoute {'/'.join(ident(d))} sets `{PROXIED_KEY}: {v!r}`. A gateway-sourced " f"record is a CNAME to the tunnel; DNS-only, it is dead. Disown the route instead " f"(`{CONTROLLER_KEY}: none`) if the name should not be public.") refused = {str(r.get("hostname")) for r in cf.get("ingress") or [] if isinstance(r, dict) and r.get("hostname") and r.get("service") == "http_status:404"} check(len(refused) > 0, "(9c) the tunnel config 404s no hostname — parse contract broken (the " "operator's LAN/VPN-only list is known to be non-empty)") published_l4: set[str] = set() for rel, d in routes: hosts = {str(h) for h in (d.get("spec") or {}).get("hostnames") or []} leaked = sorted(hosts & refused) if is_disowned(d): unrefused = sorted(hosts - refused) check(not unrefused, f"(9c) {rel} HTTPRoute {'/'.join(ident(d))} is disowned (no public record) but the " f"tunnel does not 404 {unrefused}: add them to platform/cloudflared/config.yaml above " f"the wildcard, or the two halves of \"LAN/VPN-only\" have drifted apart.") continue check(not leaked, f"(9c) {rel} HTTPRoute {'/'.join(ident(d))} PUBLISHES {leaked}, which the tunnel maps to " f"http_status:404 (LAN/VPN-only). The name would be a public proxied record that serves " f"only 404s — disown the route (`{CONTROLLER_KEY}: none`) or adjudicate the name.") for rel, d in dns_services: if is_disowned(d): continue hosts = {h.strip() for h in str(annotations_of(d).get(HOSTNAME_KEY) or "").split(",") if h.strip()} for h in sorted(hosts & refused): published_l4.add(h) check(h in L4_DNS_ONLY_NAMES, f"(9c) {rel} Service {'/'.join(ident(d))} publishes {h}, which the tunnel 404s, and it " f"is not a declared L4 DNS-only name {sorted(L4_DNS_ONLY_NAMES)}") check(L4_DNS_ONLY_NAMES <= refused, f"(9c) L4 DNS-only names {sorted(L4_DNS_ONLY_NAMES - refused)} are not 404'd by the tunnel; a " f"stray CNAME to the tunnel for them would hit the Gateway's catch-all instead of a 404") check(published_l4 == L4_DNS_ONLY_NAMES, f"(9c) L4_DNS_ONLY_NAMES is shrink-only: {sorted(L4_DNS_ONLY_NAMES - published_l4)} no longer " f"published by any `service`-source publisher — delete them from the list") check(OPERATOR_LAN_ONLY_NAMES <= refused, f"(9d) operator LAN/VPN-only names {sorted(OPERATOR_LAN_ONLY_NAMES - refused)} are missing " f"from the tunnel's http_status:404 rules (platform/cloudflared/config.yaml)") for name in sorted(OPERATOR_LAN_ONLY_NAMES): naming = [(rel, d) for rel, d in routes if name in {str(h) for h in (d.get("spec") or {}).get("hostnames") or []}] check(len(naming) > 0, f"(9d) no HTTPRoute names {name} — stale OPERATOR_LAN_ONLY_NAMES entry") for rel, d in naming: check(is_disowned(d), f"(9d) {rel} HTTPRoute {'/'.join(ident(d))} names LAN/VPN-only {name} but is not " f"disowned, so external-dns publishes it publicly (operator decision 2026-11-08)") # ============================================================================================= if FAILURES: for f in FAILURES: print(f"check FAIL: {f}", file=sys.stderr) print(f"check FAILED: {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) raise SystemExit(1) print(f"check OK: {CHECKS} assertions passed " f"({len(sds)} ServiceDefinitions; {len(internal_with_routes)} internal + " f"{len(public_with_routes)} public services with routes; {len(routes)} HTTPRoutes and " f"{len(dns_services)} external-dns-annotated Services inventoried; " f"{len(KNOWN_NON_CATALOG_PUBLISHERS)} declared non-catalog publishers)") PY