Files
home-network-k3s-impl/conformance/run.sh
T
jakman85andClaude Opus 5 6e77150ae1 fix(ci): kill the false-green base resolution; cover R-SEC-01/02 + R-TEST-06 (L0)
Unblocks the enforced Gitea gate, which correctly BLOCKED this branch on
['R-SEC-01','R-SEC-02','R-TEST-06'] being touched but uncovered. Tests were authored;
the coverage rule was NOT narrowed.

=== THE FALSE GREEN (root cause) ===
`make -C ci gate` locally reported PASS having checked NOTHING. resolve_base() validated
nothing, and EVERY way of failing to find a base degraded silently to an empty touched
set + exit 0: an `HEAD~1` last resort, a two-dot diff fallback masking a failing
three-dot diff, and no check that HEAD is even AHEAD of the base. Run while sitting on
`main` (main == origin/main), `origin/main...HEAD` is empty -> "no changed files vs
base" -> the empty set means run.sh is invoked with NO --requirements -> coverage.py's
--require gate never runs at all. Green by vacuum.

Now: candidates are BASE_REF > GITHUB_BASE_REF > origin/main > main, each verified to
resolve. HEAD~1 and the two-dot fallback are gone. Unresolvable base / no merge base /
failed diff / HEAD-not-ahead -> loud banner, EMPTY stdout, exit 3, and a machine-readable
reason (on-base | no-base | diff-failed). gate.sh turns exit 3 into an UNDECIDED
decision: `on-base` -> "NOT APPLICABLE — no branch under gate" (exit 0 so push-to-main
runs don't break, but it never reads as PASS); `no-base`/`diff-failed` -> BLOCKED. It
also now prints the diff base it used. A stale local main warns (widens, never falsely
greens). Also fixed `make -C ci test`, which was SWALLOWING failing suites.

Regression: ci/tests/touched-requirements-base.test.sh — 31 assertions over 8
throwaway-repo scenarios including the exact false-green case, an already-merged branch,
no base ref, a single-commit repo, bogus BASE_REF, a shallow GITHUB_BASE_REF, and
origin/main-preferred-over-stale-main.

=== NEW L0 STATIC LAYER (conformance/tests/l0-static/) ===
Where docs/14 actually puts these invariants. Each check.sh really executes offline via
`run.sh --layer l0` and is wired into ci/l0.sh, so it runs on every PR. No scaffolds.

R-SEC-01 no-plaintext-secrets (165 assertions / 445 tracked files): .sops.yaml holds only
PUBLIC age recipients; every committed Secret is SOPS-wrapped with a mac and ENC[...]
values; ansible vault.yml files are $ANSIBLE_VAULT; no secret-valued ansible key carries
a literal outside a vault; no PEM key / age secret key / kubeconfig client-key-data is
tracked; Flux declares sops decryption; the disallow-plaintext-secrets policy Enforces
and has a proving `result: fail` row. Exemptions are SELF-POLICING, not path allowlists —
a .example must be referenced by no kustomization and hold only placeholders, and the
Kyverno fixture is exempt ONLY while a test asserts the policy rejects it. Proven against
7 injected violations, including deleting the negative row and weakening Enforce->Audit.

R-SEC-02 least-privilege-rbac (41 assertions): no wildcard apiGroup/resource/verb and no
escalate|bind|impersonate in any in-repo Role/ClusterRole; no ServiceAccount bound to
cluster-admin or a wildcard role; the conformance runner's own test identity is scoped
and holds no rights over RBAC objects; the default-deny/host-namespace/egress-class
policies Enforce and each carry a negative row. Proven against 6 injected violations.

R-TEST-06 spec-immutability-enforced (16 assertions): drives the REAL
ci/spec-immutability.sh against HERMETIC throwaway spec repos and asserts its exit code
in every state — clean-at-pin 0, edits under docs/ or decisions/ 1, untracked addition 1,
unadopted drift 1, explicit pin bump 0, ABSENT spec repo 3 (honest skip, never green),
unpinned SPEC_PIN 1 — plus that gate.sh sets BLOCK=1 on its failure, so the mechanism is
load-bearing. The real spec repo is never written to.

Each check documents what it does NOT cover (runtime decryption, git history, runtime
least-privilege, branch protection) rather than overclaiming. status.py now reads
check.sh too, so a placeholder L0 body still outranks an `executable: true` annotation.

Coverage 37/51 -> 40/51 (11 gaps); executable tests 32 -> 35; scaffold-only stays 5 —
no sixth scaffold was added.

Serves: R-SEC-01, R-SEC-02, R-TEST-01..06.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-31 23:26:49 -05:00

387 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
###############################################################################
# conformance/run.sh — the single-command conformance runner (R-TEST-02/03/04)
#
# ONE command runs the suite (or a requirement-/layer-scoped subset) and always
# emits JUnit + a requirement-coverage report. Local runs and the in-cluster CI
# Chainsaw Job invoke this same script so results are identical (reproducibility).
#
# ./run.sh # full suite; skips L1/L2/L3 if no cluster
# ./run.sh --requirements R-NET-01,R-NET-06 # merge-gate subset (tagged)
# ./run.sh --layer l2 # one layer (l0|l1|l2|l3|meta|all; l0/l1 need no cluster)
# ./run.sh --requirements R-NET-01,R-NET-02 --layer l2 # intersect: tagged AND in layer
# ./run.sh --coverage-only # regenerate coverage report only (no cluster)
# ./run.sh --status # DERIVED STATUS report only (no cluster): per
# # requirement, is it implemented, is it actually
# # reconciled by Flux, is its test executable or a
# # scaffold. Proof-of-pass is deliberately NOT
# # derived — see lib/status.py.
# ./run.sh --lint # structural-validate test defs (no cluster)
# ./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
# ./run.sh --requirements R-NET-01 # ALSO asserts those IDs are covered (merge gate)
# ./run.sh --require-all # phase-exit: every requirement must be covered
#
# Artifacts (always written under conformance/_artifacts/, gitignored):
# _artifacts/report.junit.xml JUnit (Chainsaw output, or skipped-scaffold shim)
# _artifacts/coverage/coverage.md human coverage report
# _artifacts/coverage/coverage.json machine coverage report
# _artifacts/status/status.md human requirement-status report (--status)
# _artifacts/status/status.json machine requirement-status report (--status)
#
# Phase 0: no cluster exists. The behavior tests are valid scaffolds that cannot
# execute yet, so run.sh emits a *skipped* JUnit and a coverage report, and exits
# green — the harness is proven reproducible while later phases fill in behavior.
###############################################################################
set -euo pipefail
CONF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CONF_ROOT
# shellcheck source=lib/common.sh
source "${CONF_ROOT}/lib/common.sh"
# shellcheck source=versions.env
source "${CONF_ROOT}/versions.env"
ARTIFACTS="${CONF_ROOT}/_artifacts"
COVERAGE_OUT="${ARTIFACTS}/coverage"
STATUS_OUT="${ARTIFACTS}/status"
JUNIT_OUT="${ARTIFACTS}/report.junit.xml"
PYTHON="${PYTHON:-python3}"
LAYER="all"
REQS=""
MODE="run" # run | coverage-only | status | lint
STRICT_COVERAGE=0
REQUIRE_ALL=0
FORCE_NO_CLUSTER=0
# Documented, out-of-scope coverage gaps exempt from the completeness gate: requirements
# owned by another workstream that WS-TEST does not yet scaffold. Listed here so the
# exemption is explicit and reviewable, not silent.
# R-ENV-02 (dev->prod promote) — owned by WS-INFRA/WS-GITOPS; unscaffolded (a GAP in
# coverage.md). The coarse path->requirement map keys
# clusters/* AND ci/* to it and the R-ENV-02 doc token
# appears in platform/kustomization.yaml's header, so ANY
# infra/overlay/CI edit (e.g. enabling a component, bumping
# SPEC_PIN) would otherwise false-block on it.
# R-GITOPS-01 (state in git/Flux) — same: owned by WS-GITOPS, unscaffolded, and the ci/*
# path-map keys to it, so editing any ci/ file (SPEC_PIN
# adoption included) would false-block.
# These are visible GAPs, not hidden — removing them from this list is the right move the
# moment WS-TEST adds their verifying tests.
#
# FOLLOW-UP (WS-TEST) — HALF DONE. The debt was "make the matcher stop keying on doc-header
# tokens so real requirements need not be exempted". The CONTENT-SCAN half is now fixed in
# ci/touched-requirements.sh: it scans the added/removed lines of the diff, and skips *.md, so
# a requirement named in a header or in prose no longer counts as a declaration. Consequence
# to be aware of: a manifest whose HEADER declares the requirements it serves no longer
# contributes those IDs unless the change actually touches a line carrying them — the
# authoritative signal is the `conformance.home.k3s/R-*` label on a test, which still is.
# The PATH-MAP half remains: ci/touched-requirements.sh keys clusters/* and ci/* to
# R-ENV-02 + R-GITOPS-01 wholesale, which is why those two must stay exempted here. Tighten
# the path map (or scaffold the two requirements) and this list empties.
#
# R-STATE-02 (node-loss) WAS listed here (owned by WS-STORAGE, "delivered with Longhorn/infra")
# but now has a real verifying L3 test — tests/l3-fault-injection/node-loss — so it is no
# longer a gap and has been removed from this list; the coverage report counts it as covered.
DOCUMENTED_GAPS="R-ENV-02 R-GITOPS-01"
usage() { sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
while [ $# -gt 0 ]; do
case "$1" in
--layer) LAYER="$2"; shift 2 ;;
--requirements) REQS="$(conf_normalize_reqs "$2")"; shift 2 ;;
--coverage-only) MODE="coverage-only"; shift ;;
--status) MODE="status"; shift ;;
--lint) MODE="lint"; shift ;;
--strict-coverage) STRICT_COVERAGE=1; shift ;;
--require-all) REQUIRE_ALL=1; shift ;;
--no-cluster) FORCE_NO_CLUSTER=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
mkdir -p "${ARTIFACTS}" "${COVERAGE_OUT}" "${STATUS_OUT}"
log() { printf '\033[1m[run.sh]\033[0m %s\n' "$*"; }
# --- Coverage report is always produced (cluster-independent, R-TEST-04) --------------
gen_coverage() {
local flags=(--allow-gap "${DOCUMENTED_GAPS}")
[ "${STRICT_COVERAGE}" = 1 ] && flags+=(--strict)
# A tagged subset run is also the merge gate: the touched requirements MUST be covered.
[ -n "${REQS}" ] && flags+=(--require "${REQS}")
[ "${REQUIRE_ALL}" = 1 ] && flags+=(--require-all)
"${PYTHON}" "${CONF_ROOT}/lib/coverage.py" \
--registry "${CONF_ROOT}/requirements/registry.yaml" \
--tests "${CONF_ROOT}/tests" \
--out "${COVERAGE_OUT}" \
"${flags[@]}"
}
# --- Derived STATUS report (no cluster) ------------------------------------------------
# Coverage answers "does a tagged test EXIST?". Status answers the operator's question —
# "what is the real state of each requirement given what is actually in the repo?" — by
# deriving, per requirement: implementing artifact? reachable from a Flux Kustomization
# (reconciled, not merely authored)? tagged test that is EXECUTABLE rather than a TODO
# scaffold? It REFUSES to derive whether a test passed: nothing tracked in git records a
# run result, and a status report that defaults to green is worse than none.
gen_status() {
"${PYTHON}" "${CONF_ROOT}/lib/status.py" \
--repo "$(cd "${CONF_ROOT}/.." && pwd)" \
--registry "${CONF_ROOT}/requirements/registry.yaml" \
--tests "${CONF_ROOT}/tests" \
--out "${STATUS_OUT}" \
--env "${CONF_ENV:-dev}" \
"$@"
}
# --- Structural lint (no cluster): parse each test def and check the tag convention ----
lint_tests() {
log "Linting test definitions (structural, no cluster)"
local rc=0 f
if command -v chainsaw >/dev/null 2>&1; then
# Prefer chainsaw's own linter when the pinned binary is available.
while IFS= read -r f; do
chainsaw lint test --file "$f" >/dev/null || { echo "LINT FAIL: $f" >&2; rc=1; }
done < <(conf_all_test_files)
else
log "chainsaw not on PATH; falling back to structural check"
# Minimal YAML-shape check: kind Test + at least one requirement label per file.
while IFS= read -r f; do
grep -Eq '^kind:\s*Test' "$f" || { echo "LINT FAIL (no kind: Test): $f" >&2; rc=1; }
grep -Eq "^[[:space:]]*${CONF_LABEL_PREFIX}/R-[A-Z0-9]+-[0-9]+:" "$f" \
|| { echo "LINT FAIL (no requirement label): $f" >&2; rc=1; }
done < <(conf_all_test_files)
fi
return $rc
}
# --- Select which test directories Chainsaw should run --------------------------------
select_dirs() {
# --requirements AND --layer together => intersection (tests carrying the tag
# AND living in that layer subtree). Either alone keeps its prior meaning, so
# this is backward compatible (LAYER defaults to "all"). Both helpers emit
# sorted, de-duplicated dirs, so `comm -12` yields the intersection.
if [ -n "${REQS}" ] && [ "${LAYER}" != all ]; then
comm -12 \
<(conf_dirs_for_requirements "${REQS}") \
<(conf_dirs_for_layer "${LAYER}")
elif [ -n "${REQS}" ]; then
conf_dirs_for_requirements "${REQS}"
else
conf_dirs_for_layer "${LAYER}"
fi
}
# --- HEAVY tests: the storage/VM/DR tests whose KubeVirt-CDI imports + Longhorn volume ops
# spike embedded-etcd I/O on the small dev VMs. Run SERIALLY (parallel=1) in their own
# pass so their etcd spikes never coincide with ANOTHER test's setup — the coincidence
# that made a single heavy test's apiserver blip fail-close Kyverno admission and abort
# every in-flight test's 0.01s setup (see config/chainsaw-config.yaml). Keyed by the test
# DIRECTORY basename (stable); override via CONF_HEAVY_TESTS to add/remove without code
# changes. Listed here even for tests still scaffolded (vm-lifecycle, dr-restore, ...) so
# the serialization is already correct the moment they grow real KubeVirt/Longhorn bodies.
CONF_HEAVY_TESTS="${CONF_HEAVY_TESTS:-vm-lifecycle vm-data-survival vm-default-deny data-survival dr-restore node-loss}"
conf_is_heavy_dir() {
# $1 = test dir; true if its basename is in the heavy set.
local base t
base="$(basename "$1")"
for t in ${CONF_HEAVY_TESTS}; do [ "$base" = "$t" ] && return 0; done
return 1
}
# One Chainsaw invocation over a specific set of dirs, at a given parallelism, writing to a
# named report file. Returns chainsaw's rc (0=all passed) WITHOUT aborting run.sh (callers
# collect both passes' rc so a light-pass failure still lets the heavy pass run, and vice
# versa). failFast stays false (config) so every test in the pass runs regardless.
run_chainsaw_pass() {
local report_name="$1" parallel="$2"; shift 2
local -a dirs=("$@")
local -a test_dir_args=()
local d
for d in "${dirs[@]}"; do test_dir_args+=(--test-dir "$d"); done
# Chainsaw writes JUnit per config/chainsaw-config.yaml (report.* under _artifacts).
# --report-path is passed ABSOLUTE (overriding the config's relative `_artifacts`) so the
# report write succeeds regardless of the caller's CWD. --report-name/--parallel override
# the config per-pass so the two passes don't clobber each other's report and the heavy
# pass forces serial execution.
local rc=0
chainsaw test \
--config "${CONF_ROOT}/config/chainsaw-config.yaml" \
--report-path "${ARTIFACTS}" \
--report-name "${report_name}" \
--parallel "${parallel}" \
"${test_dir_args[@]}" || rc=$?
return $rc
}
# --- Execute the Chainsaw suite against a live cluster ---------------------------------
# Split into a LIGHT pass (concurrent, config parallelism) and a HEAVY pass (serial,
# parallel=1). Robust single-pass green: the heavy etcd spikes are isolated in time from the
# light tests' setup, so a heavy test's transient apiserver blip can no longer cascade into
# mass fail-closed setup-aborts of unrelated tests. The two JUnit reports are stitched back
# into the one stable artifact CI collects (report.junit.xml) via lib/merge_junit.py, so the
# split is invisible to everything downstream.
run_chainsaw() {
local -a dirs
mapfile -t dirs < <(select_dirs)
if [ "${#dirs[@]}" -eq 0 ]; then
log "No tests match the selection (layer=${LAYER} reqs='${REQS}')."
return 0
fi
local -a light=() heavy=()
local d
for d in "${dirs[@]}"; do
if conf_is_heavy_dir "$d"; then heavy+=("$d"); else light+=("$d"); fi
done
mkdir -p "${ARTIFACTS}"
local light_report="${ARTIFACTS}/chainsaw-report-light.xml"
local heavy_report="${ARTIFACTS}/chainsaw-report-heavy.xml"
rm -f "${light_report}" "${heavy_report}"
local light_rc=0 heavy_rc=0
# Determine the light pass's parallelism from the shared config (single source of truth),
# falling back to 2 if unreadable — so bumping the config on prod-class hw needs no run.sh edit.
local light_parallel
light_parallel="$(sed -nE 's/^[[:space:]]*parallel:[[:space:]]*([0-9]+).*/\1/p' \
"${CONF_ROOT}/config/chainsaw-config.yaml" | head -1)"
[ -n "${light_parallel}" ] || light_parallel=2
if [ "${#light[@]}" -gt 0 ]; then
log "LIGHT pass: ${#light[@]} test(s), parallel=${light_parallel} (pinned ${CHAINSAW_VERSION})"
run_chainsaw_pass "chainsaw-report-light" "${light_parallel}" "${light[@]}" || light_rc=$?
else
log "LIGHT pass: no tests selected."
fi
if [ "${#heavy[@]}" -gt 0 ]; then
log "HEAVY pass: ${#heavy[@]} test(s) SERIAL (parallel=1) — ${heavy[*]##*/}"
run_chainsaw_pass "chainsaw-report-heavy" 1 "${heavy[@]}" || heavy_rc=$?
else
log "HEAVY pass: no heavy tests selected."
fi
# Stitch both passes' JUnit into the one stable artifact CI collects.
"${PYTHON}" "${CONF_ROOT}/lib/merge_junit.py" \
--out "${JUNIT_OUT}" --name chainsaw-report \
"${light_report}" "${heavy_report}"
# Overall result is the union: fail if EITHER pass had a failing/erroring test. Real
# failures still surface (nothing is masked); the split only changes WHEN heavy tests run.
if [ "${light_rc}" -ne 0 ] || [ "${heavy_rc}" -ne 0 ]; then
log "Chainsaw reported failures (light rc=${light_rc}, heavy rc=${heavy_rc})."
return 1
fi
return 0
}
# --- L0/L1: the cluster-INDEPENDENT layers (docs/14) ------------------------------------
# docs/14 puts TWO layers in CI without a cluster:
# L0 static/policy tests/l0-static/** each dir carries a `check.sh` (repo invariants)
# L1 rendering tests/l1-rendering/** each dir carries a `render-check.sh` (render == golden)
# Both are executed directly here and emit a REAL pass/fail JUnit, instead of being skipped
# with the cluster-gated layers. Honors the same --layer/--requirements selection as the rest
# of the suite (select_dirs), filtered to those two subtrees.
OFFLINE_SUBTREES='/l0-static/|/l1-rendering/'
offline_selected_dirs() {
select_dirs | grep -E "${OFFLINE_SUBTREES}" || true
}
# True when EVERY requested layer is cluster-independent, so the offline result is the
# whole result and run.sh must exit on it (the ci/l0.sh + ci/l1.sh merge-gate paths).
layers_are_offline_only() {
local l
[ "${LAYER}" = all ] && return 1
for l in ${LAYER//,/ }; do
case "$l" in l0|l1) ;; *) return 1 ;; esac
done
return 0
}
run_offline_checks() {
local -a dirs
mapfile -t dirs < <(offline_selected_dirs)
if [ "${#dirs[@]}" -eq 0 ]; then
log "No L0/L1 offline checks match the selection (layer=${LAYER} reqs='${REQS}')."
return 0
fi
log "Running ${#dirs[@]} offline L0/L1 check(s) (no cluster; docs/14)."
"${PYTHON}" "${CONF_ROOT}/lib/run_l1_offline.py" \
--junit "${JUNIT_OUT}" --label-prefix "${CONF_LABEL_PREFIX}" "${dirs[@]}"
}
###############################################################################
# Main
###############################################################################
log "Coverage report first (always)"
gen_coverage
case "${MODE}" in
coverage-only)
log "coverage-only: done. See ${COVERAGE_OUT}/coverage.md"
exit 0
;;
status)
# rc: 0 report written, 1 a derivation/strict failure, 3 a check could not run
# (SKIPPED, never counted as green — ci/lib.sh convention). Propagated as-is.
rc=0
# --strict-coverage is the harness's hygiene switch; apply it to status too.
status_flags=()
[ "${STRICT_COVERAGE}" = 1 ] && status_flags+=(--strict)
gen_status ${status_flags[@]+"${status_flags[@]}"} || rc=$?
log "status: done. See ${STATUS_OUT}/status.md"
exit "${rc}"
;;
lint)
lint_tests
log "lint: done."
exit 0
;;
esac
# L0 static + L1 rendering are cluster-independent (docs/14): run the selected offline
# checks and treat their result as authoritative REGARDLESS of cluster availability. An
# offline check failure is a real failure (nothing cluster-gated about it), so it always blocks.
OFFLINE_RC=0
run_offline_checks || OFFLINE_RC=$?
if layers_are_offline_only; then
# Offline-only run (the ci/l0.sh + ci/l1.sh merge-gate paths): the offline result IS the result.
if [ "${OFFLINE_RC}" -eq 0 ]; then
log "Done (offline ${LAYER}). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md"
else
log "Offline ${LAYER} checks FAILED (rc=${OFFLINE_RC}). JUnit: ${JUNIT_OUT}"
fi
exit "${OFFLINE_RC}"
fi
if [ "${OFFLINE_RC}" -ne 0 ]; then
log "Offline L0/L1 checks FAILED (rc=${OFFLINE_RC}) — blocking (not cluster-gated)."
exit "${OFFLINE_RC}"
fi
# run mode: need a cluster for L2/L3. Without one (Phase 0), emit skipped JUnit for the
# cluster-gated layers. (L1 already ran offline above and passed.)
if [ "${FORCE_NO_CLUSTER}" = 1 ] || ! conf_cluster_reachable; then
log "No reachable cluster — emitting SKIPPED JUnit scaffold for L2/L3 (Phase 0 behavior)."
log "NOTE: the offline L0/L1 results above already ran for real; this shim covers L2/L3 only."
log "Also running structural lint so scaffolds stay valid."
lint_tests || { log "lint failed"; exit 1; }
"${PYTHON}" "${CONF_ROOT}/lib/emit_skipped_junit.py" \
--tests "${CONF_ROOT}/tests" --out "${JUNIT_OUT}"
log "Done (scaffold mode). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md"
exit 0
fi
command -v chainsaw >/dev/null 2>&1 || {
echo "ERROR: cluster reachable but 'chainsaw' (${CHAINSAW_VERSION}) not on PATH." >&2
echo "Install the pinned binary or run via the in-cluster Job (incluster/)." >&2
exit 3
}
run_chainsaw
log "Done. JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md"