Files
home-network-k3s-impl/platform/monitoring/tests/run.sh
T
jakman85andClaude Opus 5 78d0f1b0f6 feat(ci): promtool in the pinned toolchain, one alert-rule harness, and the gaps declared
The CNPG alert unit tests (324 lines, mutation-checked) were run by NOTHING. promtool was
not in the baked toolchain, no gate stage evaluated any *.promtool-test.yaml, and the only
runner `curl | tar -xz`'d a promtool release from GitHub at TEST-RUN time if one was
missing. Hours later a second, independent harness landed for the Velero rules
(platform/monitoring/tests/) with its own fallback — a `podman run` of
quay.io/prometheus/prometheus. So the repo had two harnesses, two acquisition paths, and
coverage that looked present and was enforced nowhere: the exact failure mode that had just
produced a permanently-firing CNPG alert and a structurally inert Velero alert.

PROMTOOL IS NOW PINNED LIKE EVERY OTHER TOOL. PROMTOOL_VERSION=3.14.0 in
infra/devcontainer/versions.env and ci/versions.env (the two stay in lockstep, per the
repo's rule), baked by the Containerfile from the prometheus release tarball (promtool ships
inside it), recorded in /etc/dev-toolchain-versions, passed by dev.sh, and installed for the
l0 stage by .gitea/workflows/scripts/install-tools.sh so the CI runner is not permanently
SKIPPED. The pin is not free-floating: promtool IS the rule engine, so it tracks the
Prometheus prod actually runs — verified against the live cluster, not assumed:

    kubectl --context k3s-prod get prometheus -n monitoring -o jsonpath='{.items[*].spec.version}'
      -> v3.14.0-distroless   (image quay.io/prometheus/prometheus:v3.14.0-distroless)

ONE HARNESS. platform/cnpg/run-alert-tests.sh is folded into
platform/monitoring/tests/run.sh, which now covers every PrometheusRule in the repo that has
tests, via two conventions it discovers automatically: `<manifest>.promtool-test.yaml`
beside its manifest (cnpg), and `<component>/tests/*.test.yaml` over that component's
manifests (monitoring). It writes the flattened rules under whatever name each test file's
own `rule_files:` declares, so no test file needs to know the harness exists, and it
`promtool check rules` first — a rules file that does not parse would make every unit test
pass vacuously by finding no rules at all. The cnpg script's reasoning is preserved: why a
CR must be flattened (`.spec.groups`, never re-typed), and why generated files go to a temp
dir. Its `${BASE_DOMAIN}` substitution is dropped in favour of extract_rules.py's verbatim
placeholders, with the trade-off recorded there — the hazard it guarded against is addressed
by the temp dir, and verbatim leaves one transformation fewer between the shipped rule and
the rule under test.

NO ENGINE IS ACQUIRED AT RUN TIME any more — not the GitHub release, not the container
image. Not because a pinned download is unusual here (the Containerfile downloads every tool
it bakes) but because those two decided the version, the network and the machine SILENTLY,
at test time, on whatever host ran the tests; and each quietly turned "the toolchain has no
promtool" into a green run, which is how the toolchain stayed without one. Missing promtool
is exit 3 (UNWIRED, never green); a PROMTOOL= that does not resolve is exit 2.

ONE WIRING: conformance/tests/l0-static/alert-rules-unit-tested, NOT a gate stage. The
`prom-rules` stage (ci/prom-rules.sh + its Makefile target + ci/gate.sh entry) is deleted:
it is the bespoke-per-check pattern b9f8807 had just removed from ci/l0.sh, and the
declarative replacement gives what a hand-written stage cannot — selection by
`conformance.home.k3s/scope: "whole-repo"` (asserted: the test is selected even under a
requirement filter matching nothing), requirement-attributed JUnit/coverage/ledger evidence,
and run_l1_offline.py's rule that a SKIP of a scope-declaring check is the runner's rc 3.
The scope declaration is load-bearing here: touched-requirements.sh keys apps/* to nothing
and platform/monitoring/* to R-OBS-01/02, never R-OBS-04, so a new untested PrometheusRule
would otherwise be gated by nothing.

The check asserts the pins and their lockstep, that the image bakes and records promtool,
that the promtool IN USE reports exactly the pin (a FAILURE, not a skip: the wrong engine
still prints SUCCESS), that exactly ONE harness exists and none fetches an engine, that
every rule-unit test file passes, and — on every run — that the harness CAN fail, by putting
a synthetic rule through it twice with one expectation mutated.

MISSING TESTS ARE A RATCHET, deliberately, and the reasoning is in the check's header. 51 of
58 alert rules have no unit test. Failing on all of them makes the gate red until ~51 tests
exist, so it gets disabled and protects nothing; merely reporting is exactly how the CNPG
tests came to be enforced by nothing. So the gap is DECLARED in the check as a shrink-only
register, printed with counts every run (13 alert files nothing tests at all; 35 named
alerts inside covered files that nothing asserts on), and it cannot grow: a new
PrometheusRule, or a new alert in an already-covered file, FAILS without a test, and a stale
entry FAILS too. A missing test is never an exit-3 skip — SKIPPED is reserved for the one
genuine environment gap, promtool being absent.

PROVEN, in the rebuilt image (promtool 3.14.0):
  * both suites pass through the single harness: cnpg 6 rules, monitoring 36 rules, SUCCESS.
  * the check passes: 95 assertions, 7/58 alert rules unit-tested, 13 declared gap files.
  * NOT VACUOUS, twice over. Shortening CNPGPrimariesCoLocated's `for: 30m` to 5m turns it
    red (pending expected at 25m/29m, got firing), reverted. Adding an untested
    PrometheusRule (platform/kubevirt/probe-alerts.yaml) turns it red on the ratchet,
    removed. The harness's own mutation self-test runs on every invocation.
  * the scope mechanism selects it: `run.sh --layer l0 --requirements r-nosuchreq-99
    --include-scope whole-repo --print-selection` lists alert-rules-unit-tested.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-13 00:02:27 -05:00

245 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# platform/monitoring/tests/run.sh — the repo's Prometheus RULE-UNIT-TEST harness (no cluster).
#
# WHY THIS EXISTS
# This repo has now shipped both halves of the alerting failure space to prod:
# * a PERMANENTLY-FIRING rule (the CNPG alert fixed 2026-09-12), and
# * a STRUCTURALLY INERT rule (VeleroNoRecentBackup: `time() - <absent series>` yields no
# series, so a schedule that had NEVER backed up could not raise it — prod ran with no
# volume backups at all and nothing alerted).
# Neither is visible by reading the expression, by `kubectl apply`, or by looking at a healthy
# live cluster: an inert rule and a correct rule are indistinguishable when everything is fine.
# `promtool test rules` is the tool that separates them, because it lets the BAD state be
# constructed on purpose and asserted on, offline and in CI. Every rule here is therefore
# pinned twice: quiet on the good state, firing on the bad one.
#
# The rules under test are the COMMITTED manifests (extract_rules.py flattens the
# PrometheusRule CRs into a rule file) — never a copy, so a fixture cannot drift green while
# the deployed rule rots.
#
# ONE HARNESS, REPO-WIDE (consolidated 2026-09-12). This started as the monitoring-only
# runner and there was a second, cnpg-only script (`platform/cnpg/run-alert-tests.sh`) doing
# the same three steps by hand. Two harnesses means two behaviours, and the one nothing ran
# rots — so the cnpg script is gone and this one covers EVERY PrometheusRule in the repo that
# has unit tests. It still lives under platform/monitoring/ because observability owns alert
# testing; the tests themselves live next to the rules they test.
#
# TWO TEST-FILE CONVENTIONS, both discovered automatically:
# 1. COMPANION — `<manifest>.promtool-test.yaml` beside `<manifest>.yaml`. The manifest under
# test is that sibling. This is how a platform component ships tests with its own alerts
# (platform/cnpg/alerts.promtool-test.yaml).
# 2. SUITE — `<dir>/tests/*.test.yaml`. The manifests under test are every PrometheusRule
# file in `<dir>` (platform/monitoring/tests/backups.test.yaml covers alerts.yaml +
# pvc-capacity-alerts.yaml, so one suite can assert across rule groups).
#
# WHAT THE ADAPTER DOES, AND WHY (inherited from the cnpg script's header, which was right):
# * A PrometheusRule is a Kubernetes CR; promtool wants a bare rules file with `groups:` at
# the top level. So `.spec.groups` is extracted (extract_rules.py), never re-typed.
# * The extracted file is written under whatever name the test file's own `rule_files:`
# declares, so a test file needs no knowledge of this harness.
# * `promtool check rules` runs FIRST. A rules file that does not parse would make every
# unit test "pass" vacuously by finding no rules at all.
# * Everything generated goes to a temp dir. A generated file sitting next to its source is
# how a stale copy becomes the thing under test — and it is also why leaving Flux's
# `${BASE_DOMAIN}` placeholders verbatim (see extract_rules.py) is safe here.
#
# Usage: ./run.sh # every test file in the repo (both conventions)
# ./run.sh platform/cnpg/alerts.promtool-test.yaml # one file
# ./run.sh --list # print `<test-file>\t<manifest>` pairs, run nothing
#
# `--list` exists so the L0 check can ask THIS script which manifests are under test instead of
# re-deriving the conventions itself. Two implementations of "which rules does this test cover"
# would eventually disagree, and the disagreement would show up as a file the gate believes is
# tested and the harness never touches. It needs no promtool, so it works on any host.
#
# Exit codes follow ci/lib.sh: 0 pass, 1 fail, 3 SKIPPED (toolchain unavailable — never green).
#
# PROMTOOL COMES FROM THE PINNED TOOLCHAIN, AND NOWHERE ELSE. `PROMTOOL_VERSION` in
# infra/devcontainer/versions.env (baked into the image) and ci/versions.env (installed on the
# CI runner), pinned to the Prometheus version prod actually runs — promtool IS the rule
# engine, so a test green under one version and red under another is worse than no test.
# The two run-time acquisition paths this harness and the cnpg script used to have — a
# `curl | tar -xz` of a GitHub release, and a `podman run` of quay.io/prometheus/prometheus —
# are both REMOVED. Not because a pinned download is unusual here (the Containerfile downloads
# every tool it bakes) but because those decided the version, the network and the machine
# SILENTLY, at test time, on whatever host ran the tests instead of once at reviewed
# image-build time; and each one quietly turned "the toolchain has no promtool" into a green
# run, which is how the toolchain stayed without one. Missing promtool is now exit 3, never a
# pass. Override with PROMTOOL=/path/to/promtool.
#
# ENFORCEMENT. This script is the developer entrypoint. The merge gate runs it through
# conformance/tests/l0-static/alert-rules-unit-tested, which additionally reports every
# PrometheusRule in the repo that has NO unit tests at all.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${HERE}/../../.." && pwd)"
LIST_ONLY=0
if [ "${1:-}" = "--list" ]; then LIST_ONLY=1; shift; fi
# ---------------------------------------------------------------- toolchain resolution
# Skipped for --list: that mode runs no rules, and the L0 check must be able to resolve the
# test->manifest mapping (and report the gaps) even on a host with no promtool.
PROMTOOL_BIN=""
if [ "$LIST_ONLY" = 0 ]; then
PROMTOOL_BIN="${PROMTOOL:-$(command -v promtool || true)}"
if [ -z "${PROMTOOL_BIN}" ]; then
pinned="$(sed -n 's/^PROMTOOL_VERSION="\{0,1\}\([^"]*\)"\{0,1\}.*/\1/p' \
"${ROOT}/ci/versions.env" 2>/dev/null | head -1)"
{
echo "run.sh: SKIP (exit 3, NOT a pass) — promtool is not on PATH, and this harness no"
echo " longer fetches one (no GitHub release download, no container-image pull)."
echo " promtool is pinned at v${pinned:-<see ci/versions.env>} — the Prometheus version prod runs:"
echo " * infra/devcontainer/versions.env PROMTOOL_VERSION (baked into the image)"
echo " * ci/versions.env PROMTOOL_VERSION (installed on the CI runner)"
echo " Run it in the pinned toolbox:"
echo " ./infra/devcontainer/dev.sh build # if the image predates the promtool pin"
echo " ./infra/devcontainer/dev.sh run bash -c 'platform/monitoring/tests/run.sh'"
echo " Or point at a local binary of the same version: PROMTOOL=/path/to/promtool ./run.sh"
} >&2
exit 3
fi
# A PROMTOOL= that does not resolve is an OPERATOR MISTAKE, not a missing toolchain: exit 2,
# so a typo'd override cannot masquerade as the honest "no promtool here" skip above.
if [ ! -x "${PROMTOOL_BIN}" ]; then
echo "run.sh: PROMTOOL='${PROMTOOL_BIN}' is not an executable file — fix the override" >&2
exit 2
fi
# Absolutised: each test runs with cwd inside a temp dir, where a relative PROMTOOL= breaks
# (it did, during development).
PROMTOOL_BIN="$(cd "$(dirname "${PROMTOOL_BIN}")" && pwd)/$(basename "${PROMTOOL_BIN}")"
fi
# ---------------------------------------------------------------- which test files to run
# NOT `git ls-files`: the gate runs inside a container that mounts only the worktree, whose
# `.git` file points outside the mount, so git cannot read the index there — and with
# `pipefail` that failure silently emptied the discovery, i.e. a green run over ZERO tests
# (observed 2026-09-12). `find` needs no repo metadata, so discovery cannot be disabled by
# how the tree happens to be mounted. .git/ is pruned so a stale object copy is never picked up.
discover() {
# Companion convention, anywhere in the tree.
find "${ROOT}" -path "${ROOT}/.git" -prune -o -name '*.promtool-test.yaml' -print 2>/dev/null
# Suite convention: this directory's own *.test.yaml files.
find "${HERE}" -maxdepth 1 -name '*.test.yaml' -print 2>/dev/null
}
TESTS=()
if [ "$#" -gt 0 ]; then
for t in "$@"; do
# Accept absolute, repo-relative, or cwd-relative paths.
if [ -f "$t" ]; then TESTS+=("$(cd "$(dirname "$t")" && pwd)/$(basename "$t")")
elif [ -f "${ROOT}/$t" ]; then TESTS+=("${ROOT}/$t")
else echo "run.sh: no such test file: $t" >&2; exit 2
fi
done
else
while IFS= read -r abs; do
[ -n "$abs" ] && TESTS+=("$abs")
done < <(discover | sort -u)
fi
if [ "${#TESTS[@]}" -eq 0 ]; then
echo "run.sh: SKIP (exit 3) — no *.promtool-test.yaml or tests/*.test.yaml found in ${ROOT}" >&2
exit 3
fi
# ---------------------------------------------------------------- manifest resolution
# Which committed PrometheusRule files a given test file tests. The conventions are the
# CONTRACT (see the header); anything else is refused rather than guessed at, because a test
# silently run against the wrong (or an empty) rule set is the vacuous pass this harness exists
# to prevent.
manifests_for() {
local test_abs="$1" base dir
base="$(basename "$test_abs")"
dir="$(dirname "$test_abs")"
case "$base" in
*.promtool-test.yaml)
local sib="${dir}/${base%.promtool-test.yaml}.yaml"
if [ ! -f "$sib" ]; then
echo "run.sh: ${test_abs#"${ROOT}/"} has no companion manifest (${sib#"${ROOT}/"})" >&2
return 1
fi
printf '%s\n' "$sib" ;;
*.test.yaml)
if [ "$(basename "$dir")" != tests ]; then
echo "run.sh: ${test_abs#"${ROOT}/"}: a *.test.yaml must live in a tests/ directory so its" >&2
echo " manifests are the PrometheusRules of the component above it" >&2
return 1
fi
local comp; comp="$(dirname "$dir")"
local found=0 f
while IFS= read -r f; do
found=1; printf '%s\n' "$f"
done < <(grep -rl --include='*.yaml' -e '^[[:space:]]*kind:[[:space:]]*PrometheusRule[[:space:]]*$' \
"$comp" --exclude-dir=tests 2>/dev/null | sort)
if [ "$found" = 0 ]; then
echo "run.sh: ${test_abs#"${ROOT}/"}: no PrometheusRule manifest in ${comp#"${ROOT}/"}" >&2
return 1
fi ;;
*)
echo "run.sh: ${test_abs#"${ROOT}/"}: unrecognised test-file name; use" >&2
echo " <manifest>.promtool-test.yaml (companion) or tests/<name>.test.yaml (suite)" >&2
return 1 ;;
esac
}
# The rule-file name(s) a test file expects, from its own `rule_files:` block — so the
# extracted rules land under the name the test already references and no test file needs to
# know this harness exists.
rule_file_names() {
sed -n '/^rule_files:[[:space:]]*$/,/^[^[:space:]-]/p' "$1" \
| sed -n 's/^[[:space:]]*-[[:space:]]*//p' | sed 's/[[:space:]]*$//' | grep . || true
}
# ------------------------------------------------------------------------------- --list
if [ "$LIST_ONLY" = 1 ]; then
lrc=0
for test_abs in "${TESTS[@]}"; do
mapfile -t mans < <(manifests_for "$test_abs") || { lrc=1; continue; }
for m in "${mans[@]}"; do
printf '%s\t%s\n' "${test_abs#"${ROOT}/"}" "${m#"${ROOT}/"}"
done
done
exit "$lrc"
fi
# ---------------------------------------------------------------------------------- run
rc=0
for test_abs in "${TESTS[@]}"; do
rel="${test_abs#"${ROOT}/"}"
mapfile -t mans < <(manifests_for "$test_abs") || { rc=1; continue; }
mapfile -t rule_names < <(rule_file_names "$test_abs")
if [ "${#rule_names[@]}" -eq 0 ]; then
echo "run.sh: ${rel}: no \`rule_files:\` entries — promtool would test nothing" >&2
rc=1; continue
fi
work="$(mktemp -d)"
# The test file references its rule file by bare name, so everything sits in one directory.
gen="${work}/${rule_names[0]}"
ex_rc=0
python3 "${HERE}/extract_rules.py" --out "$gen" "${mans[@]}" || ex_rc=$?
if [ "$ex_rc" -eq 4 ]; then
echo "run.sh: SKIP (exit 3) — PyYAML unavailable, cannot flatten the PrometheusRule manifests" >&2
rm -rf "$work"; exit 3
elif [ "$ex_rc" -ne 0 ]; then
rm -rf "$work"; rc=1; continue
fi
# A test file may legally name the same rule file more than once, or several names.
for n in "${rule_names[@]:1}"; do cp "$gen" "${work}/${n}"; done
cp "$test_abs" "${work}/"
# Syntax gate first: rules that do not parse would make every unit test pass vacuously.
echo "run.sh: promtool check rules ${rel} (manifests: ${mans[*]#"${ROOT}/"})" >&2
( cd "$work" && "$PROMTOOL_BIN" check rules "${rule_names[0]}" ) || rc=1
echo "run.sh: promtool test rules ${rel}" >&2
( cd "$work" && "$PROMTOOL_BIN" test rules "$(basename "$test_abs")" ) || rc=1
rm -rf "$work"
done
if [ "$rc" -eq 0 ]; then
echo "run.sh: OK — ${#TESTS[@]} rule-unit test file(s) passed under $("$PROMTOOL_BIN" --version 2>&1 | head -1)" >&2
fi
exit "$rc"