02d63ed070
Mandatory install profile (no default): byo = a host the operator does not own. Break-glass gated OFF at its call site in byo (root@pam never touched), mandatory --cores/--memory, argv-time refusals (--enable-oob/--rotate-recovery, non-9.x PVE, missing --acl-storages), host-mutation disclosure + typed-hostname ack, byo config asserts (lan_resolver/wg_tunnel/oob off; byo flips the lan_resolver write default to off), pool+ACL verify asserts in BOTH modes (R2), --preflight-only (no state, PASS/FAIL verdict), resume mode-mismatch refusal, FELHOM_INSTALL_STATE_DIR harness override. NEW scripts/hostinstall-mode-harness.sh: static refusal matrix C1-C4 + grep invariants + PVE tier (C5 + A/B dry transcripts). 16/16 PASS on felhom-pve (C5 live); red-proofs RP-1..RP-3 run->fail->revert. shellcheck clean at severity=warning. Docs: day0-install SC.5 byo section + trust model; REUSE row; CONTEXT + REPORT. Live drill = GL-6 (supervised); STOP honored (no non-dry run). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
237 lines
11 KiB
Bash
237 lines
11 KiB
Bash
#!/bin/bash
|
|
#===============================================================================
|
|
# hostinstall-mode-harness.sh — GL-2 test harness for felhom-host-install.sh's
|
|
# --mode appliance|byo install profile (spec: TASK GL-2; pattern precedent:
|
|
# felhom-agent/scripts/mkfs-guarded-harness.sh).
|
|
#
|
|
# Two tiers, PASS/FAIL per case, nonzero exit on any FAIL:
|
|
#
|
|
# STATIC tier (runs anywhere with bash + python3 — incl. a Windows Git Bash):
|
|
# bash -n; shellcheck (if present); the argument-refusal matrix C1-C4
|
|
# (exit nonzero + the exact die message, asserted BEFORE any host access);
|
|
# the C6 gate's presence; grep-invariants (exactly one GATED
|
|
# step_break_glass call site, chpasswd unreachable outside its body,
|
|
# --mode in usage).
|
|
#
|
|
# PVE tier (only on a PVE host as root; skips itself cleanly elsewhere):
|
|
# C5 (byo dies naming a bogus --acl-storages entry — read-only, dies in
|
|
# preflight before any hub contact) and the Scenario A/B dry-run transcript
|
|
# checks. A/B need a real customer + retrieval passphrase — provide them
|
|
# via FELHOM_TEST_CUSTOMER + FELHOM_TEST_PASSFILE (a 0600 passphrase file,
|
|
# the script's EXISTING non-interactive path); unset -> those cases SKIP.
|
|
#
|
|
# STATE SAFETY (spec §9 rule 9): every invocation of the script under test runs
|
|
# with FELHOM_INSTALL_STATE_DIR pointed at a throwaway temp dir, so NO case can
|
|
# ever touch a live install's /var/lib/felhom-install/state.json. (Dry-run
|
|
# state helpers are additionally no-ops — this is the belt on top.)
|
|
#
|
|
# Red-proofs (spec Part 4): run the harness against a MUTATED COPY of the
|
|
# script (gate removed / requirement dropped) and watch the matching case FAIL:
|
|
# RP-1 un-gate the step_break_glass call site -> "break-glass call site gated" FAILs
|
|
# RP-2 drop the byo --cores/--memory requirement -> C1 FAILs
|
|
# RP-3 drop the resume mode-mismatch check -> C4 FAILs
|
|
#
|
|
# Usage: hostinstall-mode-harness.sh [path-to-felhom-host-install.sh]
|
|
#===============================================================================
|
|
set -euo pipefail
|
|
|
|
SCRIPT="${1:-$(dirname "$0")/felhom-host-install.sh}"
|
|
[[ -r "$SCRIPT" ]] || { echo "no script under test at $SCRIPT" >&2; exit 2; }
|
|
command -v python3 >/dev/null || { echo "python3 required (the script's state helpers use it)" >&2; exit 2; }
|
|
|
|
pass=0; fail=0; skip=0
|
|
verdict() { # PASS|FAIL|SKIP <name> [detail]
|
|
local v="$1" name="$2" detail="${3:-}"
|
|
case "$v" in
|
|
PASS) pass=$((pass+1)) ;;
|
|
FAIL) fail=$((fail+1)) ;;
|
|
SKIP) skip=$((skip+1)) ;;
|
|
esac
|
|
printf '%-4s %s\n' "$v" "$name"
|
|
[[ -n "$detail" ]] && printf ' %s\n' "$detail"
|
|
return 0
|
|
}
|
|
|
|
# Throwaway state dir for EVERY run of the script under test (never the live one). On Git Bash the
|
|
# script's python3 may be a native Windows build — hand it a Windows-syntax path via cygpath -m.
|
|
WORK="$(mktemp -d "${TMPDIR:-/tmp}/hostinstall-harness.XXXXXX")"
|
|
trap 'rm -rf "$WORK"' EXIT
|
|
STATE_OVERRIDE="$WORK/state"
|
|
mkdir -p "$STATE_OVERRIDE"
|
|
if command -v cygpath >/dev/null 2>&1; then
|
|
STATE_OVERRIDE_ENV="$(cygpath -m "$STATE_OVERRIDE")"
|
|
else
|
|
STATE_OVERRIDE_ENV="$STATE_OVERRIDE"
|
|
fi
|
|
|
|
# run_script <args...> — run the script under test with the state override; captures stdout+stderr
|
|
# into $out and the exit code into $rc. Never lets a nonzero rc kill the harness.
|
|
out=""; rc=0
|
|
run_script() {
|
|
set +e
|
|
out=$(FELHOM_INSTALL_STATE_DIR="$STATE_OVERRIDE_ENV" bash "$SCRIPT" "$@" 2>&1)
|
|
rc=$?
|
|
set -e
|
|
}
|
|
|
|
# expect_die <name> <msg-substr> -- <args...> — the script must exit nonzero AND print msg-substr.
|
|
expect_die() {
|
|
local name="$1" msg="$2"; shift 2
|
|
[[ "${1:-}" == "--" ]] && shift
|
|
run_script "$@"
|
|
if [[ $rc -ne 0 && "$out" == *"$msg"* ]]; then
|
|
verdict PASS "$name"
|
|
else
|
|
verdict FAIL "$name" "rc=$rc; wanted substring: '$msg'; got: $(echo "$out" | tail -3 | tr '\n' ' ')"
|
|
fi
|
|
}
|
|
|
|
echo "=== hostinstall-mode-harness — script under test: $SCRIPT ==="
|
|
echo ""
|
|
echo "--- STATIC tier ---"
|
|
|
|
# S1: syntax
|
|
if bash -n "$SCRIPT" 2>"$WORK/bashn.err"; then
|
|
verdict PASS "bash -n"
|
|
else
|
|
verdict FAIL "bash -n" "$(cat "$WORK/bashn.err")"
|
|
fi
|
|
|
|
# S2: shellcheck (best-effort — required by the green gate, but the harness itself degrades)
|
|
if command -v shellcheck >/dev/null 2>&1; then
|
|
if shellcheck --shell=bash --severity=warning "$SCRIPT" >"$WORK/sc.out" 2>&1; then
|
|
verdict PASS "shellcheck (severity>=warning)"
|
|
else
|
|
verdict FAIL "shellcheck (severity>=warning)" "$(head -5 "$WORK/sc.out")"
|
|
fi
|
|
else
|
|
verdict SKIP "shellcheck" "not installed here — run it on the build server (green gate still requires it)"
|
|
fi
|
|
|
|
# C1: byo without --cores/--memory refuses (both missing, and each alone)
|
|
expect_die "C1 byo without caps refused" \
|
|
"byo mode requires explicit --cores and --memory" \
|
|
-- --customer-id t --mode byo
|
|
expect_die "C1b byo with only --cores refused" \
|
|
"byo mode requires explicit --cores and --memory" \
|
|
-- --customer-id t --mode byo --cores 4
|
|
expect_die "C1c byo with only --memory refused" \
|
|
"byo mode requires explicit --cores and --memory" \
|
|
-- --customer-id t --mode byo --memory 8192
|
|
|
|
# C2: byo refuses --enable-oob / --rotate-recovery, naming the flag
|
|
expect_die "C2a byo --enable-oob refused" \
|
|
"--enable-oob is not allowed in byo mode" \
|
|
-- --customer-id t --mode byo --cores 4 --memory 8192 --enable-oob
|
|
expect_die "C2b byo --rotate-recovery refused" \
|
|
"--rotate-recovery is not allowed in byo mode" \
|
|
-- --customer-id t --mode byo --cores 4 --memory 8192 --rotate-recovery
|
|
|
|
# C3: fresh install without --mode refuses, naming both modes
|
|
expect_die "C3 fresh install without --mode refused" \
|
|
"--mode is required: pass --mode appliance" \
|
|
-- --customer-id t
|
|
expect_die "C3b unknown --mode refused" \
|
|
"Unknown --mode: bogus (appliance|byo)" \
|
|
-- --customer-id t --mode bogus
|
|
expect_die "C3c retired --mode provision refused" \
|
|
"--mode provision was retired" \
|
|
-- --customer-id t --mode provision
|
|
|
|
# C4: --resume with a state.json recording the OTHER mode refuses
|
|
printf '{"completed":["preflight"],"customer_id":"t","mode":"appliance"}\n' > "$STATE_OVERRIDE/state.json"
|
|
expect_die "C4 resume mode-mismatch refused" \
|
|
"install started as appliance; resume with --mode appliance or start over" \
|
|
-- --customer-id t --mode byo --cores 4 --memory 8192 --resume
|
|
rm -f "$STATE_OVERRIDE/state.json"
|
|
|
|
# C6 (static shape): the byo PVE-major gate + its message exist (a non-9.x host isn't available)
|
|
if grep -q 'byo mode is validated on PVE 9.x only' "$SCRIPT" \
|
|
&& grep -B3 'byo mode is validated on PVE 9.x only' "$SCRIPT" | grep -q 'MODE" == "byo"'; then
|
|
verdict PASS "C6 byo PVE-major gate present (static grep)"
|
|
else
|
|
verdict FAIL "C6 byo PVE-major gate present (static grep)"
|
|
fi
|
|
|
|
# INV-1: exactly ONE step_break_glass invocation, and it is gated on MODE == appliance.
|
|
# (Comment lines and the function definition itself don't count.)
|
|
inv_calls=$(grep -n 'step_break_glass' "$SCRIPT" \
|
|
| grep -v -E '^[0-9]+:[[:space:]]*#' \
|
|
| grep -v 'step_break_glass()' || true)
|
|
inv_count=$(echo "$inv_calls" | grep -c 'step_break_glass' || true)
|
|
if [[ "$inv_count" == "1" ]] \
|
|
&& echo "$inv_calls" | grep -q 'should_skip break_glass' \
|
|
&& grep -B4 'should_skip break_glass' "$SCRIPT" | grep -q 'MODE" == "appliance"'; then
|
|
verdict PASS "INV-1 break-glass call site: exactly one, appliance-gated"
|
|
else
|
|
verdict FAIL "INV-1 break-glass call site: exactly one, appliance-gated" "count=$inv_count; calls: $(echo "$inv_calls" | tr '\n' ' ')"
|
|
fi
|
|
|
|
# INV-2: chpasswd is unreachable outside step_break_glass's body (non-comment occurrences only).
|
|
body=$(awk '/^step_break_glass\(\)/{s=NR} s && /^\}/{print s, NR; exit}' "$SCRIPT")
|
|
bstart=${body% *}; bend=${body#* }
|
|
inv2_ok=true
|
|
while IFS=: read -r ln _; do
|
|
[[ -z "$ln" ]] && continue
|
|
if [[ "$ln" -lt "$bstart" || "$ln" -gt "$bend" ]]; then inv2_ok=false; fi
|
|
done < <(grep -n 'chpasswd' "$SCRIPT" | grep -v -E '^[0-9]+:[[:space:]]*#' || true)
|
|
if [[ -n "$bstart" && -n "$bend" ]] && $inv2_ok; then
|
|
verdict PASS "INV-2 chpasswd unreachable outside step_break_glass ($bstart-$bend)"
|
|
else
|
|
verdict FAIL "INV-2 chpasswd unreachable outside step_break_glass" "body=$bstart-$bend"
|
|
fi
|
|
|
|
# INV-3: --mode is documented in usage (run the real -h path)
|
|
run_script -h
|
|
if [[ $rc -eq 0 && "$out" == *"--mode appliance|byo"* ]]; then
|
|
verdict PASS "INV-3 usage documents --mode appliance|byo"
|
|
else
|
|
verdict FAIL "INV-3 usage documents --mode appliance|byo" "rc=$rc"
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- PVE tier ---"
|
|
if ! command -v pveum >/dev/null 2>&1 || [[ "$(id -u)" != 0 ]]; then
|
|
verdict SKIP "PVE tier (C5 + Scenario A/B dry transcripts)" "needs a PVE host as root — run there"
|
|
else
|
|
# C5: byo with a bogus --acl-storages entry dies in preflight NAMING it (read-only: dies before
|
|
# any hub contact/passphrase and before any mutation; state override active).
|
|
expect_die "C5 byo bogus --acl-storages refused, named" \
|
|
"acl storage(s) not found on this box: definitely-not-a-storage" \
|
|
-- --customer-id t --mode byo --cores 4 --memory 8192 \
|
|
--acl-storages "local definitely-not-a-storage" --dry-run
|
|
|
|
if [[ -n "${FELHOM_TEST_CUSTOMER:-}" && -n "${FELHOM_TEST_PASSFILE:-}" && -r "${FELHOM_TEST_PASSFILE:-}" ]]; then
|
|
common=(--customer-id "$FELHOM_TEST_CUSTOMER" --passphrase-file "$FELHOM_TEST_PASSFILE" \
|
|
--vmid 990100 --cores 2 --memory 4096 --dry-run)
|
|
# H-A: appliance dry transcript still walks 4b/8 (regression guard)
|
|
run_script --mode appliance "${common[@]}"
|
|
if [[ $rc -eq 0 && "$out" == *"4b/8"* ]]; then
|
|
verdict PASS "H-A appliance dry transcript contains 4b/8"
|
|
else
|
|
verdict FAIL "H-A appliance dry transcript contains 4b/8" "rc=$rc"
|
|
fi
|
|
# H-B: byo dry transcript — no 4b/8 / chpasswd / recovery-credential; ack + caps + storages present
|
|
run_script --mode byo "${common[@]}"
|
|
hb_ok=true; hb_why=""
|
|
[[ $rc -eq 0 ]] || { hb_ok=false; hb_why+="rc=$rc "; }
|
|
for bad in "4b/8" "chpasswd" "recovery-credential"; do
|
|
[[ "$out" != *"$bad"* ]] || { hb_ok=false; hb_why+="contains '$bad' "; }
|
|
done
|
|
for want in "acknowledge the byo install" "acl storages all present" "-cores 2 -memory 4096"; do
|
|
[[ "$out" == *"$want"* ]] || { hb_ok=false; hb_why+="missing '$want' "; }
|
|
done
|
|
if $hb_ok; then
|
|
verdict PASS "H-B byo dry transcript (no root@pam path; ack+caps+storages present)"
|
|
else
|
|
verdict FAIL "H-B byo dry transcript" "$hb_why"
|
|
fi
|
|
else
|
|
verdict SKIP "H-A/H-B dry transcripts" "set FELHOM_TEST_CUSTOMER + FELHOM_TEST_PASSFILE (0600 passphrase file) to run"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "=== $pass passed, $fail failed, $skip skipped ==="
|
|
[[ $fail -eq 0 ]]
|