Files
home-network-k3s-impl/conformance/tests
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 tests — layout & requirement-tagging convention

Every test is a Chainsaw Test in its own directory:

tests/
  l0-static/           L0 — whole-repo invariants over the tracked tree (no cluster)
  l1-rendering/        L1 — ServiceDefinition -> rendered resources match golden
  l2-behavioral/       L2 — real runtime behavior (probes against a cluster)
  l3-fault-injection/  L3 — break something; assert fail-closed / no-data-loss
  meta/                harness self-checks (R-TEST-*), cluster-independent
    <test-name>/chainsaw-test.yaml

The layer a test belongs to is its subtree and its .../layer annotation; the coverage generator cross-checks the two.

L0 static (l0-static/) — what belongs here, and what does not

docs/14's L0 row is "Pre-apply, in CI · manifests/ServiceDefinitions obey invariants". Kyverno (platform/kyverno/) covers the invariants expressible over a single resource. This subtree covers the L0 invariants that are properties of the repository as a whole and that no per-resource policy can state — e.g. no plaintext secret exists anywhere in the tracked tree, no tracked RoleBinding grants a workload identity a privileged role, the spec-immutability gate actually blocks a modified spec.

Each l0-static/<name>/ dir carries a check.sh: a self-contained offline assertion script (git + Python stdlib/PyYAML; no cluster, no network) that exits 0/1. It is executed:

  • by conformance/run.sh --layer l0 (and as the offline leg of a full run), via lib/run_l1_offline.py, producing a REAL pass/fail JUnit entry — never a skip; and
  • by the merge gate's ci/l0.sh stage, so these assertions run on every PR; and
  • by the wrapping Chainsaw Test, which runs the SAME check.sh, so an in-cluster run executes byte-identical assertions.

Two authoring rules specific to this layer:

  1. State the gap. A statically-provable half of a requirement is still only half. Every check.sh header ends with an explicit "what this does NOT prove" list naming the cluster-layer remainder. Covering a requirement at L0 does not finish it.
  2. Exemptions must be self-policing. Where a check must tolerate something (a deliberately-plaintext policy fixture, an .example template), the exemption is conditioned on a property that would break if the exemption were abused — e.g. the plaintext Secret fixture is exempt only while a kyverno-test.yaml asserts the policy rejects it. Never a bare path allowlist.

Requirement-tagging convention (authoritative)

A test declares the requirement(s) it verifies three ways; the label is the source of truth, the annotations must agree with it (the coverage generator fails on drift):

metadata:
  name: default-deny
  labels:
    # ONE label per verified R-* ID. Machine-selectable + coverage source of truth.
    conformance.home.k3s/R-NET-01: "true"
  annotations:
    # Human-readable canonical list — MUST equal the label set.
    conformance.home.k3s/requirements: "R-NET-01"
    # Declared layer (L0|L1|L2|L3|META) — must match the subtree.
    conformance.home.k3s/layer: "L2"
    # true => R-TEST-05 requires this to be (or have) an L3 fault-injection test.
    conformance.home.k3s/safety-critical: "false"
    # Given/When/Then intent, for the coverage report and reviewers.
    conformance.home.k3s/intent: "Given ... When ... Then ..."

Why a label per requirement (not a comma list): Kubernetes label values can't hold a comma-separated list, and chainsaw test --selector selects on label keys/values. One conformance.home.k3s/<R-ID>=true label per requirement makes both subset-selection and coverage extraction exact and robust.

How this drives the tooling

  • Subset runs (merge gate): run.sh --requirements R-NET-01,R-NET-06 resolves the R-IDs to the set of test dirs carrying those labels (union, deduped) and runs each once.
  • Coverage (lib/coverage.py): reads these labels from every chainsaw-test.yaml, maps each registry requirement -> its verifying test(s), and flags gaps + any label/annotation mismatch + tags not in the registry.
  • JUnit: results (real or skipped-scaffold) carry the R-IDs in classname + properties, so the JUnit itself stays traceable (R-TEST-04).

Authoring rules (R-TEST-01/02/06)

  • Behavior, not existence. Assert what the system does (a probe cannot connect), never that an object merely exists (a policy can exist and not enforce).

  • Given/When/Then structure with given-*/when-*/then-* step names.

  • Hermetic & self-cleaning. Rely on Chainsaw's per-test namespace + auto-teardown; put any out-of-namespace fixture cleanup in a finally block. A leaked fixture is a failure.

  • Bounded, deterministic waits. Use assert/error polling within the config timeouts; never sleep-and-hope.

  • Safety-critical => fault-injection. For a safety-critical requirement, actually break the thing and assert fail-closed / no-data-loss, with finally restoring state.

  • Never weaken a test to go green (R-TEST-06). If the spec can't be met, escalate to the PM — do not relax the assertion or the requirement.

  • Never write a per-environment value into a test (R-ENV-02). No hostname suffix, no Gateway VIP, no LB-pool range. One authored suite must run green against dev and prod, so a test asks the cluster it is pointed at what it is. The mechanism is a chainsaw binding over the same ConfigMap Flux substitutes from:

    spec:
      bindings:
        - name: baseDomain
          value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
    

    Declare it at test level (step outputs/bindings are step-scoped), then consume it: ($baseDomain) for a pod env: value, (join('.', ['myhost', $baseDomain])) where a full hostname is needed — including inside a fixtures/*.yaml applied with apply: file:, which chainsaw templates with the same bindings. Prefer reading the actual observed value over restating a range (e.g. assert the DNS answer equals GATEWAY_VIP, or equals the LB IP the Service was assigned) — it is both stricter and environment-independent. Never take the environment from a CLI flag or an exported variable: a flag someone forgets is how a suite ends up silently asserting the other cluster's expectations. L1 goldens are the one exception: they are rendering fixtures and carry the literal ${BASE_DOMAIN} token.

How a scaffold declares itself (and how status.py tells one from a verifier)

Scaffolds in this tree carry TODO(T-x.y) markers where a fixture/assertion lands as a later roadmap phase delivers the capability under test. lib/status.py classifies each test EXECUTABLE or SCAFFOLD from the test's own files — chainsaw-test.yaml, render-check.sh, check.sh — and either signal below outranks conformance.home.k3s/executable: "true", so a placeholder body can never be reported as an executable test. Declare a scaffold in both positions:

  1. # STATUS: scaffold — <what is missing> in the file's header comment block. The declared state must be the first token after STATUS: (executable tests say # STATUS: EXECUTABLE / IMPLEMENTED / RUNNABLE).
  2. A TODO/FIXME marker carrying a message, in the body — the file from its first non-comment line onward. echo "TODO(T-4.2): inject a fault to fire a rule", or an inline comment on a missing fixture: file: fixtures/smtp-sink.yaml # TODO(T-4.1): hermetic SMTP capture sink.

What is not a scaffold marker, and why the distinction is load-bearing: a marker mentioned in the header prose (the Given/When/Then and the mandatory "what this does NOT prove" list), and a marker that is the entire content of a quoted literal — a placeholder detector's own vocabulary, e.g. SEED = ["TBD", "TODO", "FIXME", ...] in l0-static/placeholder-guard-coupling/check.sh. A whole-file "TODO" in text test used to classify that 32-assertion check as a scaffold and, through it, report the safety-critical R-STATE-03 as exec=1 scaffold=1. If your check must hold a marker as data, keep each idiom a standalone quoted literal in a list; a marker with text attached (a regex alternation, several idioms in one string) still classifies as a scaffold, because ambiguity here only ever downgrades.