docs/09 R-DNS-03 and schemas/renderer-contract.md both said an `internal` service renders no public DNS record. Nothing enforced either sentence. ExternalDNS's `gateway-httproute` source admitted EVERY HTTPRoute on the shared Gateway, so six `exposure: internal` services — jackett, pihole, qbittorrent, radarr, semaphore, sonarr — published `<name>.home.keithjackson.dev` into the public authoritative Cloudflare zone answering with the Gateway VIP 192.168.2.20. That published the internal subnet, the Gateway VIP and a full service inventory to anyone who could guess a label, and made each of those names a DNS-rebind primitive — which is why the SOHO router strips RFC1918 answers and LAN clients need per-client dnsmasq overrides to reach their own services. THE MECHANISM, established against the DEPLOYED version rather than from memory. The live prod Deployment runs registry.k8s.io/external-dns/external-dns:v0.21.0. In v0.21.0, source/annotations/processors.go IsControllerMismatch() skips a source object whose `external-dns.alpha.kubernetes.io/controller` annotation is present and != its own `dns-controller`, and source/gateway.go applies that check inside Endpoints() BEFORE any hostname is resolved; source/service.go applies the identical check, covering the second configured source. catalog/render.py now emits that annotation on every HTTPRoute it renders for an `internal` service, derived from `exposure` alone — no per-service list — so a service that omits `exposure` inherits the internal default and is unpublishable too. Verified LIVE on prod on a throwaway hostname with no existing record at risk: the annotated route published nothing, and adding the annotation to a route that already had a record produced action=DELETE for both its A record and its `a-` TXT registry entry one ~60s reconcile later, moving nothing else in the zone. WHY PER-RESOURCE AND NOT A CLUSTER-WIDE OPT-IN FILTER. `labelFilter` is the stronger default posture and v0.21.0 supports it for both configured sources, but nine publishing objects here are hand-authored platform/apps manifests with no `exposure` field, seven of which hold live records (auth, crafty, crafty-mc, grafana, headlamp, the apex, unifi). An opt-in filter deletes all seven at once, and whether each should be publicly resolvable is an operator decision per name. Per-resource exclusion has exactly the blast radius of the defect; the seven are enumerated in a shrink-only register so the unadjudicated set cannot grow silently, and the filter stays available as hardening afterwards. `policy: sync`, `txtOwnerId` and `domainFilters` are UNTOUCHED. Per-resource exclusion only ever removes an object from this instance's view, so nothing here makes prod capable of deleting a record it does not own. WHAT THIS DELETES, and it is not reversible by a revert alone: 12 records — the A record and the `a-`-prefixed TXT registry entry for each of jackett, pihole, qbittorrent, radarr, semaphore and sonarr. The zone has no `*.home.keithjackson.dev` wildcard and `*.keithjackson.dev` does not match two labels, so each becomes a public NXDOMAIN with no fallback. The complete pre-state is captured at the authority and committed as a recovery baseline (.standup-dns/dns-20260914-pre-externaldns-scope.txt), with the restore procedure for both halves of a record. MERGE ORDER IS NOT OPTIONAL: feat/pihole-lan-dns-authority must land and be verified first. Removing these records while the LAN still depends on them, with the router stripping RFC1918 and no internal authority yet, breaks name resolution outright. The five `exposure: public` services are LEFT UNCHANGED and still answer with the Gateway VIP, so they remain publicly unreachable. That is two open operator decisions, both written up in platform/externaldns/helmrelease.yaml: what address they should answer with (136.51.115.252 is the legacy non-k3s stack serving a placeholder cert, not established as correct for this cluster), and how to separate them from the non-public names that share the Gateway — because in v0.21.0 the target annotation is read only from the GATEWAY and a ListenerSet, never from an HTTPRoute, so a per-service override is not expressible while they all attach to `shared`. No knob is added for it yet: one that cannot take effect invites someone to set it and expect one. Guard: conformance/tests/l0-static/internal-services-publish-no-public-dns, a whole-repo-scoped L0 static check (no ci/l0.sh stage — the whole-repo-invariants-wired meta-test forbids one). It asserts both directions (present on internal, absent on public), the `service`-source half, that the renderer DERIVES the annotation from `exposure` and cannot emit external-dns's own claiming value, proves the shipped renderer end-to-end on internal/public/omitted-field fixtures, and ratchets the non-catalog register. Proven to fail on the pre-fix tree (9 of 53 assertions, naming all six services) and pass after (56/56). Schema 1.6.0 -> 1.7.0: no field changes, a mapping change to `exposure`. Co-Authored-By: Claude Opus 5 <[email protected]>
459 lines
25 KiB
Bash
Executable File
459 lines
25 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).
|
|
#
|
|
# 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 "<relpath>:<namespace>/<name>".
|
|
# =============================================================================================
|
|
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 "<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(
|
|
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}.<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.",
|
|
)
|
|
|
|
# =============================================================================================
|
|
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
|