diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c2ed080..5c73c3a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2399,8 +2399,20 @@ to keystone OK. **F-CV2 RESOLVED:** openstack CLI was ABSENT on the dc0 rack (07-30 queued-F1, HIT at Step 7) -> installed `python3-openstackclient 6.6.0-0ubuntu2` on the rack (see section 7 row). **EXIT GATE: (a) F-CV3 dashboard VIP TLS -- RESOLVED 2026-08-06 (D-072 VR1 amendment / - BUNDLEFIX-057, see below); (b) Step 3.4 domain-manager policy STILL NOT RUN -- the one remaining - phase-03 exit-gate item. + BUNDLEFIX-057, see below); (b) Step 3.4 domain-manager policy -- RESOLVED 2026-08-06 (successor + session): built `scripts/g3-domain-manager-probe.sh` (+ harness 12/12, gauntlet 100 ALL GREEN) + as the GA-R6 NAMED CHECK and ran it LIVE from the dc0 rack -> `G3 PASS`, 7 ok / 0 fail; teardown + VERIFIED clean (domains back to admin/service/Default, no g3-* residue); capture + `docs/audit/g3-dc0-probe-20260806.txt`. Stage-1 (PO:) verified read-only the same session: + `juju resources keystone` -> policyd-override attached (rev 2026-08-04); all 3 keystone units + `PO: Unit is ready` (the app-status AGGREGATE read "Application Ready" with no PO: -- verifying + per-UNIT is what avoided a false "override missing"). The probe is grounded in the real policy + (`domain-manager-policy.yaml:103` create_grant managed-role guard); reusable for dc1's Step 7. + (c) REMAINING phase-03 exit-gate item, NOT among the original 2: the exit gate's "Horizon + reachable AND login works" requirement -- F-CV3 fixed the VIP TLS, but D-044 cookie override + + D-075 root-redirect are PER-REBUILD (not applied this rebuild) and the VR0 external-nginx-repoint + model needs reconciling against the VR1 metal-admin-VIP-over-tailnet access model (Decision C). + To be MEASURED + RULED next before phase-03 closes. F-CV1: designate _admin backend DOWN -- **RESOLVED 2026-08-06 (BUNDLEFIX-056, operator-approved fix EXECUTED + VERIFIED).** Root cause (governing = D-052 + generic binding rule + the D-020 amendment's ruled .62 triple, NOT D-141): designate's bundle bindings OMITTED public + internal, diff --git a/docs/audit/g3-dc0-probe-20260806.txt b/docs/audit/g3-dc0-probe-20260806.txt new file mode 100644 index 0000000..3110c66 --- /dev/null +++ b/docs/audit/g3-dc0-probe-20260806.txt @@ -0,0 +1,15 @@ +=== G3 domain-manager persona probe (throwaway domain: g3-probe-20260806-235541) === +--- PASS group (manager, own domain) --- + OK: manager created a user in its own domain + OK: manager created a project in its own domain + OK: manager granted 'member' on a project in its own domain + OK: manager granted 'load-balancer_member' on a project in its own domain +--- DENY group (manager, must be refused) --- + OK: anti-escalation held: grant of 'manager' refused + OK: cross-domain isolation held: read of admin_domain refused +--- UNAFF group (cloud-admin) --- + OK: cloud-admin sees all domains (4 >= 3) +=== verdict: 7 ok / 0 fail === +G3 PASS -- persona works and is bounded +=== teardown (throwaway domain g3-probe-20260806-235541) === + teardown attempted (best-effort; verify with 'openstack domain list') diff --git a/docs/changelog-20260806-step34-g3-probe.md b/docs/changelog-20260806-step34-g3-probe.md new file mode 100644 index 0000000..d4259c9 --- /dev/null +++ b/docs/changelog-20260806-step34-g3-probe.md @@ -0,0 +1,80 @@ +# Changelog 2026-08-06 -- phase-03 Step 3.4: G3 domain-manager probe (named check + live PASS) + +Session: continue Stage 5 (dc0) from the 2026-08-06 part-3 close. Branch +`dc-dc-stage5-preconditions`. Under blanket approval the changelog is the review surface: +each item states WHAT, WHY (evidence), and HOW TO REVERT. + +Operator grounding this session (applied, not a ruling to adopt): (1) this is the D-121 HA +scale-up deployment -- reconcile any single-DC/non-HA-era step or gate against the current HA +census before grading it; (2) the standing IPAM ruling is IPv6-PRIMARY (v6 wherever possible, +v4/dual-stack only where forced, per D-101/D-139); D-141's v4-active/v6-reserved is the +NARROW, necessity-driven state of the container-hosted API-charm VIPs (juju LP #1723240), not +a cloud-wide posture. Recorded here so the sequence's Decision B (Step 12 Ceph/geneve-over-v6) +is framed as "verify the v6 target per-layer", not "defer v6". + +## Item 1 -- NEW gate tool: scripts/g3-domain-manager-probe.sh + its harness + +WHAT: added `scripts/g3-domain-manager-probe.sh` and `tests/g3-domain-manager-probe/run-tests.sh`. +The script is the appendix-C C.4 behavioral probe (phase-03 Step 3.4 stage 2, gate G3) turned +into a NAMED EXECUTABLE CHECK: as a domain-scoped `manager` it proves PASS (create user + +project in-domain, grant member + load-balancer_member), DENY (grant an unmanaged role +`manager` -> refused; read `admin_domain` -> refused), and UNAFF (cloud-admin sees all +domains). All fixtures live under a unique dated throwaway domain `g3-probe-`; a trap +tears the domain + contents down on ANY exit. Exit 0 PASS / 11 PASS-case miss / 12 DENY LEAK +(security) / 14 precondition. Self-contained (sources no lib-*), so it pipes cleanly to a +rack via `bash -s`. + +WHY: hard rule 4 -- no tested artifact existed for this job (the probe was a manual runbook +walk only); GA-R6 wants Step 3.4 to close on a named check whose output the commit cites; and +Stage 5 is per-DC, so dc1's Step 7 reuses this identical probe. Grounded in the real policy: +`policies/domain-manager-policy.yaml:103` `identity:create_grant = (domain_manager_grant AND +is_domain_managed_role) OR cloud_admin ...` -- so granting `manager`/`Admin` is refused and +granting `member`/`load-balancer_member` is allowed, exactly what the probe asserts. + +HARNESS: 12/12, every exit path (0/11/12/14) has a failing-direction fixture; teardown proven +on both the happy and the failure path; all three groups proven to run. Writing it caught two +real defects before delivery: (a) the happy case was run inside `$(...)` so its pass/fail +accounting happened in a subshell and was LOST -- a genuine happy failure would have been +invisible (fixed: account in the parent shell, capture output via a file); (b) a fake +`domain list` emitted non-hex ids while the script counts hex (real keystone shape) -- the +subshell fix immediately exposed it as a real happy-case exit 11. gauntlet ALL GREEN (100). + +REVERT: `git rm scripts/g3-domain-manager-probe.sh && git rm -r tests/g3-domain-manager-probe +&& bash scripts/run-tests-all.sh --record-manifest` (drops the manifest line too). + +## Item 2 -- tests/HARNESS-MANIFEST recorded 99 -> 100 + +WHAT: `bash scripts/run-tests-all.sh --record-manifest` added `g3-domain-manager-probe` +(line 39). The drift check FAILED the gauntlet until recorded (present-but-unpinned); the diff +was exactly one addition, zero removals. +REVERT: covered by Item 1's re-record. + +## Item 3 -- LIVE G3 probe on dc0: PASS, zero residue + +WHAT: ran `ssh vr1-dc0-rack 'bash -s' < scripts/g3-domain-manager-probe.sh` (operator-approved +mutation). Result: `G3 PASS -- persona works and is bounded`, 7 ok / 0 fail, exit 0. Capture: +`docs/audit/g3-dc0-probe-20260806.txt`. Teardown VERIFIED clean by a follow-up read-only check +(`openstack domain list` back to admin_domain/service_domain/Default; no g3-mgr/g3-user/g3-proj). + +WHY: closes phase-03 Step 3.4 stage 2. Stage 1 (override loaded) was verified read-only the +same session: `juju resources keystone` shows `policyd-override` attached (rev 2026-08-04) and +all THREE keystone units carry `PO: Unit is ready` -- note the app-status AGGREGATE read +"Application Ready" (no PO:), so verifying at the UNIT level is what prevented a false +"override missing" report (assert-before-check). + +REVERT: none required -- the probe self-cleans and touched only objects it created; no +persistent cloud change. (The capture file is evidence; remove with `git rm` if unwanted.) + +## Item 4 -- CURRENT-STATE: Step 3.4 recorded RESOLVED + +WHAT: section-1 phase-03 block -- item (b) "Step 3.4 ... STILL NOT RUN" -> RESOLVED with the +capture cited; header count reconciled. The phase-03 exit gate now turns on the Horizon +reachable/login-works item (D-044 cookie + D-075 root-redirect per-rebuild + the VR0 +nginx-repoint vs VR1 metal-admin-VIP-over-tailnet access-model reconciliation = the sequence's +Decision C), which is measured + ruled next. +REVERT: `git checkout HEAD~1 -- docs/CURRENT-STATE.md` (or revert the single hunk). + +## Gates at delivery +- gauntlet ALL GREEN (100 harnesses); g3 confirmed EXECUTED in-gauntlet (12 pass / 0 fail). +- repo-lint 0 fail / 1 legacy warn (D-001..018 ASCII carve-out). +- ledger-scan: decisions + SEC unchanged; no D/DOCFIX/BUNDLEFIX number consumed (new tooling). diff --git a/scripts/g3-domain-manager-probe.sh b/scripts/g3-domain-manager-probe.sh new file mode 100755 index 0000000..39fb634 --- /dev/null +++ b/scripts/g3-domain-manager-probe.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# scripts/g3-domain-manager-probe.sh -- behavioral gate G3 (phase-03 Step 3.4 stage 2). +# +# WHAT IT PROVES. The SCS Domain Manager persona (D-051/D-064, delivered by the keystone +# policyd-override) works AND is properly bounded -- the appendix-C C.4 procedure, made a +# NAMED EXECUTABLE CHECK so it can close the gate (GA-R6) and re-run per DC (Stage 5 is +# per-DC; dc1's Step 7 needs the identical probe). Before this script the probe existed only +# as a manual runbook walk, so Step 3.4 had no gradable close (hard rule 4: a gap is a +# finding -- this fills it). +# +# THREE GROUPS, exactly as C.4 states them: +# PASS -- a manager, scoped to its OWN domain, CAN: create a user, create a project, +# grant member + load-balancer_member on a project in its own domain. +# DENY -- the same manager CANNOT: grant an unmanaged role (manager/admin -> anti-escalation) +# even within its own domain; read another domain (cross-domain isolation). +# UNAFF -- cloud_admin (the operator admin) retains full authority (domain list sees all). +# Only when all three hold is the persona accepted (C.4: "Only when all three groups hold"). +# +# WHY THE PASS GROUP IS ALSO THE OVERRIDE-ACTIVE CHECK. Without the override, role:manager has +# no special powers (default policy), so a manager creating a user/project would be REFUSED -- +# the PASS group failing is exactly the "PO: shows but the policy is not effective" signal that +# a status-only check (D-051: the charm validates YAML only) cannot catch. So this replaces a +# PO:-only pass, per Step 3.4's own gate ("Do NOT proceed to phase-04 on a PO:-only pass"). +# +# RUN-LOCATION. FROM THE DC's cloud client -- the DC rack per D-138 (juju/openstack dial the +# cloud at L3 from inside the DC), NOT voffice1. Needs ~/admin-openrc (Step 3.2) and its +# OS_CACERT (the vault root CA). Reads admin-openrc for OS_AUTH_URL + OS_CACERT; never prints +# a secret; the throwaway manager password is generated, used, and never echoed. +# +# SAFETY. All fixtures live under a UNIQUE, dated, clearly-labelled throwaway domain +# (g3-probe-). A trap tears the domain and its contents down on ANY exit, so a +# failure mid-probe leaves nothing behind. The ONLY identities it touches are ones it created; +# it never modifies admin_domain / service_domain / Default or any real tenant. +# +# EXIT: 0 all three groups hold | 11 a PASS case failed (persona cannot do what it must -- +# override likely not effective) | 12 a DENY case LEAKED (anti-escalation or cross-domain +# -- CRITICAL, security boundary broken) | 13 the cloud-admin unaffected check failed | +# 14 precondition (no admin-openrc, auth fails, or a required role is absent). +# Harness: tests/g3-domain-manager-probe/run-tests.sh (offline; fakes openstack). ASCII + LF. +set -uo pipefail + +OPENSTACK="${OPENSTACK:-openstack}" # overridable so the harness can inject a fake +OPENRC="${G3_OPENRC:-$HOME/admin-openrc}" +STAMP="$(date -u +%Y%m%d-%H%M%S)" +DOM="g3-probe-${STAMP}" # throwaway domain, unique + dated +MGR="g3-mgr"; USR="g3-user"; PROJ="g3-proj" +PASS=0; FAIL=0 +DOM_ID="" # set once the domain is created (drives teardown) + +say(){ printf '%s\n' "$*"; } +ok(){ PASS=$((PASS+1)); say " OK: $*"; } +bad(){ FAIL=$((FAIL+1)); say " FAIL: $*"; } + +# ---- auth envs ------------------------------------------------------------- +# admin_env: the operator admin exactly as Step 3.2 wrote it. +admin_env(){ for v in $(env | awk -F= '/^OS_/{print $1}'); do unset "$v"; done; . "$OPENRC"; } +# mgr_env: the throwaway domain-manager, DOMAIN-scoped (a manager operates on its domain, not a +# project). Reuse the admin-openrc endpoint + CA; override identity only. Password via stdin-free +# env (never on a command line, never printed). +AUTH_URL=""; CACERT="" +mgr_env(){ for v in $(env | awk -F= '/^OS_/{print $1}'); do unset "$v"; done + export OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3 + [ -n "$CACERT" ] && export OS_CACERT="$CACERT" + export OS_USERNAME="$MGR" OS_USER_DOMAIN_NAME="$DOM" OS_PASSWORD="$MGR_PW" + export OS_DOMAIN_NAME="$DOM"; } # domain scope + +os(){ "$OPENSTACK" "$@" &1; } # /dev/null 2>&1 || true + os user delete "$USR" --domain "$DOM_ID" >/dev/null 2>&1 || true + os user delete "$MGR" --domain "$DOM_ID" >/dev/null 2>&1 || true + os domain set --disable "$DOM_ID" >/dev/null 2>&1 || true + os domain delete "$DOM_ID" >/dev/null 2>&1 || true + say " teardown attempted (best-effort; verify with 'openstack domain list')" +} +trap teardown EXIT + +# ---- preconditions --------------------------------------------------------- +[ -s "$OPENRC" ] || { say "PRECOND: no admin-openrc at $OPENRC"; exit 14; } +AUTH_URL="$(awk -F= '/^[[:space:]]*export[[:space:]]+OS_AUTH_URL=/{print $2}' "$OPENRC" | tr -d '"'"'"' ' | tail -1)" +CACERT="$(awk -F= '/^[[:space:]]*export[[:space:]]+OS_CACERT=/{print $2}' "$OPENRC" | tr -d '"'"'"' ' | tail -1)" +CACERT="${CACERT/#\~/$HOME}" +[ -n "$AUTH_URL" ] || { say "PRECOND: OS_AUTH_URL not found in $OPENRC"; exit 14; } + +admin_env +if ! os token issue -f value -c expires >/dev/null 2>&1; then + say "PRECOND: admin-openrc does not authenticate (scoped token failed)"; exit 14; fi +ROLES="$(os role list -f value -c Name)" +for r in manager member load-balancer_member; do + printf '%s\n' "$ROLES" | grep -qx "$r" || { say "PRECOND: role '$r' absent -- persona cannot be tested"; exit 14; } +done +# the unmanaged role we will attempt to escalate to: prefer 'admin', else the measured 'Admin' +ESC_ROLE="manager" # granting 'manager' is escalation AND is never in is_domain_managed_role + +say "=== G3 domain-manager persona probe (throwaway domain: $DOM) ===" + +# ---- setup (admin): domain + manager account ------------------------------ +DOM_ID="$(os domain create "$DOM" -f value -c id)" +case "$DOM_ID" in *[!0-9a-fA-F]*|'') say "PRECOND: domain create failed: $DOM_ID"; exit 14;; esac +MGR_PW="$(openssl rand -base64 18 2>/dev/null | tr -d '/+=' | cut -c1-20)" +[ -n "$MGR_PW" ] || { say "PRECOND: could not generate a manager password"; exit 14; } +MGR_ID="$(os user create --domain "$DOM_ID" --password "$MGR_PW" "$MGR" -f value -c id)" +case "$MGR_ID" in *[!0-9a-fA-F]*|'') say "PRECOND: manager user create failed: $MGR_ID"; exit 14;; esac +os role add --domain "$DOM_ID" --user "$MGR_ID" manager >/dev/null 2>&1 \ + || { say "PRECOND: could not grant manager on the domain"; exit 14; } + +# ---- PASS group (as the manager, in its OWN domain) ----------------------- +say "--- PASS group (manager, own domain) ---" +mgr_env +if os user create --domain "$DOM_ID" "$USR" -f value -c id | grep -qE '^[0-9a-fA-F]+$'; then + ok "manager created a user in its own domain"; else bad "manager could NOT create a user in its own domain"; fi +if os project create --domain "$DOM_ID" "$PROJ" -f value -c id | grep -qE '^[0-9a-fA-F]+$'; then + ok "manager created a project in its own domain"; else bad "manager could NOT create a project in its own domain"; fi +for role in member load-balancer_member; do + if os role add --project "$PROJ" --project-domain "$DOM_ID" --user "$USR" --user-domain "$DOM_ID" "$role" >/dev/null 2>&1; then + ok "manager granted '$role' on a project in its own domain"; else bad "manager could NOT grant '$role' in its own domain"; fi +done + +# ---- DENY group (the manager must be refused) ----------------------------- +say "--- DENY group (manager, must be refused) ---" +# D1 anti-escalation: grant an UNMANAGED role (manager) even within own domain -> must fail. +if os role add --project "$PROJ" --project-domain "$DOM_ID" --user "$USR" --user-domain "$DOM_ID" "$ESC_ROLE" >/dev/null 2>&1; then + bad "ANTI-ESCALATION LEAK: manager granted unmanaged role '$ESC_ROLE'"; FAIL=$((FAIL+9)); else ok "anti-escalation held: grant of '$ESC_ROLE' refused"; fi +# D2 cross-domain: read users of a DIFFERENT domain (admin_domain) -> must fail. +if os user list --domain admin_domain -f value -c ID | grep -qE '^[0-9a-fA-F]+'; then + bad "CROSS-DOMAIN LEAK: manager read users of admin_domain"; FAIL=$((FAIL+9)); else ok "cross-domain isolation held: read of admin_domain refused"; fi + +# ---- UNAFF group (cloud-admin retains full authority) --------------------- +say "--- UNAFF group (cloud-admin) ---" +admin_env +NDOM="$(os domain list -f value -c ID | grep -cE '^[0-9a-fA-F]+')" +if [ "${NDOM:-0}" -ge 3 ]; then ok "cloud-admin sees all domains ($NDOM >= 3)"; else bad "cloud-admin domain list unexpectedly small ($NDOM)"; fi + +# ---- verdict -------------------------------------------------------------- +say "=== verdict: $PASS ok / $FAIL fail ===" +# classify the failure: a leaked DENY (recorded as +9) is CRITICAL (12); a plain PASS/UNAFF +# miss is 11/13. teardown runs via the EXIT trap regardless. +if [ "$FAIL" -eq 0 ]; then say "G3 PASS -- persona works and is bounded"; exit 0; fi +if [ "$FAIL" -ge 9 ]; then say "G3 FAIL -- a security boundary LEAKED"; exit 12; fi +say "G3 FAIL -- the persona could not perform a required action (override likely not effective)"; exit 11 diff --git a/tests/HARNESS-MANIFEST b/tests/HARNESS-MANIFEST index c8e6ae7..5a5dd10 100644 --- a/tests/HARNESS-MANIFEST +++ b/tests/HARNESS-MANIFEST @@ -36,6 +36,7 @@ dc-region-topology dc-selector dc-snap-proxy +g3-domain-manager-probe juju-spaces-check keystone-policy-drift ledger-scan diff --git a/tests/g3-domain-manager-probe/run-tests.sh b/tests/g3-domain-manager-probe/run-tests.sh new file mode 100755 index 0000000..1e0da68 --- /dev/null +++ b/tests/g3-domain-manager-probe/run-tests.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# tests/g3-domain-manager-probe/run-tests.sh -- offline harness for scripts/g3-domain-manager-probe.sh. +# Fakes `openstack` (via $OPENSTACK) and admin-openrc; NO cloud access, mutates nothing outside +# mktemp. Proves each exit path can FIRE (repo rule: a new assertion is decoration until it has +# a failing-direction fixture): +# happy -> 0 (all three groups hold; teardown log shows a domain delete) +# pass-miss -> 11 (manager cannot create a user -> override not effective) +# escalation -> 12 (manager grants an unmanaged role -> anti-escalation LEAK) +# xdomain -> 12 (manager reads another domain -> cross-domain LEAK) +# no-openrc -> 14 (precondition: admin-openrc absent) +# auth-fail -> 14 (precondition: admin-openrc does not authenticate) +# missing-role -> 14 (precondition: load-balancer_member role absent) +# teardown-runs -> the EXIT trap tears the throwaway domain down even when the probe FAILS +# Exit: 0 all pass | 1 any case failed. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +SUT="$REPO/scripts/g3-domain-manager-probe.sh" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 +BIN="$TMP/bin"; mkdir -p "$BIN" + +# ---- fake admin-openrc ----------------------------------------------------- +RC="$TMP/admin-openrc" +cat > "$RC" <<'RCEOF' +export OS_AUTH_URL=https://10.12.4.50:5000/v3 +export OS_CACERT=/dev/null +export OS_USERNAME=admin +export OS_PASSWORD=fake +export OS_PROJECT_NAME=admin +export OS_USER_DOMAIN_NAME=admin_domain +export OS_PROJECT_DOMAIN_NAME=admin_domain +export OS_IDENTITY_API_VERSION=3 +RCEOF + +# ---- fake openstack -------------------------------------------------------- +# Behaviour keyed by $G3_FAKE_MODE. Logs mutating teardown calls to $G3_FAKE_LOG. +cat > "$BIN/openstack" <<'FB' +#!/usr/bin/env bash +mode="${G3_FAKE_MODE:-happy}"; log="${G3_FAKE_LOG:-/dev/null}" +args="$*" +has(){ case " $args " in *" $1 "*) return 0;; *) return 1;; esac; } +sub="$1 $2" +case "$sub" in + "token issue") + [ "$mode" = auth-fail ] && exit 1 + echo "2026-08-07T00:00:00+0000"; exit 0;; + "role list") + echo Admin; echo manager; echo member; echo reader; echo service + [ "$mode" = missing-role ] || echo load-balancer_member + echo load-balancer_admin; exit 0;; + "domain create") echo "aaaa0000000000000000000000000001"; exit 0;; + "user create") + # admin creates the manager (has --password); manager creates g3-user (no --password) + if has --password; then echo "bbbb0000000000000000000000000001"; exit 0; fi + [ "$mode" = pass-miss ] && { echo "HTTP 403 Forbidden: create_user"; exit 1; } + echo "cccc0000000000000000000000000001"; exit 0;; + "project create") echo "dddd0000000000000000000000000001"; exit 0;; + "role add") + # setup grant (domain-scoped, no --project) always succeeds + has --project || exit 0 + # manager granting member/load-balancer_member on a project: allowed + if has member || has load-balancer_member; then exit 0; fi + # manager granting the unmanaged role (manager): refused UNLESS we inject the leak + [ "$mode" = escalation ] && exit 0 + exit 1;; + "user list") + # manager reading another domain: refused UNLESS we inject the leak + if has admin_domain; then [ "$mode" = xdomain ] && { echo "eeee0000000000000000000000000001"; exit 0; }; exit 1; fi + exit 0;; + "domain list") + echo "1111000000000000000000000000000a"; echo "2222000000000000000000000000000b" + echo "3333000000000000000000000000000c"; echo "4444000000000000000000000000000d"; exit 0;; + "project delete"|"user delete"|"domain set"|"domain delete") + echo "$sub $args" >> "$log"; exit 0;; + *) exit 0;; +esac +FB +chmod +x "$BIN/openstack" + +# ---- runner ---------------------------------------------------------------- +run(){ # [openrc-override] -- accounts in the PARENT shell (no $(...)) + local name="$1" exp="$2" mode="$3" rc="${4:-$RC}" log="$TMP/td.$1.log" outf="$TMP/out.$1" code + : > "$log" + G3_FAKE_MODE="$mode" G3_FAKE_LOG="$log" OPENSTACK="$BIN/openstack" G3_OPENRC="$rc" \ + bash "$SUT" > "$outf" 2>&1; code=$? + if [ "$code" -eq "$exp" ]; then PASS=$((PASS+1)); echo "PASS: $name (exit $code)" + else FAIL=$((FAIL+1)); echo "FAIL: $name (got $code want $exp)"; sed 's/^/ /' "$outf"; fi +} + +echo "=== g3-domain-manager-probe harness ===" +run happy 0 happy +run pass-miss 11 pass-miss +run escalation 12 escalation +run xdomain 12 xdomain +run auth-fail 14 auth-fail +run missing-role 14 missing-role +run no-openrc 14 happy "$TMP/does-not-exist" +HAPPY_OUT="$(cat "$TMP/out.happy")" + +# teardown must have run in the happy path (domain delete logged) +if grep -q "domain delete" "$TMP/td.happy.log"; then PASS=$((PASS+1)); echo "PASS: teardown ran (domain delete logged)" +else FAIL=$((FAIL+1)); echo "FAIL: teardown did not log a domain delete"; fi +# teardown must ALSO run when the probe fails mid-way (escalation leak case) +if grep -q "domain delete" "$TMP/td.escalation.log"; then PASS=$((PASS+1)); echo "PASS: teardown ran on failure path" +else FAIL=$((FAIL+1)); echo "FAIL: teardown did not run on failure path"; fi +# happy output must actually show the three group headers (guards a silently-skipped group) +for grp in "PASS group" "DENY group" "UNAFF group"; do + if printf '%s' "$HAPPY_OUT" | grep -q "$grp"; then PASS=$((PASS+1)); echo "PASS: happy ran $grp" + else FAIL=$((FAIL+1)); echo "FAIL: happy missing $grp"; fi +done + +echo "=== $PASS pass / $FAIL fail ===" +[ "$FAIL" -eq 0 ]