diff --git a/docs/v1-redeploy-changelog.md b/docs/v1-redeploy-changelog.md index a0b0856..b5e98f8 100644 --- a/docs/v1-redeploy-changelog.md +++ b/docs/v1-redeploy-changelog.md @@ -1650,3 +1650,37 @@ (wrap tenant-acceptance), d011-06-vault-unseal (attest SEC-003 in security-ledger -> MANUAL). Next-free: D-071, DOCFIX-086, BUNDLEFIX-009. + +### 2026-07-03 (addendum 9) -- validate checks batch 1: d011-01-charms, d011-06-vault-unseal + +Two checks composed on the lib-validate foundation (addendum 8); harnessed 18/18; gauntlet 30. + +scripts/checks/d011-01-charms.sh -- D-011 item 1 (all charms active/idle). FOCUSED check, NOT +a cloud-assert wrapper: cloud-assert is all-or-nothing (A0-A8, no per-section invocation), so +wrapping it would over-scope item 1 and double-count other items. Asserts workload=active + +agent=idle across all units + subordinates via a fixture-tested jq walk; tolerates carried gss +units in workload 'unknown' (documented in cloud-assert A0). Model unreachable / non-JSON -> +HOLD (cannot determine, not a charm failure); a unit not active/idle -> FAIL (names it). + +scripts/checks/d011-06-vault-unseal.sh -- D-011 item 6 as amended by D-069 (second-person +unseal). NOT scriptable (needs a different human), so an ATTESTATION GATE: reads the SEC-003 +row in docs/security-ledger.md. OPEN/PENDING -> PASS_PENDING_MANUAL(3); CLOSED/REHEARSED/DONE +-> PASS(0); missing row/file or unrecognized status -> HOLD(2). Never auto-passes an undone +safety item (the D-070 failure mode). Currently SEC-003 is OPEN, so this returns MANUAL until +R-1 (custodian assignment) + the rehearsal are done and the ledger row is closed. + +DESIGN ADJUSTMENT (flagged): plan said "wrap cloud-assert A0"; on inspection cloud-assert has no +per-section mode, so item 1 is a focused reimplementation (thin, exact D-011 semantics). +Complementary to cloud-assert, not duplicative -- different granularity. +HARNESS LESSON: integration test initially copied checks to an isolated temp, breaking their +relative lib-validate resolution ($HERE/../lib-validate.sh). Real layout (scripts/checks/ + +scripts/lib-validate.sh sibling) is mandatory; integration now runs from the real checks dir. + +NUMBERING NOTE: "next-free D-071" in prior addenda is CONTENDED -- a parallel Claude Code +workstream (jumphost) is filing D-071 (Juju controller update cadence / patch policy). D-071 +is relinquished to that stream; this repo's next-free advances to D-072+ AFTER that push lands +and is re-grepped. These validate checks consume NO new D/DOCFIX/BUNDLEFIX number (they +implement the addendum-8 approved design). + +REMAINING: batch 2 (d011-02-vip-jumphost, d011-03-vip-tenant); batch 3 (d011-04-octavia-lb w/ +amphora failover + N+1 headroom HOLD-guard, d011-05-magnum-e2e wrapping tenant-acceptance). diff --git a/scripts/checks/d011-01-charms.sh b/scripts/checks/d011-01-charms.sh new file mode 100644 index 0000000..c8dd333 --- /dev/null +++ b/scripts/checks/d011-01-charms.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# scripts/checks/d011-01-charms.sh -- D-011 item 1: all charms active/idle. +# Focused check (NOT the cloud-assert behavioral sweep -- that is all-or-nothing and +# covers other D-011 items too; this asserts exactly "active/idle" per the D-011 wording). +# Toleration: carried gss units in workload 'unknown' are accepted (documented in +# cloud-assert A0; a known-benign carried state on this cloud). +# Exit (standard contract): 0 PASS | 1 FAIL (a unit not active/idle) | 2 HOLD (juju/jq +# unavailable or model unreachable -- cannot determine, not a charm failure). +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib-validate.sh +. "$HERE/../lib-validate.sh" +ID=d011-01-charms; vr_begin "$ID" +MODEL="${VR_MODEL:-openstack}" + +vr_need juju jq || { emit "$ID" "$VR_HOLD" "missing tool (juju/jq)"; exit "$VR_HOLD"; } +if ! vr_json ST juju status -m "$MODEL" --format json; then + vr_err_tail; emit "$ID" "$VR_HOLD" "model '$MODEL' unreachable (cannot determine)"; exit "$VR_HOLD" +fi +# validate JSON parses before trusting it +if ! jq -e . >/dev/null 2>&1 <<<"$ST"; then + emit "$ID" "$VR_HOLD" "juju status did not return parseable JSON"; exit "$VR_HOLD" +fi + +WALK="$(jq -r ' + .applications // {} | to_entries[] | .value.units // {} | to_entries[] | + "\(.key) \(.value["workload-status"].current // "?") \(.value["juju-status"].current // "?")", + (.value.subordinates // {} | to_entries[] | "\(.key) \(.value["workload-status"].current // "?") \(.value["juju-status"].current // "?")") +' <<<"$ST")" +[ -n "$WALK" ] || { emit "$ID" "$VR_HOLD" "no units found in model '$MODEL'"; exit "$VR_HOLD"; } + +BAD="" +while read -r unit wl agent; do + [ -n "$unit" ] || continue + # documented toleration: carried gss unit in workload 'unknown' + if [[ "$unit" == gss/* ]] && [ "$wl" = unknown ]; then + echo " tolerate: $unit workload=unknown (carried gss, documented)"; continue + fi + if [ "$wl" != active ] || [ "$agent" != idle ]; then + echo " NOT active/idle: $unit workload=$wl agent=$agent" + BAD="$BAD $unit" + fi +done <<<"$WALK" + +N="$(grep -c . <<<"$WALK")" +if [ -n "$BAD" ]; then + emit "$ID" "$VR_FAIL" "units not active/idle:$BAD"; exit "$VR_FAIL" +fi +emit "$ID" "$VR_PASS" "all $N units active/idle (gss-unknown tolerated)"; exit "$VR_PASS" diff --git a/scripts/checks/d011-06-vault-unseal.sh b/scripts/checks/d011-06-vault-unseal.sh new file mode 100644 index 0000000..ac4b5a4 --- /dev/null +++ b/scripts/checks/d011-06-vault-unseal.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# scripts/checks/d011-06-vault-unseal.sh -- D-011 item 6 (as amended by D-069): +# second-person Vault unseal rehearsal. This is NOT scriptable -- it requires a +# different human to unseal. So this check is an ATTESTATION GATE: it reads the +# SEC-003 row in docs/security-ledger.md and reports the manual item's status. +# It NEVER auto-passes an undone safety item (the failure mode D-070 just retired). +# Exit (standard contract): +# 0 PASS SEC-003 shows the rehearsal DONE/CLOSED/REHEARSED +# 3 PASS_PENDING_MANUAL SEC-003 OPEN/PENDING -- rehearsal still outstanding +# 2 HOLD ledger or SEC-003 row missing / status unrecognized +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib-validate.sh +. "$HERE/../lib-validate.sh" +ID=d011-06-vault-unseal; vr_begin "$ID" +LEDGER="${VR_LEDGER:-$HERE/../../docs/security-ledger.md}" + +[ -r "$LEDGER" ] || { emit "$ID" "$VR_HOLD" "security-ledger not readable: $LEDGER"; exit "$VR_HOLD"; } +ROW="$(grep -E '^\|[[:space:]]*SEC-003[[:space:]]*\|' "$LEDGER" | head -1)" +[ -n "$ROW" ] || { emit "$ID" "$VR_HOLD" "SEC-003 row not found in ledger"; exit "$VR_HOLD"; } + +# status is the LAST pipe-delimited column +STATUS="$(awk -F'|' '{print $(NF-1)}' <<<"$ROW" | sed 's/^ *//; s/ *$//')" +echo " SEC-003 status: $STATUS" +SU="$(tr '[:lower:]' '[:upper:]' <<<"$STATUS")" +case "$SU" in + *CLOSED*|*DONE*|*REHEARSED*|*COMPLETE*|*PASS*) + emit "$ID" "$VR_PASS" "SEC-003 rehearsal attested: $STATUS"; exit "$VR_PASS" ;; + *OPEN*|*PENDING*|*TODO*) + emit "$ID" "$VR_MANUAL" "SEC-003 second-person unseal rehearsal OUTSTANDING (manual)"; exit "$VR_MANUAL" ;; + *) + emit "$ID" "$VR_HOLD" "SEC-003 status unrecognized: '$STATUS'"; exit "$VR_HOLD" ;; +esac diff --git a/tests/checks/run-tests.sh b/tests/checks/run-tests.sh new file mode 100644 index 0000000..149537f --- /dev/null +++ b/tests/checks/run-tests.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# tests/checks/run-tests.sh -- unit tests for scripts/checks/d011-*.sh (grows per batch). +# Batch 1: d011-01-charms (mock juju), d011-06-vault-unseal (mock ledger). +# Also an INTEGRATION assertion: run both via the real orchestrator against mocks. +set -u +SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; REPO="$(cd "$SD/../.." && pwd)" +CHK="$REPO/scripts/checks"; VAL="$REPO/scripts/validate.sh" +P=0; F=0; ok(){ echo "PASS: $1"; P=$((P+1)); }; no(){ echo "FAIL: $1"; F=$((F+1)); } +chk(){ [ "$2" = "$3" ] && ok "$1" || no "$1 (got '$2' want '$3')"; } +W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT; mkdir -p "$W/bin" + +# --- mock juju (scenario via MOCK_JUJU) --- +cat > "$W/bin/juju" <<'JM' +#!/usr/bin/env bash +case "${MOCK_JUJU:-ok}" in + unreachable) echo "ERROR connection refused" >&2; exit 1 ;; + notjson) echo "not json at all" ;; + blocked) cat <<'J' +{"applications":{"nova":{"units":{"nova/0":{"workload-status":{"current":"blocked"},"juju-status":{"current":"idle"}}}}}} +J + ;; + gss) cat <<'J' +{"applications":{"gss":{"units":{"gss/0":{"workload-status":{"current":"unknown"},"juju-status":{"current":"idle"}}}}, + "vault":{"units":{"vault/0":{"workload-status":{"current":"active"},"juju-status":{"current":"idle"}, + "subordinates":{"vault-mysql-router/0":{"workload-status":{"current":"active"},"juju-status":{"current":"idle"}}}}}}}} +J + ;; + *) cat <<'J' +{"applications":{"vault":{"units":{"vault/0":{"workload-status":{"current":"active"},"juju-status":{"current":"idle"}}}}}} +J + ;; +esac +JM +chmod +x "$W/bin/juju"; command -v jq >/dev/null || { echo "SKIP: jq absent"; exit 0; } + +runchk(){ PATH="$W/bin:$PATH" bash "$CHK/d011-01-charms.sh" 2>&1; } + +# d011-01-charms +OUT="$(MOCK_JUJU=ok runchk)"; chk "charms all-active PASS" "$?" 0 +grep -qE '^RESULT d011-01-charms PASS 0 ' <<<"$OUT" && ok "charms PASS line" || no "charms PASS line" +OUT="$(MOCK_JUJU=blocked runchk)"; chk "charms blocked FAIL" "$?" 1 +grep -q 'nova/0 workload=blocked' <<<"$OUT" && ok "charms names the bad unit" || no "charms names bad unit" +OUT="$(MOCK_JUJU=gss runchk)"; chk "charms gss-unknown tolerated PASS" "$?" 0 +grep -q 'tolerate: gss/0' <<<"$OUT" && ok "charms tolerates gss" || no "charms tolerates gss" +OUT="$(MOCK_JUJU=unreachable runchk)"; chk "charms unreachable HOLD" "$?" 2 +OUT="$(MOCK_JUJU=notjson runchk)"; chk "charms notjson HOLD" "$?" 2 + +# d011-06-vault-unseal (mock ledger via VR_LEDGER) +mkl="$(mktemp)" +mkledger(){ printf '| id | date | item | src | owner | status |\n|---|---|---|---|---|---|\n| SEC-003 | 2026-07-03 | unseal custody | D-069 | operator | %s |\n' "$1" > "$mkl"; } +runvault(){ VR_LEDGER="$mkl" bash "$CHK/d011-06-vault-unseal.sh" 2>&1; } +mkledger "OPEN -- assign custodians + rehearse"; OUT="$(runvault)"; chk "vault OPEN -> MANUAL(3)" "$?" 3 +grep -qE '^RESULT d011-06-vault-unseal PASS_PENDING_MANUAL 3 ' <<<"$OUT" && ok "vault MANUAL line" || no "vault MANUAL line" +mkledger "CLOSED 2026-07-10 -- rehearsed by A.Jones (second person)"; OUT="$(runvault)"; chk "vault CLOSED -> PASS(0)" "$?" 0 +mkledger "REHEARSED 2026-07-10"; OUT="$(runvault)"; chk "vault REHEARSED -> PASS(0)" "$?" 0 +mkledger "banana"; OUT="$(runvault)"; chk "vault unknown-status -> HOLD(2)" "$?" 2 +printf 'no sec-003 row here\n' > "$mkl"; OUT="$(runvault)"; chk "vault missing-row -> HOLD(2)" "$?" 2 +OUT="$(VR_LEDGER=/nonexistent bash "$CHK/d011-06-vault-unseal.sh" 2>&1)"; chk "vault missing-file -> HOLD(2)" "$?" 2 + +# INTEGRATION: real orchestrator runs both checks against mocks, from the REAL checks dir +# (checks resolve lib-validate.sh relative to their own location, so they must run from +# scripts/checks/ with the library sibling present -- do NOT copy them to an isolated temp). +mkledger "OPEN -- outstanding" +OUT="$(MOCK_JUJU=ok VR_LEDGER="$mkl" VR_CHECKDIR="$CHK" PATH="$W/bin:$PATH" bash "$VAL" --checks d011-01-charms,d011-06-vault-unseal 2>&1)"; RC=$? +chk "orchestrator overall = PASS_PENDING_MANUAL(3)" "$RC" 3 +grep -q 'MANUAL=1' <<<"$OUT" && ok "orchestrator counts manual" || no "orchestrator counts manual" +grep -q 'PASS=1' <<<"$OUT" && ok "orchestrator counts charms pass" || no "orchestrator counts charms pass" + +echo; [ "$F" = 0 ] && { echo "ALL PASS ($P checks)"; exit 0; } || { echo "FAILURES: $F"; exit 1; }