Six discovery sites walked ROOT without pruning dot-directories, so they descended into .claude/worktrees/agent-*/ — up to 18 full copies of the repo, created for isolated agent runs and gitignored (.gitignore:43). The visible half was false FAILs: four ServiceMonitors reported as selecting no Service, and nine PrometheusRule manifests reported as "under test but not in the repo", all of them worktree paths. The whole-repo L0 gate was red for reasons that had nothing to do with authored surface. The dangerous half is quieter. A check that asks "does anything in the repo declare X" can be satisfied by a worktree copy instead of real source, so the same bug that invents failures can also manufacture a pass. That is the reason to fix all six sites rather than only the ones that happened to be failing. egress-paths-selected-by-rules already had this fix and documents it (it once saw 5 CiliumEgressGatewayPolicy objects instead of the 1 authored); this applies the same dot-directory predicate everywhere else, which skips dot-DIRECTORIES while still matching dot-FILES such as .sops.yaml. platform/monitoring/tests/run.sh is the other side of the same failure: its `--list` reported each manifest once per worktree, and the L0 check compares that listing against the tree. Both find calls now prune .claude alongside .git. `--list` returns 21 real pairs where it previously returned them ~19x over. Verified in the pinned toolbox: L0 --include-scope whole-repo is 21 cases, 0 failures, 0 skips, 0 unwired (was 4 failures, then 1). L1 3/3. The 18 stale worktrees are all on already-merged branches and remain on disk; cleaning them up is a separate change and is no longer load-bearing for the gate. Co-Authored-By: Claude Opus 5 <[email protected]>
257 lines
14 KiB
Bash
Executable File
257 lines
14 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,
|
|
# and .claude/ with it: .claude/worktrees/agent-*/ holds full repo copies for isolated agent
|
|
# runs, so an unpruned find reported the SAME manifest under test once per worktree (up to 18
|
|
# times), and the L0 check that compares this listing against the tree failed on paths that
|
|
# are not authored surface at all (.claude/ is gitignored, .gitignore:43).
|
|
discover() {
|
|
# Companion convention, anywhere in the tree.
|
|
find "${ROOT}" \( -path "${ROOT}/.git" -o -path "${ROOT}/.claude" \) -prune -o -name '*.promtool-test.yaml' -print 2>/dev/null
|
|
# Suite convention, ANYWHERE in the tree — not just this directory.
|
|
#
|
|
# This used to be `find "${HERE}" -maxdepth 1`, which made the suite convention a
|
|
# platform/monitoring-only privilege while the header above documented it as a general one. The
|
|
# consequence was silent: a suite added under any other component (apps/wave-0/tests/, say) was
|
|
# never discovered, never run, and still LOOKED like coverage in review — the same shape of hole
|
|
# as the rules this harness exists to test. Found 2026-09-13 adding apps/wave-*/tests/. Scoped by
|
|
# NAME only; manifests_for() then refuses (loudly) any *.test.yaml that is not inside a tests/
|
|
# directory, so a misplaced file fails rather than disappearing.
|
|
find "${ROOT}" \( -path "${ROOT}/.git" -o -path "${ROOT}/.claude" \) -prune -o -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"
|