Newer
Older
openstack-caracal-dc-dc / scripts / preflight.sh
#!/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)
#
# 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
# ---- 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 ================"
python3 scripts/provider-bundle-check.py bundle.yaml; 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)"

case "$WORST" in
  0) echo; echo "PREFLIGHT: PASS -- clear to add-model / deploy" ;;
  2) echo; echo "PREFLIGHT: WARN -- review warnings, then decide" ;;
  *) echo; echo "PREFLIGHT: FAIL -- do NOT deploy" ;;
esac
exit "$WORST"