The gate BLOCKED this branch, correctly, and the block was the interesting part.
Attaching the media library selects R-STATE-04 as a touched requirement ("bulk media storage has
a capacity strategy distinct from the replicated tier"), and R-STATE-04 had NO scaffolded test, so
the coverage REQUIRE stage failed both l0 and l1. There were two ways out. One was to keep
avoiding the token — the existing discipline in catalog/plex.yaml and catalog/qbittorrent.yaml,
which say in as many words that naming the requirement would block the merge. That made sense while
the tier was a stub. It does not make sense in the change that DELIVERS the tier: it would mean
shipping the requirement and claiming nothing. So: the check.
WHAT IT ASSERTS, and why each one is a real failure mode rather than a shape
The `bulk` StorageClass sat as `server: REPLACE-ME` for six roadmap phases, and the only thing
keeping a multi-terabyte library off the 3-replica `longhorn` class was four hand-written comments.
That is the class of failure being closed: a bulk volume on the replicated tier does not fail
loudly — Longhorn accepts the PVC and starts replicating 3x of data three mini-PCs cannot hold.
(1) DISTINCTNESS — no bulk PV/PVC on any class the repo declares with the Longhorn provisioner
(derived from platform/longhorn/, so adding a Longhorn class cannot create a blind spot),
and the class is explicitly EMPTY.
(2) CAPACITY STRATEGY as arithmetic — a bulk volume at least an order of magnitude above the
same service's replicated volume. The requirement is about two magnitudes of data; a "bulk"
volume sized like a config PVC means the tier was named, not applied. The 10x floor is
CALIBRATED against the real corpus, not guessed: the media library is ~460x its config
volume and the Nextcloud archive ~13x its 20Gi, so a higher floor would fire on a legitimate
volume and teach the next reader to weaken the check. My first draft used 100x and did
exactly that — the corpus caught it.
(3) PRE-BOUND FROM BOTH ENDS (claimRef + volumeName, agreeing). Guards the most deceptive
failure available in this tier: a dynamic bulk class hands out a FRESH EMPTY directory, the
pod mounts, the PVC Binds, everything is green, and the 8 TiB was never attached.
(4) Retain, always. (5) mountOptions carry `hard` and exactly one nfsvers=4.x, and never `soft`
— a soft mount hands a server hiccup to the app as a short read, which for a media library
is silent corruption; and v4 is what keeps the firewall surface at one port.
(6) ONE EXPORT, AT MOST ONE MOUNT PER WORKLOAD. This is the assertion I most wanted. Splitting
one export into two subPath mounts looks TIGHTER and breaks link() with EXDEV; Sonarr and
Radarr then degrade to copy-then-delete, rewriting every import over NFS and destroying the
seeding copy, surfacing weeks later as "imports are slow and my ratio is gone". No reviewer
sees that in a diff.
(7) READ-ONLY COUPLING — nfs.readOnly, the volumeMount flag and ReadOnlyMany must agree, so
dropping one still leaves the others; and every bulk mode is a `...Many`.
(8) The server is a BARE cluster-vars token, that knob has a base default, and EVERY overlay
supplies it — the seam that lets one authored definition serve two clusters that reach this
host by different addresses.
(9) LOAD-BEARING and NON-VACUOUS. Wired unconditionally into ci/l0.sh as Stage 3e for the same
reason as 3c/3d: Stage 3b scopes to touched requirements, and the path map keys NEITHER
apps/* NOR platform/longhorn/* to this requirement — so the change most likely to break
this could attribute nothing and be gated by nothing. An EMPTY bulk corpus FAILS rather
than passing trivially, and a floor on the assertion count keeps a pass from degenerating.
It reads the COMMITTED apps/ goldens rather than calling the renderer. Asking the renderer would
only prove it agrees with itself; the goldens are what Flux reconciles, and render==golden is
already its own gate stage.
PERTURBED, because a check that has never failed is not evidence. Each of these was applied to the
real tree and each fired with the intended message: bulk PVC moved onto `longhorn`; Retain -> Delete;
`hard` -> `soft`; the token replaced by the literal 192.168.1.9; sonarr's single mount split into
two subPath mounts (assertion 6); and the prod overlay's knob deleted.
Also hardens render.py: `size` is structurally required of a bulk entry (a PV with no capacity is
rejected by the API server) but the JSON Schema only enforces `export`, so omitting it raised a bare
KeyError three frames down. It now raises a sentence naming the service, the volume and the remedy.
Found by the same perturbation pass.
Coverage: 48/51 -> 49/51 (96.1%), 2 gaps, 0 safety-critical.
BASE_REF=main make -C ci gate -> MERGE GATE: PASS.
Co-Authored-By: Claude Opus 5 <[email protected]>
222 lines
12 KiB
Bash
Executable File
222 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ci/l0.sh — Stage 2+3: ServiceDefinition schema validation + Kyverno L0 policy gate.
|
|
# Both run in CI WITHOUT a cluster (docs/14 §"Where each layer runs") — live now.
|
|
#
|
|
# Stage 2 schema : catalog/*.yaml validated against the ServiceDefinition
|
|
# schema (owner: T-0.3, schemas/).
|
|
# Stage 3 L0 : `kyverno test` over platform/kyverno/ — default-deny baseline
|
|
# present, no plaintext secrets, required labels/egress-class,
|
|
# ServiceDefinition schema conformance (owner: T-0.5, ADR-0011).
|
|
# Stage 3b L0 : conformance/run.sh --layer l0 — the WHOLE-REPO L0 invariants that
|
|
# a per-resource policy cannot express (secret hygiene across every
|
|
# tracked file, least-privilege RBAC, spec-immutability enforcement).
|
|
#
|
|
# Integration seams are env-overridable (see ci/lib.sh + README). When an owning
|
|
# subtree is still a placeholder, this reports SKIPPED (exit 3) for that sub-stage
|
|
# rather than a false pass — the gate then knows the seam is unwired, not green.
|
|
set -euo pipefail
|
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck disable=SC1091
|
|
source "${CI_DIR}/lib.sh"
|
|
|
|
rc=0
|
|
|
|
# ---- Stage 2: ServiceDefinition schema validation ---------------------------
|
|
log "Stage: ServiceDefinition schema validation (catalog/ against schemas/)"
|
|
if ! entrypoint_ready "$SCHEMA_VALIDATE_CMD"; then
|
|
skip "schema validator not present at '${SCHEMA_VALIDATE_CMD}' (T-0.3 / schemas/ not delivered yet) — UNWIRED, not green"
|
|
summary "- schema: **SKIPPED** (validator entrypoint \`${SCHEMA_VALIDATE_CMD}\` missing — T-0.3)"
|
|
else
|
|
if [ -d "$CATALOG_DIR" ] && compgen -G "${CATALOG_DIR}/*.y*ml" >/dev/null 2>&1; then
|
|
if "$SCHEMA_VALIDATE_CMD" "${CATALOG_DIR}"/*.y*ml; then
|
|
ok "schema validation passed"
|
|
summary "- schema: **passed**"
|
|
else
|
|
err "schema validation failed"; summary "- schema: **FAILED**"; rc=1
|
|
fi
|
|
else
|
|
log "no ServiceDefinitions in ${CATALOG_DIR} yet — validating validator is callable only"
|
|
"$SCHEMA_VALIDATE_CMD" --version >/dev/null 2>&1 || true
|
|
ok "schema validator present; no catalog entries to validate"
|
|
summary "- schema: **passed** (no catalog entries yet)"
|
|
fi
|
|
fi
|
|
|
|
# ---- Stage 3: Kyverno L0 policy gate ----------------------------------------
|
|
log "Stage: Kyverno L0 policy gate (kyverno test ${KYVERNO_POLICY_DIR})"
|
|
# `kyverno test <dir>` discovers kyverno-test.yaml files recursively.
|
|
if ! have kyverno; then
|
|
skip "kyverno CLI not installed (pin v${KYVERNO_VERSION}) — cannot run L0 gate"
|
|
summary "- kyverno-l0: **SKIPPED** (kyverno CLI not installed)"
|
|
# Missing tool is an environment gap, not a code pass -> exit 3 unless already failing.
|
|
[ "$rc" -eq 0 ] && rc=3
|
|
elif [ ! -d "$KYVERNO_POLICY_DIR" ] || ! compgen -G "${KYVERNO_POLICY_DIR}/**/kyverno-test.yaml" >/dev/null 2>&1 \
|
|
&& ! find "$KYVERNO_POLICY_DIR" -name 'kyverno-test.yaml' 2>/dev/null | grep -q .; then
|
|
skip "no kyverno-test.yaml under '${KYVERNO_POLICY_DIR}' (T-0.5 / platform/kyverno not delivered yet) — UNWIRED, not green"
|
|
summary "- kyverno-l0: **SKIPPED** (no policy tests yet — T-0.5)"
|
|
else
|
|
# The ClusterPolicies carry a Flux postBuild token in validationFailureAction
|
|
# (${KYVERNO_VALIDATION_ACTION}, substituted in-cluster from
|
|
# clusters/base/cluster-vars.yaml). The offline `kyverno test` CLI does NOT run
|
|
# Flux substitution, so the literal token is an invalid enum value and those
|
|
# policies fail to load (no result rows -> "Not found"). Render a throwaway copy
|
|
# with the base/prod value (Enforce — the strict posture the gate must prove)
|
|
# before testing. This does not change what the policies enforce in-cluster.
|
|
: "${KYVERNO_VALIDATION_ACTION:=Enforce}"
|
|
l0_render="$(mktemp -d)"
|
|
cp -a "${KYVERNO_POLICY_DIR}/." "$l0_render/"
|
|
find "$l0_render" -type f -name '*.yaml' -print0 \
|
|
| xargs -0 sed -i "s/\${KYVERNO_VALIDATION_ACTION}/${KYVERNO_VALIDATION_ACTION}/g"
|
|
if kyverno test "$l0_render"; then
|
|
ok "Kyverno L0 policy tests passed"
|
|
summary "- kyverno-l0: **passed**"
|
|
else
|
|
err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1
|
|
fi
|
|
rm -rf "$l0_render"
|
|
fi
|
|
|
|
# ---- Stage 3b: L0 STATIC conformance checks ---------------------------------
|
|
# docs/14's L0 row is "manifests/ServiceDefinitions obey invariants", which is wider
|
|
# than what Kyverno alone can express over single resources: whole-repo invariants
|
|
# (no plaintext secrets anywhere in git, least-privilege RBAC across every tracked
|
|
# binding, the spec-immutability gate's own behaviour) live in the conformance
|
|
# harness's tests/l0-static subtree and run with no cluster. This stage executes them
|
|
# through the same single-command runner CI and a laptop both use (R-TEST-02).
|
|
log "Stage: L0 static conformance checks (${CONFORMANCE_CMD} --layer l0)"
|
|
if ! entrypoint_ready "$CONFORMANCE_CMD"; then
|
|
skip "conformance runner not present at '${CONFORMANCE_CMD}' — UNWIRED, not green"
|
|
summary "- l0-static: **SKIPPED** (conformance runner missing)"
|
|
if [ "$rc" -eq 0 ]; then rc=3; fi
|
|
else
|
|
load_touched_requirements
|
|
if "$CONFORMANCE_CMD" --layer l0 ${REQ_ARGS[@]+"${REQ_ARGS[@]}"}; then
|
|
ok "L0 static conformance checks passed"
|
|
summary "- l0-static: **passed**"
|
|
else
|
|
err "L0 static conformance checks failed"
|
|
summary "- l0-static: **FAILED**"
|
|
rc=1
|
|
fi
|
|
fi
|
|
|
|
# ---- Stage 3c: Flux post-build substitution safety (UNCONDITIONAL) ----------
|
|
# R-ENV-02's promote-once mechanism is `postBuild.substituteFrom cluster-vars`, and it
|
|
# FAILS OPEN: an undefined ${TOKEN} becomes the empty string and the Kustomization still
|
|
# reports Ready. That emptied Cilium's k8sServiceHost and broke the CNI on 2/3 dev nodes
|
|
# (DECISIONS-LOG 2026-09-01, "substitution race that fails OPEN"). The check asserts every
|
|
# ${...} in every substituted path is DEFINED in cluster-vars or deliberately ESCAPED
|
|
# `$${...}` — i.e. that enabling the StrictPostBuildSubstitutions feature gate cannot fail
|
|
# a legitimate manifest, and that no expansion silently empties today.
|
|
#
|
|
# WHY THIS IS ITS OWN STAGE and not left to Stage 3b: 3b scopes the conformance run to the
|
|
# TOUCHED requirements (R-TEST-03), and ci/touched-requirements.sh's coarse path map keys
|
|
# only clusters/* to R-ENV-02 — an edit to a SUBSTITUTED manifest under apps/ or platform/
|
|
# attributes no requirement, so the requirement-scoped selection would filter this check
|
|
# out of exactly the change that introduces the bug. The invariant is whole-repo (same
|
|
# reasoning as spec-immutability's own gate stage), so it runs on EVERY gate invocation.
|
|
# When 3b's touched set does include R-ENV-02/R-GITOPS-01 the check runs twice; it is a
|
|
# sub-second offline scan with no side effects, so the duplication is not worth avoiding.
|
|
SUBST_CHECK="${SUBST_CHECK:-${REPO_ROOT}/conformance/tests/l0-static/flux-postbuild-substitution/check.sh}"
|
|
log "Stage: Flux postBuild substitution safety (${SUBST_CHECK})"
|
|
if [ ! -f "$SUBST_CHECK" ]; then
|
|
skip "substitution check not present at '${SUBST_CHECK}' — UNWIRED, not green"
|
|
summary "- subst-safety: **SKIPPED** (check missing)"
|
|
if [ "$rc" -eq 0 ]; then rc=3; fi
|
|
else
|
|
subst_rc=0
|
|
bash "$SUBST_CHECK" || subst_rc=$?
|
|
case "$subst_rc" in
|
|
0) ok "Flux postBuild substitution safety passed"
|
|
summary "- subst-safety: **passed**" ;;
|
|
3) skip "substitution check SKIPPED (clusters/ absent) — not a pass"
|
|
summary "- subst-safety: **SKIPPED** (nothing to audit)"
|
|
if [ "$rc" -eq 0 ]; then rc=3; fi ;;
|
|
*) err "Flux postBuild substitution safety FAILED"
|
|
summary "- subst-safety: **FAILED**"; rc=1 ;;
|
|
esac
|
|
fi
|
|
|
|
# ---- Stage 3d: Keycloak client-secret declaration + reconciler (UNCONDITIONAL) ----
|
|
# The Keycloak realm import resolves each OIDC client's secret from an `(env:NAME)`
|
|
# placeholder. When the operator cannot resolve one it stores the LITERAL placeholder as
|
|
# the client secret AND THE IMPORT JOB STILL REPORTS SUCCESS, then rests at Done=True and
|
|
# never re-runs. On 2026-09-06 that had all 12 prod clients unusable ("Invalid client or
|
|
# Invalid client credentials") behind an entirely green cluster; dev had one. The check
|
|
# asserts the declaration is self-consistent (every placeholder defined, every Secret key
|
|
# present, names matching the reconciler's contract), that the Flux ordering is a REAL
|
|
# dependsOn edge rather than a comment, and — by extracting the shipped script from its
|
|
# ConfigMap and running its --selftest — that the reconciler is compare-then-write,
|
|
# idempotent, non-logging and blast-radius-bounded.
|
|
#
|
|
# WHY ITS OWN UNCONDITIONAL STAGE, same reasoning as 3c: Stage 3b scopes the conformance
|
|
# run to the TOUCHED requirements (R-TEST-03). This check's invariant spans platform/,
|
|
# platform-config/ and clusters/, so a change that breaks it need not attribute R-IDP-03/04
|
|
# and the requirement-scoped selection would filter it out of exactly the change that
|
|
# introduces the bug. It is a sub-second offline scan plus a loopback selftest, with no
|
|
# side effects, so running it twice when 3b also selects it is not worth avoiding.
|
|
KEYCLOAK_SECRET_CHECK="${KEYCLOAK_SECRET_CHECK:-${REPO_ROOT}/conformance/tests/l0-static/keycloak-client-secret-reconciler/check.sh}"
|
|
log "Stage: Keycloak client-secret reconciler safety (${KEYCLOAK_SECRET_CHECK})"
|
|
if [ ! -f "$KEYCLOAK_SECRET_CHECK" ]; then
|
|
skip "keycloak client-secret check not present at '${KEYCLOAK_SECRET_CHECK}' — UNWIRED, not green"
|
|
summary "- keycloak-client-secrets: **SKIPPED** (check missing)"
|
|
if [ "$rc" -eq 0 ]; then rc=3; fi
|
|
else
|
|
kc_rc=0
|
|
bash "$KEYCLOAK_SECRET_CHECK" || kc_rc=$?
|
|
case "$kc_rc" in
|
|
0) ok "Keycloak client-secret declaration + reconciler passed"
|
|
summary "- keycloak-client-secrets: **passed**" ;;
|
|
3) skip "keycloak client-secret check SKIPPED (platform/keycloak absent) — not a pass"
|
|
summary "- keycloak-client-secrets: **SKIPPED** (nothing to audit)"
|
|
if [ "$rc" -eq 0 ]; then rc=3; fi ;;
|
|
*) err "Keycloak client-secret declaration + reconciler FAILED"
|
|
summary "- keycloak-client-secrets: **FAILED**"; rc=1 ;;
|
|
esac
|
|
fi
|
|
|
|
# ---- Stage 3e: bulk tier distinct from the replicated tier (UNCONDITIONAL) ----
|
|
# The `bulk` StorageClass sat as a REPLACE-ME stub for six roadmap phases, and the only
|
|
# thing keeping a multi-terabyte media library off the 3-replica `longhorn` class was four
|
|
# hand-written comments in ServiceDefinitions. A bulk volume that lands on the replicated
|
|
# tier does not fail loudly — Longhorn accepts the PVC and starts replicating 3x of data
|
|
# three mini-PCs cannot hold. The check asserts the tier separation, that a bulk volume is
|
|
# statically pre-bound from BOTH ends (so nothing can dynamically provision an EMPTY
|
|
# stand-in that mounts and Binds perfectly while the real data is never attached), Retain,
|
|
# hard/NFSv4 mount options, the read-only coupling between PV and volumeMount, that no
|
|
# workload mounts one export twice (which breaks Sonarr/Radarr hardlinks with EXDEV), and
|
|
# that the server is a per-environment knob every overlay supplies.
|
|
#
|
|
# WHY ITS OWN UNCONDITIONAL STAGE, same reasoning as 3c/3d: Stage 3b scopes the conformance
|
|
# run to the TOUCHED requirements (R-TEST-03), and ci/touched-requirements.sh's path map
|
|
# keys NEITHER apps/* nor platform/longhorn/* to the bulk requirement. So the change most
|
|
# likely to break this — moving a bulk volume onto the replicated tier, or narrowing one
|
|
# export into two mounts — could attribute no requirement at all and be gated by nothing.
|
|
# The invariant spans catalog/, apps/ and clusters/, so it runs on EVERY gate invocation.
|
|
BULK_TIER_CHECK="${BULK_TIER_CHECK:-${REPO_ROOT}/conformance/tests/l0-static/bulk-tier-distinct-from-replicated/check.sh}"
|
|
log "Stage: bulk tier distinct from replicated tier (${BULK_TIER_CHECK})"
|
|
if [ ! -f "$BULK_TIER_CHECK" ]; then
|
|
skip "bulk tier check not present at '${BULK_TIER_CHECK}' — UNWIRED, not green"
|
|
summary "- bulk-tier: **SKIPPED** (check missing)"
|
|
if [ "$rc" -eq 0 ]; then rc=3; fi
|
|
else
|
|
bulk_rc=0
|
|
bash "$BULK_TIER_CHECK" || bulk_rc=$?
|
|
case "$bulk_rc" in
|
|
0) ok "Bulk tier distinctness + static binding passed"
|
|
summary "- bulk-tier: **passed**" ;;
|
|
3) skip "bulk tier check SKIPPED (catalog/ or apps/ absent) — not a pass"
|
|
summary "- bulk-tier: **SKIPPED** (nothing to audit)"
|
|
if [ "$rc" -eq 0 ]; then rc=3; fi ;;
|
|
*) err "Bulk tier distinctness + static binding FAILED"
|
|
summary "- bulk-tier: **FAILED**"; rc=1 ;;
|
|
esac
|
|
fi
|
|
|
|
case "$rc" in
|
|
0) ok "L0 stage complete" ;;
|
|
3) skip "L0 stage incomplete (unwired seam / missing tool) — not a pass" ;;
|
|
*) err "L0 stage failed" ;;
|
|
esac
|
|
exit "$rc"
|