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]>
This commit is contained in:
1 parent
d1a6628a97
commit
6a8953e288
46 files changed
+7898
-71
No files matched your search
@@ -0,0 +1,199 @@
|
||||
# =============================================================================
|
||||
# L2 BEHAVIORAL — the default egress path is the WAN; the VPN is opt-in (R-NET-04) [RUNNABLE]
|
||||
# Catalog: docs/14 L2 row ("real runtime behavior, probes against a cluster")
|
||||
# Spec: docs/00 R-NET-04 ("The platform MUST support **multiple egress paths** selected by
|
||||
# routing rules ... realized as two paths — the local `internet` path and the
|
||||
# in-cluster `vpn` path").
|
||||
# docs/03 §"Egress routing: two paths, one physical WAN": "the `internet` path is
|
||||
# simply the default route out".
|
||||
# docs/03 §"VPN egress model": "**Default is the WAN; the VPN is strictly opt-in.**
|
||||
# Every pod — and the nodes themselves — egress the local/private WAN by default and
|
||||
# **never** the VPN. Only services labeled `egress-class=vpn` are steered to the
|
||||
# tunnel"; and the tunnel "is **split-tunnel**: ... only what Cilium points at `tun0`
|
||||
# uses the VPN."
|
||||
#
|
||||
# WHAT THIS PROVES THAT tests/l2-behavioral/egress-class DOES NOT
|
||||
# --------------------------------------------------------------
|
||||
# That test (R-NET-05/07) compares two LABELLED pods — `internet` vs `vpn` — and shows their
|
||||
# public IPs differ, then flips a label. Both of its pods declared a class, so it says nothing
|
||||
# about the case R-NET-04 actually turns on: a pod that NO routing rule selects. This test adds
|
||||
# that pod, and asserts the two claims that make a rule-selected path set complete:
|
||||
#
|
||||
# TOTALITY — an UNLABELLED pod egresses the WAN, i.e. the same public IP as the
|
||||
# explicitly-`internet` pod. Absence of a matching rule IS a path, and it is
|
||||
# the local one. If the steering rule ever widened (an empty selector, a
|
||||
# matchExpression, a second rule), this pod's IP would move to the tunnel and
|
||||
# this assertion is what would notice.
|
||||
# COEXISTENCE — the WAN path and the VPN path are live AT THE SAME TIME, observed in one
|
||||
# pass: three pods, two distinct public IPs. "Multiple egress paths" is a
|
||||
# simultaneity claim, not a claim that one switch has two positions.
|
||||
# PER-DESTINATION SELECTION — the vpn-class pod still reaches an in-cluster/RFC1918
|
||||
# destination over the LOCAL path while its public traffic is tunnelled. The
|
||||
# tunnel is destination-scoped (excludedCIDRs), not a default route, so ONE pod
|
||||
# uses BOTH paths at once.
|
||||
#
|
||||
# ENVIRONMENT-AGNOSTIC (R-ENV-02): no address, hostname or range appears. Every assertion is a
|
||||
# RELATION between observed values (equal / not-equal), so it holds against dev and prod alike
|
||||
# and survives the WAN or the VPN provider rotating addresses.
|
||||
#
|
||||
# GIVEN one unlabelled, one `internet`-class and one `vpn`-class pod — the same workload three
|
||||
# times, differing only in the class they declare (or do not),
|
||||
# WHEN each asks an external echo for the public IP it egressed from, and the vpn-class pod
|
||||
# also resolves a name through the in-cluster resolver,
|
||||
# THEN the unlabelled pod's public IP EQUALS the internet-class pod's (the default route out
|
||||
# is the WAN, and the VPN is strictly opt-in);
|
||||
# AND the vpn-class pod's public IP DIFFERS from both (a second, simultaneously-live path);
|
||||
# AND the vpn-class pod still resolves in-cluster (its private-destination traffic was not
|
||||
# swallowed by the tunnel), so the same pod is using both paths at once.
|
||||
#
|
||||
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
|
||||
# - THAT NO OTHER RULE EXISTS. Three pods cannot enumerate a rule set. That exactly one
|
||||
# steering rule is authored, that it selects only the vpn class, that no `ip rule` is
|
||||
# unscoped and no nftables DROP unqualified is the L0 half, in
|
||||
# tests/l0-static/egress-paths-selected-by-rules (same requirement label).
|
||||
# - WHICH INTERFACE the packets left by. The observation is the public IP an external echo
|
||||
# reports; it distinguishes the paths without naming them. Asserting "out tun0" would need
|
||||
# host-level datapath inspection (Hubble/tcpdump on the gateway node), which no test in
|
||||
# this suite does.
|
||||
# - THE KILL-SWITCH. Tunnel-down behaviour is R-NET-06's, at L3 (vpn-killswitch): `vpn`
|
||||
# fails closed. Its mirror — that `internet` is never blackholed — is asserted statically
|
||||
# by the L0 half, not here.
|
||||
# - NODE traffic. docs/03 also says "the nodes themselves" egress the WAN by default. That
|
||||
# is a host-level fact this test cannot see from a pod.
|
||||
# - EXTERNAL DEPENDENCY, stated plainly: the public-IP echo (api.ipify.org) is off-cluster,
|
||||
# so a WAN outage or the echo being down fails this test. That is the same dependency
|
||||
# tests/l2-behavioral/egress-class already carries; the alternative — trusting the
|
||||
# cluster's own account of which path it used — would not be a behavioural test at all.
|
||||
#
|
||||
# CLEANUP: chainsaw deletes the per-test namespace (all three pods with it). Nothing outside
|
||||
# that namespace is created or changed — no label on a platform object, no policy edit.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: egress-default-path
|
||||
labels:
|
||||
conformance.home.k3s/R-NET-04: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-NET-04"
|
||||
conformance.home.k3s/layer: "L2"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
Given an unlabelled, an internet-class and a vpn-class pod, when each observes the public
|
||||
IP it egressed from, then the unlabelled pod's IP equals the internet-class pod's (the
|
||||
default route out is the WAN, the VPN is opt-in) while the vpn-class pod's differs — two
|
||||
paths live at once — and the vpn-class pod still reaches in-cluster destinations over the
|
||||
local path, so one pod uses both paths simultaneously.
|
||||
spec:
|
||||
timeouts:
|
||||
apply: 60s
|
||||
assert: 2m
|
||||
exec: 3m
|
||||
steps:
|
||||
# -----------------------------------------------------------------------------------
|
||||
- name: given-three-pods-differing-only-in-their-declared-egress-class
|
||||
try:
|
||||
- apply:
|
||||
file: fixtures/three-egress-pods.yaml
|
||||
# Bare pods (restartPolicy: Never) — Ready == Running here.
|
||||
- assert:
|
||||
resource:
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: { name: egress-unlabelled }
|
||||
status: { phase: Running }
|
||||
- assert:
|
||||
resource:
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: { name: egress-internet }
|
||||
status: { phase: Running }
|
||||
- assert:
|
||||
resource:
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: { name: egress-vpn }
|
||||
status: { phase: Running }
|
||||
# -----------------------------------------------------------------------------------
|
||||
- name: then-the-unlabelled-pod-takes-the-wan-and-the-vpn-path-is-live-too
|
||||
try:
|
||||
- script:
|
||||
content: |
|
||||
set -eu
|
||||
ns="$NAMESPACE"
|
||||
get_ip() {
|
||||
# Echo the pod's observed public IP, or empty on failure.
|
||||
kubectl -n "$ns" exec "$1" -c curl -- \
|
||||
curl -fsS --max-time 10 https://api.ipify.org 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Poll until all three answer AND the vpn class has diverged from the WAN:
|
||||
# Cilium may still be programming the egress datapath just after the pods go
|
||||
# Ready. Bounded by the loop, never a bare sleep-and-hope.
|
||||
none="" inet="" vpn=""
|
||||
for i in $(seq 1 12); do
|
||||
[ -n "$none" ] || none="$(get_ip egress-unlabelled)"
|
||||
[ -n "$inet" ] || inet="$(get_ip egress-internet)"
|
||||
[ -n "$vpn" ] || vpn="$(get_ip egress-vpn)"
|
||||
if [ -n "$none" ] && [ -n "$inet" ] && [ -n "$vpn" ]; then
|
||||
[ "$vpn" != "$inet" ] && break
|
||||
# Steering may not have converged; re-read the vpn pod only.
|
||||
vpn=""
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "unlabelled_ip=$none internet_ip=$inet vpn_ip=$vpn"
|
||||
|
||||
[ -n "$none" ] || { echo "FAIL: the UNLABELLED pod never returned a public IP — a pod that declares no egress class must still reach the internet over the default WAN path (docs/03: 'the internet path is simply the default route out'); no path at all means the default was blackholed or steered into a dead tunnel" >&2; exit 1; }
|
||||
[ -n "$inet" ] || { echo "FAIL: the internet-class pod never returned a public IP — the declared default path is down, so no comparison is possible" >&2; exit 1; }
|
||||
[ -n "$vpn" ] || { echo "FAIL: the vpn-class pod never returned a public IP — the second egress path is not carrying traffic, so the platform is not offering MULTIPLE paths (R-NET-04)" >&2; exit 1; }
|
||||
|
||||
# TOTALITY: no rule selects the unlabelled pod, so it must land on the WAN —
|
||||
# the same WAN the explicitly-internet pod uses.
|
||||
if [ "$none" != "$inet" ]; then
|
||||
echo "FAIL: the UNLABELLED pod egressed $none but the internet-class pod egressed $inet. They must be the same path: docs/03 — 'Default is the WAN; the VPN is strictly opt-in. Every pod ... egress the local/private WAN by default and never the VPN.' A different IP means some rule is selecting a pod that never opted in." >&2
|
||||
exit 1
|
||||
fi
|
||||
# COEXISTENCE: and the vpn path is a genuinely different path, live right now.
|
||||
if [ "$vpn" = "$inet" ]; then
|
||||
echo "FAIL: the vpn-class pod egressed the same public IP as the WAN ($vpn) — there is only ONE live egress path, so 'multiple egress paths selected by routing rules' (R-NET-04) does not hold" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "EGRESS_DEFAULT_PATH_IS_WAN_OK"
|
||||
check:
|
||||
(contains($stdout, 'EGRESS_DEFAULT_PATH_IS_WAN_OK')): true
|
||||
# -----------------------------------------------------------------------------------
|
||||
- name: then-the-vpn-class-pod-still-reaches-private-destinations-locally
|
||||
try:
|
||||
- script:
|
||||
# PER-DESTINATION SELECTION. The steering rule tunnels 0.0.0.0/0 MINUS all
|
||||
# RFC1918 space, so a vpn-class pod's private-destination traffic must stay on the
|
||||
# local datapath. The cluster resolver is an in-cluster (RFC1918) address reached
|
||||
# from the pod's own /etc/resolv.conf — no literal is written here — so a
|
||||
# successful lookup from the vpn-class pod proves the tunnel did not swallow its
|
||||
# local traffic, i.e. ONE pod is using BOTH paths at the same time.
|
||||
content: |
|
||||
set -eu
|
||||
ns="$NAMESPACE"
|
||||
resolver="$(kubectl -n "$ns" exec egress-vpn -c curl -- \
|
||||
sh -c 'awk "/^nameserver/{print \$2; exit}" /etc/resolv.conf' 2>/dev/null || true)"
|
||||
[ -n "$resolver" ] || { echo "FAIL: the vpn-class pod has no nameserver in /etc/resolv.conf, so its local-path reachability cannot be observed" >&2; exit 1; }
|
||||
echo "vpn_pod_resolver=$resolver (in-cluster, RFC1918 — excluded from the tunnel)"
|
||||
|
||||
# A cluster-internal name, resolvable only by the in-cluster resolver: reaching
|
||||
# it proves the private-destination path works from a vpn-class pod.
|
||||
out=""
|
||||
for i in $(seq 1 6); do
|
||||
out="$(kubectl -n "$ns" exec egress-vpn -c curl -- \
|
||||
nslookup kubernetes.default.svc.cluster.local 2>/dev/null || true)"
|
||||
case "$out" in *"Address"*) break ;; esac
|
||||
sleep 5
|
||||
done
|
||||
case "$out" in
|
||||
*Address*) : ;;
|
||||
*) echo "FAIL: the vpn-class pod could not resolve kubernetes.default.svc.cluster.local via its in-cluster resolver ($resolver). Its RFC1918 traffic is being steered into the tunnel (or blackholed) instead of taking the local path — the two paths are not coexisting per destination, and the tunnel is behaving as a default route (docs/03: split-tunnel, 'only what Cilium points at tun0 uses the VPN')" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "EGRESS_PATHS_COEXIST_PER_DESTINATION_OK"
|
||||
check:
|
||||
(contains($stdout, 'EGRESS_PATHS_COEXIST_PER_DESTINATION_OK')): true
|
||||
@@ -0,0 +1,67 @@
|
||||
# Three pods for the R-NET-04 default-path test — the SAME workload three times, differing
|
||||
# ONLY in how (or whether) they declare an egress class:
|
||||
#
|
||||
# egress-unlabelled NO egress-class label at all -> selected by NO routing rule
|
||||
# egress-internet egress-class: internet -> the declared default path
|
||||
# egress-vpn egress-class: vpn -> the only class any rule steers
|
||||
#
|
||||
# WHY AN UNLABELLED POD IS THE POINT. docs/03: "Every pod — and the nodes themselves — egress
|
||||
# the local/private WAN by default and **never** the VPN." The unlabelled pod is the case no
|
||||
# other test covers: nothing selects it, so the DATAPATH's default must carry it, and it must
|
||||
# come out the same WAN as the explicitly-`internet` pod. That is the totality half of
|
||||
# R-NET-04's "selected by routing rules" — absence of a rule is itself a path.
|
||||
#
|
||||
# The unlabelled pod is admissible here only because chainsaw's per-test namespace is not an
|
||||
# app namespace: Kyverno's require-egress-class-label (R-NET-05) rejects an unlabelled pod in
|
||||
# a namespace labelled homelab.keithjackson.dev/app-namespace=true. That admission guarantee
|
||||
# is R-NET-05's, and it is exactly why this test must run outside an app namespace to observe
|
||||
# the datapath's own default at all.
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: egress-unlabelled
|
||||
labels:
|
||||
app: egress-default-path-test
|
||||
spec:
|
||||
containers:
|
||||
- name: curl
|
||||
image: curlimages/curl:8.11.1
|
||||
command: ["sleep", "3600"]
|
||||
resources:
|
||||
requests: { cpu: 10m, memory: 16Mi }
|
||||
limits: { memory: 32Mi }
|
||||
restartPolicy: Never
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: egress-internet
|
||||
labels:
|
||||
app: egress-default-path-test
|
||||
homelab.keithjackson.dev/egress-class: internet
|
||||
spec:
|
||||
containers:
|
||||
- name: curl
|
||||
image: curlimages/curl:8.11.1
|
||||
command: ["sleep", "3600"]
|
||||
resources:
|
||||
requests: { cpu: 10m, memory: 16Mi }
|
||||
limits: { memory: 32Mi }
|
||||
restartPolicy: Never
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: egress-vpn
|
||||
labels:
|
||||
app: egress-default-path-test
|
||||
homelab.keithjackson.dev/egress-class: vpn
|
||||
spec:
|
||||
containers:
|
||||
- name: curl
|
||||
image: curlimages/curl:8.11.1
|
||||
command: ["sleep", "3600"]
|
||||
resources:
|
||||
requests: { cpu: 10m, memory: 16Mi }
|
||||
limits: { memory: 32Mi }
|
||||
restartPolicy: Never
|
||||
Reference in new issue
Block a user