R-TEST-03 runs the conformance subset a change TOUCHES. That has one blind spot, and it
is the worst-shaped kind: an invariant that quantifies over the whole tracked tree — or
over subtrees ci/touched-requirements.sh's coarse path map keys to NOTHING (apps/, infra/,
most of platform/) — cannot be selected BY the requirements a change touches, because the
change most likely to break it attributes no requirement at all. Requirement-scoped
selection then filters the check out of exactly that change.
The repo's answer had been a hand-written "UNCONDITIONAL" stage per such check in
ci/l0.sh: 3c flux-postbuild-substitution, 3d keycloak-client-secret-reconciler, 3e
bulk-tier-distinct-from-replicated. Three near-identical 12-line rationales, three
hardcoded check.sh paths, three copies of the same rc 0/3/* case block — and worst, a
mechanism that failed SILENTLY, because whoever added the NEXT whole-repo invariant had
to REMEMBER to write a stage, and if they forgot the check ran only when some unrelated
requirement happened to be touched.
A test now DECLARES the scope of its invariant and the runner enforces it:
metadata:
annotations:
conformance.home.k3s/scope: "whole-repo"
* conformance/lib/common.sh conf_dirs_for_scope(s) resolves the annotation. Carries the
general rationale, once, instead of per stage.
* conformance/run.sh --include-scope SCOPE unions declaring tests into select_dirs,
still bounded by --layer. New --print-selection resolves the
selection and exits, running nothing.
* lib/run_l1_offline.py --unwired-scope: a SKIP of a declaring test exits 3, not 0.
--summary-out: one step-summary line per executed check.
* ci/l0.sh Stage 3b gains --include-scope whole-repo and an rc-3 arm.
Stages 3c/3d/3e DELETED (-150 lines).
* ci/l1.sh, ci/conformance.sh pass --include-scope too, so the next whole-repo invariant
at ANY layer needs only the annotation.
rc 3 SEMANTICS PRESERVED EXACTLY. A whole-repo check reporting "nothing to audit" is
reporting that the tree it audits has VANISHED. The deleted stages called that "not a
pass" and set the gate's rc=3; the runner now returns 3 and ci/l0.sh maps it to rc=3.
A missing check file still degrades to not-green, never to silence.
THE REASONING WAS RELOCATED, NOT DELETED. Each stage's incident narrative moved into the
header of the test it describes, beside its scope annotation: the Cilium k8sServiceHost
empty-substitution CNI outage (2026-09-01), 12 unusable Keycloak clients behind a green
cluster (2026-09-06), and a multi-TB bulk volume landing on the 3-replica tier.
AUDITED ALL 13 EXISTING l0-static TESTS, individually. 10 declare whole-repo scope; 3 are
DELIBERATELY left requirement-scoped and say so in their own headers:
* placeholder-guard-coupling — inputs are bootstrap/ + clusters/, both already mapped to
a requirement it declares. Scoping cannot hide a breaking change.
* dev-nodes-provisioned-as-code — inputs are infra/ and nothing else.
* spec-immutability-enforced — inputs are ci/ and the read-only spec repo.
The latter two were nonetheless under-gated because the PATH MAP was incomplete, which is
the right place to fix a path-scoped invariant: infra/* -> R-ENV-01 added, and R-TEST-06
added to .gitea/*|ci/* (it is no longer an uncovered ID — spec-immutability-enforced
carries the label, so attributing it no longer false-blocks the coverage gate).
NEW conformance/tests/l0-static/whole-repo-invariants-wired (R-TEST-03) keeps the
declarative mechanism honest for ALL declaring tests at once, replacing the three tests'
individual "ci/l0.sh greps my own check.sh path" self-assertions. It proves the union
EMPIRICALLY: with a requirement filter matching no test the L0 selection is empty, and
with --include-scope it is every declaring test; a scope-declared synthetic SKIP exits 3
while an undeclared one exits 0; and a ci/*.sh that hardcodes a check.sh path must belong
to a test that also declares the scope, so the bespoke pattern cannot creep back.
TASK 2 — a changed test attributes the requirements it CERTIFIES.
ci/touched-requirements.sh gains detection rule 3: a change anywhere under
conformance/tests/<test>/ attributes that test's own conformance.home.k3s/R-* labels, read
from its chainsaw-test.yaml. The content scan could not close this — it reads only
added/removed lines, so it sees a change to the LABEL BLOCK but not to the test BODY,
which is exactly the case that matters. Labels are read from HEAD or, failing that, from
the BASE, so DELETING a test still attributes what it used to certify; otherwise removing
the only verifier of a requirement is the cheapest way to make the gate stop checking it.
Whole-line `#` comments are dropped first and *.md is untouched, honouring the existing
"reference, not declaration" rules.
Both changes are fail-closed: every one of them can only ADD tests or requirements to a
run, never remove one.
TESTS: new ci/tests/touched-requirements-test-attribution.test.sh (15 assertions) covers
rule 3 — body-only edit, nested-fixture walk-up, test deletion, header-comment R-IDs not
attributed, non-test paths unaffected, and both new path-map entries. The two existing
suites still pass (39 assertions). `make -C ci test` runs all three.
Co-Authored-By: Claude Opus 5 <[email protected]>
551 lines
27 KiB
Bash
Executable File
551 lines
27 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 --layer l0 --requirements R-X --include-scope whole-repo
|
||
# # UNION: the tagged subset PLUS every test that
|
||
# # DECLARES `conformance.home.k3s/scope: whole-repo`.
|
||
# # A whole-repo invariant must run on EVERY gate
|
||
# # invocation — see lib/common.sh's scope block for
|
||
# # why, and why a SKIP of one is exit 3, not green.
|
||
# ./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, and whether the committed run ledger
|
||
# # (runs/ledger.jsonl) actually proves it — see
|
||
# # lib/status.py. No ledger row => not-derivable.
|
||
# ./run.sh --lint # structural-validate test defs (no cluster)
|
||
# ./run.sh --print-selection # print the test dirs the CURRENT selection resolves
|
||
# # to, one per line, and exit. Runs no check. This is
|
||
# # how a test can PROVE the selection includes it
|
||
# # (tests/l0-static/whole-repo-invariants-wired) without
|
||
# # executing the suite and recursing into itself.
|
||
# ./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
|
||
# ./run.sh --record # RUN AND CAPTURE: tee the whole run to
|
||
# # runs/<UTC>-<short-sha>/run.log, copy the JUnit
|
||
# # beside it, and append one ledger row per
|
||
# # testcase to runs/ledger.jsonl (lib/record_run.py)
|
||
#
|
||
# --record — PROOF-OF-PASS, CAPTURED AT RUN TIME (why it is a separate flag)
|
||
# --------------------------------------------------------------------------
|
||
# Without it, nothing about a green run ever reaches repo state: _artifacts/ is gitignored
|
||
# and the root .gitignore kills *.junit.xml globally, which is why `--status` reports
|
||
# PROVEN 0 for all 51 requirements. `--record` writes the one tracked thing that fixes
|
||
# that — `conformance/runs/` (see conformance/.gitignore for the negations).
|
||
#
|
||
# RECORDING IS DELIBERATELY EXPLICIT. A run that silently records itself is how a bad run
|
||
# becomes a permanent false proof, so the plain `./run.sh` writes no ledger row at all.
|
||
#
|
||
# BOTH FILES ARE KEPT, and the LOG is the load-bearing one. The L2/L3 tests self-skip
|
||
# GREEN: when a precondition is missing they echo e.g. `DR_RESTORE_SKIPPED_OK` and exit 0,
|
||
# and Chainsaw records `passed` exactly as on a real run. Chainsaw's JUnit carries no
|
||
# stdout, so the sentinel token in run.log is the ONLY thing that separates proven from
|
||
# skipped. A JUnit committed on its own would record a skip as proof of R-STATE-03.
|
||
#
|
||
# 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)
|
||
#
|
||
# Run ledger (TRACKED — commit these; --record only):
|
||
# runs/<UTC>-<short-sha>/run.log full tee'd run output (carries the sentinels)
|
||
# runs/<UTC>-<short-sha>/report.junit.xml the JUnit for that same run
|
||
# runs/ledger.jsonl append-only, one row per testcase per run
|
||
#
|
||
# 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}"
|
||
|
||
# Every argument as given, so `--record` can re-exec this script unchanged (minus itself)
|
||
# under `tee`. Captured BEFORE parsing so nothing is normalized away.
|
||
ORIG_ARGS=("$@")
|
||
|
||
LAYER="all"
|
||
REQS=""
|
||
INCLUDE_SCOPES="" # --include-scope: scopes unioned into the selection (see lib/common.sh)
|
||
MODE="run" # run | coverage-only | status | lint
|
||
STRICT_COVERAGE=0
|
||
REQUIRE_ALL=0
|
||
FORCE_NO_CLUSTER=0
|
||
RECORD=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.
|
||
#
|
||
# EMPTY as of 2026-09-01. R-ENV-02 and R-GITOPS-01 were the last two entries — exempted
|
||
# because they were unscaffolded while the coarse path->requirement map keys clusters/*
|
||
# and ci/* to them, so any overlay/CI edit would have false-blocked. They now have a real
|
||
# verifying L0 test (tests/l0-static/flux-postbuild-substitution), which asserts the
|
||
# substitution mechanism that IS the "authored once, promoted" contract, so they are
|
||
# covered rather than exempt and the completeness gate can hold them to it. That test
|
||
# names its own limits in its header: it proves the STATIC property (no manifest renders
|
||
# an undefined variable), not the live-cluster half of either requirement.
|
||
#
|
||
# 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=""
|
||
|
||
# Print the header block (line 2 through the closing `####` rule, which is dropped) as
|
||
# help. Bounded by the rule rather than a fixed line number so the header can grow.
|
||
usage() { sed -n '2,/^#####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; }
|
||
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--layer) LAYER="$2"; shift 2 ;;
|
||
--requirements) REQS="$(conf_normalize_reqs "$2")"; shift 2 ;;
|
||
--include-scope) INCLUDE_SCOPES="${INCLUDE_SCOPES:+${INCLUDE_SCOPES},}$2"; shift 2 ;;
|
||
--coverage-only) MODE="coverage-only"; shift ;;
|
||
--status) MODE="status"; shift ;;
|
||
--lint) MODE="lint"; shift ;;
|
||
--print-selection) MODE="print-selection"; shift ;;
|
||
--strict-coverage) STRICT_COVERAGE=1; shift ;;
|
||
--require-all) REQUIRE_ALL=1; shift ;;
|
||
--no-cluster) FORCE_NO_CLUSTER=1; shift ;;
|
||
--record) RECORD=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' "$*"; }
|
||
|
||
###############################################################################
|
||
# --record: capture this run as committed evidence, then hand it to the ledger.
|
||
#
|
||
# Implemented as a ONE-LEVEL re-exec of this same script under `tee`, so the run itself is
|
||
# byte-for-byte the ordinary run (same code path, same artifacts) and the log is the exact
|
||
# operator-visible output — no second, differently-behaving "recording mode" to drift.
|
||
# CONF_RECORD_CHILD guards against recursion.
|
||
#
|
||
# The recorder runs REGARDLESS of the run's exit status: a red run must be recorded as red
|
||
# (that is evidence too), and run.sh still exits with the child's real rc, so no gate is
|
||
# softened by recording. `${PIPESTATUS[0]}` is what carries that rc past `tee`.
|
||
###############################################################################
|
||
if [ "${RECORD}" = 1 ] && [ -z "${CONF_RECORD_CHILD:-}" ]; then
|
||
case "${MODE}" in
|
||
run) ;;
|
||
# --record is about EVIDENCE OF A RUN. --coverage-only/--status/--lint execute no
|
||
# test, so a ledger row from one of them would be a row about nothing.
|
||
*) echo "--record captures a TEST RUN; --${MODE} executes none." >&2; exit 2 ;;
|
||
esac
|
||
RUN_SHA="$(git -C "${CONF_ROOT}" rev-parse --short HEAD 2>/dev/null || echo nogit)"
|
||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-${RUN_SHA}"
|
||
RUN_DIR="${CONF_ROOT}/runs/${RUN_ID}"
|
||
mkdir -p "${RUN_DIR}"
|
||
# Drop any JUnit left behind by a PREVIOUS run before starting, so this run dir can only
|
||
# ever capture a report THIS run produced. Copying a stale artifact would pair someone
|
||
# else's result with this run's log — a fabricated pairing, which is precisely the class
|
||
# of false proof the ledger exists to prevent.
|
||
rm -f "${JUNIT_OUT}"
|
||
# Strip --record from the argv handed to the child; everything else passes through.
|
||
CHILD_ARGS=()
|
||
for a in ${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"}; do
|
||
[ "$a" = "--record" ] || CHILD_ARGS+=("$a")
|
||
done
|
||
log "RECORD: capturing this run to runs/${RUN_ID}/ (run.log + report.junit.xml)"
|
||
set +e
|
||
CONF_RECORD_CHILD=1 "${BASH_SOURCE[0]}" ${CHILD_ARGS[@]+"${CHILD_ARGS[@]}"} 2>&1 \
|
||
| tee "${RUN_DIR}/run.log"
|
||
RUN_RC=${PIPESTATUS[0]}
|
||
set -e
|
||
if [ -f "${JUNIT_OUT}" ]; then
|
||
cp "${JUNIT_OUT}" "${RUN_DIR}/report.junit.xml"
|
||
else
|
||
log "RECORD: no ${JUNIT_OUT} was produced — the ledger step will SKIP (never green)."
|
||
fi
|
||
# Explicit, separate step: read the JUnit + the log and append one row per testcase.
|
||
# rc 3 = inputs absent (nothing recorded, not green); it must not mask the run's own rc.
|
||
# CONF_LEDGER exists so the harness's own tests can exercise this path without writing
|
||
# to the real ledger; operators should never set it.
|
||
"${PYTHON}" "${CONF_ROOT}/lib/record_run.py" \
|
||
--run-dir "${RUN_DIR}" --env "${CONF_ENV:-dev}" \
|
||
--ledger "${CONF_LEDGER:-${CONF_ROOT}/runs/ledger.jsonl}" || true
|
||
log "RECORD: run rc=${RUN_RC}. Commit conformance/runs/${RUN_ID}/ WITH its run.log and"
|
||
log "RECORD: conformance/runs/ledger.jsonl — an uncommitted ledger proves nothing."
|
||
exit "${RUN_RC}"
|
||
fi
|
||
|
||
# --- Coverage report is always produced (cluster-independent, R-TEST-04) --------------
|
||
gen_coverage() {
|
||
# Pass --allow-gap only when there IS an exemption: an empty DOCUMENTED_GAPS means no
|
||
# requirement is exempt, and handing the flag an empty string would be a silent no-op
|
||
# that reads like an exemption in the CLI log.
|
||
local flags=()
|
||
[ -n "${DOCUMENTED_GAPS}" ] && 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 --------------------------------
|
||
# Requirement-scoped selection, then the DECLARED-SCOPE union on top:
|
||
#
|
||
# (--requirements ∩ --layer) ∪ (--include-scope ∩ --layer)
|
||
#
|
||
# The left half is R-TEST-03's touched subset. The right half is the fix for its blind
|
||
# spot: an invariant that quantifies over the whole tracked tree cannot be selected BY the
|
||
# requirements a change touches, because the change most likely to break it touches none of
|
||
# them. See lib/common.sh's scope block for the incidents. The union is fail-closed by
|
||
# construction — it can only ADD tests to a run, never remove one.
|
||
select_dirs() {
|
||
{
|
||
requirement_scoped_dirs
|
||
# The scope union is still LAYER-bounded: `--layer l0` must not pull a whole-repo-scoped
|
||
# L2 test into an offline run. With no --layer (all), nothing is bounded away.
|
||
if [ -n "${INCLUDE_SCOPES}" ]; then
|
||
if [ "${LAYER}" != all ]; then
|
||
comm -12 \
|
||
<(conf_dirs_for_scopes "${INCLUDE_SCOPES}") \
|
||
<(conf_dirs_for_layer "${LAYER}")
|
||
else
|
||
conf_dirs_for_scopes "${INCLUDE_SCOPES}"
|
||
fi
|
||
fi
|
||
} | sort -u
|
||
}
|
||
|
||
# The pre-existing requirement/layer selection, unchanged:
|
||
# --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.
|
||
requirement_scoped_dirs() {
|
||
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
|
||
}
|
||
|
||
# Markdown one-liner per executed offline check, for the CI step summary. Written next to
|
||
# the JUnit; ci/l0.sh + ci/l1.sh splice it into $GITHUB_STEP_SUMMARY so the gate output still
|
||
# NAMES every check that ran (that is what the deleted per-check gate stages did, one hard-coded
|
||
# `summary` line each — now generated for all of them, whole-repo-scoped ones marked as such).
|
||
: "${OFFLINE_SUMMARY_OUT:=${ARTIFACTS}/offline-summary.md}"
|
||
|
||
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
|
||
# Declared scopes are passed through so the runner knows WHICH cases must not be allowed to
|
||
# report a SKIP as green (rc 3). See lib/common.sh's scope block and run_l1_offline.py.
|
||
local -a scope_args=() s
|
||
for s in ${INCLUDE_SCOPES//,/ }; do scope_args+=(--unwired-scope "$s"); done
|
||
log "Running ${#dirs[@]} offline L0/L1 check(s) (no cluster; docs/14)."
|
||
[ -n "${INCLUDE_SCOPES}" ] && log " unconditional (declared scope ${INCLUDE_SCOPES}): $(conf_dirs_for_scopes "${INCLUDE_SCOPES}" | xargs -n1 basename 2>/dev/null | tr '\n' ' ')"
|
||
"${PYTHON}" "${CONF_ROOT}/lib/run_l1_offline.py" \
|
||
--junit "${JUNIT_OUT}" --label-prefix "${CONF_LABEL_PREFIX}" \
|
||
--scope-annotation "${CONF_SCOPE_ANNOTATION}" \
|
||
--summary-out "${OFFLINE_SUMMARY_OUT}" \
|
||
${scope_args[@]+"${scope_args[@]}"} "${dirs[@]}"
|
||
}
|
||
|
||
###############################################################################
|
||
# Main
|
||
###############################################################################
|
||
# --print-selection is deliberately handled FIRST: it resolves the selection and exits,
|
||
# writing nothing and running no check. A test that asserts "the gate's selection cannot
|
||
# filter me out" needs to ask that question without executing the suite it is part of.
|
||
if [ "${MODE}" = "print-selection" ]; then
|
||
select_dirs
|
||
exit 0
|
||
fi
|
||
|
||
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.
|
||
# rc 3 is the harness-wide SKIPPED/not-run code (ci/README.md): NOT a pass, NOT a fail. The
|
||
# offline runner returns it when a test carrying a --unwired-scope scope SKIPPED — a whole-repo
|
||
# invariant reporting "nothing to audit" means the subtree it audits is gone, which the gate
|
||
# must see as UNWIRED. It is propagated verbatim so ci/l0.sh can set the gate's own rc=3,
|
||
# exactly as the deleted per-check stages did.
|
||
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.
|
||
case "${OFFLINE_RC}" in
|
||
0) log "Done (offline ${LAYER}). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md" ;;
|
||
3) log "Offline ${LAYER}: a scope-declared check SKIPPED — UNWIRED, not green (rc=3). JUnit: ${JUNIT_OUT}" ;;
|
||
*) log "Offline ${LAYER} checks FAILED (rc=${OFFLINE_RC}). JUnit: ${JUNIT_OUT}" ;;
|
||
esac
|
||
exit "${OFFLINE_RC}"
|
||
fi
|
||
if [ "${OFFLINE_RC}" -eq 3 ]; then
|
||
log "Offline L0/L1: a scope-declared check SKIPPED — UNWIRED, not green (rc=3)."
|
||
exit 3
|
||
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"
|