Gateway target -> fc81938b-...cfargotunnel.com, --cloudflare-proxied globally, L4 service publishers (unifi, crafty-mc) pinned DNS-only, Plex web UI and Headlamp disowned (LAN/VPN-only). L0 internal-services-publish-no-public-dns gains assertion (9) tying the Gateway target to the tunnel UUID, requiring cloudflare-proxied=false on every service-source publisher, and keeping the tunnel's 404 list and the unpublished set consistent. L2 dns-exposure fixture opts out of proxying. Co-Authored-By: Claude Opus 5.5 <[email protected]>
654 lines
38 KiB
Bash
Executable File
654 lines
38 KiB
Bash
Executable File
#!/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 `<name>.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 `<tunnel:>.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 `<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"
|
|
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 "<relpath>:<namespace>/<name>".
|
|
# =============================================================================================
|
|
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/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 "<service>-<port>" in namespace "<service>" (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}.<zone> 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 <uuid>.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
|