Files
home-network-k3s-impl/conformance/tests/l2-behavioral/gateway-ingress-reachable/chainsaw-test.yaml
T
jakman85andClaude Opus 5 6a8953e288 feat(prod): observability for the two failure modes that went unseen, plus OIDC self-healing
Minimal prod delivery, authored directly on main (= prod's live revision d1a6628)
rather than by merging work/prod-standup-campaign. That branch carries the DNS apex
inversion, which would have set prod's BASE_DOMAIN to home.keithjackson.dev; prod
runs external-dns with policy: sync and txtOwnerId: prod, so it would have begun
rewriting apex records dev owns while the non-k3s prod still serves 443 there.
Operator sequencing is "dev migrates first". Nothing under clusters/ is touched
here at all, which is what makes this safe to land now.

Also deliberately EXCLUDED, because prod's live cluster-vars has no CNPG_BACKUP_PATH
key and StrictPostBuildSubstitutions would fail the whole tier: the R2 rework of
platform/velero/ and the CNPG destinationPath changes. Those land as
feat/r2-backup-prod together with the endpoint and bucket knobs.

WHAT THIS FIXES

1. Nothing was watching CNPG. All three prod databases have run since bootstrap with
   ContinuousArchiving=False and firstRecoverabilityPoint=null, and no alert existed
   anywhere. Because archive_mode=on and the archive command fails, Postgres refuses
   to recycle WAL: keycloak-db holds every segment ever written (257 MiB on a 5 GiB
   volume, ~6.8 MiB/h) — roughly 29 days to a disk-full PANIC on the cluster's SSO
   database. Found by hand, 38h late. CNPGWALArchiveBacklog would have fired within
   the hour, and evaluates to 3 firing series on prod today.
   Rules key on cnpg_collector_* rather than cnpg_pg_stat_archiver_*, because
   nextcloud-db's exporter cannot log in to its own database (permission denied for
   database "nextcloud", both clusters) so the pg_stat_archiver family does not exist
   for it — the obvious metric would have been blind to one database in three.

2. No PVC outside namespace=monitoring had any fill alert. New rules are partitioned
   by namespace so they cannot double-fire with MonitoringVolumeFillingUp, pinned to
   job="kubelet" (the apiserver copy of the same series reports namespace=default),
   and guarded by inodes > 0 — block-mode PVCs such as unifi-rootdisk report
   available=0 forever and would otherwise have fired critical permanently.

3. VPNTunnelDown could never clear on a cluster with no gateway node; it has been
   firing critical on prod since 2026-09-05. Now guarded so prod self-arms the moment
   a node carries the egress-gateway label, with dev's behaviour unchanged today.

4. The Keycloak realm import fails OPEN: an unresolved placeholder is stored
   LITERALLY and the import still reports success, then sits at Done=True and never
   re-runs. On 2026-09-06 all 12 prod OIDC clients held the literal
   $(env:..._CLIENT_SECRET) string and every SSO login failed, while every signal was
   green. The CronJob is the level-triggered correction; two alerts make its silence
   observable. Repaired live already — this stops it recurring.

Also: the R-NET-04 conformance scan descended into nested agent worktrees under
.claude/, counting 4 egress policies where 1 is authored.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 00:59:22 -05:00

333 lines
17 KiB
YAML

# =============================================================================
# L2 BEHAVIORAL — Exposed app reachable through the Gateway under default-deny
# (R-NET-01, R-NET-02, R-NET-03) [RUNNABLE]
# Catalog: docs/14 (default-deny + explicit allow family)
# Spec: docs/03 "Default-deny baseline" / "Explicit allow rules";
# docs/09 "Interaction with default-deny" (ingress opens NORTH-SOUTH only).
#
# THE GAP THIS CLOSES: an onboarded app's rendered default-deny has `ingress: []`
# (deny ALL), so the Cilium Gateway (Envoy, reserved identity `ingress`) cannot
# reach the backend — an Accepted HTTPRoute is still unreachable. The renderer now
# also emits an ADDITIVE gateway-ingress CiliumNetworkPolicy allowing ONLY the
# `ingress` identity to ONLY the declared port. This test proves that end-to-end
# through the REAL shared Gateway:
#
# GIVEN an exposed app namespace with default-deny + the gateway-ingress allow
# (fromEntities: [ingress] -> declared port 8080 only), attached to the
# shared Gateway with a declared route (8080) and an undeclared route (9091),
# WHEN a client dials the Gateway VIP for the DECLARED host -> THEN it reaches the
# backend (proves the additive allow opens the north-south path, R-NET-02/03);
# WHEN a client dials the Gateway VIP for the UNDECLARED port -> THEN the backend is
# NOT reached (default-deny still drops it, R-NET-02);
# WHEN an in-namespace peer (NOT the `ingress` identity) dials the declared port
# east-west -> THEN it is DENIED (the allow is least-privilege / gateway-only;
# default-deny east-west is intact, R-NET-01).
#
# This asserts observable BEHAVIOR through the live Gateway (green Job = the intended
# verdict), not object existence (R-TEST-01).
#
# TWO FAILURE MODES ARE DISTINGUISHED (per the WS-NET pod->Gateway-VIP reachability
# finding, DECISIONS-LOG): the gateway probes classify each attempt as
# * REACHED (backend marker in the body) -> the intended verdict for the declared host;
# * DENIED (a real HTTP status, e.g. 503, but NO backend marker) -> default-deny drop
# (the intended verdict for the undeclared port);
# * NO-GATEWAY (curl code 000 / connect timeout) -> the probe's node cannot reach the
# Gateway VIP AT ALL. That is a SEPARATE platform fault (a node-local Cilium L7LB/
# envoy desync makes pods on an affected node unable to reach the Gateway VIP or
# ClusterIP), NOT default-deny. The probe SELF-SKIPS that case (exits 0 with a SKIP
# note) so this test never false-fails on the unrelated reachability bug. The
# hermetic east-west denial (below) needs no Gateway and always asserts.
#
# SELF-SKIPPING: the whole L2 layer is gated by CLUSTER_AVAILABLE in conformance/
# run.sh (skipped with no cluster). Within the test, `given` asserts the shared
# Gateway is Programmed (a missing Gateway fails fast/diagnostically), and the gateway
# probes self-skip the node-local reachability fault as above. The backend uses NO
# readinessProbe: the default-deny baseline blocks the kubelet node->pod probe, so a
# probe-bearing pod would never go Ready (DECISIONS-LOG K4); `phase: Running` is the
# correct readiness signal. Cleanup deletes both fixture namespaces in `finally`.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: gateway-ingress-reachable
labels:
conformance.home.k3s/R-NET-01: "true"
conformance.home.k3s/R-NET-02: "true"
conformance.home.k3s/R-NET-03: "true"
annotations:
conformance.home.k3s/requirements: "R-NET-01, R-NET-02, R-NET-03"
conformance.home.k3s/layer: "L2"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
An exposed app is reachable through the shared Gateway under default-deny via an
additive gateway-ingress allow (ingress identity -> declared port only); an
undeclared port is not reachable through the Gateway and east-west stays denied.
spec:
# A generous timeout: pulling images + programming two HTTPRoutes on the Gateway.
timeouts:
apply: 60s
assert: 180s
# -- R-ENV-02: the fixture's route hostnames MUST fall under the Gateway's wildcard listener
# (*.${BASE_DOMAIN}) or they never attach — so the suffix is READ FROM THE CLUSTER UNDER TEST
# (flux-system/cluster-vars, the ConfigMap Flux substitutes ${BASE_DOMAIN} from) rather than
# authored here. Test-level => in scope for the fixture apply AND every probe step below
# (step `outputs`, by contrast, are step-scoped — see the gwvip re-capture comments).
# fixtures/app-and-probe.yaml consumes it via chainsaw resource templating.
bindings:
- name: baseDomain
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
steps:
# -- GIVEN: the exposed-app fixture (default-deny + gateway-ingress allow + backend +
# Service + declared/undeclared HTTPRoutes) and a probe namespace. Assert the shared
# Gateway is Programmed (precondition) and the backend pod is Running (listeners up).
# Each gateway-probe step below re-captures the Gateway VIP into a binding (chainsaw
# output bindings are step-scoped) so probes dial the real ingress address that
# split-horizon DNS resolves service hostnames to — env-agnostic, no hardcoded IP.
- name: given-exposed-app-under-default-deny
try:
- apply:
file: fixtures/app-and-probe.yaml
- assert:
resource:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared
namespace: gateway
# JMESPath binding (parenthesized key) rather than a list literal: chainsaw does
# STRICT length-equal list matching, so asserting one element against the full
# conditions[] would fail (DECISIONS-LOG K4). This pinpoints Programmed=True.
status:
(conditions[?type == 'Programmed'] | [0].status): "True"
- assert:
resource:
apiVersion: v1
kind: Pod
metadata:
labels:
app.kubernetes.io/name: gw-backend
namespace: conformance-gw-ingress
status:
# scalar match — Running means both httpd listeners are up (no readinessProbe
# under default-deny; DECISIONS-LOG K4).
phase: Running
# -- THEN(reachable): a client in the unrestricted probe namespace dials the Gateway VIP
# for the DECLARED host. --connect-to sets SNI/Host to the hostname but dials the VIP
# (the real ingress address; -k because we test REACHABILITY, not TLS — trusted-tls
# covers the chain). The backend's marker in the body proves the Gateway (identity
# `ingress`) reached the backend THROUGH default-deny (green Job = REACHED). If the
# probe's node cannot reach the VIP at all (code 000), self-skip (green + SKIP note):
# that is the separate pod->Gateway-VIP platform fault, not default-deny.
- name: then-reachable-through-gateway
try:
# chainsaw output bindings are step-scoped, so re-capture the VIP in this step.
- command:
entrypoint: kubectl
args: [-n, gateway, get, gateway, shared, -o, "jsonpath={.status.addresses[0].value}"]
outputs:
- name: gwvip
value: ($stdout)
- apply:
resource:
apiVersion: batch/v1
kind: Job
metadata:
name: probe-reachable
namespace: conformance-gw-probe
spec:
backoffLimit: 6
activeDeadlineSeconds: 180
template:
spec:
restartPolicy: Never
containers:
- name: probe
image: curlimages/curl:8.11.1
env:
- name: GWVIP
value: ($gwvip)
- name: BASE_DOMAIN
value: ($baseDomain)
command:
- sh
- -c
- |
set -u
host="gw-declared-conformance.${BASE_DOMAIN}"
last=000
i=0
while [ "$i" -lt 30 ]; do
code="$(curl -k -sS --max-time 8 -o /tmp/body \
-w '%{http_code}' \
--connect-to "${host}:443:${GWVIP}:443" \
"https://${host}/" 2>/dev/null || echo 000)"
last="$code"
if grep -q GW_BACKEND_REACHED /tmp/body 2>/dev/null; then
echo "REACHED: Gateway reached the backend through default-deny (code ${code})"
exit 0
fi
i=$((i+1)); sleep 5
done
if [ "$last" = "000" ]; then
echo "SKIP: this node cannot reach the Gateway VIP (code 000) —" 1>&2
echo " separate pod->Gateway-VIP platform fault, NOT default-deny" 1>&2
exit 0
fi
echo "FAIL: Gateway returned ${last} but the backend marker was absent — default-deny drop of the declared port?" 1>&2
exit 1
- assert:
resource:
apiVersion: batch/v1
kind: Job
metadata: { name: probe-reachable, namespace: conformance-gw-probe }
status: { succeeded: 1 }
# -- THEN(undeclared port denied through the Gateway): same Gateway VIP, same backend, the
# UNDECLARED host -> port 9091. Routing is identical to the declared route (proven to
# reach the backend), so the ONLY difference is the missing allow on 9091: the Gateway
# Envoy's upstream connect is DROPPED by default-deny. The backend marker MUST be
# absent. A leak (the 9091 marker appears) fails the Job. If the node cannot reach the
# VIP at all (code 000), self-skip (same platform fault as above).
- name: then-undeclared-port-denied-through-gateway
try:
# chainsaw output bindings are step-scoped, so re-capture the VIP in this step.
- command:
entrypoint: kubectl
args: [-n, gateway, get, gateway, shared, -o, "jsonpath={.status.addresses[0].value}"]
outputs:
- name: gwvip
value: ($stdout)
- apply:
resource:
apiVersion: batch/v1
kind: Job
metadata:
name: probe-undeclared
namespace: conformance-gw-probe
spec:
backoffLimit: 4
activeDeadlineSeconds: 150
template:
spec:
restartPolicy: Never
containers:
- name: probe
image: curlimages/curl:8.11.1
env:
- name: GWVIP
value: ($gwvip)
- name: BASE_DOMAIN
value: ($baseDomain)
command:
- sh
- -c
- |
set -u
host="gw-undeclared-conformance.${BASE_DOMAIN}"
reached_gw=no
i=0
while [ "$i" -lt 8 ]; do
code="$(curl -k -sS --max-time 8 -o /tmp/body \
-w '%{http_code}' \
--connect-to "${host}:443:${GWVIP}:443" \
"https://${host}/" 2>/dev/null || echo 000)"
if grep -q GW_BACKEND_UNDECLARED /tmp/body 2>/dev/null; then
echo "LEAK: undeclared port 9091 reachable through the Gateway (code ${code})" 1>&2
exit 1
fi
[ "$code" != "000" ] && reached_gw=yes
i=$((i+1)); sleep 5
done
if [ "$reached_gw" = "yes" ]; then
echo "DENIED: undeclared port not reachable through the Gateway (backend marker never returned)"
exit 0
fi
echo "SKIP: this node cannot reach the Gateway VIP (code 000) —" 1>&2
echo " separate pod->Gateway-VIP platform fault, NOT default-deny" 1>&2
exit 0
- assert:
resource:
apiVersion: batch/v1
kind: Job
metadata: { name: probe-undeclared, namespace: conformance-gw-probe }
status: { succeeded: 1 }
# -- THEN(east-west still denied): HERMETIC (no Gateway needed). An in-namespace peer
# (NOT the `ingress` identity), covered by the namespace-wide default-deny, dials the
# DECLARED port east-west. The gateway-ingress allow is least-privilege (only
# fromEntities: [ingress]), so this connect MUST be dropped — default-deny east-west
# is intact (R-NET-01). The probe EXITS 0 only when the connection is BLOCKED (green
# Job = denied). This step always runs the real assertion (independent of the pod->VIP
# reachability fault above).
- name: then-east-west-still-denied
try:
- apply:
resource:
apiVersion: batch/v1
kind: Job
metadata:
name: probe-eastwest
namespace: conformance-gw-ingress
spec:
backoffLimit: 4
activeDeadlineSeconds: 120
template:
metadata:
labels: { app: eastwest-peer }
spec:
restartPolicy: Never
containers:
- name: probe
image: busybox:1.36.1
command:
- sh
- -c
- |
set -u
# DNS resolves (baseline allows it); the TCP connect must drop.
if nc -w 5 gw-backend 8080 </dev/null 2>/dev/null; then
echo "LEAK: east-west reached the declared port under default-deny" 1>&2
exit 1
fi
echo "DENIED: east-west blocked; gateway allow is least-privilege"
exit 0
- assert:
resource:
apiVersion: batch/v1
kind: Job
metadata: { name: probe-eastwest, namespace: conformance-gw-ingress }
status: { succeeded: 1 }
# -- Cleanup: delete both fixture namespaces (fixed names, outside chainsaw's managed
# namespace). finally accepts delete (DECISIONS-LOG: step-level finally = script/
# command/delete/wait only).
finally:
- delete:
ref:
apiVersion: v1
kind: Namespace
name: conformance-gw-ingress
- delete:
ref:
apiVersion: v1
kind: Namespace
name: conformance-gw-probe
# -----------------------------------------------------------------------------------
# PROOF-OF-PASS SENTINEL (R-SEC-03). Every step above is an assert/Job-status gate, so
# chainsaw already fails the test before reaching this step if any of them fails — but a
# green Chainsaw JUnit carries NO stdout, and the L2/L3 tests in this suite can self-skip
# GREEN, so lib/record_run.py refuses to treat a bare `passed` as evidence and records
# `unknown-no-sentinel-declared` instead. Without a declared token this test could pass
# forever and NEVER become a recorded proof of R-NET-01/02/03 — north-south reachability under default-deny, part of the isolation proof
# — which is precisely what docs/07 asks for ("These proofs are part of the platform
# acceptance suite, not one-time checks"). This step exists only to emit that token, and it
# is reached only when everything above has passed. Asserted by
# tests/l0-static/auditability-proofs-producible.
- name: then-emit-the-proof-of-pass-sentinel
try:
- script:
content: echo GATEWAY_INGRESS_ISOLATION_PROVEN_OK
check:
(contains($stdout, 'GATEWAY_INGRESS_ISOLATION_PROVEN_OK')): true