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>
307 lines
14 KiB
Bash
Executable File
307 lines
14 KiB
Bash
Executable File
#!/bin/sh
|
|
# Machine-check the PrometheusRule manifests in this repo: parse every rule with
|
|
# `promtool check rules`, run the committed unit suites with `promtool test
|
|
# rules`, then damage the rules on purpose and require the suites to notice.
|
|
#
|
|
# Run: sh scripts/ci/promtool-rules.sh [repo-root]
|
|
# Needs: promtool on PATH (3.x — see the version note below), sh, find, sed, awk.
|
|
# Offline: no cluster, no registry, no secrets, no network.
|
|
# Tests: scripts/ci/test-promtool-rules.sh (runs the guards against fixture
|
|
# trees with a stub promtool; no real promtool needed).
|
|
#
|
|
# WHY THE MIRROR. promtool reads Prometheus rule files, which are bare `groups:`
|
|
# documents. A PrometheusRule is a Kubernetes object carrying the same thing
|
|
# under `spec:`, so promtool cannot read one directly. This script copies every
|
|
# PrometheusRule into a temporary tree at the SAME relative path with `spec:`
|
|
# stripped and the body dedented, and runs promtool there. Nothing is written
|
|
# back into the checkout.
|
|
#
|
|
# WHY THE MUTATION PHASE. Two failure modes bit while the first suite was being
|
|
# written, and neither changed promtool's exit code:
|
|
#
|
|
# * a `rule_files:` entry that did not resolve. promtool prints
|
|
# "WARNING: no file match pattern ...", loads ZERO rules, and then every
|
|
# `exp_alerts: []` assertion passes against an empty rule set. Ninety
|
|
# silence assertions, green, testing nothing.
|
|
# * traces generated against the wrong clock window, so every input series was
|
|
# flat zero. Same outcome: green, testing nothing.
|
|
#
|
|
# A suite that passes tells you the rules match the assertions. It does not tell
|
|
# you the assertions reach the rules. The two guards in step 4 and step 5 cover
|
|
# the first failure mode by name; the mutation phase in step 6 is the backstop
|
|
# for the general case, and is the only part of this that would have caught the
|
|
# second.
|
|
#
|
|
# PROMTOOL VERSION. Must match the Prometheus the cluster actually runs, which is
|
|
# whatever kube-prometheus-stack >= 72.0.0 ships — 3.x. Alert `for` evaluation is
|
|
# version-sensitive, so a 2.x promtool would be checking a different evaluator
|
|
# than production and the margins asserted in the suites would be claims about
|
|
# the wrong program. The exact pin lives on the `image:` line in
|
|
# .woodpecker/ci-manifests-test.yml; this script asserts only the major version,
|
|
# so a patch bump there needs no change here.
|
|
set -eu
|
|
|
|
ROOT="${1:-.}"
|
|
cd "$ROOT"
|
|
|
|
# Floors against silent discovery failure. Both are LOOSE on purpose. A floor
|
|
# tight enough to catch the loss of one file sits inside normal churn, so it
|
|
# fails spuriously — and the fix a hurried person reaches for is lowering the
|
|
# floor, which is how a guard becomes decoration. These catch total failure and
|
|
# gross partial loss, which is the failure that actually happens: a `find`
|
|
# expression the image's BusyBox does not support, or a moved directory.
|
|
#
|
|
# Same reasoning, and the same asymmetry, as the floors in
|
|
# .woodpecker/ci-manifests-test.yml. 17 PrometheusRules and 1 suite exist today.
|
|
RULE_FLOOR="${PROMTOOL_RULE_FLOOR:-12}"
|
|
SUITE_FLOOR="${PROMTOOL_SUITE_FLOOR:-1}"
|
|
MUTATION_FLOOR="${PROMTOOL_MUTATION_FLOOR:-1}"
|
|
|
|
TESTS_DIR="${PROMTOOL_TESTS_DIR:-kubernetes/apps/monitoring-rules/tests}"
|
|
MUTATIONS="${PROMTOOL_MUTATIONS:-$TESTS_DIR/mutations.txt}"
|
|
|
|
WORK="$(mktemp -d)"
|
|
trap 'rm -rf "$WORK"' EXIT INT TERM
|
|
MIRROR="$WORK/mirror" # stripped manifests + suites, at repo-relative paths
|
|
MUTANT="$WORK/mutant" # a damaged copy of the above, one mutation at a time
|
|
mkdir -p "$MIRROR"
|
|
|
|
die() { echo "FAIL: $*" >&2; exit 1; }
|
|
|
|
command -v promtool >/dev/null 2>&1 || die "promtool is not on PATH."
|
|
|
|
# `promtool --version` writes to stderr on some builds, hence the 2>&1.
|
|
PROMTOOL_VERSION="$(promtool --version 2>&1 \
|
|
| sed -n 's/^promtool, version \([0-9][0-9.]*\).*/\1/p' | head -1)"
|
|
[ -n "$PROMTOOL_VERSION" ] || die "cannot parse 'promtool --version' output."
|
|
case "$PROMTOOL_VERSION" in
|
|
3.*) : ;;
|
|
*) die "promtool $PROMTOOL_VERSION is not 3.x. The cluster runs Prometheus 3
|
|
(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. Fix the image in .woodpecker/ci-manifests-test.yml." ;;
|
|
esac
|
|
echo "promtool $PROMTOOL_VERSION"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Discover PrometheusRule manifests.
|
|
# ---------------------------------------------------------------------------
|
|
# NOT `find -printf` and NOT `grep -r --include`: this runs on the BusyBox find
|
|
# and grep in prom/prometheus, which reject both. `find -print0 | xargs -0 grep`
|
|
# is the portable shape, and unlike a command substitution it does not run into
|
|
# ARG_MAX on a tree this wide. Anchoring `^kind:` at column 0 keeps the match to
|
|
# the top-level field and away from anything nested or quoted in a comment.
|
|
find kubernetes -name '*.yaml' -type f -print0 2>/dev/null \
|
|
| xargs -0 grep -l '^kind: PrometheusRule' 2>/dev/null \
|
|
| sort > "$WORK/rules" || true
|
|
|
|
rule_count=$(grep -c . "$WORK/rules" || true)
|
|
if [ "$rule_count" -lt "$RULE_FLOOR" ]; then
|
|
echo "FAIL: found only $rule_count PrometheusRule manifests; floor is $RULE_FLOOR." >&2
|
|
echo " If the tree legitimately shrank, lower PROMTOOL_RULE_FLOOR in" >&2
|
|
echo " .woodpecker/ci-manifests-test.yml — do not delete the guard." >&2
|
|
echo " Otherwise discovery is broken, and a check that found nothing" >&2
|
|
echo " must not report success." >&2
|
|
exit 1
|
|
fi
|
|
echo "discovered $rule_count PrometheusRule manifests"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Mirror each manifest as a bare rule file, and parse it.
|
|
# ---------------------------------------------------------------------------
|
|
# The awk strips the `spec:` line, dedents its block by two, and stops at the
|
|
# first line back at column 0 — so a trailing top-level key (`status:`) is
|
|
# dropped rather than emitted at the wrong depth. Blank lines pass through
|
|
# unchanged; they carry no indentation to dedent.
|
|
fail=0
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
mkdir -p "$MIRROR/$(dirname "$f")"
|
|
awk '
|
|
!ins && /^spec:[ \t]*$/ { ins = 1; next }
|
|
ins {
|
|
if ($0 == "") { print ""; next }
|
|
if (substr($0, 1, 2) == " ") { print substr($0, 3); next }
|
|
exit
|
|
}
|
|
' "$f" > "$MIRROR/$f"
|
|
|
|
# `promtool check rules` reports "SUCCESS: 0 rules found" on an empty document
|
|
# and exits 0, so a strip that produced nothing would pass. Require the result
|
|
# to actually be a rule file.
|
|
first="$(sed -n '/^[[:space:]]*$/d; p' "$MIRROR/$f" | head -1)"
|
|
if [ "$first" != "groups:" ]; then
|
|
echo "FAIL $f" >&2
|
|
echo " stripping spec: yielded [$first], expected [groups:]." >&2
|
|
echo " This manifest's rules are NOT being checked. Either spec: is" >&2
|
|
echo " not a top-level key here, or its body is not a groups: list." >&2
|
|
fail=1
|
|
continue
|
|
fi
|
|
|
|
if out=$(promtool check rules "$MIRROR/$f" 2>&1); then
|
|
echo "ok $f — $(echo "$out" | sed -n 's/.*\(SUCCESS: .*\)/\1/p' | head -1)"
|
|
else
|
|
echo "FAIL $f" >&2
|
|
echo "$out" | sed 's/^/ /' >&2
|
|
fail=1
|
|
fi
|
|
done < "$WORK/rules"
|
|
[ "$fail" -eq 0 ] || exit 1
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Discover the committed unit suites and copy them into the mirror.
|
|
# ---------------------------------------------------------------------------
|
|
find "$TESTS_DIR" -name '*_test.yaml' -type f 2>/dev/null | sort > "$WORK/suites" || true
|
|
suite_count=$(grep -c . "$WORK/suites" || true)
|
|
if [ "$suite_count" -lt "$SUITE_FLOOR" ]; then
|
|
echo "FAIL: found only $suite_count *_test.yaml suites under $TESTS_DIR;" >&2
|
|
echo " floor is $SUITE_FLOOR. Discovery is broken, or the suites were" >&2
|
|
echo " deleted. Either way this step must not report success." >&2
|
|
exit 1
|
|
fi
|
|
echo "discovered $suite_count unit suite(s)"
|
|
|
|
while IFS= read -r t; do
|
|
[ -n "$t" ] || continue
|
|
mkdir -p "$MIRROR/$(dirname "$t")"
|
|
cp "$t" "$MIRROR/$t"
|
|
done < "$WORK/suites"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Resolve every rule_files entry BEFORE running anything.
|
|
# ---------------------------------------------------------------------------
|
|
# The guard for the first failure mode at the top of this file. promtool joins
|
|
# dirname(suite) onto each `rule_files` entry and, when the result matches
|
|
# nothing, prints a WARNING and carries on with zero rules loaded — so the suite
|
|
# passes having asserted nothing. There is no promtool flag that makes it fatal,
|
|
# so resolve the paths here and refuse.
|
|
#
|
|
# The extraction is deliberately narrow: `rule_files:` at column 0, then the
|
|
# ` - path` lines under it, stopping at the next column-0 key. The flow form
|
|
# (`rule_files: [a, b]`) is not parsed — so it is rejected by name below rather
|
|
# than skipped in silence, which would reopen the hole this closes.
|
|
while IFS= read -r t; do
|
|
[ -n "$t" ] || continue
|
|
if grep -q '^rule_files:[[:space:]]*[^[:space:]]' "$t"; then
|
|
die "$t writes rule_files inline. Use the block form (rule_files: followed
|
|
by ' - path' lines): this script does not resolve the inline form, and
|
|
an unresolved path there would be checked by nobody."
|
|
fi
|
|
awk '
|
|
/^rule_files:[[:space:]]*$/ { inrf = 1; next }
|
|
inrf && /^[[:space:]]*-[[:space:]]*/ {
|
|
sub(/^[[:space:]]*-[[:space:]]*/, "")
|
|
sub(/[[:space:]]*(#.*)?$/, "")
|
|
gsub(/^["'"'"']|["'"'"']$/, "")
|
|
print
|
|
next
|
|
}
|
|
inrf && /^[^[:space:]]/ { inrf = 0 }
|
|
' "$t" > "$WORK/rf"
|
|
|
|
n=$(grep -c . "$WORK/rf" || true)
|
|
[ "$n" -gt 0 ] || die "$t declares no rule_files. It would load zero rules and
|
|
pass every exp_alerts: [] assertion against nothing."
|
|
|
|
while IFS= read -r rf; do
|
|
[ -n "$rf" ] || continue
|
|
case "$rf" in
|
|
/*) resolved="$rf" ;;
|
|
*) resolved="$MIRROR/$(dirname "$t")/$rf" ;;
|
|
esac
|
|
[ -f "$resolved" ] || die "$t references rule_files entry [$rf], which does
|
|
not resolve. promtool would treat that as a WARNING, load zero rules and
|
|
pass. Entries are relative to the SUITE's own directory, not the repo
|
|
root: from $TESTS_DIR, a manifest in the parent is '../name.yaml'."
|
|
done < "$WORK/rf"
|
|
echo "ok $t — $n rule_files entry/entries resolved"
|
|
done < "$WORK/suites"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Run the suites.
|
|
# ---------------------------------------------------------------------------
|
|
# run_suites <dir> -> 0 if every suite passed, 1 otherwise. Output goes to
|
|
# $WORK/out so the mutation phase can stay quiet when a mutation is correctly
|
|
# caught, and the caller can print it when the result is not what it expected.
|
|
run_suites() {
|
|
_dir="$1"
|
|
_rc=0
|
|
: > "$WORK/out"
|
|
while IFS= read -r _t; do
|
|
[ -n "$_t" ] || continue
|
|
if ! (cd "$_dir" && promtool test rules "$_t") >> "$WORK/out" 2>&1; then
|
|
_rc=1
|
|
fi
|
|
done < "$WORK/suites"
|
|
# Belt and braces on the same trap step 4 covers by path: if a future promtool
|
|
# reaches a missing rule file by a route this script does not model, the
|
|
# WARNING is still the tell. A warning here is a failure.
|
|
if grep -q 'WARNING: no file match pattern' "$WORK/out"; then
|
|
echo " (promtool warned about an unresolved rule file — zero rules loaded)" \
|
|
>> "$WORK/out"
|
|
_rc=1
|
|
fi
|
|
return $_rc
|
|
}
|
|
|
|
if run_suites "$MIRROR"; then
|
|
sed 's/^/ /' "$WORK/out"
|
|
echo "ok all $suite_count suite(s) pass against the rules as committed"
|
|
else
|
|
echo "FAIL: the committed rules do not satisfy their own unit suites." >&2
|
|
sed 's/^/ /' "$WORK/out" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Mutation phase: break the rules on purpose; the suites must notice.
|
|
# ---------------------------------------------------------------------------
|
|
[ -f "$MUTATIONS" ] || die "$MUTATIONS is missing. Without it the suites vouch
|
|
for themselves — see WHY THE MUTATION PHASE at the top of this script."
|
|
|
|
mutation_count=0
|
|
while IFS='|' read -r name target program; do
|
|
case "${name:-}" in ''|\#*) continue ;; esac
|
|
[ -n "${target:-}" ] && [ -n "${program:-}" ] \
|
|
|| die "$MUTATIONS: malformed entry [$name]; expected name|path|sed-program."
|
|
[ -f "$MIRROR/$target" ] \
|
|
|| die "$MUTATIONS: mutation [$name] targets $target, which is not one of
|
|
the $rule_count discovered PrometheusRule manifests."
|
|
|
|
rm -rf "$MUTANT"
|
|
cp -R "$MIRROR" "$MUTANT"
|
|
sed "$program" "$MIRROR/$target" > "$MUTANT/$target"
|
|
|
|
# A sed program that matches nothing leaves the tree undamaged, the suites
|
|
# pass, and the mutation reads as undetected — so it would already fail below.
|
|
# It is checked separately because the two cases need different repairs: "the
|
|
# suite got weaker" means fix the suite, "the rule was renamed or reworded"
|
|
# means fix the sed program.
|
|
if cmp -s "$MIRROR/$target" "$MUTANT/$target"; then
|
|
die "mutation [$name] changed nothing in $target. Its sed program no longer
|
|
matches — the rule was probably renamed or reworded. Update the program
|
|
in $MUTATIONS; do not delete the control."
|
|
fi
|
|
|
|
if run_suites "$MUTANT"; then
|
|
echo "FAIL: mutation [$name] was NOT detected — the suites still pass with" >&2
|
|
echo " $target damaged. Whatever this mutation breaks is uncovered." >&2
|
|
echo " Diff applied to the stripped manifest:" >&2
|
|
diff "$MIRROR/$target" "$MUTANT/$target" | sed 's/^/ /' >&2 || true
|
|
exit 1
|
|
fi
|
|
echo "ok mutation [$name] detected"
|
|
mutation_count=$((mutation_count + 1))
|
|
done < "$MUTATIONS"
|
|
|
|
if [ "$mutation_count" -lt "$MUTATION_FLOOR" ]; then
|
|
echo "FAIL: ran only $mutation_count mutations; floor is $MUTATION_FLOOR." >&2
|
|
echo " An empty mutation phase leaves the suites vouching for" >&2
|
|
echo " themselves, which is the thing it exists to prevent." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "PASS $rule_count manifests parsed, $suite_count suite(s) green," \
|
|
"$mutation_count mutation(s) caught"
|