# ============================================================================= # 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. — 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"'