Files
home-network-k3s/docs/14-testing-and-conformance.md
jakman85andClaude Opus 5.5 250fb54154 docs: bring the specification in line with the accepted implementation
The operator ruled the implementation repo's main (50c8719) the latest
and accepted state. Sync the spec to it.

Accepted changes are reflected: one bare-metal environment, TrueNAS bulk
storage, R2 off-cluster backup, CloudNativePG, single-node VPN gateway
with Cilium fail-closed, inbound WireGuard, per-app auth sidecars,
Renovate, the CI gate as approver, and the pm-agent. New ADR-0017..0025;
ADR-0016 corrected and accepted; R-* IDs unchanged, eight amended.

Where the implementation has simply not met a requirement, the
requirement stands and the shortfall is recorded in docs/16-known-gaps.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:46:00 -06:00

15 KiB

14 — Testing & Conformance

Serves: R-TEST-01..06, and is the verification mechanism for every other requirement. Related: ADR-0010.

This spec changes what "done" means. Nothing is accepted on a manual demo, a screenshot, or "it reconciled." Every requirement is proven by an automated, reproducible, behavior-based test that a machine can run on demand and that is tagged with the requirement ID(s) it verifies.

Principles

  1. Behavior-based (R-TEST-01). Tests assert observable system behavior — Given a state, When an action, Then an outcome — not implementation internals. "A pod with no allow rules cannot reach Plex" is a behavior; "a CiliumNetworkPolicy object exists" is not (it may exist and still not enforce). Test the behavior.
  2. Reproducible (R-TEST-02). Deterministic, hermetic, version-pinned, single-command, and environment-agnostic. Any conforming cluster + a kubeconfig ⇒ the same pass/fail. No flakiness budget; eventual-consistency is handled with bounded polling, not sleeps-and-hope.
  3. Conformance gates delivery (R-TEST-03). The suite blocks merges (for touched requirements) and blocks phase exits (full phase suite). Green suite is the definition of progress.
  4. Traceable (R-TEST-04). Each test declares its R-* IDs; a generated coverage report maps every requirement to its verifying test(s). Uncovered requirements are visible gaps.
  5. Fault-injection for safety-critical behavior (R-TEST-05). Default-deny, VPN kill-switch, data-survival, DR restore, and auto-rollback are proven by breaking things and asserting fail-closed / no-data-loss — not by happy-path checks.
  6. Specs are the fixed target (R-TEST-06). Tests encode the spec's intent. Agents never weaken a test or a requirement to get green; a genuine inability to meet the spec is an escalation to the human, not a spec/test edit. The implementation pins the spec commit it is built against, and each spec advance is adopted explicitly (../CONTRIBUTING.md).

Reproducibility contract (what "reproducible" concretely requires)

  • Single command runs the suite (or a tagged subset) locally and in CI, in the same pinned toolbox image in both places.
  • Hermetic fixtures: each test provisions its own namespace/workloads/probes and tears them down; it never depends on pre-existing mutable state or on another test's side effects.
  • Deterministic: fixed inputs, pinned tool/image versions, bounded retries with explicit timeouts for eventual consistency; no reliance on wall-clock timing or ordering.
  • Isolated & parallel-safe: unique namespaces/labels; tests can run concurrently without cross-talk.
  • Machine-readable output: JUnit (or equivalent) plus a requirement-coverage report; every result carries the R-* tags it verifies.
  • Self-cleaning: on pass or fail, fixtures are removed so a re-run starts clean (leaked fixtures are themselves a test failure).
  • Versioned with the code: tests live in the implementation repo next to what they verify and evolve only when the human-owned spec they encode changes.

Test layers

Layer Runs Verifies Example
L0 Static/policy Pre-apply, in CI Manifests/ServiceDefinitions obey invariants Every namespace has a default-deny baseline; no plaintext secrets; schema-valid ServiceDefinition
L1 Rendering CI, no cluster ServiceDefinition → rendered resources match golden expectations egress: vpn renders the kill-switched egress path
L2 Behavioral/integration Against the live cluster Real runtime behavior Default-deny probe, dependency allow/deny, egress-IP, TLS validity, DNS, PVC rebuild
L3 Fault-injection Against the live cluster Fail-closed / no-data-loss under failure VPN tunnel forced down ⇒ no leak; node killed ⇒ data survives; bad update ⇒ auto-rollback; DR restore
L4 Conformance suite CI + on-demand Aggregate, requirement-tagged gate The union of the above, reported per R-*

Behavior test catalog (normative — implementers author these as automated tests)

Each maps to requirement IDs; phrased as Given/When/Then intent.

  • Default-deny (R-NET-01): Given a fresh workload with no allow rules, When it attempts any in-cluster connection except DNS, Then the connection fails; DNS succeeds.
  • Explicit allow / deny-undeclared (R-NET-02): Given declared dependencies, When the source reaches a declared dest port, Then it succeeds; When it reaches an undeclared dest, Then it fails.
  • Egress class (R-NET-05): Given egress: internet vs egress: vpn workloads, When each queries an external echo of its public IP, Then the internet one shows the home/WAN IP and the vpn one shows the VPN IP.
  • VPN kill-switch (R-NET-06, L3): Given a vpn workload with a healthy tunnel, When the tunnel is forced down, Then the workload has zero egress to the plain internet and internet-class workloads are unaffected; When the tunnel recovers, connectivity returns.
  • Egress flip (R-NET-07): Given a running service, When its egress class is changed in git and reconciled, Then its observed egress path changes with no workload rewrite.
  • Data survival (R-STATE-01, L3): Given a stateful service with written data, When the workload is destroyed and re-applied from git, Then the data is intact.
  • Node loss (R-STATE-02, L3): Given a 3-replica volume, When a node is removed, Then the volume stays available and the cluster reconverges.
  • DR restore (R-STATE-03, L3): Given an off-cluster backup, When a namespace + volumes are restored into a clean state, Then the service returns with its data.
  • Trusted TLS (R-TLS-01/02): Given any exposed endpoint (incl. internal), When its cert is inspected, Then it is valid, non-self-signed, unexpired; renewal occurs before expiry with no manual step.
  • DNS/exposure (R-DNS-*): Given a service, When resolved, Then the record exists and internal clients hit the internal Gateway; internal is unreachable publicly, public is reachable + TLS-valid.
  • Public ingress path (R-DNS-04/05, R-SEC-04): Given the tunnel ingress config (a deny-list of LAN/VPN-only hostnames ahead of a zone wildcard to the shared Gateway), When it is rendered, Then every rule targets only the shared Gateway or a 404, every LAN/VPN-only hostname 404s ahead of the wildcard, the last rule is http_status:404, and no rule targets the Kubernetes API (L0). When a LAN/VPN-only hostname is requested at the tunnel, Then it gets 404. When a public name is resolved via a public resolver from the LAN, Then it is reachable + TLS-valid (L2).
  • Hands-off update (R-UPD-01/02, L3): Given an out-of-date service under a stable policy, When the update schedule elapses, Then a machine-authored change advances it through the merge gate with no human/AI edit; Given a deliberately bad update, Then health-gating auto-rolls-back and an email alert fires; Given a pinned service, Then it does not advance.
  • VM lifecycle (R-PLAT-03, L2/L3): Given a kind: vm ServiceDefinition, When create/start/ stop/destroy via kubectl/virtctl, Then it behaves; its disk survives rebuild; default-deny + egress class apply.
  • Alerting (R-OBS-02/04, L3): Given a monitored condition, When a fault is injected, Then an email alert is delivered and the dashboard reflects it.
  • GitOps drift (R-GITOPS-03, L3): Given a reconciled resource, When it is mutated live, Then Flux reverts it to the git-declared state.
  • Onboarding completeness (WS-APPS): Given only a merged ServiceDefinition, Then the service is fully wired (compute/storage/policy/egress/TLS/DNS/monitoring/update) with no manual step — asserted by the union of the relevant tests above against that service.

Gating rules (R-TEST-03)

  • Per-change (merge gate): the conformance subset tagged with the requirement IDs the change touches MUST pass before merge.
  • Per-phase (exit gate): the full conformance suite for the phase's requirements MUST pass before the phase's exit criteria are considered met (../roadmap/phases.md).
  • Regression: the growing suite runs continuously against the live cluster; a previously-green requirement going red blocks further delivery until fixed.
  • Coverage: a requirement with no passing test is not satisfied, regardless of implementation state.
  • Non-answers are never green: a stage that did not run is never reported as passing (05; see known gaps).

Coverage, status and proof (R-TEST-04)

Three different questions, answered separately so that none is mistaken for another:

  • Coverage — does a requirement-tagged test exist for each R-*?
  • Status — derived from the repository alone: is the requirement implemented, is that implementation actually reconciled by Flux, and is its test executable or a scaffold?
  • Proof — did a run actually pass? Proof is a committed, machine-readable run ledger. A test that skips itself is never proof, and a proof goes stale when the implementation or test moves on.

./conformance/run.sh --status in the implementation repo produces the live view. 16-known-gaps.md is a dated snapshot.

Tooling (pinned — ADR-0011)

The approach (ADR-0010) is realized with two tools from the Kyverno ecosystem:

  • Kyverno — L0 static/policy gate and in-cluster admission enforcer. In CI, the Kyverno CLI (kyverno apply / kyverno test, incl. kyverno-json for ServiceDefinitions) validates manifests and rendered resources against platform invariants (default-deny baseline present, no plaintext secrets, required labels/egress-class, schema conformance). The same policies also run in-cluster as an admission controller, rejecting any non-conforming resource at reconcile time (defense in depth — a resource that somehow bypassed CI still cannot be admitted). Enforcement never replaces behavior tests: a policy admitting a manifest is not proof the runtime behaves (R-TEST-01), so L2/L3 Chainsaw tests remain mandatory. Policies are authored once and shared between the CLI gate and the admission controller.
    • ServiceDefinition validation is two complementary gates, not one (do not consolidate): the JSON Schema (schemas/servicedefinition.schema.json) checks shape — types, enums, required fields, exactly-one-of source, public ⇒ auth.mode≠none — as a fast, cluster-free PR check; Kyverno kyverno-json enforces the cross-cutting invariants the schema can't express (longhorn ⇒ size, irreplaceable⇒off-cluster-backup, label conventions). Both are L0 and run in CI; they overlap by design (defense in depth), not by redundancy.
  • Chainsaw — L1 rendering, L2 behavioral, and L3 fault-injection. Declarative steps (apply/assert/error, script/command, try/catch/finally), bounded polling with explicit timeouts, per-test namespace isolation, and automatic cleanup satisfy the reproducibility contract. Fault-injection (force the tunnel down, cordon/kill a node, inject a bad image, trigger a restore) is expressed as Chainsaw script steps, asserting fail-closed / no-data-loss via probe fixtures, with finally restoring state. Cluster-free L0 and L1 checks are declared as Chainsaw tests for tagging and execute through the harness's offline runner, so they need no cluster.
  • Repository invariants — L0 also carries whole-repo checks a per-resource policy cannot express (secret hygiene across every tracked file, least-privilege RBAC, the spec pin), which run on every gate regardless of the requirements a change touches.

Probes are ephemeral job pods; egress-IP checks use an external echo; tests carry their R-* IDs (labels/annotations/naming) so the coverage report is generated automatically; both tools' output feeds JUnit + the requirement-coverage report and the CI merge / phase-exit gates. Tool versions are pinned for determinism.

Where each layer runs

  • L0 (Kyverno CLI) and L1 (rendering assertions) run in CI without a cluster — pure manifest/ ServiceDefinition validation; fast merge-gate feedback.
  • L2 behavioral and L3 fault-injection run as an in-cluster Chainsaw Job on the live cluster. CI triggers the Job (and waits for it); the Job — not CI — holds cluster access, so CI never needs a broad kubeconfig. CI holds only a narrow trigger credential, whose reach is bounded by a fail-closed admission policy on the Job's shape. This keeps credentials in-cluster and least-privileged (R-SEC-02).
    • The Job runs under a dedicated, scoped test ServiceAccount whose RBAC grants exactly what the behavior/fault tests need (create/delete test namespaces + fixtures + probes, apply/patch NetworkPolicies, scale the VPN sidecar/gateway, cordon/drain a node, inject a bad image tag, trigger a Velero restore). This is a privileged test identity — it MUST be scoped, named, and documented, MUST NOT be reused by workloads, and its own reach is itself an audit item.
    • The Job emits JUnit + coverage artifacts over its log stream, in an integrity-checked envelope that CI collects to enforce the merge / phase-exit gates.
    • Fixtures are hermetic and self-cleaning (finally steps); a leaked fixture fails the run.

Acceptance gates (for the testing capability itself)

  • A single command runs the suite and emits requirement-tagged JUnit + coverage.
  • Every R-* requirement maps to at least one passing behavior test (coverage report proves it).
  • Safety-critical requirements each have a fault-injection test that fails closed / loses no data.
  • Re-running the suite yields identical results (reproducibility) with no leaked fixtures.

Open questions

  • Tool mix is resolved: Kyverno (L0) + Chainsaw (L1/L2/L3), ADR-0011.
  • Harness location is resolved: L2/L3 run as an in-cluster Chainsaw Job triggered by CI; CI holds no broad cluster credential, only the admission-bounded trigger.
  • Kyverno-as-admission-enforcer is resolved: yes — the L0 policies also enforce at admission in-cluster (defense in depth), in addition to the CI gate.
  • The test ServiceAccount's RBAC is resolved: scoped least-privilege to precisely the fault-injection actions it performs (07), with its grants justified per test in the implementation repo.
  • The results-artifact mechanism is resolved: the log stream, as above.