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>
265 lines
10 KiB
Bash
Executable File
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
|