Two debt items, both about making a latent problem impossible rather than fixed. === 1. Node timezone pinned to Etc/UTC (node_baseline, `system` tag) === The prod nodes disagreed, measured from k3s's own etcd snapshot annotations: prod-1/2 at -05:00 (Central), prod-3 at -04:00 (Eastern). Functionally harmless — Kubernetes and etcd are UTC internally, and in UTC those snapshots are ~4 minutes apart, which is correct staggering — but it makes cross-node log correlation actively misleading, and this cluster has had two unplanned reboots in two days. `Etc/UTC` chosen on repo convention, not just principle: catalog/plex.yaml records "no other service in the repo sets a timezone ... so use the safe default" with TZ: Etc/UTC, and apps/wave-0/plex.yaml matches (platform/crafty is America/Chicago DELIBERATELY — a user-facing game schedule, not a log clock). Decisive extra fact: THE THREE DEV NODES ARE ALREADY Etc/UTC, so this makes prod match dev rather than inventing a convention. UTC also removes the class — no DST discontinuity, node time == platform time. New knob `node_baseline_timezone`, overridable per environment via group_vars (the same seam as node_baseline_backports_kernel) but deliberately NOT restated there: no environment needs to differ, and a second copy is a second thing to disagree. Guarded by an assert in the role's existing BLOCKED style before the module runs. `--check --diff --tags system -e k3s_hosts=k3s_prod` INDEPENDENTLY CONFIRMS the offset evidence, and is the whole verification — the play was never run for real: k3s-prod-1: -"America/Chicago" +"Etc/UTC" changed k3s-prod-2: -"America/Chicago" +"Etc/UTC" changed k3s-prod-3: -"America/New_York" +"Etc/UTC" changed The same --check against DEV reports the task `ok` on all three — idempotence demonstrated against live hosts, in check mode only. `--list-hosts` confirms `-e k3s_hosts=k3s_prod` selects the three prod nodes (the diagnostic plays take their OWN var; the wrong flag silently targets dev). *** AUTHORED, NOT APPLIED. *** Applying it is a live-cluster action and was deliberately left for the operator to schedule: prod's Longhorn had only just finished resyncing after a node reboot, and changing a node's clock config during storage recovery is not an unattended move. `ansible-inventory --list` was NOT used anywhere — it prints DECRYPTED vault values (the passphrase is mounted into the dev container) and has leaked the prod become-password to a transcript once already. === 2. New L0 check `placeholder-guard-coupling` (R-ENV-02, R-STATE-03) === On 2026-09-05 the PM changed a placeholder in clusters/base/cluster-vars.yaml from `TBD-PROD-S3-BACKUP-ENDPOINT` to `https://TBD-R2-ACCOUNT-ID...`. The guards in bootstrap/standup.sh matched the glob `TBD-*` — a PREFIX. All three silently died, and `resume` would have enabled velero on prod against a nonexistent hostname: installs clean, reports healthy, no backups exist. Found by accident. A guard in one file keyed on the SHAPE of a value in another file, with nothing tying them. The check exercises the REAL predicate — it does not reimplement it. standup.sh is not sourceable (top-level argv parsing hard-exits, cd, export, EXIT trap), so the check extracts the predicate's own source lines and sources only those. Faithfulness is pinned by: discovery of every `*_is_placeholder` (not a hardcoded name), exactly-one-definition, "extracted block is a verbatim substring", a self-containment gate, a canary (must refuse "", must accept a real URL), and a scan for a divergent second copy. If the predicate grows a dependency the check FAILS LOUDLY and names the remedy rather than degrading to testing a copy — the failure mode it exists for. Both sides derive from real files: guarded knobs from a taint trace in standup.sh (`x="$(cvar KEY)"` -> alias hop -> `*_is_placeholder "$x"`); values from base cluster-vars plus each overlay's JSON6902 /data/KEY patches. The independent detector is deliberately NOT the guard: seed idioms UNION the guard's own words with glob anchors STRIPPED — so a prefix-anchored guard cannot pass, and deleting an arm cannot make a value stop being placeholder-shaped. Real tree: 32 assertions, 18 values through the extracted predicate, PASS. NON-VACUITY VERIFIED BY ME, twice, on throwaway copies (the real repo and the read-only spec repo untouched). My first perturbation tripped the self-containment rail instead of the anchoring property, so I redid it shape-preserving — substring globs -> prefix globs, nothing else — and it failed on exactly the right property, naming mid-string and tail acceptances with the diagnosis "This is the 2026-09-05 failure exactly". Control: unperturbed copy exits 0. === 3. ci/touched-requirements.sh: bootstrap/ was UNMAPPED (found by the agent) === The path->requirement map had no `bootstrap/*` entry, so a change touching standup.sh ALONGSIDE any already-mapped path scoped the L0 run to that other path's requirements and silently excluded the new check — the one that exists to catch exactly the 2026-09-05 class. A safety net with a hole where the incident was. Mapped to R-STATE-03 R-GITOPS-01; verified the map now resolves bootstrap paths. VERIFIED: full CI gate PASS. `run.sh --layer l0` 8/8 PASS including the new check. Coverage unchanged at 45/51 (the new check tags existing requirements). Refs R-ENV-02, R-STATE-03, R-TEST-01, R-PLAT-01. Co-Authored-By: Claude Opus 5 <[email protected]>
256 lines
14 KiB
Bash
Executable File
256 lines
14 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/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 "$@"
|