Six L2 behavioural tests embedded `home.keithjackson.dev`. They are test INPUTS,
not reconciled manifests, so postBuild substitution never touched them — correct
while dev held the apex, wrong the moment prod came up on
prod.home.keithjackson.dev, and blocking §9's "suite passes green against prod".
Each test now asks the cluster what it is, via a test-level chainsaw binding over
flux-system/cluster-vars — the SAME ConfigMap Flux renders the platform from, so
a test cannot disagree with the cluster it runs on:
bindings:
- name: baseDomain
value: (x_k8s_get($client,'v1','ConfigMap','flux-system','cluster-vars').data.BASE_DOMAIN)
Chosen over a flag or env var because there is nothing to forget at run time, and
test-level scope covers every step (step outputs/bindings are step-scoped).
THE APEX WAS NOT THE ONLY LEAK. dns-exposure and dns-internal-resolution also
hardcoded DEV'S Cilium LB range 192.168.1.53-.60; prod's is 192.168.2.20-.30, so
both were wrong for prod regardless of hostname. Replaced with assertions on the
OBSERVED value — the DNS answer must equal that cluster's GATEWAY_VIP, and the
public record must equal the LB IP the Service was actually assigned — which is
strictly STRONGER than the old range check as well as environment-independent.
sso-forward-auth held the literal only in prose (it talks over Service DNS).
PROVEN, NOT ASSERTED — and the positive control is the important half:
* expressions extracted verbatim from the tracked files resolve to the correct,
DIFFERENT values per cluster (dev home.keithjackson.dev / 192.168.1.53,
prod prod.home.keithjackson.dev / 192.168.2.20);
* the same assert with the HARDCODED apex passes on dev and FAILS on prod
("Invalid value: *.prod.home... Expected: *.home..."), so the harness is
provably not blind — that is exactly the drift removed;
* chainsaw lint VALID on all six; run.sh --lint clean over the whole tree.
END-TO-END L2 RUN AGAINST LIVE DEV: `dns-exposure` PASSED (42.6s) — the binding
produced conformance-dns.home.keithjackson.dev, external-dns published it, and the
probe Job asserted the record against the assigned LB IP. This is the first time
one of these tests has been executed since parameterization, and it exercises the
full path rather than just the binding.
FINDING FROM THAT RUN, worth recording: the test's teardown is ASYNCHRONOUS. It
reports PASS as soon as chainsaw deletes the namespace, but the Cloudflare record
is reaped later by external-dns's own --interval=1m sync. A zone query immediately
after PASS still showed conformance-dns.home.keithjackson.dev plus its owner=default
TXT registry entry. Harmless here, but it means a green dns-exposure does NOT prove
cleanup happened, and if external-dns were suspended the record would persist
silently. Verified separately that it clears.
conformance/incluster/rbac.yaml: its cluster-wide `get configmaps` is now
LOAD-BEARING for these bindings — narrowing it to the test namespace breaks five
tests. Commented so it is not "tidied" later.
l3-fault-injection/dr-restore: its KNOWN-GAPS block said off-host is unproven
because the MinIO shares a Proxmox host with dev's VMs. Still true as run, so the
text now says both — that the R2 move (32d6766) is what closes it, that this test's
pass is against MinIO, and that the ledger row proving R-STATE-03 PREDATES the move,
so off-host DR cannot be claimed until the drill is re-run against R2.
NOT VERIFIED, plainly: no full L2 suite run against prod, so "green against prod"
remains unproven — what is proven is that the tests now derive the CORRECT
expectation for whichever cluster they are pointed at. Whether prod SATISFIES them
(split-horizon, LE wildcard, Grafana's issuer) is untested.
VERIFIED: full CI gate PASS. flux-postbuild-substitution 260 assertions,
render-golden 10/10.
Refs R-TEST-01, R-TEST-02, R-TEST-03, R-TEST-04, R-ENV-02, R-DNS-01/02/03.
Co-Authored-By: Claude Opus 5 <[email protected]>
144 lines
7.2 KiB
YAML
144 lines
7.2 KiB
YAML
# =============================================================================
|
|
# L2 BEHAVIORAL — DNS auto-record / exposure (R-DNS-02) [RUNNABLE]
|
|
# Catalog: docs/14 "DNS/exposure (R-DNS-*)"
|
|
# Spec: docs/09 (ExternalDNS auto-manages records; split-horizon foundation)
|
|
#
|
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): the hostname is built from the ${BASE_DOMAIN} of the CLUSTER
|
|
# UNDER TEST (read from flux-system/cluster-vars via a test-level chainsaw binding), and the
|
|
# expected answer is the LB IP that THIS Service was actually assigned (read back from its own
|
|
# status) — so neither the name nor the address is authored per environment, and no run-time flag
|
|
# can be forgotten. Requesting a record under the cluster's own suffix is also what keeps the two
|
|
# environments from ever describing the same public name (the 2026-09-04 incident precondition).
|
|
#
|
|
# GIVEN a LoadBalancer Service annotated with an external-dns hostname,
|
|
# WHEN ExternalDNS reconciles (source=service, provider=cloudflare, policy=sync),
|
|
# THEN a PUBLIC DNS record for that hostname is auto-created, pointing at the
|
|
# Service's Cilium-assigned LB IP (R-DNS-02) — asserted as an EXACT match against that
|
|
# Service's own `status.loadBalancer.ingress[0].ip`, i.e. clients reach the cluster endpoint.
|
|
#
|
|
# CLEANUP: chainsaw deletes the per-test namespace (Service included) at teardown;
|
|
# ExternalDNS policy=sync then REMOVES the record from Cloudflare on its next pass.
|
|
# This test creates a real record in the live keithjackson.dev zone (operator-approved).
|
|
#
|
|
# SCOPE: asserts R-DNS-02 (auto-managed record appears) concretely. R-DNS-01/03
|
|
# (split-horizon internal-vs-external resolution) need the internal resolver and
|
|
# are covered separately.
|
|
# =============================================================================
|
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
|
kind: Test
|
|
metadata:
|
|
name: dns-exposure
|
|
labels:
|
|
conformance.home.k3s/R-DNS-02: "true"
|
|
annotations:
|
|
conformance.home.k3s/requirements: "R-DNS-02"
|
|
conformance.home.k3s/layer: "L2"
|
|
conformance.home.k3s/safety-critical: "false"
|
|
conformance.home.k3s/executable: "true"
|
|
conformance.home.k3s/intent: >-
|
|
A LoadBalancer Service annotated with an external-dns hostname gets a public
|
|
DNS record auto-created by ExternalDNS (Cloudflare), resolving to its LB IP.
|
|
spec:
|
|
# ExternalDNS reconcile + Cloudflare propagation can take a couple of minutes;
|
|
# widen the assert/exec windows for this test only.
|
|
timeouts:
|
|
apply: 90s
|
|
assert: 5m
|
|
exec: 6m
|
|
# -- R-ENV-02: the environment's hostname suffix is READ FROM THE CLUSTER UNDER TEST.
|
|
# flux-system/cluster-vars is the ConfigMap Flux substitutes ${BASE_DOMAIN} from, so this
|
|
# binding is the same source of truth the platform is rendered from — dev asks dev, prod
|
|
# asks prod, and there is no per-environment fork and no flag to forget.
|
|
bindings:
|
|
- name: baseDomain
|
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
|
|
steps:
|
|
- name: given-a-service-requesting-a-dns-name
|
|
try:
|
|
- apply:
|
|
resource:
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: dns-probe-svc
|
|
annotations:
|
|
# conformance-dns.<this cluster's BASE_DOMAIN> — never a literal apex.
|
|
external-dns.alpha.kubernetes.io/hostname: (join('.', ['conformance-dns', $baseDomain]))
|
|
spec:
|
|
type: LoadBalancer
|
|
# No real backend needed — Cilium LB-IPAM assigns a VIP regardless, and
|
|
# ExternalDNS creates the record from the Service's LB IP + annotation.
|
|
selector:
|
|
app: dns-probe-no-such-backend
|
|
ports:
|
|
- name: http
|
|
port: 80
|
|
targetPort: 80
|
|
# Wait for Cilium LB-IPAM to actually assign the VIP: it is the value ExternalDNS will
|
|
# publish and the value the probe below asserts EXACTLY. `|| ` + empty-array literal
|
|
# keeps the expression nil-safe while the address is still absent, so the assert POLLS
|
|
# (fails and retries) instead of erroring out on the first evaluation.
|
|
- assert:
|
|
resource:
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: dns-probe-svc
|
|
status:
|
|
(length(loadBalancer.ingress || `[]`)): 1
|
|
- name: then-externaldns-creates-the-public-record
|
|
# Step-scoped binding: read back the LB IP THIS Service was assigned (the previous step's
|
|
# assert guarantees it exists by now — step bindings are evaluated when the step runs).
|
|
# Expecting that exact address is both stricter than the old hardcoded 192.168.1.53-.60
|
|
# range and correct in every environment: dev's pool is 192.168.1.53-.60, prod's is
|
|
# 192.168.2.20-.30, and neither is written down here.
|
|
bindings:
|
|
- name: lbIp
|
|
value: (x_k8s_get($client, 'v1', 'Service', $namespace, 'dns-probe-svc').status.loadBalancer.ingress[0].ip)
|
|
try:
|
|
- apply:
|
|
resource:
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: dns-verify
|
|
spec:
|
|
backoffLimit: 2
|
|
activeDeadlineSeconds: 330
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
containers:
|
|
- name: probe
|
|
image: busybox:1.36.1
|
|
# Both values come from the cluster under test: the suffix from
|
|
# flux-system/cluster-vars, the address from this Service's own status.
|
|
env:
|
|
- name: BASE_DOMAIN
|
|
value: ($baseDomain)
|
|
- name: LB_IP
|
|
value: ($lbIp)
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
set -u
|
|
host="conformance-dns.${BASE_DOMAIN}"
|
|
# Poll PUBLIC DNS (1.1.1.1) until the auto-created A record appears and
|
|
# points at THIS Service's assigned LB IP. Proves ExternalDNS created it.
|
|
for i in $(seq 1 28); do
|
|
ip="$(nslookup "$host" 1.1.1.1 2>/dev/null | awk '/^Address: /{print $2}' | grep -Fx "${LB_IP}" | head -1)"
|
|
if [ -n "$ip" ]; then echo "RECORD_FOUND $host -> $ip"; exit 0; fi
|
|
echo "poll $i: record not yet published as ${LB_IP}"; sleep 10
|
|
done
|
|
echo "FAIL: ExternalDNS did not publish ${host} -> ${LB_IP} within timeout"; exit 1
|
|
- assert:
|
|
resource:
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata: { name: dns-verify }
|
|
status: { succeeded: 1 }
|
|
finally:
|
|
- script:
|
|
content: 'echo "teardown: chainsaw deletes the namespace/Service; ExternalDNS policy=sync removes the Cloudflare record"'
|