#!/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). # # 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. # - Anything about the five public services' TARGET. They still answer with the Gateway VIP; the # routable-target decision is open and recorded in platform/externaldns/helmrelease.yaml. # - 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. CONTROLLER_KEY = "external-dns.alpha.kubernetes.io/controller" CONTROLLER_CLAIMED = "dns-controller" HOSTNAME_KEY = "external-dns.alpha.kubernetes.io/hostname" 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. # # EVERY ENTRY HERE IS A NAME THAT STILL ANSWERS WITH AN RFC1918 ADDRESS IN A PUBLIC ZONE. That is # the honest state after this change, and it is deliberate: converting them is an operator # decision per name (is grafana meant to be publicly resolvable at all?), 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 Gateway VIP. 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 Gateway VIP, 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 Gateway VIP. An admin dashboard; almost " "certainly should not be publicly resolvable, but it is not a catalog service and " "withdrawing it is an operator call with the same LAN-resolution prerequisite as the six " "internal services (Pi-hole must answer first).", "platform/headlamp/httproute.yaml:headlamp/headlamp": "publishes headlamp.${BASE_DOMAIN} -> the Gateway VIP. A Kubernetes web UI with native " "Keycloak OIDC; same adjudication as grafana.", "platform/homepage/httproute.yaml:homepage/homepage": "publishes the APEX ${BASE_DOMAIN} itself -> the Gateway VIP, 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 Gateway VIP (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.", "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) 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( a.startswith("external-dns.alpha.kubernetes.io/") 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.", ) # ============================================================================================= 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