Files
home-network-k3s-impl/ci/touched-requirements.sh
T
jakman85andClaude Opus 5 a984442cae harden: pin node timezone to UTC, and TEST the guard/value coupling that bit us
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]>
2026-09-06 08:47:08 -05:00

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 "$@"