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]>
This commit is contained in:
1 parent
f4f6e5f7a3
commit
a92fa11701
10 files changed
+1069
-1
No files matched your search
@@ -0,0 +1,119 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — every metric an alert rule queries is CLAIMED by a scrape target this repo configures
|
||||
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||
#
|
||||
# THE DEFECT THIS CLOSES. 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, so the rule is permanently quiet, permanently "healthy", and
|
||||
# indistinguishable from a correct rule on a healthy cluster. It survives review (the PromQL is
|
||||
# right), it survives `promtool test rules` (the unit test supplies the series itself), and it
|
||||
# survives every dashboard (nobody looks for a panel that was never built).
|
||||
#
|
||||
# MEASURED ON PROD, 2026-09-13, before this change — 13 rules across three families were inert:
|
||||
# count(longhorn_volume_robustness) => (empty) 4 rules
|
||||
# count({__name__=~"certmanager.*"}) => (empty) 3 rules
|
||||
# count(hubble_drop_total) => (empty) 1 rule
|
||||
# count by (job) (up) => 20 jobs, none Longhorn/cert-manager/Hubble
|
||||
# So storage health was unmonitored on a cluster whose prod-1 had just had a failing SSD
|
||||
# (DECISIONS-LOG), TLS expiry was unmonitored with the live `wildcard-home` wildcard 89 days from
|
||||
# expiry, and the platform had no policy-drop visibility at all. The Flux family (gotk_*, 5 rules)
|
||||
# was the same defect and was fixed separately in a79693d.
|
||||
#
|
||||
# GIVEN every alerting rule in every PrometheusRule in the repo (platform/**, apps/wave-*),
|
||||
# WHEN each rule's PromQL is parsed for the metric names it selects,
|
||||
# THEN (1) every metric name is CLAIMED by exactly one declared scrape owner, and each owner's
|
||||
# claim is PROVEN AGAINST THE REPO — the file that enables that scrape must still contain
|
||||
# the enabling declaration, so deleting `metrics.serviceMonitor.enabled` from
|
||||
# platform/longhorn/helmrelease.yaml fails this check instead of silently re-inerting four
|
||||
# storage alerts;
|
||||
# (2) an owner's NEGATIVE conditions hold too, where the enablement can be defeated without
|
||||
# being deleted — cert-manager's chart calls `fail` if podmonitor and servicemonitor are
|
||||
# both set, Longhorn's own NetworkPolicies drop the scrape unless
|
||||
# networkpolicy-metrics.yaml is wired, and kube_node_labels does not exist at all unless
|
||||
# kube-state-metrics' metricLabelsAllowlist names the label;
|
||||
# (3) a metric with NO owner FAILS, unless it is declared in KNOWN_UNWIRED_METRICS — a
|
||||
# shrink-only register (hubble_drop_total today, with the CNI-rollout reason recorded);
|
||||
# (4) `up{job="X"}` is audited on its own axis, because `up` always exists and so the metric
|
||||
# check above can never see this failure: where the repo authors a Service and a
|
||||
# ServiceMonitor in the SAME file, the monitor's endpoint port must be a port that Service
|
||||
# actually defines. All 12 apps/* `<app>-down` SLI rules fail this today (the monitors name
|
||||
# port `metrics`; no app Service defines one), so they are registered in
|
||||
# KNOWN_UNWIRED_JOBS with the live proof that their `up{job=...}` series do not exist;
|
||||
# AND (5) the check is LOAD-BEARING: this Test DECLARES `conformance.home.k3s/scope: whole-repo`,
|
||||
# so ci/l0.sh Stage 3b's `--include-scope whole-repo` unions it into EVERY gate
|
||||
# invocation, and ci/gate.sh blocks the merge when the l0 stage fails.
|
||||
#
|
||||
# WHY BOTH REGISTERS ARE RATCHETS AND NOT BLANKET FAILURES: the same reasoning as
|
||||
# alert-rules-unit-tested. Failing outright on the 12 app SLI rules would make the gate red until
|
||||
# exporters exist for Plex, Pi-hole, Jackett, qBittorrent, Sonarr, Radarr, Seerr and friends —
|
||||
# none of which speak Prometheus natively — so the gate would be disabled, and a disabled gate
|
||||
# protects nothing. Merely printing the gap is how these 13 rules stayed inert for three phases.
|
||||
# So each gap is DECLARED, counted on every run, and CANNOT GROW: a new alert on an unclaimed
|
||||
# metric FAILS, a new Service+ServiceMonitor pair with a mismatched port FAILS, and a STALE
|
||||
# register entry (the gap was closed, or the alert deleted) FAILS too, so neither register can rot
|
||||
# into fiction.
|
||||
#
|
||||
# STATUS: EXECUTABLE. `check.sh` is an offline assertion script (PyYAML, the same dependency as
|
||||
# catalog/render.py and dev-nodes-provisioned-as-code); conformance/run.sh --layer l0 and ci/l0.sh
|
||||
# run it directly, and this Chainsaw Test wraps the SAME script.
|
||||
#
|
||||
# NOT proven (all named in check.sh's header, which carries the full list). The one worth stating
|
||||
# here because it bit this very repo: an owner claims metrics by PREFIX, so this proves the SCRAPE
|
||||
# TARGET exists, never that the exporter emits that exact SERIES NAME.
|
||||
# `kubevirt_vmi_phase_count{phase="Failed"}` sat inside the scraped kubevirt_ family on an UP target
|
||||
# and did not exist at all on the deployed KubeVirt (corrected here to
|
||||
# `kubevirt_vmi_info{phase="failed"}`, lowercase — a capital-F matcher is equally inert). Catching
|
||||
# that needs the exporter's own metric list, i.e. a live read; it belongs to a future L1/L3 check
|
||||
# that diffs alert metrics against /api/v1/label/__name__/values.
|
||||
#
|
||||
# Also not proven: that a target is UP. This is static — it asserts the
|
||||
# enabling declaration is in git, never that the endpoint answers, that the chart still honours the
|
||||
# value key, or that a NetworkPolicy is sufficient. Those were established by live prod reads
|
||||
# recorded in the commit message (longhorn_volume_robustness 44 series, certmanager_* 104 series,
|
||||
# all six new targets up==1). It also cannot check a monitor whose Service comes from a Helm chart
|
||||
# rather than from this repo — the port name lives outside the tree — which is why (4) is scoped to
|
||||
# same-file Service+ServiceMonitor pairs. And it does not judge thresholds or reachability-to-human
|
||||
# (R-OBS-02 / the L3 alerting test).
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: alert-metrics-have-scrape-targets
|
||||
labels:
|
||||
conformance.home.k3s/R-OBS-04: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-OBS-04"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
# WHOLE-REPO SCOPE — this invariant quantifies over every PrometheusRule in the tree AND over
|
||||
# every scrape-enabling declaration that feeds them, which live in eight different components
|
||||
# (platform/monitoring, platform/longhorn, platform/cert-manager, platform/velero,
|
||||
# platform/kubevirt, platform/keycloak, platform/cnpg, apps/wave-*). No single requirement
|
||||
# path-maps to that set.
|
||||
#
|
||||
# WHY IT MUST NOT BE REQUIREMENT-SCOPED: ci/touched-requirements.sh maps apps/* to NOTHING and
|
||||
# platform/monitoring/* to R-OBS-01/R-OBS-02 — not R-OBS-04. A change that adds an alert under
|
||||
# apps/ or platform/kubevirt/, or one that flips a chart's `serviceMonitor.enabled` back to
|
||||
# false under platform/longhorn/, therefore attributes a requirement set that would FILTER THIS
|
||||
# CHECK OUT of exactly the change that re-inerts the alerts. Declaring the scope makes
|
||||
# ci/l0.sh Stage 3b union it into every run instead, and makes an honest SKIP loud (rc 3 =
|
||||
# UNWIRED, never green) rather than silently green.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
Every metric name queried by every alert rule is claimed by a scrape target whose enabling
|
||||
declaration is still present in this repo, every `up{job=...}` selector names a job a repo
|
||||
monitor can actually produce, and the two gaps that remain are enumerated in registers that
|
||||
can only shrink — so an alert can never again be authored, reviewed and merged against a
|
||||
metric nothing scrapes.
|
||||
spec:
|
||||
steps:
|
||||
- name: audit-every-alert-metric-against-the-configured-scrape-targets
|
||||
description: >-
|
||||
Parse every metric name out of every alerting rule, resolve each against the declared
|
||||
scrape-owner table, prove each owner's enabling declaration (and its negative conditions)
|
||||
still hold in git, audit `up{job=...}` against same-file Service/ServiceMonitor port
|
||||
agreement, and ratchet both registers of declared gaps.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,702 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — alert-metrics-have-scrape-targets
|
||||
# Serves: R-OBS-04 ("The platform alerts on its own health (nodes, storage, GitOps, certs,
|
||||
# VPN)." — spec docs/10-observability-and-alerting.md)
|
||||
#
|
||||
# WHY THIS EXISTS — AN ALERT ON AN UNSCRAPED METRIC IS AN ABSENT ALERT THAT READS AS A PRESENT ONE
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# `longhorn_volume_robustness == 2` evaluated against a TSDB that holds no longhorn_* series
|
||||
# returns no vector. The rule is therefore permanently quiet, permanently "healthy", and
|
||||
# INDISTINGUISHABLE from a correct rule on a healthy cluster. It passes review (the PromQL is
|
||||
# right). It passes `promtool test rules` (the unit test supplies the series itself, so a unit
|
||||
# test proves nothing about whether anything scrapes them). It shows up on no dashboard, because
|
||||
# nobody looks for a panel that was never built.
|
||||
#
|
||||
# Measured on prod 2026-09-13, BEFORE the change that added this check — 13 rules were inert:
|
||||
# count(longhorn_volume_robustness) => (empty) LonghornVolumeDegraded/Faulted/
|
||||
# CapacityHigh, LonghornNodeStorageHigh
|
||||
# count({__name__=~"certmanager.*"}) => (empty) CertificateExpiringSoon/
|
||||
# ExpiryImminent/NotReady
|
||||
# count(hubble_drop_total) => (empty) HubbleDeniedFlowSpike
|
||||
# count by (job) (up) => 20 jobs, none Longhorn, cert-manager or Hubble
|
||||
# Storage health was unmonitored on the cluster whose prod-1 had just had a failing SSD; TLS
|
||||
# expiry was unmonitored with the live `wildcard-home` wildcard 89 days out; there was no
|
||||
# policy-drop visibility at all. The gotk_* family (5 more rules) was the same defect, fixed
|
||||
# separately in a79693d.
|
||||
#
|
||||
# WHAT MAKES THIS CLASS DIFFERENT from the two failure modes alert-rules-unit-tested catches: a
|
||||
# permanently-firing rule is loud, and a rule that behaves wrongly against synthetic series is
|
||||
# caught by promtool. This one is caught by NEITHER, because the missing thing is not in the rule
|
||||
# file at all — it is a `serviceMonitor.enabled` that was never set in a completely different
|
||||
# component's HelmRelease. So the audit has to cross from the alert to the scrape config, which is
|
||||
# what the owner table below does.
|
||||
#
|
||||
# GIVEN every alerting rule in every PrometheusRule in the repo,
|
||||
# WHEN each rule's PromQL is parsed for the metric names it selects,
|
||||
# THEN (1) every metric name is CLAIMED by exactly one declared owner in SCRAPE_OWNERS, and the
|
||||
# owner's `require` regexes still match its file — so deleting the enablement from
|
||||
# platform/longhorn/helmrelease.yaml FAILS here instead of silently re-inerting four
|
||||
# storage alerts;
|
||||
# (2) each owner's `forbid` regexes do NOT match, for the cases where the enablement can be
|
||||
# defeated without being deleted (cert-manager's chart `fail`s if podmonitor and
|
||||
# servicemonitor are both enabled; an `enabled: false` inside the exporter's own block);
|
||||
# (3) a metric with no owner FAILS unless declared in KNOWN_UNWIRED_METRICS (shrink-only);
|
||||
# (4) `up{job="X"}` is audited separately — `up` itself always exists, so (1) is blind to
|
||||
# this — by requiring that where this repo authors a Service and a ServiceMonitor in the
|
||||
# SAME file, the monitor's endpoint port is a port that Service actually defines;
|
||||
# violations must be declared in KNOWN_UNWIRED_JOBS (shrink-only);
|
||||
# AND (5) the check is LOAD-BEARING: this test declares `conformance.home.k3s/scope: whole-repo`
|
||||
# so ci/l0.sh unions it into every gate run, and ci/gate.sh blocks on an l0 failure.
|
||||
#
|
||||
# WHY THE REGISTERS ARE RATCHETS (the decision and its reasons) — the same three options as
|
||||
# alert-rules-unit-tested faced:
|
||||
# * FAIL outright on all 13: correct in principle, but 12 of them are app SLI rules that need
|
||||
# Prometheus exporters for Plex, Pi-hole, Jackett, qBittorrent, Sonarr, Radarr and Seerr —
|
||||
# none of which speak Prometheus natively. The gate would be red for every change until that
|
||||
# work lands, so it would get disabled, and a disabled gate protects nothing.
|
||||
# * merely REPORT: that is precisely how these rules stayed inert across phases 4-6.
|
||||
# * RATCHET (chosen): each gap is declared here, printed with counts on every run, and CANNOT
|
||||
# GROW. A new alert on an unclaimed metric FAILS. A new same-file Service+ServiceMonitor pair
|
||||
# with a mismatched port FAILS. A STALE entry — the gap was closed, or the alert deleted —
|
||||
# also FAILS, so neither register can rot into fiction.
|
||||
#
|
||||
# WHAT THIS DOES *NOT* PROVE (honest gaps — this is a STATIC check):
|
||||
# - It does not prove a target is UP, that an endpoint answers, that the chart still honours the
|
||||
# values key, or that a NetworkPolicy is sufficient. Those were established by live prod reads
|
||||
# recorded in the commit message: with the enablements applied, longhorn_volume_robustness
|
||||
# returned 44 series, {__name__=~"certmanager.*"} returned 104, and all six new targets read
|
||||
# up==1. A live probe needs a cluster, which L0 by definition does not have.
|
||||
# - It cannot audit a ServiceMonitor whose Service is created by a Helm chart rather than by this
|
||||
# repo: the port name lives outside the tree. That is why (4) is scoped to same-file
|
||||
# Service+ServiceMonitor pairs — the apps/* ServiceDefinition pattern — and why the
|
||||
# platform/* monitors (oauth2-proxy, kubevirt, keycloak, flux) are out of its reach. Their
|
||||
# liveness is covered by the fact that their metrics have owners under (1).
|
||||
# - IT PROVES THE SCRAPE TARGET, NOT THE SERIES NAME. An owner claims a metric by PREFIX, so a
|
||||
# metric in a scraped family whose exact name the exporter does not emit still passes. This is
|
||||
# a real residual hole and it was live in this repo: `kubevirt_vmi_phase_count{phase="Failed"}`
|
||||
# (KubeVirtVMLaunchFailing) sat inside the scraped `kubevirt_` family on an UP target, and the
|
||||
# series simply does not exist on the deployed KubeVirt — verified on prod 2026-09-13, where the
|
||||
# only kubevirt_vmi_* names are `info` and `number_of_outdated`, and the only phase-bearing
|
||||
# names are the phase_transition_time histograms. Corrected in the same change to
|
||||
# `kubevirt_vmi_info{phase="failed"}` (note the LOWERCASE value KubeVirt writes: a capital-F
|
||||
# matcher selects nothing and is just as inert). Catching that class needs the exporter's own
|
||||
# metric list, which is a live read, not a static one — it belongs to a future L1/L3 check that
|
||||
# diffs every alert metric against `/api/v1/label/__name__/values`. The same limit applies to
|
||||
# every LABEL VALUE an alert matches on.
|
||||
# - The metric-name parser is a PromQL approximation, not a parser: it strips string literals,
|
||||
# label-matcher bodies and aggregation label lists, then treats remaining bare identifiers as
|
||||
# metric selectors. A metric referenced ONLY through a `{__name__=~"..."}` regex selector would
|
||||
# be missed. No rule in this repo does that, and assertion (0) fails if the parser stops
|
||||
# finding metrics at all.
|
||||
# - It does not judge thresholds, and does not prove an alert reaches a human (R-OBS-02 and the
|
||||
# L3 alerting test own that).
|
||||
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
|
||||
#
|
||||
# Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, and because this test
|
||||
# declares the whole-repo scope the runner turns it into rc 3 = UNWIRED, never green) only if the
|
||||
# repo contains no PrometheusRule at all — i.e. there is genuinely nothing to audit.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
# Honest SKIP (never green for a scope-declaring test) if there is nothing to audit at all.
|
||||
if ! grep -rl 'kind: PrometheusRule' "${ROOT}/platform" "${ROOT}/apps" >/dev/null 2>&1; then
|
||||
echo "check SKIPPED: no PrometheusRule found under platform/ or apps/ — no alert rules to audit" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
cd "${ROOT}"
|
||||
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||
"""Static audit: every alert rule's metrics are claimed by a scrape target this repo configures.
|
||||
|
||||
PyYAML is used deliberately rather than the line-based parsing the neighbouring checks favour:
|
||||
alert `expr:` values are multi-line block scalars, and a line-based reader of PromQL would be the
|
||||
kind of approximate parser that silently stops matching — the exact failure class this check
|
||||
exists to prevent. Same dependency as catalog/render.py and dev-nodes-provisioned-as-code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
|
||||
|
||||
FAILURES: list[str] = []
|
||||
CHECKS = 0
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def read(p: Path) -> str:
|
||||
try:
|
||||
return p.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# THE DECLARED SCRAPE-OWNER TABLE (assertions 1 and 2).
|
||||
#
|
||||
# Each entry claims a set of metric names (exact names and/or prefixes) and names the file whose
|
||||
# declaration is what makes those metrics exist in the TSDB, plus the regexes that prove it. This
|
||||
# is the crossing from "an alert queries X" to "something in this repo scrapes X", and it is the
|
||||
# part that makes the check a REGRESSION guard rather than a one-time audit: every `require`
|
||||
# regex is re-evaluated against the working tree on every gate run.
|
||||
#
|
||||
# `require` — ALL must match the file. The enablement itself.
|
||||
# `forbid` — NONE may match. For enablements that can be defeated without being deleted.
|
||||
#
|
||||
# Longest prefix wins, so kube_velero_schedule_ is resolved before kube_.
|
||||
# =============================================================================================
|
||||
KPS = "platform/monitoring/kube-prometheus-stack.yaml"
|
||||
|
||||
SCRAPE_OWNERS: list[dict] = [
|
||||
{
|
||||
"owner": "kube-prometheus-stack: bundled node-exporter DaemonSet + its ServiceMonitor",
|
||||
"prefixes": ("node_",),
|
||||
"file": KPS,
|
||||
# The subchart block must be present and configured here (it is where the exporter's
|
||||
# resources live). The chart default is enabled:true, so the regression to guard is a
|
||||
# DISABLE, not a missing enable.
|
||||
"require": [r"^\s*prometheus-node-exporter:\s*$"],
|
||||
"forbid": [r"prometheus-node-exporter:\s*\n(?:\s*#[^\n]*\n)*\s*enabled:\s*false"],
|
||||
"why": "node_* powers all seven platform-health.nodes rules including the two IO-pressure "
|
||||
"rules that exist because of prod-1's etcd WAL starvation",
|
||||
},
|
||||
{
|
||||
"owner": "kube-prometheus-stack: the kubelet ServiceMonitor (cAdvisor + volume stats)",
|
||||
"prefixes": ("kubelet_",),
|
||||
"file": KPS,
|
||||
# Chart default kubelet.enabled:true. There is no explicit enable to require, so the only
|
||||
# honest assertion is that it has not been turned off the way kubeProxy/kubeEtcd were.
|
||||
"require": [r"kubeProxy:"], # sentinel: the exporter-toggle block is still authored here
|
||||
"forbid": [r"\n\s*kubelet:\s*\n(?:\s*#[^\n]*\n)*\s*enabled:\s*false"],
|
||||
"why": "kubelet_volume_stats_* powers MonitoringVolumeFillingUp and all three "
|
||||
"pvc-capacity-alerts rules",
|
||||
},
|
||||
{
|
||||
"owner": "kube-prometheus-stack: kube-state-metrics custom-resource-state for Velero Schedules",
|
||||
"prefixes": ("kube_velero_schedule_",),
|
||||
"file": KPS,
|
||||
"require": [
|
||||
r"customResourceState:\s*\n\s*enabled:\s*true",
|
||||
r"metricNamePrefix:\s*kube_velero_schedule",
|
||||
# The RBAC is MANDATORY, not cosmetic: without velero.io read access KSM emits the
|
||||
# custom-resource-state metrics as an EMPTY SET and logs a forbidden error — silent,
|
||||
# and the inert-alert failure mode all over again (measured; see the block's header).
|
||||
r'apiGroups:\s*\["velero\.io"\]',
|
||||
r'resources:\s*\["schedules"\]',
|
||||
],
|
||||
"forbid": [r"customResourceState:\s*\n\s*enabled:\s*false"],
|
||||
"why": "kube_velero_schedule_* is the only thing that can see a Schedule that has NEVER "
|
||||
"backed up (VeleroScheduleNeverBackedUp / SchedulePaused / NoVolumeDataBackup); "
|
||||
"Velero itself publishes nothing until a backup is attempted",
|
||||
},
|
||||
{
|
||||
"owner": "kube-prometheus-stack: bundled kube-state-metrics",
|
||||
"prefixes": ("kube_",),
|
||||
"file": KPS,
|
||||
"require": [r"^\s*kube-state-metrics:\s*$"],
|
||||
"forbid": [r"kube-state-metrics:\s*\n(?:\s*#[^\n]*\n)*\s*enabled:\s*false"],
|
||||
# kube_node_labels is a SPECIAL CASE and is checked on its own below: KSM exposes no
|
||||
# custom node labels by default, so the metric does not exist unless metricLabelsAllowlist
|
||||
# names the label. The prefix claim alone would pass while VPNTunnelDown's join matched
|
||||
# nothing.
|
||||
"why": "kube_* powers the Keycloak, KubeVirt-crashloop, CNPG-colocation and Velero-schedule "
|
||||
"rules",
|
||||
},
|
||||
{
|
||||
"owner": "kube-state-metrics metricLabelsAllowlist (the egress-gateway node label)",
|
||||
"exact": ("kube_node_labels",),
|
||||
"file": KPS,
|
||||
"require": [
|
||||
r"metricLabelsAllowlist:",
|
||||
r"nodes=\[homelab\.keithjackson\.dev/egress-gateway\]",
|
||||
],
|
||||
"forbid": [],
|
||||
"why": "KSM exposes NO custom labels by default. Without the allowlist entry "
|
||||
"kube_node_labels carries no egress-gateway label and VPNTunnelDown's join silently "
|
||||
"matches nothing — a rule that is present, evaluating, and structurally inert",
|
||||
},
|
||||
{
|
||||
"owner": "CNPG: enablePodMonitor on the Cluster CRs (the operator renders one PodMonitor each)",
|
||||
"prefixes": ("cnpg_",),
|
||||
"file": "platform/keycloak/postgres-cluster.yaml",
|
||||
"require": [r"^\s*enablePodMonitor:\s*true\s*$"],
|
||||
"forbid": [],
|
||||
"why": "cnpg_* powers all six platform-health.cnpg rules, including the WAL-archive backlog "
|
||||
"and no-recoverability-point rules that guard R-STATE-03",
|
||||
},
|
||||
{
|
||||
"owner": "Flux controllers: platform/monitoring/flux-podmonitor.yaml (merged a79693d)",
|
||||
"prefixes": ("gotk_",),
|
||||
"file": "platform/monitoring/flux-podmonitor.yaml",
|
||||
"require": [r"kind:\s*PodMonitor", r"port:\s*http-prom"],
|
||||
"forbid": [],
|
||||
"why": "gotk_* powers FluxReconciliationFailure, FluxResourceSuspended and the three "
|
||||
"automated-update rules — on a platform whose entire delivery model is Flux",
|
||||
},
|
||||
{
|
||||
"owner": "cert-manager: prometheus.servicemonitor.enabled in the HelmRelease",
|
||||
"prefixes": ("certmanager_",),
|
||||
"file": "platform/cert-manager/helmrelease.yaml",
|
||||
"require": [r"prometheus:\s*\n\s*servicemonitor:\s*\n\s*enabled:\s*true"],
|
||||
# The chart calls `fail` when both monitor kinds are enabled, which would break the whole
|
||||
# HelmRelease rather than just the scrape. Measured against v1.21.2.
|
||||
"forbid": [r"podmonitor:\s*\n\s*enabled:\s*true"],
|
||||
"why": "certmanager_* powers CertificateExpiringSoon / ExpiryImminent / NotReady — the only "
|
||||
"warning that an ACME DNS-01 renewal of the live wildcard has quietly failed",
|
||||
},
|
||||
{
|
||||
"owner": "Longhorn: metrics.serviceMonitor.enabled in the HelmRelease",
|
||||
"prefixes": ("longhorn_",),
|
||||
"file": "platform/longhorn/helmrelease.yaml",
|
||||
"require": [r"metrics:\s*\n\s*serviceMonitor:\s*\n\s*enabled:\s*true"],
|
||||
"forbid": [],
|
||||
"why": "longhorn_* powers the four storage rules on a cluster whose recurring failure mode "
|
||||
"is a disk",
|
||||
# The ServiceMonitor ALONE is not sufficient here — see the companion assertion below.
|
||||
"companion": {
|
||||
"file": "platform/longhorn/kustomization.yaml",
|
||||
"require": [r"^\s*-\s*networkpolicy-metrics\.yaml\s*$"],
|
||||
"why": "Longhorn 1.12.1 ships its own effectively-unconditional NetworkPolicies "
|
||||
"(guarded on networkPolicies.restrictInternalTraffic, default true — NOT on "
|
||||
"networkPolicies.enabled) whose longhorn-manager policy admits only other "
|
||||
"Longhorn components in the same namespace. MEASURED on prod: with the "
|
||||
"ServiceMonitor applied and no allowance, all three targets were DISCOVERED and "
|
||||
"every scrape failed with 'context deadline exceeded' after 10.0s — a drop, not "
|
||||
"a refusal — and the TSDB stayed empty. So without networkpolicy-metrics.yaml "
|
||||
"the four storage alerts are inert even though the monitor exists",
|
||||
},
|
||||
},
|
||||
{
|
||||
"owner": "KubeVirt/CDI: platform/kubevirt/servicemonitor.yaml",
|
||||
"prefixes": ("kubevirt_",),
|
||||
"file": "platform/kubevirt/servicemonitor.yaml",
|
||||
"require": [r"kind:\s*ServiceMonitor", r"port:\s*metrics"],
|
||||
"forbid": [],
|
||||
"why": "kubevirt_* powers the four platform-health.kubevirt rules (note the CDI rules use "
|
||||
"the kubevirt_cdi_* names, not bare cdi_*)",
|
||||
},
|
||||
{
|
||||
"owner": "Velero: metrics.serviceMonitor.enabled in the HelmRelease",
|
||||
"prefixes": ("velero_",),
|
||||
"file": "platform/velero/helmrelease.yaml",
|
||||
"require": [r"metrics:\s*\n\s*serviceMonitor:\s*\n\s*enabled:\s*true"],
|
||||
"forbid": [],
|
||||
"why": "velero_* powers VeleroBackupFailure / PartialFailure / NoRecentBackup — the rules "
|
||||
"that failed to notice prod running with zero volume backups",
|
||||
},
|
||||
]
|
||||
|
||||
# `up` is produced by EVERY scrape target, so no owner can meaningfully claim it and assertion (1)
|
||||
# is structurally blind to `up{job="X"}` naming a job nothing produces. That axis is assertion (4).
|
||||
UNIVERSAL = {"up"}
|
||||
|
||||
# =============================================================================================
|
||||
# REGISTER 1 — KNOWN_UNWIRED_METRICS. Shrink-only. A metric here is queried by an alert and
|
||||
# claimed by NOTHING, so that alert is inert and the platform is blind to what it describes.
|
||||
# Removing an entry means wiring the scrape. An entry that stops being referenced by any alert,
|
||||
# or that gains an owner, FAILS as stale.
|
||||
# =============================================================================================
|
||||
KNOWN_UNWIRED_METRICS: dict[str, str] = {
|
||||
"hubble_drop_total":
|
||||
"HubbleDeniedFlowSpike — the platform has NO policy-drop / NetworkPolicy-regression "
|
||||
"visibility. Unlike Longhorn and cert-manager (whose charts already serve /metrics and "
|
||||
"only lacked a ServiceMonitor) Hubble publishes NOTHING to scrape until it is switched on "
|
||||
"in Cilium's own config: platform/cilium/helmrelease.yaml sets hubble.{enabled,relay,ui} "
|
||||
"but not hubble.metrics.enabled, and not prometheus.enabled for the agent. Turning them on "
|
||||
"OPENS PORT 9965 ON EVERY cilium-agent AND ROLLS THE CNI DaemonSet across all three nodes "
|
||||
"— the single most dangerous rollout on this cluster (the 2026-09-01 k8sServiceHost outage; "
|
||||
"cilium was moved to its own Flux Kustomization precisely to isolate that blast radius) — "
|
||||
"and requires a matching edit to roles/cilium/templates/cilium-values.yaml.j2 in the same "
|
||||
"commit or a rebuilt node's bootstrap CNI config silently disagrees with the reconciled "
|
||||
"one. Deliberately DEFERRED to its own change with a one-node canary; see the rule's "
|
||||
"header in platform/monitoring/alerts.yaml.",
|
||||
}
|
||||
|
||||
# =============================================================================================
|
||||
# REGISTER 2 — KNOWN_UNWIRED_JOBS. Shrink-only. Each is a `up{job="X"}` selector whose monitor
|
||||
# names an endpoint port that the Service it selects does not define, so the monitor discovers
|
||||
# ZERO targets, no `up{job="X"}` series exists, and `up{job="X"} == 0` can never fire. This is the
|
||||
# nastiest variant of the class: `up` exists in abundance, so nothing about the metric name looks
|
||||
# wrong, and `PrometheusTargetsMissing` cannot see it either — a target that was never discovered
|
||||
# has no up series to be 0.
|
||||
#
|
||||
# All twelve are the apps/* ServiceDefinition SLI pattern: the rendered ServiceMonitor names
|
||||
# `port: metrics`, and no app Service defines a port of that name (verified: zero occurrences of
|
||||
# `name: metrics` across all twelve manifests). VERIFIED on prod 2026-09-13: `count by (job) (up)`
|
||||
# listed 20 jobs and none of these twelve appeared; all twelve `<app>-down` rules returned no
|
||||
# series. Closing this needs a Prometheus exporter per app — Plex, Pi-hole, Jackett, qBittorrent,
|
||||
# Sonarr, Radarr and Seerr do not speak Prometheus natively — so it is its own piece of work, and
|
||||
# for keycloak specifically the job labels that DO exist are `keycloak-service`/`keycloak-discovery`
|
||||
# (platform/keycloak/servicemonitor.yaml), so that one is a rename away rather than an exporter.
|
||||
# =============================================================================================
|
||||
KNOWN_UNWIRED_JOBS: dict[str, str] = {
|
||||
"gitea": "apps/wave-0/gitea.yaml — Service defines only port-3000",
|
||||
"jackett": "apps/wave-0/jackett.yaml — no exporter",
|
||||
"pihole": "apps/wave-0/pihole.yaml — needs pihole-exporter",
|
||||
"plex": "apps/wave-0/plex.yaml — no native Prometheus endpoint",
|
||||
"qbittorrent": "apps/wave-0/qbittorrent.yaml — needs qbittorrent-exporter",
|
||||
"vm-demo": "apps/wave-0/vm-demo.yaml — a KubeVirt demo VM, no exporter",
|
||||
"keycloak": "apps/wave-1/keycloak.yaml — the live jobs are keycloak-service / "
|
||||
"keycloak-discovery (platform/keycloak/servicemonitor.yaml); this SLI rule names "
|
||||
"neither, so it is a rename away from being live rather than an exporter away",
|
||||
"nextcloud": "apps/wave-1/nextcloud.yaml — needs nextcloud-exporter (serverinfo token)",
|
||||
"radarr": "apps/wave-1/radarr.yaml — needs exportarr",
|
||||
"semaphore": "apps/wave-1/semaphore.yaml — no exporter",
|
||||
"sonarr": "apps/wave-1/sonarr.yaml — needs exportarr",
|
||||
"seerr": "apps/wave-2/seerr.yaml — no exporter",
|
||||
}
|
||||
|
||||
# =============================================================================================
|
||||
# (0) PARSE EVERY ALERT RULE
|
||||
# =============================================================================================
|
||||
# PromQL metric-name approximation. Documented as a gap in the header: string literals,
|
||||
# label-matcher bodies and aggregation/vector-matching label lists are stripped first, then every
|
||||
# remaining bare identifier that is not a PromQL keyword or function is treated as a metric
|
||||
# selector. Assertion (0b) fails loudly if this stops finding metrics at all, which is the way an
|
||||
# approximate parser silently stops guarding.
|
||||
PROMQL_KEYWORDS = set("""
|
||||
abs absent absent_over_time ceil changes clamp clamp_max clamp_min day_of_month day_of_week
|
||||
day_of_year days_in_month delta deriv exp floor histogram_quantile histogram_count histogram_sum
|
||||
histogram_fraction histogram_stddev histogram_stdvar holt_winters hour idelta increase irate
|
||||
label_join label_replace ln log2 log10 minute month predict_linear rate resets round scalar sgn
|
||||
sort sort_desc sort_by_label sort_by_label_desc sqrt time timestamp vector year avg count
|
||||
count_values group max min quantile stddev stdvar sum topk bottomk limitk limit_ratio
|
||||
last_over_time avg_over_time min_over_time max_over_time sum_over_time count_over_time
|
||||
quantile_over_time stddev_over_time stdvar_over_time present_over_time mad_over_time
|
||||
double_exponential_smoothing info ts_of_max_over_time ts_of_min_over_time ts_of_last_over_time
|
||||
by without on ignoring group_left group_right offset bool and or unless if atan2 start end
|
||||
inf nan NaN Inf
|
||||
""".split())
|
||||
|
||||
IDENT_RE = re.compile(r"(?<![\w:.])([a-zA-Z_:][a-zA-Z0-9_:]*)")
|
||||
UP_JOB_RE = re.compile(r"""\bup\s*\{[^}]*\bjob\s*=\s*["']([^"']+)["']""")
|
||||
|
||||
|
||||
def metric_names(expr: str) -> list[str]:
|
||||
e = re.sub(r'"(?:[^"\\]|\\.)*"', '""', expr)
|
||||
e = re.sub(r"'(?:[^'\\]|\\.)*'", "''", e)
|
||||
e = re.sub(r"`(?:[^`\\]|\\.)*`", "``", e)
|
||||
e = re.sub(r"\{[^{}]*\}", " ", e) # label matchers
|
||||
for _ in range(6): # nested aggregations
|
||||
e = re.sub(r"\b(?:by|without|on|ignoring|group_left|group_right)\s*\([^()]*\)", " ", e)
|
||||
out, seen = [], set()
|
||||
for m in IDENT_RE.finditer(e):
|
||||
n = m.group(1)
|
||||
if n in PROMQL_KEYWORDS or re.fullmatch(r"\d+[smhdwy]?", n) or n in seen:
|
||||
continue
|
||||
seen.add(n)
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
def is_golden_fixture(p: Path) -> bool:
|
||||
"""conformance/tests/**/golden/** holds EXPECTED-OUTPUT fixtures, not deployed manifests.
|
||||
|
||||
conformance/tests/l1-rendering/egress-class-render/golden/*.expected.yaml each contain a
|
||||
rendered `<app>-down` PrometheusRule. They are the assertion data of another test, never
|
||||
reconciled into a cluster, so auditing their scrape targets would be auditing a copy — the
|
||||
same "reference, not declaration" distinction ci/touched-requirements.sh draws for ci/tests/
|
||||
fixture paths. The templates they are rendered FROM are audited through apps/ and catalog/.
|
||||
"""
|
||||
parts = p.parts
|
||||
return "conformance" in parts and "golden" in parts
|
||||
|
||||
|
||||
rule_files = sorted(
|
||||
p for p in ROOT.rglob("*.yaml")
|
||||
if ".git" not in p.parts and not is_golden_fixture(p) and "kind: PrometheusRule" in read(p)
|
||||
)
|
||||
check(len(rule_files) > 0,
|
||||
"no file declaring `kind: PrometheusRule` found — the parser or the repo layout changed; "
|
||||
"this check would silently audit nothing")
|
||||
|
||||
# metric -> [ "<relpath>:<alert>", ... ] ; and the same shape for up{job="X"} selectors
|
||||
refs: dict[str, list[str]] = {}
|
||||
job_refs: dict[str, list[str]] = {}
|
||||
n_rules = 0
|
||||
for f in rule_files:
|
||||
rel = str(f.relative_to(ROOT))
|
||||
try:
|
||||
docs = list(yaml.safe_load_all(read(f)))
|
||||
except yaml.YAMLError as exc:
|
||||
check(False, f"{rel} is not parseable YAML ({exc}); every alert in it is unaudited")
|
||||
continue
|
||||
for d in docs:
|
||||
if not isinstance(d, dict) or d.get("kind") != "PrometheusRule":
|
||||
continue
|
||||
for grp in (d.get("spec") or {}).get("groups") or []:
|
||||
for r in grp.get("rules") or []:
|
||||
if "alert" not in r:
|
||||
continue # recording rules: not this check's subject
|
||||
n_rules += 1
|
||||
site = f"{rel}:{r['alert']}"
|
||||
expr = str(r.get("expr", ""))
|
||||
for n in metric_names(expr):
|
||||
refs.setdefault(n, []).append(site)
|
||||
for j in UP_JOB_RE.findall(expr):
|
||||
job_refs.setdefault(j, []).append(site)
|
||||
|
||||
check(n_rules > 0,
|
||||
"no alerting rule parsed out of any PrometheusRule — the parse contract broke and this "
|
||||
"check would pass vacuously while every alert's metric went unaudited")
|
||||
check(len(refs) >= 20,
|
||||
f"only {len(refs)} distinct metric name(s) parsed out of {n_rules} alerting rule(s). The "
|
||||
f"PromQL approximation has stopped matching — which is exactly how an approximate parser "
|
||||
f"turns into a guard that passes while checking nothing. Fix metric_names() or replace it "
|
||||
f"with a real parser")
|
||||
|
||||
# =============================================================================================
|
||||
# (1)(2) RESOLVE EVERY METRIC TO AN OWNER, AND PROVE THE OWNER'S CLAIM AGAINST THE REPO
|
||||
# =============================================================================================
|
||||
def resolve(metric: str) -> dict | None:
|
||||
"""Longest-prefix / exact-name match against SCRAPE_OWNERS."""
|
||||
best, best_len = None, -1
|
||||
for o in SCRAPE_OWNERS:
|
||||
if metric in o.get("exact", ()): # exact names win outright
|
||||
return o
|
||||
for pfx in o.get("prefixes", ()):
|
||||
if metric.startswith(pfx) and len(pfx) > best_len:
|
||||
best, best_len = o, len(pfx)
|
||||
return best
|
||||
|
||||
|
||||
# Every owner's FILE must exist, whether or not a metric currently lands on it: an owner pointing
|
||||
# at a deleted file is a claim nothing backs.
|
||||
for o in SCRAPE_OWNERS:
|
||||
check((ROOT / o["file"]).is_file(),
|
||||
f"SCRAPE_OWNERS names {o['file']} as the scrape owner for "
|
||||
f"{o.get('prefixes') or o.get('exact')}, and that file does not exist in this repo — the "
|
||||
f"claim is unbacked and the alerts that depend on it are inert")
|
||||
|
||||
claimed: dict[str, dict] = {}
|
||||
unowned: list[str] = []
|
||||
for metric in sorted(refs):
|
||||
if metric in UNIVERSAL:
|
||||
continue
|
||||
o = resolve(metric)
|
||||
if o is None:
|
||||
if metric in KNOWN_UNWIRED_METRICS:
|
||||
continue # declared gap; ratcheted below
|
||||
unowned.append(metric)
|
||||
check(False,
|
||||
f"alert metric `{metric}` (queried by {', '.join(sorted(set(refs[metric])))}) is "
|
||||
f"claimed by NO entry in SCRAPE_OWNERS. Nothing in this repo configures a scrape "
|
||||
f"that produces it, so every rule above is STRUCTURALLY INERT: it evaluates to no "
|
||||
f"vector, stays silent forever, and is indistinguishable from a healthy result. "
|
||||
f"Either wire the scrape and add the owner entry, or — if it cannot be wired yet — "
|
||||
f"declare it in KNOWN_UNWIRED_METRICS with the reason, so the gap is reported "
|
||||
f"instead of silent")
|
||||
continue
|
||||
check(metric not in KNOWN_UNWIRED_METRICS,
|
||||
f"`{metric}` is listed in KNOWN_UNWIRED_METRICS but is now claimed by "
|
||||
f"{o['owner']} — the register is shrink-only: delete the entry so the next real gap is "
|
||||
f"the one that shows up")
|
||||
claimed[metric] = o
|
||||
|
||||
# The owners that are actually load-bearing right now get their declarations verified.
|
||||
for o in SCRAPE_OWNERS:
|
||||
if o not in claimed.values():
|
||||
continue # nothing queries it today; nothing to guard
|
||||
body = read(ROOT / o["file"])
|
||||
for rx in o.get("require", []):
|
||||
check(re.search(rx, body, re.M) is not None,
|
||||
f"{o['file']} no longer matches /{rx}/, which is what makes {o['owner']} scrape. "
|
||||
f"Removing it re-inerts every alert on "
|
||||
f"{o.get('prefixes') or o.get('exact')} — {o['why']}")
|
||||
for rx in o.get("forbid", []):
|
||||
check(re.search(rx, body, re.M) is None,
|
||||
f"{o['file']} matches the FORBIDDEN pattern /{rx}/, which defeats {o['owner']} "
|
||||
f"even though the enablement is still authored. {o['why']}")
|
||||
comp = o.get("companion")
|
||||
if comp:
|
||||
cbody = read(ROOT / comp["file"])
|
||||
check((ROOT / comp["file"]).is_file(),
|
||||
f"{comp['file']} is missing — {comp['why']}")
|
||||
for rx in comp.get("require", []):
|
||||
check(re.search(rx, cbody, re.M) is not None,
|
||||
f"{comp['file']} no longer matches /{rx}/. The monitor alone is NOT enough here: "
|
||||
f"{comp['why']}")
|
||||
|
||||
# The Longhorn NetworkPolicy must actually grant the path it exists to grant, and no more: the
|
||||
# whole point of splitting it out instead of flipping networkPolicies.restrictInternalTraffic off.
|
||||
NP = ROOT / "platform/longhorn/networkpolicy-metrics.yaml"
|
||||
if any(m.startswith("longhorn_") for m in refs):
|
||||
np = read(NP)
|
||||
check(np != "", f"{NP.relative_to(ROOT)} is missing; the Longhorn scrape is dropped and its "
|
||||
f"four alerts are inert")
|
||||
check(re.search(r"kind:\s*NetworkPolicy", np) is not None,
|
||||
"platform/longhorn/networkpolicy-metrics.yaml is not a NetworkPolicy")
|
||||
check(re.search(r"app:\s*longhorn-manager", np) is not None,
|
||||
"the Longhorn metrics NetworkPolicy does not select app=longhorn-manager, so it does not "
|
||||
"open the pods that serve /metrics")
|
||||
check(re.search(r"kubernetes\.io/metadata\.name:\s*monitoring", np) is not None,
|
||||
"the Longhorn metrics NetworkPolicy does not admit namespace monitoring, so Prometheus "
|
||||
"still cannot reach longhorn-manager and the storage alerts stay inert")
|
||||
check(re.search(r"port:\s*9500", np) is not None,
|
||||
"the Longhorn metrics NetworkPolicy does not name port 9500 (longhorn-manager's `manager` "
|
||||
"port, which serves /metrics)")
|
||||
# LEAST PRIVILEGE (R-SEC-02): ingress only, one port, and NOT the webhook/recovery ports.
|
||||
check(re.search(r"policyTypes:\s*\n\s*-\s*Ingress\s*$", np, re.M) is not None
|
||||
and "Egress" not in np,
|
||||
"the Longhorn metrics NetworkPolicy is not Ingress-only — it must grant exactly the "
|
||||
"scrape path and nothing else (R-SEC-02)")
|
||||
# Comments are stripped first: the file's header deliberately NAMES 9502/9503 to record that
|
||||
# they stay closed, and a substring search over prose would fail on the documentation of the
|
||||
# very property it is asserting.
|
||||
np_spec = "\n".join(re.sub(r"#.*$", "", ln) for ln in np.splitlines())
|
||||
for closed in ("9502", "9503"):
|
||||
check(closed not in np_spec,
|
||||
f"the Longhorn metrics NetworkPolicy names port {closed} (admission webhook / "
|
||||
f"recovery backend). Only 9500 serves metrics; opening the others to ns monitoring "
|
||||
f"is reach this observability path does not need (R-SEC-02)")
|
||||
|
||||
# stale KNOWN_UNWIRED_METRICS entries
|
||||
for m in sorted(KNOWN_UNWIRED_METRICS):
|
||||
check(m in refs,
|
||||
f"KNOWN_UNWIRED_METRICS declares `{m}`, which NO alert rule in this repo queries any "
|
||||
f"more — a stale exemption hides the next real gap; delete the entry")
|
||||
|
||||
# =============================================================================================
|
||||
# (4) up{job="X"} — the axis assertion (1) is structurally blind to.
|
||||
#
|
||||
# Scoped to files where THIS REPO authors both the Service and the ServiceMonitor (the apps/*
|
||||
# ServiceDefinition pattern), because only then is the port name inside the tree. A monitor whose
|
||||
# Service comes from a Helm chart cannot be audited statically and is out of scope (header gap).
|
||||
# =============================================================================================
|
||||
def yaml_docs(path: Path) -> list[dict]:
|
||||
try:
|
||||
return [d for d in yaml.safe_load_all(read(path)) if isinstance(d, dict)]
|
||||
except yaml.YAMLError:
|
||||
return []
|
||||
|
||||
|
||||
# job name -> (relpath, monitor endpoint ports, ports the co-authored Services define)
|
||||
same_file_monitors: dict[str, tuple[str, set[str], set[str]]] = {}
|
||||
for p in sorted(ROOT.rglob("*.yaml")):
|
||||
if ".git" in p.parts:
|
||||
continue
|
||||
txt = read(p)
|
||||
if "kind: ServiceMonitor" not in txt or "kind: Service" not in txt:
|
||||
continue
|
||||
docs = yaml_docs(p)
|
||||
monitors = [d for d in docs if d.get("kind") == "ServiceMonitor"]
|
||||
services = [d for d in docs if d.get("kind") == "Service"]
|
||||
if not monitors or not services:
|
||||
continue
|
||||
svc_ports: set[str] = set()
|
||||
for s in services:
|
||||
for port in (s.get("spec") or {}).get("ports") or []:
|
||||
if isinstance(port, dict) and port.get("name"):
|
||||
svc_ports.add(str(port["name"]))
|
||||
for m in monitors:
|
||||
ep_ports = {str(e["port"]) for e in (m.get("spec") or {}).get("endpoints") or []
|
||||
if isinstance(e, dict) and e.get("port")}
|
||||
name = (m.get("metadata") or {}).get("name")
|
||||
if name:
|
||||
same_file_monitors[str(name)] = (str(p.relative_to(ROOT)), ep_ports, svc_ports)
|
||||
|
||||
for job in sorted(job_refs):
|
||||
sites = ", ".join(sorted(set(job_refs[job])))
|
||||
mon = same_file_monitors.get(job)
|
||||
if mon is None:
|
||||
# No same-file Service+ServiceMonitor pair is named for this job. It may well be produced
|
||||
# by a platform monitor against a chart Service (oauth2-proxy, kubevirt, velero, flux all
|
||||
# are), which this check cannot see — so this is NOT a failure, only unauditable.
|
||||
continue
|
||||
rel, ep_ports, svc_ports = mon
|
||||
missing = sorted(ep_ports - svc_ports)
|
||||
if missing:
|
||||
check(job in KNOWN_UNWIRED_JOBS,
|
||||
f"`up{{job=\"{job}\"}}` ({sites}) can never produce a series: {rel}'s ServiceMonitor "
|
||||
f"scrapes port(s) {missing}, and the Service(s) in that same file define only "
|
||||
f"{sorted(svc_ports) or ['(no named ports)']}. The monitor therefore discovers ZERO "
|
||||
f"targets, there is no `up{{job=\"{job}\"}}` to compare against 0, and the rule is "
|
||||
f"structurally inert — note PrometheusTargetsMissing cannot see this either, because "
|
||||
f"a target that was never discovered has no up series to be 0. Give the Service a "
|
||||
f"named port the workload actually serves metrics on (and an exporter if it has "
|
||||
f"none), or declare it in KNOWN_UNWIRED_JOBS")
|
||||
else:
|
||||
check(job not in KNOWN_UNWIRED_JOBS,
|
||||
f"`{job}` is listed in KNOWN_UNWIRED_JOBS but {rel}'s ServiceMonitor port(s) "
|
||||
f"{sorted(ep_ports)} now all exist on its Service — the register is shrink-only: "
|
||||
f"delete the entry")
|
||||
|
||||
# stale KNOWN_UNWIRED_JOBS entries
|
||||
for job in sorted(KNOWN_UNWIRED_JOBS):
|
||||
check(job in job_refs,
|
||||
f"KNOWN_UNWIRED_JOBS declares `{job}`, which no alert rule selects via up{{job=...}} any "
|
||||
f"more — a stale exemption hides the next real gap; delete the entry")
|
||||
|
||||
# =============================================================================================
|
||||
# (5) THE CHECK IS LOAD-BEARING. Asserted against the DECLARATIVE mechanism, in both halves —
|
||||
# neither alone is sufficient. The per-test hand-written ci/l0.sh stage is deliberately NOT used
|
||||
# (whole-repo-invariants-wired FAILS the gate if one reappears); the property is DECLARED here and
|
||||
# HONOURED by ci/l0.sh's --include-scope union.
|
||||
# =============================================================================================
|
||||
SCOPE_DECL_RE = r"^\s*conformance\.home\.k3s/scope:\s*[\"']?whole-repo"
|
||||
SELF = read(ROOT / "conformance" / "tests" / "l0-static" / "alert-metrics-have-scrape-targets"
|
||||
/ "chainsaw-test.yaml")
|
||||
check(re.search(SCOPE_DECL_RE, SELF, re.M) is not None,
|
||||
"alert-metrics-have-scrape-targets/chainsaw-test.yaml does not declare "
|
||||
"conformance.home.k3s/scope: whole-repo, so the merge gate selects this check only when a "
|
||||
"change happens to touch R-OBS-04. ci/touched-requirements.sh maps apps/* to NOTHING and "
|
||||
"platform/monitoring/* to R-OBS-01/R-OBS-02 — so adding an alert under apps/ or flipping a "
|
||||
"chart's serviceMonitor.enabled back to false would be filtered out of exactly the change "
|
||||
"that re-inerts the alerts")
|
||||
L0 = read(ROOT / "ci" / "l0.sh")
|
||||
GATE = read(ROOT / "ci" / "gate.sh")
|
||||
check("--include-scope" in L0,
|
||||
"ci/l0.sh no longer passes --include-scope to the conformance runner, so the L0 stage is a "
|
||||
"purely requirement-scoped run again and the scope annotation above is inert decoration")
|
||||
check(re.search(r"^for s in .*\bl0\b.*; do", GATE, re.M) is not None
|
||||
and re.search(r"fail\).*BLOCK=1", GATE) is not None,
|
||||
"ci/gate.sh does not set the merge block when the l0 stage fails")
|
||||
|
||||
# ======================================================================== report
|
||||
owners_used = {id(o): o for o in claimed.values()}
|
||||
print(f"Alert metric inventory: {n_rules} alerting rule(s) across {len(rule_files)} "
|
||||
f"PrometheusRule file(s) -> {len(refs)} distinct metric name(s)")
|
||||
print(f" claimed by a declared scrape owner: {len(claimed)}/{len(refs) - len(UNIVERSAL & set(refs))}"
|
||||
f" owners exercised: {len(owners_used)}/{len(SCRAPE_OWNERS)}")
|
||||
for o in SCRAPE_OWNERS:
|
||||
ms = sorted(m for m, oo in claimed.items() if oo is o)
|
||||
mark = " " if ms else "· "
|
||||
print(f" {mark}{o['file']:<46} {len(ms):>2} metric(s) {o['owner'][:64]}")
|
||||
print(f"\n DECLARED GAPS (shrink-only registers):")
|
||||
print(f" KNOWN_UNWIRED_METRICS: {len(KNOWN_UNWIRED_METRICS)} — "
|
||||
f"{', '.join(sorted(KNOWN_UNWIRED_METRICS))}")
|
||||
print(f" KNOWN_UNWIRED_JOBS: {len(KNOWN_UNWIRED_JOBS)} inert up{{job=...}} SLI rule(s) — "
|
||||
f"{', '.join(sorted(KNOWN_UNWIRED_JOBS))}")
|
||||
if unowned:
|
||||
print(f"\n UNCLAIMED (inert, undeclared): {', '.join(unowned)}", file=sys.stderr)
|
||||
|
||||
print()
|
||||
if not FAILURES:
|
||||
print(f"check OK: alert-metrics-have-scrape-targets — {CHECKS} assertions (R-OBS-04)")
|
||||
raise SystemExit(0)
|
||||
print(f"CHECK FAIL (alert-metrics-have-scrape-targets): {len(FAILURES)} failed, "
|
||||
f"{CHECKS - len(FAILURES)} passed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" FAIL {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
Reference in new issue
Block a user