#!/usr/bin/env bash
# scripts/preflight.sh [repo-root]
#
# THE single pre-deploy gate (DOCFIX-073). Before this existed the gate surface
# was six artifacts the operator had to remember independently (pre-flight-checks
# + its three delegates + provider-bundle-check + standup dry-run) -- the same
# fragmentation that let review-bundle.py rot into noise. This runs every
# stage-1 (pre-add-model) gate in dependency order with sectioned output and
# worst-exit aggregation, then prints the stage-2 gates it CANNOT run yet.
#
# Mutates NOTHING. Stages:
# P1 repo lint scripts/repo-lint.sh (static hygiene)
# P2 bundle invariants scripts/provider-bundle-check.py
# P3 channel assert scripts/channel_assert.py (needs charmhub; WARN if offline)
# P4 live pre-flight scripts/pre-flight-checks.sh (needs MAAS; overlay/VIP/planes/nodes)
# P5 credential matrix scripts/creds-matrix.py --tier2 (D-137 ruling 1;
# tier 1 STATIC + tier 2 LOCAL. Hermetic: no ssh, no sudo.)
# P6 reminders stage-2 gates: juju-spaces-check.sh (AFTER add-model),
# osd-blank-check.sh (sudo), 'juju deploy --dry-run' (phase-01 1.2)
#
# DC-AWARE END TO END (F5, 2026-07-29). `DC` selects the datacenter this run gates;
# it defaults to vr1-dc0, is validated against the D-119 region-qualified token set,
# is EXPORTED so every sub-gate measures the same DC, and is printed in both the
# opening header and the verdict line. P2 and P4 are the DC-sensitive gates.
# DC=vr1-dc1 bash scripts/preflight.sh
#
# Exit: 0 all pass | 1 any FAIL (do NOT deploy) | 2 warnings only (review, then decide).
# ASCII + LF.
set -uo pipefail
REPO="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
cd "$REPO" || { echo "FAIL: cannot cd to repo root $REPO"; exit 1; }
WORST=0
# ---- F5: the DC selection is ONE decision, made ONCE, EXPORTED, and PRINTED -----
# MEASURED DEFECT (2026-07-29, docs/audit/stage5-findings-20260729-successor.md F5):
# this used to be set at the top of P2 as a plain shell variable and never exported, so
# whether the sub-gates saw it depended on the CALLER'S INVOCATION FORM --
# `DC=vr1-dc1 bash scripts/preflight.sh` put it in the environment (children saw it),
# a bare invocation did not (children saw nothing and silently used their own default).
# Two call shapes, two behaviours, neither documented. `export` collapses them to one.
#
# REFUSE an unrecognised DC rather than defaulting to success: an unknown token used to
# flow straight into `--dc $DC` and the overlay paths, where it produced "overlay not
# found" noise instead of "you named a DC that does not exist". The token set is the
# D-119 region-qualified one enforced by lib-net.sh/lib-hosts.sh; bare `dcN` is RETIRED
# there and is rejected here with the same reason so the message does not depend on
# which gate happens to see it first.
DC="${DC:-vr1-dc0}"
case "$DC" in
vr0-dc0|vr1-dc0|vr1-dc1) ;;
dc0|dc1|dc2)
echo "FAIL: DC='$DC' -- bare 'dcN' is RETIRED (D-119): it meant VR0's live DC0 in"
echo " lib-net.sh but VR1's FIRST DC in the NetBox importer. Use a"
echo " region-qualified selector: vr0-dc0 | vr1-dc0 | vr1-dc1"
exit 1 ;;
*)
echo "FAIL: DC='$DC' is not a recognised datacenter selector (expected vr0-dc0,"
echo " vr1-dc0 or vr1-dc1). REFUSING rather than gating an unknown target --"
echo " a gate that cannot say WHAT it measured is not a gate."
exit 1 ;;
esac
export DC
echo "PREFLIGHT TARGET: DC=$DC (override: DC=vr1-dc1 bash scripts/preflight.sh)"
echo " Every gate below is run against THIS DC; the verdict line repeats it."
# ---- R15(3): preflight must not FAIL OPEN (ruled 2026-07-27, OPS) --------------
# MEASURED DEFECT: note() tested ONLY rc 1 and 2, so a sub-gate exiting 127 (command
# not found), 126 (not executable), 130 (interrupted) or any rc>=3 left WORST
# untouched and this printed "PREFLIGHT: PASS -- clear to add-model / deploy".
# Reproduced for all four. This is the gate that AUTHORISES the deploy.
#
# rc=2 IS PER-GATE, and R15's blanket framing ("rc=2 is how these checkers signal
# 'I could not evaluate anything'") is only PARTLY right -- measured per checker:
# P1 repo-lint.sh rc2 = "warnings only" (documented header) -> WARN
# P2 provider-bundle-check rc2 = could-not-evaluate (PyYAML missing at :32,
# cannot parse :108, cannot merge overlay :113) -> FAIL
# P3 channel_assert.py rc2 = BOTH: sys.exit(2) on missing PyYAML at :19, but
# `return 2 if warns` at :55 is a LEGITIMATE warn.
# Ambiguous IN THE CHECKER, so preflight cannot
# resolve it -- kept WARN because the offline/
# charmhub-unreachable warn is the common path, and
# the missing-PyYAML root cause is caught anyway by
# P2, which shares it and is strict. Flagged, not
# guessed: making rc2 unambiguous belongs in
# channel_assert.py, not here.
# P4 pre-flight-checks.sh rc2 = "warning" (documented header) -> WARN
# P5 creds-matrix.py rc2 = could-not-evaluate (bad --dc at :1037,
# unreadable matrix at :116) -> FAIL
# Blanket-remapping 2 to FAIL would have turned the standing L1 legacy-ASCII WARN
# into a deploy blocker. Measured before implementing.
#
# note <rc> <gate-label> <what-rc2-means: warn|fail>
note() {
local rc="$1" gate="$2" two="${3:-warn}"
case "$rc" in
0) ;;
1) WORST=1 ;;
2)
if [ "$two" = "fail" ]; then
echo " [FAIL] $gate exited 2 = COULD NOT EVALUATE (not 'no findings'). A gate"
echo " that could not look must never read as a gate that found nothing."
WORST=1
else
[ "$WORST" -ne 1 ] && WORST=2
fi
;;
*)
echo " [FAIL] $gate exited $rc -- UNEXPECTED exit code (127=command not found,"
echo " 126=not executable, 130=interrupted, >=3=undefined). Treated as"
echo " FAIL: this used to leave 'PREFLIGHT: PASS -- clear to deploy'."
WORST=1
;;
esac
}
echo "================ P1: repo lint ================"
bash scripts/repo-lint.sh "$REPO"; note $? "P1 repo lint" warn
echo "================ P2: bundle invariants ================"
# Ruling 3 (2026-07-25) made bundle.yaml VIP-FREE, so the BARE base is no longer a
# deployable input -- validating it alone would report 11 phantom "hacluster
# relation but no vip" failures for VIPs that simply moved. P2 therefore validates
# the MERGED dc0 deploy input, which is what actually gets deployed. The overlay is
# passed only when present so this stays runnable on a tree without it.
#
# DC-AWARE since 2026-07-29 (chain audit finding 16). P2 used to hardcode
# `overlays/vr1-dc0-vips.yaml --dc vr1-dc0` with NO dc1 branch, while
# `runbooks/dc-dc-phase4-juju-bundle-per-dc.md` invokes this script bare as the *dc1*
# gate and tells the operator to expect PASS. A dc1 operator therefore got a green gate
# that had validated dc0's numbers against dc0's bands and never parsed a dc1 artifact.
# Set DC= to gate the other site; the default keeps every existing caller unchanged.
#
# It now assembles the SAME overlay set the deploy uses, in the SAME order, rather than a
# partial one -- the audit's point was that a gate validating a different input than the
# deploy is not validating the deploy. Each overlay is added only if present, so a tree
# missing the gitignored octavia-pki secret still runs (CHECK 0 in P4 is what fails on
# that, deliberately, and it is a different gate).
# $DC is selected, validated and EXPORTED at the top of this file (F5).
# The octavia-pki overlay is PER-DC since F1 (2026-07-29) -- one shared
# overlays/octavia-pki.yaml would have carried whichever DC's CA was generated last,
# which is the cross-DC amphora root-of-trust the D-109 R7 amendment refused.
P2_ARGS="bundle.yaml"
for _ovl in "overlays/${DC}-vips.yaml" "overlays/${DC}-machines.yaml" "overlays/${DC}-octavia-pki.yaml"; do
[ -f "$_ovl" ] && P2_ARGS="$P2_ARGS --overlay $_ovl"
done
P2_ARGS="$P2_ARGS --dc $DC"
echo " validating the MERGED $DC deploy input: $P2_ARGS"
python3 scripts/provider-bundle-check.py $P2_ARGS; note $? "P2 bundle invariants" fail
echo "================ P3: channel assert (charmhub) ================"
python3 scripts/channel_assert.py bundle.yaml; note $? "P3 channel assert" warn
echo "================ P4: live pre-flight (MAAS/overlay/nodes) ================"
if [ -x scripts/pre-flight-checks.sh ] || [ -f scripts/pre-flight-checks.sh ]; then
bash scripts/pre-flight-checks.sh; note $? "P4 live pre-flight" warn
else
echo " [FAIL] scripts/pre-flight-checks.sh missing"; note 1 "P4 live pre-flight" fail
fi
echo "================ P5: credential matrix (D-137 tier 1 + tier 2 local) ================"
# D-137 ruling 1: enforcement is BLOCKING IN PREFLIGHT. Advisory was rejected as the
# posture that demonstrably failed -- `creds-audit` read CLEAN on 2026-07-15 while four
# region-VM secrets sat undeclared, and `admin.pass` surfaced 12 days later only because
# someone looked. Tier 1 is wired here because it is OFFLINE and its findings ARE the three
# ruled failure classes (expected-but-absent / undeclared / per-DC-asymmetric).
#
# LOCAL HALF ONLY, deliberately. `--tier2` without `--remote` is HERMETIC -- no ssh, no
# sudo, no reachability coupling -- and still checks every jumphost creds folder for
# undeclared files, wrong modes and missing credentials. The REMOTE half
# (`--tier2 --remote --privileged`) is an operator-run check at DC-standup close and stage
# close, NOT a deploy gate: it would let an unrelated host outage, a NOPASSWD change, or a
# MAAS snap refresh turn the deploy gate red for reasons that are not about credentials,
# and a gate that fails environmentally is a gate people learn to bypass. Same reasoning
# `run-tests-all.sh:7-8` gives for staying out of preflight: different cadence.
#
# EXPECTED TO FAIL TODAY, BY DESIGN. The register is red on real defects (SEC-021's
# declaration gaps, the SEC-020 ruling-5 identity conflation, the per-DC power-key
# asymmetry). That is this gate working, not misconfigured: a deploy should not proceed
# over an unreconciled credential estate. Going green is remediation, gated per row --
# do NOT silence it by deleting matrix rows or by demoting this to a warning.
# FAIL CLOSED if the checker is absent, mirroring P4. Without this guard a missing file
# makes python3 exit 2, which `note` maps to WARN -- so DELETING the gate would downgrade
# it to a warning instead of stopping the deploy. A gate that vanishes must never read as
# a pass (harness T9 encodes this).
if [ -f scripts/creds-matrix.py ]; then
python3 scripts/creds-matrix.py --tier2; note $? "P5 credential matrix" fail
else
echo " [FAIL] scripts/creds-matrix.py missing"; note 1 "P5 credential matrix" fail
fi
echo "================ P6: stage-2 reminders (NOT run here) ================"
echo " - after 'juju add-model': bash scripts/juju-spaces-check.sh"
echo " - with sudo: bash scripts/osd-blank-check.sh"
echo " - phase-01 Step 1.2: juju deploy --dry-run (plan: 50 apps / 97 relations)"
# The `PREFLIGHT: <verdict>` token stays CONTIGUOUS and leading -- the harness and the
# runbooks grep for it verbatim. $DC is appended so a verdict can never be read against
# the wrong datacenter (F5: two DCs, one combined verdict line, no way to tell).
case "$WORST" in
0) echo; echo "PREFLIGHT: PASS (DC=$DC) -- clear to add-model / deploy" ;;
2) echo; echo "PREFLIGHT: WARN (DC=$DC) -- review warnings, then decide" ;;
*) echo; echo "PREFLIGHT: FAIL (DC=$DC) -- do NOT deploy" ;;
esac
exit "$WORST"