Files
home-network-k3s-impl/platform/monitoring/tests/extract_rules.py
T
jakman85andClaude Opus 5 7057bbb711 fix(monitoring): alert when a Velero schedule has NEVER backed up, or is paused
VeleroNoRecentBackup could not fire for the failure mode that actually happened.
`(time() - velero_backup_last_successful_timestamp{schedule!=""}) > 24*3600` is the
right SHAPE — per-schedule, un-aggregated — but Velero only creates that series once
a backup for the schedule SUCCEEDS, and `time() - <absent series>` yields no series.
So it detects "backed up once, then stopped" and is structurally INERT on "never
backed up". Prod ran with `velero-daily` (the schedule carrying VOLUME DATA) paused
and never-successful: no volume backups at all, nothing alerted, and it was masked
because `velero-daily-resources-only` completed cleanly every 03:00, so every other
backup signal read green while only Kubernetes objects were protected.

A PromQL-only fix is impossible, verified rather than assumed: velero v1.18.1 exports
35 velero_* metric names and every one carrying a `schedule` label is created by a
backup ATTEMPT, not by the Schedule object. A throwaway `paused: true` Schedule on
prod produced ZERO velero_* series, and nothing exposes `spec.paused`. The fact is
not in the TSDB, so the Schedule CRs are exported via kube-state-metrics'
custom-resource-state collector — the path this repo already uses for
kube_node_labels — as kube_velero_schedule_{created,paused,protects_volume_data,info}.
Proven by running KSM v2.20.0 with this exact config against prod's API: the CRD
list/watch rule the chart adds is MANDATORY, not cosmetic — without it KSM emits the
CRS metrics as an empty set, silently.

Three rules, all keyed on PROPERTIES rather than schedule names:
  * VeleroScheduleNeverBackedUp (critical) — a Schedule older than 36h with no
    successful backup ever. The 36h grace comes from creationTimestamp so a newly
    added schedule is not paged before its first run is even due.
  * VeleroSchedulePaused (warning) — the direct root-cause signal; a paused backup
    schedule must never be silently indefinite.
  * VeleroNoVolumeDataBackup (critical) — THE ANTI-MASKING RULE. Fires unless some
    schedule with spec.template.defaultVolumesToFsBackup=true succeeded in 36h, so a
    healthy resources-only schedule can never make the platform look protected.
    Reading the property that DEFINES volume protection (not a name list) matches
    platform/velero/volume-policies.yaml and survives renames. `or vector(0)` is
    load-bearing: `count(<empty>) == 0` returns no series and would be inert —
    absence of the exporter is deliberately the alarm.

Adds the repo's first Prometheus rule-unit-test harness (platform/monitoring/tests/,
wired as the `prom-rules` gate stage). It flattens the COMMITTED PrometheusRule CRs —
never a copy — and pins every rule in the group both ways: quiet on today's healthy
prod state, firing on the reconstructed bad state, with `for:` durations asserted.
Two mutation checks confirm the tests are not vacuous: dropping `or vector(0)` and
breaking the `unless on (schedule)` join each turn the suite red. This is the tool
that makes both of this repo's recurring alert bugs — permanently-firing and
structurally inert — provable offline, which neither yamllint, Kyverno, nor a healthy
cluster can do.

Proof of the fix is by construction; the live metrics cannot show it any more, because
`velero-daily` was unpaused on 2026-09-12 and has since completed 2492/2492 items.
The throwaway probe Schedule and the probe KSM deployment were deleted and the
deletion confirmed; `velero-daily` remains unpaused.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-12 23:45:15 -05:00

95 lines
3.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Flatten this repo's PrometheusRule CRs into a plain Prometheus rule file.
`promtool test rules` consumes a rule file (`{groups: [...]}`), not a Kubernetes
PrometheusRule object, so the committed manifests cannot be fed to it directly. This
script is the (deliberately dumb) adapter: it walks the given YAML files, picks out every
`kind: PrometheusRule` document, and concatenates their `spec.groups` into one rule file.
Why an adapter instead of a second copy of the rules: a rule-unit test is only worth
anything if it tests the EXACT expression that ships. Copying expressions into a fixture
would let the fixture stay green while the deployed rule rots -- which is the class of bug
this harness exists to catch (a permanently-firing CNPG alert and a structurally inert
Velero alert both reached prod in 2026-09).
Flux `postBuild` placeholders (`${BASE_DOMAIN}`) are left verbatim: no expression in
platform/monitoring uses one, and leaving them alone keeps annotation text identical to
the file under test. If a placeholder ever appears inside an `expr`, that is refused
loudly rather than silently producing an untestable rule file.
Group names are made unique per source file so two files may legally reuse a name.
Usage:
extract_rules.py --out generated.rules.yaml alerts.yaml [more.yaml ...]
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover - reported by the runner as a SKIP, never a pass
print(
"extract_rules.py: PyYAML is required (it ships transitively with the pinned "
"yamllint used by ci/lint.sh: pip install 'yamllint==1.35.1')",
file=sys.stderr,
)
raise SystemExit(4)
PLACEHOLDER = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}")
def extract(paths: list[Path]) -> dict:
groups: list[dict] = []
seen: set[str] = set()
for path in paths:
docs = [d for d in yaml.safe_load_all(path.read_text()) if d]
rules_docs = [d for d in docs if d.get("kind") == "PrometheusRule"]
if not rules_docs:
raise SystemExit(f"{path}: contains no PrometheusRule documents")
for doc in rules_docs:
for group in doc.get("spec", {}).get("groups", []) or []:
for rule in group.get("rules", []) or []:
expr = str(rule.get("expr", ""))
if PLACEHOLDER.search(expr):
raise SystemExit(
f"{path}: rule {rule.get('alert') or rule.get('record')!r} has a "
f"postBuild placeholder inside its expr; it cannot be unit-tested "
f"as written"
)
# Namespace the group by source file so names cannot collide.
group = dict(group)
name = f"{path.stem}::{group['name']}"
if name in seen:
raise SystemExit(f"duplicate rule group {name!r}")
seen.add(name)
group["name"] = name
groups.append(group)
if not groups:
raise SystemExit("no rule groups found")
return {"groups": groups}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", required=True, type=Path, help="rule file to write")
ap.add_argument("manifests", nargs="+", type=Path, help="PrometheusRule YAML files")
args = ap.parse_args()
out = extract(args.manifests)
args.out.write_text(yaml.safe_dump(out, sort_keys=False, default_flow_style=False))
n = sum(len(g.get("rules", []) or []) for g in out["groups"])
print(
f"extract_rules.py: {len(out['groups'])} group(s), {n} rule(s) -> {args.out}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())