Minimal prod delivery, authored directly on main (= prod's live revision d1a6628)
rather than by merging work/prod-standup-campaign. That branch carries the DNS apex
inversion, which would have set prod's BASE_DOMAIN to home.keithjackson.dev; prod
runs external-dns with policy: sync and txtOwnerId: prod, so it would have begun
rewriting apex records dev owns while the non-k3s prod still serves 443 there.
Operator sequencing is "dev migrates first". Nothing under clusters/ is touched
here at all, which is what makes this safe to land now.
Also deliberately EXCLUDED, because prod's live cluster-vars has no CNPG_BACKUP_PATH
key and StrictPostBuildSubstitutions would fail the whole tier: the R2 rework of
platform/velero/ and the CNPG destinationPath changes. Those land as
feat/r2-backup-prod together with the endpoint and bucket knobs.
WHAT THIS FIXES
1. Nothing was watching CNPG. All three prod databases have run since bootstrap with
ContinuousArchiving=False and firstRecoverabilityPoint=null, and no alert existed
anywhere. Because archive_mode=on and the archive command fails, Postgres refuses
to recycle WAL: keycloak-db holds every segment ever written (257 MiB on a 5 GiB
volume, ~6.8 MiB/h) — roughly 29 days to a disk-full PANIC on the cluster's SSO
database. Found by hand, 38h late. CNPGWALArchiveBacklog would have fired within
the hour, and evaluates to 3 firing series on prod today.
Rules key on cnpg_collector_* rather than cnpg_pg_stat_archiver_*, because
nextcloud-db's exporter cannot log in to its own database (permission denied for
database "nextcloud", both clusters) so the pg_stat_archiver family does not exist
for it — the obvious metric would have been blind to one database in three.
2. No PVC outside namespace=monitoring had any fill alert. New rules are partitioned
by namespace so they cannot double-fire with MonitoringVolumeFillingUp, pinned to
job="kubelet" (the apiserver copy of the same series reports namespace=default),
and guarded by inodes > 0 — block-mode PVCs such as unifi-rootdisk report
available=0 forever and would otherwise have fired critical permanently.
3. VPNTunnelDown could never clear on a cluster with no gateway node; it has been
firing critical on prod since 2026-09-05. Now guarded so prod self-arms the moment
a node carries the egress-gateway label, with dev's behaviour unchanged today.
4. The Keycloak realm import fails OPEN: an unresolved placeholder is stored
LITERALLY and the import still reports success, then sits at Done=True and never
re-runs. On 2026-09-06 all 12 prod OIDC clients held the literal
$(env:..._CLIENT_SECRET) string and every SSO login failed, while every signal was
green. The CronJob is the level-triggered correction; two alerts make its silence
observable. Repaired live already — this stops it recurring.
Also: the R-NET-04 conformance scan descended into nested agent worktrees under
.claude/, counting 4 egress policies where 1 is authored.
Co-Authored-By: Claude Opus 5 <[email protected]>
279 lines
16 KiB
Bash
Executable File
279 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ci/touched-requirements.sh — compute the requirement subset a change "touches".
|
|
#
|
|
# R-TEST-03 gates a merge on the conformance subset tagged with the requirement
|
|
# IDs the change touches. This script derives that subset from the diff so the
|
|
# conformance runner can be filtered to it (conformance.sh --requirements ...).
|
|
#
|
|
# Output (stdout): newline-separated, sorted, unique R-* IDs. Nothing else goes
|
|
# to stdout, so callers can `mapfile`/`$(...)` it directly.
|
|
#
|
|
# SOURCE OF TRUTH (reconciled, PM 2026-08-16): the conformance requirement TAGS are
|
|
# authoritative. Each Chainsaw test declares the requirements it verifies via
|
|
# `conformance.home.k3s/<R-ID>` labels; the coverage report (conformance/_artifacts/
|
|
# coverage/coverage.json) is generated from those tags. This script's job is only to
|
|
# select which tagged subset a change TOUCHES, then hand those IDs to
|
|
# `conformance.sh --requirements ...` which runs exactly the tests carrying those tags.
|
|
#
|
|
# Detection = union of:
|
|
# 1. R-* tokens on the ADDED/REMOVED LINES of the diff, in non-Markdown files —
|
|
# including the authoritative `conformance.home.k3s/R-*` tags on changed tests, and
|
|
# the R-IDs that policies/ServiceDefinitions self-declare (docs/14 + ADR-0011).
|
|
# Untouched lines and prose are NOT scanned; see changed_diff() for why.
|
|
# 2. A coarse path->requirement fallback for changes that don't self-declare IDs.
|
|
# The coarse map (2) NEVER overrides an explicit tag; it only widens coverage for
|
|
# untagged changes so nothing silently escapes the gate.
|
|
set -euo pipefail
|
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck disable=SC1091
|
|
source "${CI_DIR}/lib.sh"
|
|
|
|
# ---------------------------------------------------------------------------------
|
|
# BASE-REF RESOLUTION — and why it now REFUSES rather than guesses.
|
|
#
|
|
# BUG THIS FIXES (local/CI divergence, 2026-08-31): every failure to establish a
|
|
# reliable diff base used to degrade SILENTLY to an EMPTY touched set, which main()
|
|
# reported as "no changed files vs base" and exit 0. An empty set means the coverage
|
|
# `--require` gate is never applied, so the merge gate printed "MERGE GATE: PASS"
|
|
# having checked NOTHING — while CI, with a correct base, computed 17 touched
|
|
# requirements and blocked. A false green is the worst outcome this project can
|
|
# produce, so every one of those degradations is now a LOUD exit 3:
|
|
#
|
|
# * HEAD~1 last resort REMOVED. It silently narrowed the diff to the last commit
|
|
# (and on a one-commit repo failed into an empty set).
|
|
# * two-dot fallback REMOVED. It masked a failing three-dot diff as "no changes".
|
|
# * unresolvable base exit 3 (was: HEAD~1 or empty).
|
|
# * diff command fails exit 3 (was: empty set).
|
|
# * HEAD not ahead of exit 3 (was: exit 0 with an empty set — this is the case
|
|
# the base that produced the false green: running the gate while
|
|
# sitting on `main`, or on a branch already merged).
|
|
#
|
|
# Exit 3 is the ci/README.md "SKIPPED / not-run" code: NOT a pass, NOT a fail. Callers
|
|
# (ci/gate.sh) must surface it as an UNDECIDED merge decision. An honest, noisy skip
|
|
# beats a quiet false pass.
|
|
#
|
|
# Order of preference: BASE_REF (explicit) > GITHUB_BASE_REF (Actions PR runs, target
|
|
# branch) > origin/main > main. Each candidate is VERIFIED to resolve to a commit
|
|
# before it is used, so a shallow checkout that lacks origin/main is reported, not
|
|
# papered over.
|
|
: "${BASE_REF:=}"
|
|
BASE="" # resolved base ref
|
|
BASE_SOURCE="" # where it came from (for the log line)
|
|
BASE_PROBLEM="" # why resolution failed, when it did
|
|
|
|
rev() { git -C "$REPO_ROOT" rev-parse --verify -q "$1^{commit}" 2>/dev/null; }
|
|
|
|
# Loud, structured refusal. Prints to stderr only; stdout stays empty so a caller that
|
|
# captures stdout gets NO requirement list rather than a silently-empty one.
|
|
#
|
|
# $1 is a machine-readable REASON CODE, also written to $TOUCHED_REASON_FILE when that
|
|
# env var names a path. ci/gate.sh uses it to tell the two cases apart:
|
|
# on-base HEAD is the base branch (or already merged) — nothing to gate. Legitimate
|
|
# (a push-to-main CI run lands here), so the gate reports NOT APPLICABLE
|
|
# rather than PASS, and does not fail the job.
|
|
# no-base no reliable base ref exists in this checkout — the ENVIRONMENT is broken,
|
|
# diff-failed or git could not produce the diff. A merge decision is impossible, so the
|
|
# gate BLOCKS rather than guessing.
|
|
undecidable() {
|
|
local code="$1"; shift
|
|
if [ -n "${TOUCHED_REASON_FILE:-}" ]; then printf '%s\n' "$code" > "$TOUCHED_REASON_FILE"; fi
|
|
err "TOUCHED-REQUIREMENT SET NOT DETERMINABLE (${code})"
|
|
err " reason: $*"
|
|
err " consequence: the R-TEST-03 coverage gate CANNOT be applied to this checkout."
|
|
err " This is exit 3 (SKIPPED / not-run), never a pass — see ci/README.md."
|
|
err " Fix one of:"
|
|
err " * check out the feature branch you mean to gate (not the base branch), or"
|
|
err " * git fetch origin main (so origin/main exists and is current), or"
|
|
err " * BASE_REF=<ref> make -C ci gate to name the base explicitly."
|
|
exit 3
|
|
}
|
|
|
|
resolve_base() {
|
|
local cand
|
|
if [ -n "$BASE_REF" ]; then
|
|
if rev "$BASE_REF" >/dev/null; then BASE="$BASE_REF"; BASE_SOURCE="BASE_REF"; return 0; fi
|
|
BASE_PROBLEM="BASE_REF='${BASE_REF}' does not resolve to a commit in ${REPO_ROOT}"
|
|
return 1
|
|
fi
|
|
if [ -n "${GITHUB_BASE_REF:-}" ]; then
|
|
for cand in "origin/${GITHUB_BASE_REF}" "${GITHUB_BASE_REF}"; do
|
|
if rev "$cand" >/dev/null; then BASE="$cand"; BASE_SOURCE="GITHUB_BASE_REF"; return 0; fi
|
|
done
|
|
BASE_PROBLEM="GITHUB_BASE_REF='${GITHUB_BASE_REF}' is set, but neither 'origin/${GITHUB_BASE_REF}' nor '${GITHUB_BASE_REF}' exists here (shallow checkout? the workflow needs fetch-depth: 0)"
|
|
return 1
|
|
fi
|
|
for cand in origin/main main; do
|
|
if rev "$cand" >/dev/null; then BASE="$cand"; BASE_SOURCE="default"; return 0; fi
|
|
done
|
|
BASE_PROBLEM="no base ref resolves (tried origin/main, main) in ${REPO_ROOT}"
|
|
return 1
|
|
}
|
|
|
|
# Both diff helpers assume BASE is already resolved and validated by main(). They return
|
|
# git's exit status so the CALLER can distinguish "no changes" from "the diff failed" —
|
|
# conflating those two is exactly what produced the false green.
|
|
changed_files() {
|
|
# Three-dot: changes on this branch since it diverged from base.
|
|
git -C "$REPO_ROOT" diff --name-only "${BASE}...HEAD"
|
|
}
|
|
|
|
# The ADDED/REMOVED lines of the change, for the R-ID content scan — excluding
|
|
# documentation.
|
|
#
|
|
# THREE scoping rules, all learned from the gate blocking merges it should not have:
|
|
#
|
|
# 1. Scan the DIFF, not whole file contents. Grepping whole files meant a change
|
|
# inherited every requirement those files had EVER mentioned, on lines the author
|
|
# never touched. Since some of those are legitimately uncovered, `--require-all`
|
|
# then failed and BLOCKED the merge over unrelated prose.
|
|
#
|
|
# 2. Skip `*.md`. A requirement ID in prose is a REFERENCE, not a declaration —
|
|
# explaining a requirement is not the same as changing behaviour against it.
|
|
# Without this, the decision log cannot record which requirements a change
|
|
# concerned without thereby claiming to have touched them, and the CI-fix commit
|
|
# that explains a mis-attribution re-triggers the very mis-attribution it fixes.
|
|
# Nothing escapes: authoritative declarations (`conformance.home.k3s/R-*` tags,
|
|
# Kyverno policies, ServiceDefinitions) all live in YAML, and the doc subtrees
|
|
# that matter (conformance/, schemas/, catalog/, clusters/, ci/) are covered by
|
|
# the path->requirement fallback regardless of file type.
|
|
#
|
|
# 3. Skip COMMENT-ONLY lines in non-Markdown files — rule 2's logic, applied to the
|
|
# prose that lives INSIDE code files. A requirement ID cited in a YAML/shell `#`
|
|
# comment is documentation, not a declaration, exactly like an ID in a `*.md` file.
|
|
# Rule 2 drops whole Markdown files, but a `.gitea/workflows/*.yml` or `ci/*.sh`
|
|
# HEADER that explains a requirement would otherwise re-attribute it. Concretely:
|
|
# gate.yml's header cites R-TEST-06 (enforced by the spec-immutability STAGE, not by
|
|
# any Chainsaw test, so a known-uncovered ID); before this rule, editing that comment
|
|
# re-attributed R-TEST-06 and the coverage `--require` check false-blocked the merge —
|
|
# the same self-referential trap rule 2 fixed for `*.md`. Applied in main()'s content
|
|
# scan (see there): after the diff marker is stripped, a line whose first non-blank
|
|
# character is `#` is dropped BEFORE the R-ID grep. Deliberately conservative — only
|
|
# WHOLE-LINE comments are dropped; an R-ID on a real code/spec line is still detected
|
|
# (authoritative declarations — test tags, policies, ServiceDefinitions — are never a
|
|
# bare comment), so this only narrows PROSE, never a declaration.
|
|
#
|
|
# Removed lines count: deleting a test's requirement tag, or a policy's self-declared
|
|
# ID, touches that requirement just as much as adding one.
|
|
# `:(exclude)*.md` drops Markdown from the content scan (rule 2 above).
|
|
# `:(exclude)ci/tests/` drops this dir's OWN unit-test fixtures: those files embed example
|
|
# R-IDs as DATA (asserting the scan's behaviour), never as declarations — the same
|
|
# "reference, not declaration" logic as rule 2. Their gate coverage is unaffected: any
|
|
# ci/ change (ci/tests/ included) still attributes R-GITOPS-01/R-TEST-02/R-TEST-03 via the
|
|
# path->requirement map below. NOTE: this excludes ci/tests/ only — conformance/tests/
|
|
# carry AUTHORITATIVE `conformance.home.k3s/R-*` labels and are deliberately still scanned.
|
|
# Three-dot to match changed_files(): changes on this branch since it diverged from base.
|
|
changed_diff() {
|
|
local -a excl=(':(exclude)*.md' ':(exclude)ci/tests/')
|
|
git -C "$REPO_ROOT" diff -U0 "${BASE}...HEAD" -- . "${excl[@]}"
|
|
}
|
|
|
|
# Coarse path -> requirement map. Deliberately conservative; extend as subtrees
|
|
# fill in. Left side is a path prefix (relative to repo root).
|
|
path_requirements() {
|
|
case "$1" in
|
|
catalog/*) echo "R-GITOPS-02" ;;
|
|
schemas/*) echo "R-GITOPS-02" ;;
|
|
platform/kyverno/*) echo "R-TEST-01 R-NET-01 R-SEC-01" ;;
|
|
platform/cilium/*) echo "R-NET-01 R-NET-02 R-NET-05" ;;
|
|
platform/longhorn/*) echo "R-STATE-01 R-STATE-02" ;;
|
|
platform/cert-manager/*) echo "R-TLS-01 R-TLS-02" ;;
|
|
platform/externaldns/*) echo "R-DNS-02" ;;
|
|
platform/velero/*) echo "R-STATE-03" ;;
|
|
platform/monitoring/*) echo "R-OBS-01 R-OBS-02" ;;
|
|
# platform/cnpg was UNMAPPED until 2026-09-06. It is the PostgreSQL DR path for keycloak-db /
|
|
# nextcloud-db / semaphore-db (barmanObjectStore WAL archiving + ScheduledBackup), so a change
|
|
# there is an R-IDP-05 / R-STATE-03 change; it now also carries the platform-health alert rules
|
|
# for that path (R-OBS-04). Without this line an edit under platform/cnpg/ contributed ZERO
|
|
# requirements unless it happened to retype an R-ID on a non-comment line, and escaped the gate.
|
|
platform/cnpg/*) echo "R-IDP-05 R-STATE-03 R-OBS-04" ;;
|
|
# vpn-egress was UNMAPPED until 2026-09-06. platform/vpn-egress/egress-steering.yaml IS the
|
|
# R-NET-04 datapath (the rule that selects between the two egress paths) and the R-NET-05/06/07
|
|
# steering policy — it even self-declares those IDs in its `homelab.keithjackson.dev/requirements`
|
|
# annotation. But the content scan only reads ADDED/REMOVED lines, so any edit that does not
|
|
# happen to retype an R-ID (a selector change, an excludedCIDRs change, a comment) contributed
|
|
# ZERO requirements and escaped the gate entirely. Mapped explicitly; the annotation still wins
|
|
# when a diff does carry the tags.
|
|
platform/vpn-egress/*) echo "R-NET-04 R-NET-05 R-NET-06 R-NET-07" ;;
|
|
# Keycloak is the whole SSO surface: the realm import (declarative identity config),
|
|
# the SOPS client secrets, and the reconciler that heals them. Added 2026-09-06 after
|
|
# the client-secret incident, in which editing platform/keycloak/ attributed NO
|
|
# requirement, so the L2 keycloak tests were filtered out of the merge gate's touched
|
|
# subset — the change that could break every OIDC login was gated by nothing.
|
|
# R-IDP-05 is in scope because platform/keycloak/ also carries the CNPG DB Cluster and
|
|
# its off-cluster backup credential, not only the realm.
|
|
platform/keycloak/*) echo "R-IDP-01 R-IDP-02 R-IDP-03 R-IDP-04 R-IDP-05 R-IDP-06" ;;
|
|
platform/oauth2-proxy/*) echo "R-IDP-02 R-IDP-06" ;;
|
|
platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;;
|
|
conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;;
|
|
clusters/*) echo "R-GITOPS-01 R-ENV-02" ;;
|
|
# bootstrap/ was UNMAPPED until 2026-09-06, and the omission was load-bearing:
|
|
# bootstrap/standup.sh carries the placeholder guards that decide whether `velero` may be
|
|
# enabled against a real backup endpoint. A change touching standup.sh ALONGSIDE any
|
|
# already-mapped path scoped the L0 run to that other path's requirements and silently
|
|
# excluded conformance/tests/l0-static/placeholder-guard-coupling — the check that exists
|
|
# precisely to catch a guard/value mismatch. The 2026-09-05 guard bug (a prefix glob that
|
|
# stopped matching when the value it guards changed, in a different file) is exactly the
|
|
# class that omission would hide.
|
|
bootstrap/*) echo "R-STATE-03 R-GITOPS-01" ;;
|
|
.gitea/*|ci/*) echo "R-GITOPS-01 R-TEST-02 R-TEST-03" ;;
|
|
*) : ;;
|
|
esac
|
|
}
|
|
|
|
main() {
|
|
local files reqs="" rc=0 base_sha head_sha local_main merge_base
|
|
|
|
resolve_base || undecidable no-base "$BASE_PROBLEM"
|
|
base_sha="$(rev "$BASE")" || undecidable no-base "resolved base '${BASE}' vanished"
|
|
head_sha="$(rev HEAD)" || undecidable no-base "HEAD does not resolve to a commit (empty repo?)"
|
|
log "diff base: ${BASE} (${BASE_SOURCE}) @ ${base_sha:0:12} ... HEAD @ ${head_sha:0:12}"
|
|
|
|
# Advisory: a never-fetched origin/main silently WIDENS the set (over-broad, never a
|
|
# false green) — surfaced so an unexpectedly large touched set has an explanation.
|
|
if [ "$BASE" = "origin/main" ] && local_main="$(rev main)" && [ -n "$local_main" ] \
|
|
&& [ "$local_main" != "$base_sha" ]; then
|
|
warn "local 'main' (${local_main:0:12}) != origin/main (${base_sha:0:12}) — if origin/main"
|
|
warn " was not fetched recently the touched set may be stale: git fetch origin main"
|
|
fi
|
|
|
|
merge_base="$(git -C "$REPO_ROOT" merge-base "$BASE" HEAD 2>/dev/null)" \
|
|
|| undecidable no-base "no merge base between '${BASE}' and HEAD (unrelated histories, or a shallow clone)"
|
|
if [ "$merge_base" = "$head_sha" ]; then
|
|
undecidable on-base "HEAD is not ahead of '${BASE}' (merge-base == HEAD): this checkout contains no branch commits to gate. The empty touched set would be empty BY DEFAULT, not by verification."
|
|
fi
|
|
|
|
files="$(changed_files)" || rc=$?
|
|
[ "$rc" -eq 0 ] || undecidable diff-failed "git diff --name-only ${BASE}...HEAD failed (rc=${rc})"
|
|
if [ -z "$files" ]; then
|
|
warn "HEAD is ahead of ${BASE} but the three-dot diff is empty (self-cancelling commits?)"
|
|
warn " -> genuinely empty touched-requirement set; base WAS established."
|
|
exit 0
|
|
fi
|
|
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
reqs+=" $(path_requirements "$f")"
|
|
done <<< "$files"
|
|
|
|
# Self-declared R-IDs on the ADDED/REMOVED lines of the change (see changed_diff).
|
|
# `^[+-]` keeps hunk headers (`@@`) and the `+++`/`---` file headers out of scope.
|
|
# `sed 's/^[+-]//'` then strips the single-char diff marker (safe: -U0 emits one), so
|
|
# what remains is the SOURCE line, and `grep -vE '^[[:space:]]*#'` drops whole-line
|
|
# YAML/shell comments (rule 3 in changed_diff's header) BEFORE the R-ID grep — a
|
|
# requirement cited only in a `#` comment is prose, not a declaration.
|
|
local diff_txt
|
|
diff_txt="$(changed_diff)" || rc=$?
|
|
[ "$rc" -eq 0 ] || undecidable diff-failed "git diff -U0 ${BASE}...HEAD failed (rc=${rc})"
|
|
reqs+=" $(printf '%s\n' "$diff_txt" \
|
|
| grep -E '^[+-]' \
|
|
| grep -vE '^(\+\+\+|---)' \
|
|
| sed -E 's/^[+-]//' \
|
|
| grep -vE '^[[:space:]]*#' \
|
|
| grep -hoE 'R-[A-Z]+-[0-9]+' \
|
|
| tr '\n' ' ' || true)"
|
|
|
|
# Normalize: split, unique, sort. Emit to stdout.
|
|
printf '%s\n' $reqs | sed '/^$/d' | sort -u
|
|
}
|
|
main "$@"
|