Files
home-network-k3s-impl/conformance
jakman85andClaude Opus 5 a92fa11701 feat(monitoring): scrape Longhorn + cert-manager — 8 alerts were structurally inert
An alert rule whose metric is never scraped is not a broken alert, it is an ABSENT
alert that reads as a present one. `longhorn_volume_robustness == 2` over a TSDB
with no longhorn_* series returns no vector: permanently quiet, permanently
"healthy", indistinguishable from a correct rule. It survives review (the PromQL is
right), it survives `promtool test rules` (the unit test supplies the series
itself), and it appears on no dashboard.

VERIFIED ABSENT ON PROD before this change (re-verified 2026-09-13 21:0xZ, ~15h
after the first audit, because a stale reading is not evidence):

  count(longhorn_volume_robustness)                      => (empty)   4 rules inert
  count({__name__=~"certmanager.*"})                     => (empty)   3 rules inert
  count(hubble_drop_total) / {__name__=~"cilium.*"}      => (empty)   1 rule inert
  count by (job) (up)                                    => 20 jobs, none of them
                                                            Longhorn, cert-manager,
                                                            Cilium or Hubble

Consequence: storage health was unmonitored on the cluster whose prod-1 had just
had a failing SSD, and TLS expiry was unmonitored with the live `wildcard-home`
wildcard 89 days from expiry on an ACME DNS-01 renewal that can fail silently.

WHAT THIS CHANGES

  * platform/longhorn: `metrics.serviceMonitor.enabled: true` (the supported chart
    way — rendering longhorn 1.12.1 emits exactly 1 ServiceMonitor with the flag and
    0 without), plus networkpolicy-metrics.yaml. The NetworkPolicy is LOAD-BEARING,
    not belt-and-braces: measured with the monitor applied and no allowance, all 3
    targets were DISCOVERED and every scrape failed `context deadline exceeded`
    after 10.0s — a drop, not a refusal — while curling the endpoint from inside
    the manager pod returned 200 in 0.031s. Longhorn 1.12.1 ships 6 NetworkPolicies
    of its own, guarded on `networkPolicies.restrictInternalTraffic` (default TRUE)
    and NOT on `networkPolicies.enabled`, so they are effectively unconditional and
    the manager policy admits only same-namespace Longhorn components. The one-line
    alternative (restrictInternalTraffic: false) was rejected: it deletes the
    internal-traffic restriction across all six components to open one port to one
    namespace, trading an R-SEC-02 control for an observability one when the two do
    not conflict. This policy is Ingress-only, ns monitoring -> app=longhorn-manager,
    TCP 9500 only; 9502/9503 (webhook, recovery backend) stay closed.

  * platform/cert-manager: `prometheus.servicemonitor.enabled: true`. Chart default
    already serves 9402; only the monitor was missing. No NetworkPolicy needed —
    neither ns cert-manager nor ns monitoring carries one. NOT paired with
    prometheus.podmonitor.enabled: the chart calls `fail` if both are set.

  * platform/kubevirt: KubeVirtVMLaunchFailing queried
    `kubevirt_vmi_phase_count{phase="Failed"}`, which DOES NOT EXIST on the deployed
    KubeVirt — a different flavour of the same defect, with the scrape target up and
    healthy. Prod has only `kubevirt_vmi_info` and `kubevirt_vmi_number_of_outdated`;
    the only phase-bearing names are the phase_transition_time histograms. Corrected
    to `kubevirt_vmi_info{phase="failed"}` — LOWERCASE, because KubeVirt lowercases
    the value (observed `phase="running"` for the unifi VMI) and a capital-F matcher
    would select nothing and be exactly as inert.

PROVEN, NOT ASSERTED. The rendered ServiceMonitors were applied to prod by hand and
the metrics observed to land, then REMOVED again so the merge's Helm upgrade creates
and owns them rather than colliding with an unowned object:

  count(up{job="longhorn-backend"})        => 3        (all three managers, up==1)
  count({__name__=~"longhorn.*"})          => 8717     (was empty)
  count(longhorn_volume_robustness)        => 44
  up{namespace="cert-manager"}             => 3 targets, all 1
  count({__name__=~"certmanager.*"})       => 104      (was empty)
  wildcard-home days to expiry             => 89.2, ready_status{condition="True"}=1

All 58 alert rules in the repo were then evaluated against prod one by one: every
one returns a result without error, and exactly ONE fires.

TRIAGE OF THE ONE NEWLY-FIRING ALERT — A TRUE POSITIVE, NOT TUNED AWAY.
LonghornVolumeCapacityHigh on the Prometheus TSDB volume:
`longhorn_volume_actual_size_bytes / longhorn_volume_capacity_bytes` = 0.971
(48.6 GiB allocated of 50 GiB) while the kubelet's view of the same filesystem is
only 20.4% used. Longhorn is not reclaiming freed blocks, so ~48.6 GiB x 3 replicas
is committed for ~10 GiB of real data. On a cluster whose failure mode is disk
pressure and whose etcd WAL shares a spindle with Longhorn, that is worth knowing
and was invisible until now. The threshold is left alone; the follow-up is
filesystem trim, not a bigger number.

THE GUARD (conformance/tests/l0-static/alert-metrics-have-scrape-targets)

Whole-repo L0 invariant, 118 assertions. It crosses from each alert to the scrape
config that makes its metric exist: every metric name is claimed by exactly one
declared owner, and each owner's enabling declaration is re-proven against the
working tree on every gate run, including the negative conditions that defeat an
enablement without deleting it. `up{job="X"}` is audited on its own axis, because
`up` always exists: where this repo authors a Service and a ServiceMonitor in the
same file, the monitor's endpoint port must be a port that Service defines.

It DECLARES conformance.home.k3s/scope: "whole-repo" and adds NO ci/l0.sh stage —
the whole-repo-invariants-wired meta-test fails the gate if one reappears.

PROVEN ABLE TO FAIL, five ways, each reverted:
  1. an alert on a fabricated metric          -> FAIL "claimed by NO entry"
  2. longhorn metrics.serviceMonitor removed  -> FAIL "no longer matches"
  3. networkpolicy-metrics.yaml unwired       -> FAIL "the monitor alone is NOT enough"
  4. cert-manager podmonitor also enabled     -> FAIL "FORBIDDEN pattern"
  5. a register entry made stale / removed    -> FAIL in both directions

TWO SHRINK-ONLY REGISTERS, because a blanket fail would make the gate red until
work that is not this change lands, and a disabled gate protects nothing:

  * KNOWN_UNWIRED_METRICS (1): hubble_drop_total. See below.
  * KNOWN_UNWIRED_JOBS (12): every apps/* `<app>-down` SLI rule is ALSO inert, and
    this was found by the re-audit, not previously known. The rendered
    ServiceMonitors name `port: metrics` and NOT ONE of the twelve app Services
    defines a port of that name (zero occurrences of `name: metrics` across all
    twelve manifests); prod confirms none of the twelve jobs appears in `up`. This
    is the nastiest variant of the class: `up` exists in abundance so nothing looks
    wrong, and PrometheusTargetsMissing cannot see it either, because a target that
    was never discovered has no up series to be 0. Closing it needs an exporter per
    app (Plex, Pi-hole, Jackett, qBittorrent, Sonarr, Radarr, Seerr speak no
    Prometheus) — except keycloak, where the live jobs are keycloak-service /
    keycloak-discovery and the rule is a rename away from being live.

NOT DONE HERE, DELIBERATELY: Hubble. hubble.metrics.enabled RECONFIGURES THE CNI —
it opens 9965 on every cilium-agent and rolls the DaemonSet across all three nodes,
serialised by this repo's own maxUnavailable: 1 (set after the 2026-09-01
k8sServiceHost outage, where a CNI roll deadlocked 2 of 3 agents). It also needs a
matching edit to roles/cilium/templates/cilium-values.yaml.j2 in the same commit,
because platform/cilium/helmrelease.yaml carries a [parity] contract against it and
a rebuilt node would otherwise bootstrap a CNI config that disagrees with the
reconciled one. Recommended as its own change on the cilium Kustomization, with the
Ansible parity edit, a narrow labelsContext, and a one-node canary — not smuggled in
here.

ALSO FOUND, NOT FIXED HERE: kube-state-metrics is OOMKilled every ~10 minutes on
prod (90 restarts in 16h against a 128Mi limit) while its logs show the
custom-resource-state factory reloading every 3 seconds. Thirteen alert rules read
kube_* metrics, so those rules are INTERMITTENTLY inert — the same defect class,
arriving through a crashloop rather than a missing monitor. Needs its own change.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-13 16:21:44 -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")
./run.sh --layer l0 --include-scope whole-repo   # union: the tagged subset PLUS every test
                                      #   declaring `conformance.home.k3s/scope: whole-repo`
./run.sh --layer l0 --print-selection # print the dirs the selection resolves to; run nothing

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.

  • --include-scope whole-repo — the escape hatch scoping needs, because scoping has one blind spot and it is the worst-shaped kind. Some L0 invariants quantify over the WHOLE tracked tree (no plaintext secrets anywhere; no RBAC wildcard anywhere), or over subtrees the coarse path→requirement map keys to nothing (apps/, infra/, most of platform/). For those, the change most likely to break the invariant attributes no requirement at all, so requirement-scoped selection filters the check out of exactly that change. Such a test declares its scope:

    metadata:
      annotations:
        conformance.home.k3s/scope: "whole-repo"
    

    and --include-scope whole-repo unions every declaring test into the selection. The CI stages (ci/l0.sh, ci/l1.sh, ci/conformance.sh) all pass it, so adding the annotation is the only step — there is nothing to remember and nothing to hand-write. Two consequences, both deliberate: a declaring test always runs, and a SKIP of a declaring test is UNWIRED, not green (the runner exits 3, which ci/l0.sh turns into the gate's own rc 3 — never a pass). tests/l0-static/whole-repo-invariants-wired proves the mechanism end-to-end and falsifiably, so it cannot rot silently. This replaced a hand-written "UNCONDITIONAL" stage per check in ci/l0.sh; see lib/common.sh for the full rationale and the incidents behind it.

  • 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.