feat(conformance): cover R-PLAT-01, R-NET-04, R-SEC-03 — 88.2% -> 94.1%, and fix an unrecordable-proof defect

Coverage 45/51 -> 48/51, gaps 6 -> 3 (R-C2-01, R-C2-02, R-STATE-04), 0
safety-critical. --strict-coverage exit 0; run.sh --layer l0 11/11 PASS.

R-PLAT-01 — split L0 + L2, because "3 nodes with production-grade HA control
plane" is half authored and half observable.
  l0-static/ha-control-plane-as-code (92 assertions): environments DERIVED from
  the R-ENV-02 seam (never a hardcoded group name); k3s installed as `server` with
  no --datastore-endpoint/--disable-etcd anywhere; exactly ONE --cluster-init
  founder per environment; odd, quorum-capable server count with tolerance >= 1;
  tls-sans carry the VIP and every server IP; kube-vip leader-elected with
  cp_enable/svc_enable parsed from the template's OWN env pairs; each VIP outside
  its environment's LB pool and not a node IP.
  l2-behavioral/ha-control-plane: control-plane AND etcd role on every node,
  every node a REGISTERED etcd member, exactly 3 when the cluster says
  CLUSTER_ENV=prod, and an in-cluster Job curling /readyz through the VIP AND every
  server's own address — which proves the VIP fronts the API and that every server
  runs an apiserver, not one founder plus two joiners.

  An earlier draft probed those endpoints FROM THE RUNNER: that passes from prod's
  LAN and fails against dev (nodes behind the pfSense NAT), making the test a
  property of where it was launched. Hence the in-cluster Job.

R-NET-04 — what it adds over R-NET-05/07 is the PATH SET and the
totality/disjointness of the selecting rules, not another per-service check.
  l0-static/egress-paths-selected-by-rules (68 assertions): exactly two paths, the
  same closed set in schema + Kyverno policy + renderer; EXACTLY ONE
  CiliumEgressGatewayPolicy with a single selector matching only egress-class=vpn;
  nothing selects `internet` (absence of a rule IS the path); RFC1918 containment
  computed with `ipaddress` plus every address each overlay assigns itself; the
  tunnel default route and blackhole confined to table 51820 behind an ip rule that
  must carry a from/fwmark/iif selector; and the nft chain `policy accept` with
  every drop qualified — the internet-fails-OPEN direction that R-NET-06's L3 test
  does not assert.

R-SEC-03 — refuses the tautology "a test exists, therefore it is auditable".
  l0-static/auditability-proofs-producible (78 assertions) drives the real
  machinery: each of docs/07's five proofs must have an EXECUTABLE test at the layer
  the spec's wording demands (fault injection and DR drill => L3), taken from
  status.py's own verdict; run.sh --requirements must resolve and pass the gate's
  --require assertion; each proof declares a sentinel record_run.py can attribute,
  computed with record_run.parse_sentinels itself; and TWO FORGERIES are mounted
  against the real tools — a green JUnit whose log holds only a SKIP token, and a
  ledger row claiming `proven` at the root commit — which must come back not-proven
  and stale-proof. The five proofs are parsed from docs/07 when the spec is mounted,
  not hand-listed.

*** A REAL DEFECT FOUND AND FIXED, not asserted around. *** l2-behavioral's
default-deny, trusted-tls and gateway-ingress-reachable declared NO pass sentinel,
so record_run.py could never attribute a pass to them — the isolation and TLS audit
proofs were STRUCTURALLY UNRECORDABLE (landing as unknown-no-sentinel-declared,
and status.py requires all of a requirement's tests to be proven). Each now ends
with an additive terminal sentinel step reached only when every prior step passed.
Verified: all three carry it.

TWO HOLLOW DRAFT ASSERTIONS CAUGHT BY THE AGENT ITSELF, both the failure mode this
repo keeps hitting: (a) `svc_enable=false` matched a COMMENT in the kube-vip role
defaults, so flipping the template to true still passed — now parses the DaemonSet
template's env pairs; (b) the nft scoping check searched for the VALUE of a Jinja
variable while the template carries its NAME, matching nothing and passing
vacuously — templates are now rendered against role defaults first. 30 perturbations
across the three checks, all caught, plus 16 synthetic L2 perturbations via a
fake-kubectl shim.

R-C2-01/02 judged IMPLEMENTATION gaps, so no test was written: Homepage's
widgets.yaml carries only datetime and search, kubernetes.yaml is empty,
automountServiceAccountToken is false, and its NetworkPolicy allows DNS egress
only — no health source is even reachable. It has already drifted (crafty authors a
route with no tile). A test today would go red on both; it must ship WITH the
config fix.

VERIFIED: full CI gate PASS. Neither cluster mutated.

Refs R-PLAT-01, R-NET-04, R-SEC-03, R-TEST-01..05.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-09-06 19:35:18 -05:00
1 parent 5ec1460532
commit cb1916f092
12 files changed
+2488

No files matched your search

@@ -153,3 +153,20 @@ spec:
finally:
- script:
content: 'echo "cleanup handled by chainsaw namespace teardown"'
# -----------------------------------------------------------------------------------
# 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 — the isolation half of docs/07's audit proof list
# — 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 DEFAULT_DENY_PROVEN_OK
check:
(contains($stdout, 'DEFAULT_DENY_PROVEN_OK')): true
@@ -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
@@ -313,3 +313,20 @@ spec:
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
@@ -0,0 +1,373 @@
# =============================================================================
# L2 BEHAVIORAL — the cluster under test IS an HA k3s control plane (R-PLAT-01) [RUNNABLE]
# Catalog: docs/14 L2 row ("real runtime behavior, probes against a cluster")
# Spec: docs/00 R-PLAT-01 ("The platform MUST run on k3s across 3 nodes with
# production-grade HA control plane in prod; dev/test MAY run fewer nodes but MUST
# exercise the same multi-node topology.")
# docs/01 "Kubernetes distro | **k3s** (HA, embedded etcd) | R-PLAT-01/02"
# docs/02 table: "Control plane | HA embedded etcd (k3s server on all 3) | HA
# embedded etcd (k3s server on all 3)"
# "Node count | 3 VMs (mirror prod topology) | 3 physical nodes"
# docs/02 §"k3s topology decisions": "HA control plane with **embedded etcd** across
# all 3 nodes (no external datastore)"; "Nodes are both control-plane and workload
# (3-node converged cluster)".
#
# WHY L2 — AND WHAT L0 ALREADY DID
# --------------------------------
# tests/l0-static/ha-control-plane-as-code (same requirement label) proves the AUTHORED
# half offline: the repo builds a 3-server embedded-etcd control plane in both environments,
# one founder, no external datastore, a leader-elected control-plane-only VIP in the serving
# cert's SANs. It cannot prove that any of it is TRUE OF A RUNNING CLUSTER — an inventory
# entry is not a node, and a templated DaemonSet is not a floating VIP. That is this test:
# every assertion below is read from the cluster the suite is pointed at.
#
# ENVIRONMENT-AGNOSTIC (R-ENV-02): no address, hostname, node count or range is written
# here. The expected node count comes from the cluster's OWN declared identity
# (flux-system/cluster-vars CLUSTER_ENV, via a test-level chainsaw binding — the same
# ConfigMap Flux substitutes from); the control-plane VIP is read out of the kube-vip
# DaemonSet the cluster is actually running; the server addresses come from the Node
# objects; and the LoadBalancer pool the VIP must stay outside of is read from cluster-vars
# too. R-PLAT-01 itself distinguishes prod from dev/test ("3 nodes ... in prod; dev/test MAY
# run fewer"), so the assertion is necessarily environment-AWARE — but it takes the
# environment from the cluster, never from a flag or a literal, which is precisely the
# mechanism the tests/README.md convention prescribes.
#
# GIVEN the cluster this suite is pointed at,
# WHEN its Nodes, its kube-vip DaemonSet and its kube-apiserver endpoints are inspected,
# THEN DISTRO: every node's kubelet is a k3s build (`+k3s`) — the distro really is k3s;
# SERVERS: every Ready node is BOTH control-plane AND etcd (the converged 3-node
# topology docs/02 fixes — no agent-only node, no control-plane-only node),
# the count is ODD and quorum-capable (tolerates >= 1 loss), and it is
# EXACTLY three when the cluster declares itself prod;
# ETCD: every one of those nodes is a REGISTERED embedded-etcd member (k3s stamps
# `etcd.k3s.cattle.io/node-name` on a node that joined the etcd cluster), so
# the datastore is embedded and replicated across all of them;
# VIP: kube-vip is Ready on EVERY control-plane node, is control-plane-only
# (svc_enable=false — it must not contend with Cilium LB-IPAM for ARP), and
# the address it floats is neither a node address, nor the ingress Gateway
# VIP, nor inside the Cilium LoadBalancer pool;
# ENDPOINT: the kube-apiserver answers `/readyz` THROUGH THAT VIP over TLS that
# validates against the cluster CA for the VIP itself — i.e. the VIP really
# fronts the API and the serving cert really carries it;
# EVERY SERVER SERVES: the apiserver also answers `/readyz` on EVERY server's own
# address, again with a CA-valid cert for that address. This is the assertion
# that separates a real HA control plane from one server plus two joiners:
# if only the founder ran an apiserver, the other addresses would refuse.
#
# THE PROBE RUNS IN-CLUSTER, DELIBERATELY. An earlier draft dialled the VIP and the node
# addresses from the RUNNER. That works from a host on the node LAN (verified against prod)
# and FAILS against dev, whose nodes sit behind a NAT the runner reaches only via a
# forwarded port — i.e. it would have made the test a property of where it was launched
# from. A Job on the cluster is on the node network in both environments, so the probe is
# the same probe everywhere. The Job is created imperatively (kubectl heredoc) rather than
# as a fixture file because its address list is DISCOVERED from the cluster at run time —
# writing it into a fixture is exactly the per-environment literal R-ENV-02 forbids.
#
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
# - FAILOVER / SURVIVAL. Nothing here removes a node. That etcd keeps quorum, that the VIP
# migrates, and that the API stays reachable when a server dies is an L3 fault-injection
# claim and is proven NOWHERE in this suite yet (tests/l3-fault-injection/node-loss
# drains a node for R-STATE-02's data-availability claim and asserts nothing about the
# control plane). This is the largest remaining R-PLAT-01 gap.
# - THE NEXT environment. That the repo would build an HA control plane again is the L0
# half (tests/l0-static/ha-control-plane-as-code).
# - "PRODUCTION-GRADE" as a capacity/performance judgement (operator's, docs/02 + §2 of
# infra/PROD-PROVISIONING.md).
# - Cilium's own `k8sServiceHost` (cluster-vars `K3S_API_HOST`) is the PRIMARY NODE's
# address, not this VIP, in both environments. That single-node dependency in the CNI's
# control-plane path is escalated to the PM, not asserted here (R-TEST-06).
# - etcd health beyond membership registration (raft leader, DB size, alarms) — k3s exposes
# it only on the node-local etcd metrics port, which is not reachable through the
# Kubernetes API this test is allowed to use.
#
# CLEANUP: the probe Job lives in chainsaw's per-test namespace and is deleted with it
# (config/chainsaw-config.yaml skipDelete: false). Everything else is read-only: this test
# creates nothing outside its own namespace and mutates no platform object.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: ha-control-plane
labels:
conformance.home.k3s/R-PLAT-01: "true"
annotations:
conformance.home.k3s/requirements: "R-PLAT-01"
conformance.home.k3s/layer: "L2"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
Given the cluster under test, when its Nodes, kube-vip DaemonSet and apiserver
endpoints are probed, then every node is a k3s server that is both control-plane and a
registered embedded-etcd member, the count is odd and quorum-capable (exactly three
when the cluster declares itself prod), kube-vip floats a control-plane-only VIP that
collides with no allocator, and the apiserver answers /readyz through that VIP AND on
every server's own address with a CA-valid cert.
spec:
timeouts:
apply: 60s
assert: 2m
exec: 3m
# -- R-ENV-02: the environment's identity and its address allocations are READ FROM THE
# CLUSTER UNDER TEST. flux-system/cluster-vars is the ConfigMap Flux itself substitutes
# from, so these are the same values the platform was rendered with. Test-level, so
# they are in scope for every step (step outputs/bindings are step-scoped).
bindings:
- name: clusterEnv
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CLUSTER_ENV)
- name: gatewayVip
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.GATEWAY_VIP)
- name: lbPoolStart
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CILIUM_LB_POOL_START)
- name: lbPoolStop
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CILIUM_LB_POOL_STOP)
steps:
# -----------------------------------------------------------------------------------
- name: then-the-node-set-is-a-quorum-capable-converged-k3s-control-plane
description: >-
Read the Node objects: k3s on every node, every node both control-plane and a
registered embedded-etcd member, an odd quorum-capable count, exactly three in prod.
try:
- script:
env:
- name: CLUSTER_ENV
value: ($clusterEnv)
content: |
set -eu
fail() { echo "FAIL: $*" >&2; exit 1; }
total="$(kubectl get nodes -o name | grep -c . || true)"
[ "$total" -ge 1 ] || fail "the cluster reports NO nodes at all"
# --- k3s, on every node (R-PLAT-01 names the distro; docs/01 ADR-0001) -----
# A non-k3s kubelet here would mean the platform is not running on k3s.
notk3s="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.metadata.name}{"="}{.status.nodeInfo.kubeletVersion}{"\n"}{end}' \
| grep -v '+k3s' | grep -c . || true)"
[ "$notk3s" = "0" ] \
|| fail "$notk3s node(s) do not run a k3s kubelet — R-PLAT-01 requires the platform to run on k3s"
# --- every node is BOTH control-plane AND etcd (docs/02 converged topology) -
# Counted by label EXISTENCE, so it does not matter whether k3s writes "true"
# or "". A control-plane node without the etcd role would be a server with an
# external/absent datastore; an etcd node without control-plane would be an
# isolated datastore member. Both are excluded by requiring all three counts
# to be equal.
cp="$(kubectl get nodes -l node-role.kubernetes.io/control-plane -o name | grep -c . || true)"
etcd="$(kubectl get nodes -l node-role.kubernetes.io/etcd -o name | grep -c . || true)"
echo "nodes_total=$total control_plane=$cp etcd=$etcd cluster_env=${CLUSTER_ENV:-<unset>}"
[ "$cp" = "$total" ] \
|| fail "$cp of $total nodes carry node-role.kubernetes.io/control-plane — docs/02 fixes a converged cluster where every node is a control-plane member ('k3s server on all 3')"
[ "$etcd" = "$total" ] \
|| fail "$etcd of $total nodes carry node-role.kubernetes.io/etcd — docs/02 requires embedded etcd 'across all 3 nodes (no external datastore)'"
# --- every node is a REGISTERED embedded-etcd member -----------------------
# k3s stamps etcd.k3s.cattle.io/node-name on a node that actually joined the
# embedded-etcd cluster. The role label alone is a label; this annotation is
# the datastore's own record of membership.
members="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.metadata.annotations.etcd\.k3s\.cattle\.io/node-name}{"\n"}{end}' \
| grep -c . || true)"
[ "$members" = "$total" ] \
|| fail "$members of $total nodes are registered embedded-etcd members (etcd.k3s.cattle.io/node-name) — the datastore is not replicated across every server"
# --- Ready ---------------------------------------------------------------
notready="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.metadata.name}{"="}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"\n"}{end}' \
| grep -v '=True$' | grep -c . || true)"
[ "$notready" = "0" ] \
|| fail "$notready node(s) are not Ready — a control plane missing a member is not the HA topology under test"
# --- quorum arithmetic ---------------------------------------------------
# Embedded etcd needs floor(N/2)+1 members to commit. Fault tolerance is
# N - quorum; 'production-grade HA' must survive at least one loss, which
# rules out 1 and 2 members. An EVEN count buys no extra tolerance and adds
# a tie, so it is also rejected.
quorum=$(( total / 2 + 1 ))
tolerance=$(( total - quorum ))
echo "quorum=$quorum tolerance=$tolerance"
[ "$tolerance" -ge 1 ] \
|| fail "$total etcd member(s) give quorum $quorum and tolerate $tolerance loss(es) — an HA control plane must survive losing a server"
[ $(( total % 2 )) -eq 1 ] \
|| fail "$total etcd members is EVEN: quorum is the same as for $(( total - 1 )) members, so the extra member adds a tie instead of tolerance"
# --- the count R-PLAT-01 itself fixes, per environment --------------------
# The environment is the cluster's OWN CLUSTER_ENV (cluster-vars), never a
# flag: 'k3s across 3 nodes ... in prod; dev/test MAY run fewer nodes but MUST
# exercise the same multi-node topology'.
case "${CLUSTER_ENV:-}" in
prod)
[ "$total" = "3" ] \
|| fail "this cluster declares CLUSTER_ENV=prod and has $total nodes — R-PLAT-01 requires 'k3s across 3 nodes ... in prod' (docs/02: '3 physical nodes')" ;;
"")
fail "flux-system/cluster-vars has no CLUSTER_ENV — the cluster does not declare which environment it is, so R-PLAT-01's prod clause has no referent (and no literal may be substituted for it)" ;;
*)
[ "$total" -ge 2 ] \
|| fail "this cluster declares CLUSTER_ENV=${CLUSTER_ENV} and has $total node(s) — dev/test may run fewer nodes than prod but MUST 'exercise the same multi-node topology'" ;;
esac
echo "HA_TOPOLOGY_OK"
check:
(contains($stdout, 'HA_TOPOLOGY_OK')): true
# -----------------------------------------------------------------------------------
- name: then-a-control-plane-only-vip-is-floated-on-every-server
description: >-
Read the running kube-vip DaemonSet: Ready on every control-plane node,
control-plane-only, and floating an address that collides with no other allocator.
try:
- script:
env:
- name: GATEWAY_VIP
value: ($gatewayVip)
- name: LB_POOL_START
value: ($lbPoolStart)
- name: LB_POOL_STOP
value: ($lbPoolStop)
content: |
set -eu
fail() { echo "FAIL: $*" >&2; exit 1; }
# dotted quad -> integer, for range comparison without ipcalc/python.
n() { echo "$1" | awk -F. '{printf "%d", ($1*16777216)+($2*65536)+($3*256)+$4}'; }
# The DaemonSet is found by the label roles/kube_vip stamps on it, not by a
# hardcoded name in a namespace this test guessed.
ds="$(kubectl -n kube-system get ds -l app.kubernetes.io/name=kube-vip-ds -o name | head -1)"
[ -n "$ds" ] \
|| fail "no kube-vip DaemonSet is running in kube-system — the kube-apiserver has no floating endpoint, so losing the founder takes the API endpoint with it"
desired="$(kubectl -n kube-system get "$ds" -o jsonpath='{.status.desiredNumberScheduled}')"
ready="$(kubectl -n kube-system get "$ds" -o jsonpath='{.status.numberReady}')"
cp="$(kubectl get nodes -l node-role.kubernetes.io/control-plane -o name | grep -c . || true)"
vip="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="address")].value}')"
cp_enable="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="cp_enable")].value}')"
svc_enable="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="svc_enable")].value}')"
echo "kube_vip=$ds ready=$ready/$desired control_plane_nodes=$cp vip=$vip cp_enable=$cp_enable svc_enable=$svc_enable"
[ -n "$vip" ] \
|| fail "the kube-vip DaemonSet announces no address — nothing is floated"
[ "$cp_enable" = "true" ] \
|| fail "kube-vip runs with cp_enable=$cp_enable — control-plane mode is off, so it floats no API VIP"
[ "$svc_enable" = "false" ] \
|| fail "kube-vip runs with svc_enable=$svc_enable — it would also serve Service LoadBalancers and contend with Cilium LB-IPAM for ARP on the same addresses (ADR-0002 gives Service LB to Cilium)"
[ "$desired" = "$cp" ] \
|| fail "kube-vip is scheduled on $desired node(s) but there are $cp control-plane nodes — the VIP must be able to move to ANY server, or its failover set is smaller than the control plane"
[ "$ready" = "$desired" ] \
|| fail "kube-vip is Ready on $ready of $desired nodes — a VIP holder that is not running cannot take the address over"
# --- the VIP collides with nothing --------------------------------------
node_ips="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}' \
| grep -c "^${vip}$" || true)"
[ "$node_ips" = "0" ] \
|| fail "the floated VIP $vip is ALSO a node's own InternalIP — a VIP that is a node's address cannot float off that node"
[ "$vip" != "${GATEWAY_VIP:-}" ] \
|| fail "the control-plane VIP and the ingress Gateway VIP are the same address ($vip) — kube-vip and Cilium would both ARP for it"
if [ -n "${LB_POOL_START:-}" ] && [ -n "${LB_POOL_STOP:-}" ]; then
v="$(n "$vip")"; lo="$(n "$LB_POOL_START")"; hi="$(n "$LB_POOL_STOP")"
[ "$lo" -le "$hi" ] \
|| fail "the cluster's Cilium LB pool ${LB_POOL_START}-${LB_POOL_STOP} is inverted, so the VIP cannot be proven to be outside it"
if [ "$v" -ge "$lo" ] && [ "$v" -le "$hi" ]; then
fail "the control-plane VIP $vip lies INSIDE this cluster's Cilium LoadBalancer pool ${LB_POOL_START}-${LB_POOL_STOP} — LB-IPAM could hand the kube-API's VIP to a Service"
fi
else
fail "flux-system/cluster-vars declares no CILIUM_LB_POOL_START/STOP — the separation between the control-plane VIP and the Service-LB allocator cannot be checked, and no literal range may be substituted for it (R-ENV-02)"
fi
echo "HA_VIP_CONTROL_PLANE_ONLY_OK"
check:
(contains($stdout, 'HA_VIP_CONTROL_PLANE_ONLY_OK')): true
# -----------------------------------------------------------------------------------
- name: then-the-apiserver-answers-through-the-vip-and-on-every-server
description: >-
From inside the cluster, probe /readyz on the floated VIP and on every server's own
address, validating TLS against the cluster CA for the address dialled.
try:
- script:
# The address list is DISCOVERED here (Nodes + the kube-vip DaemonSet), so the
# Job cannot be a static fixture — writing the addresses down is the
# per-environment literal R-ENV-02 forbids. Hence the imperative heredoc, the
# same pattern tests/l3-fault-injection/dr-restore uses for run-time-shaped
# fixtures. The Job lives in chainsaw's per-test namespace and is torn down with
# it; nothing outside that namespace is created or changed.
content: |
set -eu
ns="$NAMESPACE"
fail() { echo "FAIL: $*" >&2; exit 1; }
ds="$(kubectl -n kube-system get ds -l app.kubernetes.io/name=kube-vip-ds -o name | head -1)"
vip="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="address")].value}')"
servers="$(kubectl get nodes -l node-role.kubernetes.io/control-plane \
-o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{" "}{end}')"
[ -n "$vip" ] || fail "no floated VIP to probe"
[ -n "$servers" ] || fail "no control-plane node addresses to probe"
echo "probing vip=$vip servers=$servers"
# The probe pod authenticates with its own ServiceAccount token and validates
# TLS against the cluster CA, both from the projected SA volume. /readyz is
# authorized for any authenticated identity via the built-in
# system:public-info-viewer role — anonymous access is NOT relied on (verified:
# an unauthenticated /readyz on this platform answers 401).
kubectl -n "$ns" apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata:
name: ha-api-endpoint-probe
spec:
backoffLimit: 1
activeDeadlineSeconds: 150
template:
spec:
restartPolicy: Never
containers:
- name: probe
image: curlimages/curl:8.11.1
env:
- name: VIP
value: "${vip}"
- name: SERVERS
value: "${servers}"
command:
- sh
- -c
- |
set -u
SA=/var/run/secrets/kubernetes.io/serviceaccount
probe() { # \$1 = address to dial
curl -sS --max-time 15 \\
--cacert "\$SA/ca.crt" \\
-H "Authorization: Bearer \$(cat \$SA/token)" \\
"https://\$1:6443/readyz"
}
rc=0
out="\$(probe "\$VIP" 2>&1)" || rc=1
echo "vip \$VIP -> [\$out]"
if [ "\$rc" != "0" ] || [ "\$out" != "ok" ]; then
echo "FAIL: the kube-apiserver did not answer /readyz through the floated VIP \$VIP with a CA-valid cert for that address — the VIP does not front the API (or it is missing from the serving cert's SANs)"
exit 1
fi
for s in \$SERVERS; do
rc=0
out="\$(probe "\$s" 2>&1)" || rc=1
echo "server \$s -> [\$out]"
if [ "\$rc" != "0" ] || [ "\$out" != "ok" ]; then
echo "FAIL: server \$s does not answer /readyz with a CA-valid cert for its own address — it is not running a kube-apiserver, so the control plane is not HA across every node"
exit 1
fi
done
echo "HA_API_ENDPOINT_OK"
EOF
# Bounded wait; on failure surface the probe's own output before failing.
if ! kubectl -n "$ns" wait --for=condition=complete --timeout=140s job/ha-api-endpoint-probe; then
kubectl -n "$ns" logs job/ha-api-endpoint-probe --tail=40 || true
fail "the in-cluster API-endpoint probe did not complete — see its log above"
fi
logs="$(kubectl -n "$ns" logs job/ha-api-endpoint-probe --tail=40)"
echo "$logs"
case "$logs" in
*HA_API_ENDPOINT_OK*) echo "HA_API_ENDPOINT_REACHABLE_OK" ;;
*) fail "the probe completed without reporting success" ;;
esac
check:
(contains($stdout, 'HA_API_ENDPOINT_REACHABLE_OK')): true
@@ -93,3 +93,20 @@ spec:
kind: Job
metadata: { name: tls-probe }
status: { succeeded: 1 }
# -----------------------------------------------------------------------------------
# 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-TLS-01/03 — the TLS half of docs/07's audit proof list
# — 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 TLS_TRUSTED_PROVEN_OK
check:
(contains($stdout, 'TLS_TRUSTED_PROVEN_OK')): true