Files
homelab/scripts/ci/test-promtool-rules.sh
9a3c6ef9af
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci(monitoring-rules): machine-check PromQL with promtool (REN-161)
Nothing in this repo checked an alert expression. ci-manifests-test.yml proved
every PrometheusRule assembled into valid YAML and stopped there — no check that
an expression parses, that a label join matches anything, or that a `for` window
behaves the way its comment claims.

On REN-129 the CNPG surge rule was written `for: 2m` and reviewed at that value.
Against Iris's seven measured nights it fires on six of them: two owner emails a
night, 04:16 and 04:22 local, for a WAL backlog that clears itself in under ten
minutes. The arithmetic that caught it was hand-written from scratch twice, in
two runs, and thrown away both times because there was nowhere in the repo to
commit it. It is now a negative control.

Adds:

* scripts/ci/promtool-rules.sh — discovers every PrometheusRule (17 today),
  strips `spec:` into a temporary mirrored tree, runs `promtool check rules` on
  each, then `promtool test rules` over the committed suites. Discovery floors
  on both, as in the sibling steps.
* a `promtool` step in ci-manifests-test.yml, `depends_on: []` like the other
  four, on prom/prometheus:v3.15.0. The 3.x pin is load-bearing: the cluster
  runs kube-prometheus-stack >= 72.0.0, and alert `for` evaluation is
  version-sensitive, so a 2.x promtool would check a different evaluator than
  production. The script asserts the major version it is handed.
* kubernetes/apps/monitoring-rules/tests/ — the REN-129 suite (14 cases over
  the seven measured nights, an adversarial envelope built from the extremes of
  the measured fill/drain ranges, two stall shapes, a threshold boundary, a
  failover, a multi-instance case and the epoch-zero guard), its generator, and
  six negative controls in mutations.txt.
* a mutation phase. Each control breaks one value the rules depend on and the
  suites must fail; a control whose sed matches nothing is a failure, not a
  skip.
* scripts/ci/test-promtool-rules.sh, run from ci-scripts-test.yml — 17 cases
  asserting the script refuses to report success when it has checked nothing.

The mutation phase is not belt-and-braces, it is the point. Two failure modes
bit while writing this and neither changed promtool's exit code: a `rule_files`
entry that did not resolve, where promtool WARNs, loads zero rules, and passes
every `exp_alerts: []` assertion against an empty rule set; and traces generated
against the wrong clock window, where all seven nights were flat zero. Both were
green. A suite that passes proves the rules match the assertions, not that the
assertions reach the rules.

Production: nothing applied to the cluster. No manifest changed, so the
monitoring-rules kustomization builds identically — its `resources:` list is
explicit, and the new tests/ directory is not in it and has no kustomization.yaml
of its own, so Flux does not see it. Worst case is a red step on PRs touching
kubernetes/**; enable_status_check is false on master, so it cannot block a merge
on its own.

Verified with real promtool 3.15.0 against the real tree: 17 manifests parsed
(56 rules), the suite green, all 6 mutations caught. Run again under BusyBox
1.35.0 sh/awk/sed/find/xargs, the toolchain prom/prometheus actually ships.
Guard tests 17/17 under both bash and BusyBox sh. shellcheck 0.11.0 clean at
every severity. woodpecker-cli 3.18.0 lint clean on all 17 pipeline files.
Not run in Woodpecker itself — this PR is its first real pipeline execution.

Revert: revert this commit. It adds files and one step; nothing else reads them.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 08:25:59 +00:00

265 lines
10 KiB
Bash
Executable File

#!/bin/sh
# Offline self-test for the guards in promtool-rules.sh.
#
# Run: sh scripts/ci/test-promtool-rules.sh
#
# No real promtool, no network, no cluster — a stub `promtool` on PATH plus small
# fixture trees. That is deliberate: what is being tested here is not whether
# promtool evaluates PromQL correctly (it does, and ci-manifests-test.yml checks
# the real rules with the real binary) but whether promtool-rules.sh REFUSES TO
# REPORT SUCCESS when it has actually checked nothing. Those are the cases a
# green pipeline cannot distinguish from real coverage, so they need their own
# test, and stubbing promtool is what lets this run on the ci-claude image next
# to the other script tests.
#
# Every case below is a failure mode that occurred or was one edit away:
#
# empty-discovery the floor guard the issue asked for
# no-suites same, for the test files
# unresolved-rule-file the one that actually shipped — promtool WARNs, loads
# zero rules, and passes every silence assertion
# warning-only the same hole reached by a route the path check cannot
# see; caught by scanning promtool's output
# inline-rule-files a shape the resolver cannot parse, rejected by name
# rather than skipped in silence
# empty-rule-files a suite that declares no rules at all
# unstrippable-spec a manifest the spec: strip does not turn into a rule
# file, which promtool would report as "0 rules found"
# mutation-no-op a control whose sed no longer matches anything
# mutation-undetected a control the suite does not catch
# happy-path all of the above satisfied: must PASS
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
SUT="$HERE/promtool-rules.sh"
[ -f "$SUT" ] || { echo "FAIL: $SUT not found"; exit 1; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT INT TERM
fails=0
# --- the stub -------------------------------------------------------------
# `check rules` always succeeds. `test rules` fails if the tree it is run from
# contains the sentinel a mutation introduces, which is what makes the mutation
# loop genuinely exercised rather than mocked: the baseline tree has no
# sentinel and passes, a mutated tree has one and fails.
#
# STUB_VERSION what to report for --version
# STUB_ALWAYS_PASS `test rules` always exits 0, even when mutated
# STUB_WARN `test rules` emits promtool's unresolved-rule-file warning
# and still exits 0
mkdir -p "$TMP/bin"
cat > "$TMP/bin/promtool" <<'STUB'
#!/bin/sh
case "${1:-}" in
--version) echo "promtool, version ${STUB_VERSION:-3.15.0} (branch: stub)"; exit 0 ;;
esac
case "${1:-} ${2:-}" in
"check rules") echo " SUCCESS: 1 rules found"; exit 0 ;;
"test rules")
[ -n "${STUB_WARN:-}" ] && echo " WARNING: no file match pattern nowhere.yaml"
[ -n "${STUB_ALWAYS_PASS:-}" ] && { echo " SUCCESS"; exit 0; }
if grep -rq 'MUTATED-SENTINEL' . 2>/dev/null; then
echo " FAILED: sentinel present"; exit 1
fi
echo " SUCCESS"; exit 0 ;;
esac
echo "stub: unhandled [$*]" >&2; exit 2
STUB
chmod +x "$TMP/bin/promtool"
# --- fixtures -------------------------------------------------------------
# A whole fixture repo per case, so no case can leak into another. `rules` is
# the count of PrometheusRule manifests to emit; the suite references the first
# one via `../`, the same relative shape the real suites use.
mkfixture() { # <dir> [n-manifests]
d="$1"; n="${2:-1}"
mkdir -p "$d/kubernetes/apps/monitoring-rules/tests"
i=1
while [ "$i" -le "$n" ]; do
cat > "$d/kubernetes/apps/monitoring-rules/prometheusrule-fix$i.yaml" <<YAML
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: fix$i
spec:
groups:
- name: fix$i.rules
rules:
- alert: Fixture$i
expr: up == 0
for: 8m
YAML
i=$((i + 1))
done
cat > "$d/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml" <<'YAML'
rule_files:
- ../prometheusrule-fix1.yaml
tests:
- interval: 1m
input_series:
- series: 'up'
values: '1x10'
alert_rule_test:
- eval_time: 10m
alertname: Fixture1
exp_alerts: []
YAML
# A control whose sed inserts the stub's sentinel, so the baseline passes and
# the mutant fails — the shape a real control has.
printf 'sentinel|kubernetes/apps/monitoring-rules/prometheusrule-fix1.yaml|s/for: 8m/for: 8m # MUTATED-SENTINEL/\n' \
> "$d/mutations.txt"
}
# run <fixture-dir> [env assignments...] -> exit status of the SUT, output in
# $TMP/out. Trailing assignments are appended AFTER the defaults, and `env`
# applies them in order, so a case can override any default.
run() {
d="$1"; shift
set +e
( cd "$d" && env PATH="$TMP/bin:$PATH" \
PROMTOOL_RULE_FLOOR=1 \
PROMTOOL_MUTATIONS=mutations.txt \
"$@" sh "$SUT" . ) > "$TMP/out" 2>&1
rc=$?
set -e
return $rc
}
# expect_fail <name> <fixture> <substring the output must contain> [env...]
#
# The substring is not decoration. A test that only asserts "exited non-zero"
# passes when the script fails for an unrelated reason — a typo'd fixture, a
# floor left set by an earlier case — and that is how a guard test rots into a
# test of nothing, which is the same disease this whole script exists to treat.
expect_fail() {
name="$1"; d="$2"; want="$3"; shift 3
if run "$d" "$@"; then
printf 'FAIL %s: expected non-zero exit, got 0\n' "$name"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
elif ! grep -qF "$want" "$TMP/out"; then
printf 'FAIL %s: rejected, but not for the expected reason\n' "$name"
printf ' wanted output containing: %s\n' "$want"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
else
printf 'ok %s\n' "$name"
fi
}
expect_pass() { # <name> <fixture> [env...]
name="$1"; d="$2"; shift 2
if run "$d" "$@"; then
printf 'ok %s\n' "$name"
else
printf 'FAIL %s: expected exit 0\n' "$name"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
fi
}
# --- happy path, first: if this does not pass, nothing below means anything --
mkfixture "$TMP/happy" 1
expect_pass "happy-path" "$TMP/happy"
# A green run must actually say it ran the mutation. Guards against the whole
# phase being skipped while the script still exits 0.
if ! grep -q 'mutation \[sentinel\] detected' "$TMP/out"; then
echo "FAIL happy-path did not report the mutation as detected"
sed 's/^/ /' "$TMP/out"
fails=$((fails + 1))
else
echo "ok happy-path reports the mutation as detected"
fi
# --- the floors -----------------------------------------------------------
mkdir -p "$TMP/empty/kubernetes"
: > "$TMP/empty/mutations.txt"
expect_fail "empty-discovery (0 manifests)" "$TMP/empty" \
"found only 0 PrometheusRule manifests"
mkfixture "$TMP/nosuites" 1
rm "$TMP/nosuites/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
expect_fail "no-suites (0 test files)" "$TMP/nosuites" \
"found only 0 *_test.yaml suites"
mkfixture "$TMP/floor" 1
expect_fail "rule floor not met (1 < 5)" "$TMP/floor" \
"found only 1 PrometheusRule manifests; floor is 5" PROMTOOL_RULE_FLOOR=5
# --- the hole that shipped ------------------------------------------------
mkfixture "$TMP/unresolved" 1
sed 's|\.\./prometheusrule-fix1\.yaml|kubernetes/apps/monitoring-rules/prometheusrule-fix1.yaml|' \
"$TMP/unresolved/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml" > "$TMP/u.tmp"
mv "$TMP/u.tmp" "$TMP/unresolved/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
# The expected substring must sit on ONE line: die() messages are wrapped in the
# source, so a phrase spanning the wrap will not match.
expect_fail "unresolved-rule-file (repo-root path, not suite-relative)" "$TMP/unresolved" \
"references rule_files entry"
mkfixture "$TMP/warn" 1
expect_fail "warning-only (promtool WARNs but exits 0)" "$TMP/warn" \
"do not satisfy their own unit suites" STUB_WARN=1
mkfixture "$TMP/inline" 1
printf 'rule_files: [../prometheusrule-fix1.yaml]\ntests: []\n' \
> "$TMP/inline/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
expect_fail "inline-rule-files" "$TMP/inline" "writes rule_files inline"
mkfixture "$TMP/norf" 1
printf 'tests: []\n' > "$TMP/norf/kubernetes/apps/monitoring-rules/tests/fixture_test.yaml"
expect_fail "empty-rule-files" "$TMP/norf" "declares no rule_files"
# --- a manifest the strip cannot turn into a rule file --------------------
mkfixture "$TMP/badspec" 2
cat > "$TMP/badspec/kubernetes/apps/monitoring-rules/prometheusrule-fix2.yaml" <<'YAML'
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: fix2
# No top-level spec: at all. promtool check rules on the empty strip would
# report "SUCCESS: 0 rules found" and exit 0.
YAML
expect_fail "unstrippable-spec (no groups: after strip)" "$TMP/badspec" \
"expected [groups:]"
# --- the mutation phase's own guards -------------------------------------
mkfixture "$TMP/noop" 1
printf 'noop|kubernetes/apps/monitoring-rules/prometheusrule-fix1.yaml|s/for: 99m/for: 1m/\n' \
> "$TMP/noop/mutations.txt"
expect_fail "mutation-no-op (sed matches nothing)" "$TMP/noop" "changed nothing in"
mkfixture "$TMP/undetected" 1
expect_fail "mutation-undetected (suite passes while mutated)" "$TMP/undetected" \
"was NOT detected" STUB_ALWAYS_PASS=1
mkfixture "$TMP/nomut" 1
printf '# only a comment, no mutations\n' > "$TMP/nomut/mutations.txt"
expect_fail "mutation floor not met (0 mutations)" "$TMP/nomut" "ran only 0 mutations"
mkfixture "$TMP/mutmissing" 1
rm "$TMP/mutmissing/mutations.txt"
expect_fail "mutations file missing" "$TMP/mutmissing" "mutations.txt is missing"
mkfixture "$TMP/mutbadtarget" 1
printf 'bad|kubernetes/apps/monitoring-rules/does-not-exist.yaml|s/a/b/\n' \
> "$TMP/mutbadtarget/mutations.txt"
expect_fail "mutation targets an undiscovered manifest" "$TMP/mutbadtarget" \
"which is not one of"
# --- the version pin ------------------------------------------------------
mkfixture "$TMP/v2" 1
expect_fail "promtool 2.x" "$TMP/v2" "is not 3.x" STUB_VERSION=2.55.1
mkfixture "$TMP/vjunk" 1
expect_fail "unparseable promtool version" "$TMP/vjunk" \
"cannot parse" STUB_VERSION=not-a-version
if [ "$fails" -eq 0 ]; then
echo "PASS all promtool-rules.sh guard tests"
exit 0
fi
echo "FAIL $fails promtool-rules.sh guard test(s)"
exit 1