Files
home-network-k3s-impl/conformance
jakman85andClaude Opus 5 cb1916f092 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]>
2026-09-06 19:35:18 -05:00
..

conformance/ — the home-k3s conformance harness (WS-TEST)

This subtree is the definition of "done" for the whole platform. Nothing is accepted on a demo, a screenshot, or "it reconciled": every requirement is proven by an automated, reproducible, behavior-based test tagged with the requirement ID(s) it verifies (spec docs/14, ADR-0010, ADR-0011).

Tooling is fixed by ADR-0011: Kyverno for L0 (the CI policy gate + in-cluster admission enforcer — owned by task T-0.5) and Chainsaw for L1 rendering / L2 behavioral / L3 fault-injection (this harness).

The single command

./run.sh                              # full suite (skips L1/L2/L3 if no cluster)
./run.sh --requirements R-NET-01,R-NET-06   # merge-gate subset (tagged)
./run.sh --layer l2                   # one layer: l1 | l2 | l3 | meta | all
./run.sh --coverage-only              # regenerate the coverage report (no cluster)
./run.sh --status                     # derived requirement-STATUS report (no cluster)
./run.sh --lint                       # structural-validate test defs (no cluster)
./run.sh --strict-coverage            # hygiene gate: fail on tag/annotation drift
./run.sh --require-all                # phase-exit gate: every requirement must be covered
./run.sh --record                     # run AND capture the evidence (see "Proof of pass")

Gating is scoped, not "all 51 always" (R-TEST-03). Three distinct checks:

  • --strict-coverage — hygiene (always-on, WS-TEST owns it): fails only on tag/annotation drift or a tag absent from the registry. Uncovered future-phase requirements are visible gaps in the report, not a harness failure.
  • --requirements R-… — merge gate: selects the tagged tests to run and asserts those touched requirements are covered (a listed safety-critical one must have an L3 test).
  • --require-all — phase-exit gate: every registry requirement must be covered.
  • Documented out-of-scope gaps (e.g. R-ENV-02 dev→prod promote and R-GITOPS-01 state-in-git, owned by WS-INFRA/WS-GITOPS and keyed wholesale by the coarse path map) are exempted from the completeness gates via --allow-gap, wired in run.sh as DOCUMENTED_GAPS so the exemption is explicit and reviewable. (R-STATE-02 node-loss used to be here; it now has a real L3 test — tests/l3-fault-injection/node-loss — and is covered, not exempted.)

Coverage vs status — two different questions

coverage.md answers "does a requirement-tagged test EXIST for R-x?" (R-TEST-04). status.md (./run.sh --status, generator lib/status.py) answers the operator's question — "what is the real state of each requirement given what is actually in the repo?" — deriving three orthogonal axes per requirement:

  1. implementation — is there a tracked artifact naming the requirement?
  2. reconciliation — is that artifact reachable by walking resources: from clusters/<env>/kustomization.yaml and expanding every Flux Kustomization's spec.path? This is what separates authored from actually reconciled: a commented-out resources: entry (e.g. platform/image-automation) is correctly reported as in-git-but-not-on-any-cluster.
  3. verification — is the tagged test EXECUTABLE, or a TODO scaffold? Coverage counts both as "covered"; status does not. TODO markers in a body always outrank an executable: "true" annotation, so ambiguity never upgrades a test.

It deliberately refuses to derive proof-of-pass. Nothing tracked in git records a test result — _artifacts/ and results/ are git-ignored and DECISIONS-LOG.md prose is not a signal — so every requirement's proof state is reported as not-derivable rather than defaulting to green. A status report that overstates completion is worse than none (agents/definition-of-done.md). Closing that gap needs a committed, machine-readable run ledger; until then the strongest claim the report makes is wired and testable. --strict-coverage also applies status's hygiene gate (registry↔spec drift, annotation/body conflicts, a safety-critical requirement whose only L3 test is a scaffold). Exit codes follow ci/lib.sh: 0 pass / 1 fail / 3 skipped — a skip is never green.

run.sh is the only entrypoint. Local runs and the in-cluster CI Chainsaw Job call the same script with the same config, so results are identical.

Artifacts (always emitted, under _artifacts/, git-ignored)

Path What Consumer
_artifacts/report.junit.xml JUnit — Chainsaw results, or a skipped-scaffold shim when no cluster CI gate
_artifacts/coverage/coverage.md Human requirement-coverage report PR reviewers / logs
_artifacts/coverage/coverage.json Machine coverage: {requirement: [tests]}, gaps, safety gaps CI gate
_artifacts/status/status.md Human requirement-STATUS report derived from repo state operator / PM
_artifacts/status/status.json Machine status: implementation / reconciliation / verification per requirement tooling

Reproducibility contract (R-TEST-02 — what the harness guarantees)

  • Single command. ./run.sh (or a tagged/layer subset) runs everything and emits the artifacts above — locally and in CI.
  • Hermetic. Each Chainsaw test provisions its own namespace + fixtures/probes and tears them down (config/chainsaw-config.yaml auto-creates/auto-deletes the namespace; tests add finally for anything out-of-namespace). It never relies on pre-existing state.
  • Deterministic. Tool versions pinned in versions.env; eventual consistency handled by bounded polling with explicit timeouts (config), never sleeps or wall-clock/ordering assumptions.
  • Isolated & parallel-safe. Unique generated namespaces + per-requirement labels; tests run concurrently without cross-talk.
  • Machine-readable & requirement-tagged. JUnit + coverage, every result carrying its R-* tags (convention in tests/README.md).
  • Self-cleaning. Fixtures removed on pass or fail; a leaked namespace/fixture is itself a failure (skipDelete: false, enforced delete/cleanup timeouts).
  • Versioned with the code. Tests live next to what they verify and change only when the human-owned spec they encode changes — never weakened to go green (R-TEST-06).

Layout

run.sh                      single-command runner (the entrypoint)
versions.env                pinned toolchain (Chainsaw/kubectl/Kyverno) — determinism
config/chainsaw-config.yaml  shared execution contract (timeouts, ns isolation, JUnit)
requirements/registry.yaml   mirror of EVERY spec R-* ID (coverage enumerates from here)
lib/
  common.sh                  tagging convention + test discovery/selection helpers
  coverage.py                requirement-coverage generator (stdlib only)
  status.py                  requirement-STATUS generator (stdlib only; --selftest)
  record_run.py              run-ledger writer: PROOF-of-pass (stdlib only; --selftest)
  emit_skipped_junit.py      Phase-0 skipped-JUnit shim (no cluster)
runs/
  ledger.jsonl               TRACKED append-only proof-of-pass ledger (one row per
                             testcase per run) — the only source status.py trusts
  <UTC>-<short-sha>/         TRACKED per-run evidence: run.log + report.junit.xml
tests/
  l1-rendering/  l2-behavioral/  l3-fault-injection/  meta/   (see tests/README.md)
incluster/
  chainsaw-job.yaml  rbac.yaml   in-cluster L2/L3 Job + scoped test SA (ADR-0011)

Proof of pass — the run ledger (runs/)

--status used to report PROVEN: 0 for all 51 requirements, and said so honestly: proof-of-pass was not recoverable from repo state. _artifacts/ is gitignored and the root .gitignore kills *.junit.xml globally, so no run result ever reached git; the only record of a green run was DECISIONS-LOG.md prose. runs/ is the fix lib/status.py asked for — a committed, machine-readable run ledger.

./run.sh --record                     # runs normally, and captures the evidence
# -> conformance/runs/<UTC>-<short-sha>/run.log            (tee'd, carries the sentinels)
# -> conformance/runs/<UTC>-<short-sha>/report.junit.xml   (that run's JUnit)
# -> conformance/runs/ledger.jsonl                         (one appended row per testcase)
python3 lib/record_run.py --run-dir runs/<id> [--dry-run]  # or record an existing run dir
python3 lib/record_run.py --selftest                       # deterministic, no cluster

Recording is explicit, never implicit. Plain ./run.sh writes no ledger row: a run that silently records itself is how a bad run becomes a permanent false proof. Commit the run dir and the ledger — an uncommitted ledger proves nothing.

Why a committed JUnit alone would be WORSE than nothing

The L2/L3 tests are runnable and self-skipping: when a precondition is missing (no longhorn StorageClass, no Velero CRDs, fewer than 3 Ready nodes) they echo a skip token and exit 0, and their check: accepts either outcome —

(contains($stdout, 'DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK')
 || contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true

So a self-skip exits 0 and Chainsaw records passed exactly as a real run does, and Chainsaw's JUnit carries no stdout — its <testcase> elements hold only <properties>. A committed JUnit on its own would therefore record DR_RESTORE_SKIPPED_OK as proof of R-STATE-03: fake green, and worse than no ledger because it would look authoritative.

The sentinel token in run.log is the only thing that separates proven from skipped. record_run.py derives each test's tokens from its own chainsaw-test.yaml (every contains($stdout, 'TOKEN'); *_SKIP*_OK is a skip token, the last non-skip token is the verdict token), so the table below is never hand-maintained:

Test pass token (proof) skip token (never proof)
l3/node-loss NODE_LOSS_VOLUME_AVAILABLE_OK NODE_LOSS_SKIPPED_OK
l3/dr-restore DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK (+ OFF_CLUSTER_TARGET_OK, BACKUPS_ARE_SCHEDULED_OK) DR_RESTORE_SKIPPED_OK, DR_SCHEDULE_SKIPPED_OK
l3/data-survival DATA_SURVIVED_OK DATA_SURVIVAL_SKIPPED_OK
l3/vm-data-survival VM_DATA_SURVIVED_OK VM_DATA_SURVIVAL_SKIPPED_OK
l3/hands-off-update HANDS_OFF_UPDATE_OK HANDS_OFF_UPDATE_SKIPPED_OK
l2/vm-lifecycle VM_LIFECYCLE_OK VM_LIFECYCLE_SKIPPED_OK
l2/vm-default-deny VM_DEFAULT_DENY_OK VM_DEFAULT_DENY_SKIPPED_OK

A row reaches proof: "proven" on one path: JUnit passed, every declared pass token present in that test's log lines, and no skip token anywhere in them. A skip token beats a pass token (worst wins). Anything else is skipped, failed, junit-skipped, unknown-no-sentinel-declared or unknown-sentinel-absent — all non-green. L0/L1 need no sentinel because lib/run_l1_offline.py already renders their skips as JUnit <skipped/>, never as a green pass.

Staleness — a proof is a claim about a tree, not a badge

--status honours a proven row only while the run's git_sha is a descendant of the last commit touching that requirement's implementing manifests or its verifying test. Otherwise the row degrades to stale-proof, which is not green. A run captured from a dirty working tree is recorded as <sha>-dirty, which no ancestry check can satisfy — so it never counts. Re-run with --record to refresh.

A requirement reads proven only when every verifying test does; one skipped, failed or unrecorded test drags the whole requirement down. With an empty ledger --status behaves exactly as before: every requirement not-derivable, headline PROVEN 0.

How CI invokes it

CI (Gitea Actions, wired in T-0.2 / migrated in T-6.1b) calls this same run.sh:

  1. No-cluster stage (every PR, fast): ./run.sh --lint and ./run.sh --coverage-only --strict-coverage --requirements <IDs the change touches>. Validates every test parses
    • is tagged, regenerates coverage, fails the PR on tag/annotation drift (hygiene) and on any touched requirement lacking a test (merge gate). (L0 Kyverno CLI runs alongside — task T-0.5.)
  2. Cluster stage (L2/L3): CI does not hold a kubeconfig. It triggers the in-cluster Chainsaw Job (incluster/), which runs ./run.sh under the scoped conformance-runner ServiceAccount and writes JUnit + coverage to a results volume CI collects.
    • Merge gate: the Job runs --requirements <IDs the change touches>; that subset runs green and those requirements are covered to merge (R-TEST-03).
    • Phase-exit gate: the Job runs --layer all --strict-coverage; add --require-all only at the milestone where the full requirement set is expected covered. The phase suite must be green before the phase's exit criteria are met.

Phase 0 status (no cluster yet)

No cluster exists, so L1/L2/L3 cannot execute. The behavior tests are valid scaffolds (steps + assertions authored, tagged, TODO(T-x.y) where a later phase delivers the fixture). In this phase run.sh:

  • always generates the coverage report (cluster-independent), and
  • emits a skipped JUnit listing every scaffold (so the gate is green and the artifact shape is real), and
  • the meta/harness-selfcheck checks (reproducible output, JUnit+coverage emitted, everything tagged, zero safety-critical gaps) are what run.sh --strict-coverage actually enforces today.

As later phases deliver capabilities, each scaffold's TODOs are filled and the test flips from skipped to executing green — landing coverage without re-architecting the harness.