Newer
Older
openstack-caracal-dc-dc / tests / cloud-assert / run-tests.sh
#!/usr/bin/env bash
# tests/cloud-assert/run-tests.sh -- offline fakebin harness for cloud-assert.sh
# (DOCFIX-075). Fake juju/openstack/jq-free: real jq stays real; fake juju and
# openstack replay fixture outputs keyed by argument patterns, selected per test
# via FIXDIR. Asserts the behavioral checks fire on the exact incident
# signatures they were written for (D-045/D-046/D-051/D-062/vault-sealed).
# Mutates nothing outside $TMP. Exit: 0 all pass | 1 any failed.  ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
BIN="$TMP/bin"; mkdir -p "$BIN"

cat > "$BIN/juju" <<'FB'
#!/usr/bin/env bash
# fake juju: replay fixtures from $FIXDIR keyed by argv patterns
# `juju config -m <model> <app> <key>` is matched POSITIONALLY (argv 4 and 5), not by
# a glob on the joined string: the A10 arity check needs per-app/per-key answers, and
# a missing fixture must reproduce real juju's "key not found" error + rc1 so the
# REFUSAL branch is exercised rather than being handed an empty success.
if [ "${1:-}" = "config" ]; then
  f="$FIXDIR/config/${4:-}.${5:-}"
  [ -f "$f" ] && { cat "$f"; exit 0; }
  echo "ERROR key \"${5:-}\" not found" >&2; exit 1
fi
A="$*"
case "$A" in
  *"status -m"*"keystone --format=json"*) cat "$FIXDIR/ks-status.json" ;;
  *"status -m"*"--format=json"*)          cat "$FIXDIR/status.json" ;;
  *"vault/0"*"vault status"*)             cat "$FIXDIR/vault.txt" ;;
  *"cluster/status OVN_Northbound"*)      cat "$FIXDIR/ovn-nb.txt" ;;
  *"cluster/status OVN_Southbound"*)      cat "$FIXDIR/ovn-sb.txt" ;;
  *"connection-status"*)                  cat "$FIXDIR/chassis.txt" ;;
  *"ps -ww -C magnum-conductor"*)         cat "$FIXDIR/conductor.txt" ;;
  *) echo "fake-juju: unmatched: $A" >&2; exit 1 ;;
esac
FB
cat > "$BIN/openstack" <<'FB'
#!/usr/bin/env bash
A="$*"
# truthful-mock knobs (DOCFIX-094): FIXDIR/noise -> realistic deprecation notice on
# STDERR for the three list calls; FIXDIR/svc-err -> compute service list ERRORS
# (stderr + rc1, no stdout), the shape that was silently fail-open pre-fix.
case "$A" in
  *"hypervisor list"*|*"compute service list"*|*"loadbalancer list"*)
    [ -f "$FIXDIR/noise" ] && echo "WARNING: this CLI call is deprecated and will be removed" >&2 ;;
esac
case "$A" in
  *"hypervisor list"*)        cat "$FIXDIR/hypervisors.json" ;;
  *"compute service list"*)
    if [ -f "$FIXDIR/svc-err" ]; then echo "Unauthorized (HTTP 401)" >&2; exit 1; fi
    cat "$FIXDIR/services.json" ;;
  *"loadbalancer list"*)      cat "$FIXDIR/lbs.json" ;;
  *"domain show magnum"*)     cat "$FIXDIR/domain.txt" ;;
  *"user show magnum_domain_admin"*) cat "$FIXDIR/user.txt" ;;
  *"coe service list"*)       cat "$FIXDIR/coe.txt" ;;
  *) echo "fake-openstack: unmatched: $A" >&2; exit 1 ;;
esac
FB
chmod +x "$BIN/juju" "$BIN/openstack"

mkgood() { # healthy-cloud fixture set
  local d="$1"; mkdir -p "$d"
  cat > "$d/status.json" <<'J'
{"applications":{
 "mysql-innodb-cluster":{"units":{
   "mysql-innodb-cluster/0":{"workload-status":{"current":"active","message":"Unit is ready: Mode: R/W, Cluster is ONLINE"}},
   "mysql-innodb-cluster/1":{"workload-status":{"current":"active","message":"Unit is ready: Mode: R/O, Cluster is ONLINE"}},
   "mysql-innodb-cluster/2":{"workload-status":{"current":"active","message":"Unit is ready: Mode: R/O, Cluster is ONLINE"}}}},
 "ovn-central":{"units":{"ovn-central/0":{"workload-status":{"current":"active"}},
   "ovn-central/1":{"workload-status":{"current":"active"}},"ovn-central/2":{"workload-status":{"current":"active"}}}},
 "nova-compute":{"units":{"nova-compute/0":{"workload-status":{"current":"active"}}}},
 "octavia":{"units":{"octavia/0":{"workload-status":{"current":"active"}}}},
 "glance-simplestreams-sync":{"units":{"glance-simplestreams-sync/0":{"workload-status":{"current":"unknown"}}}}
}}
J
  printf 'Initialized  true\nSealed       false\n' > "$d/vault.txt"
  printf 'Cluster ID: abcd (uuid)\nServers: 3\n' > "$d/ovn-nb.txt"
  cp "$d/ovn-nb.txt" "$d/ovn-sb.txt"
  printf 'connected\n' > "$d/chassis.txt"
  printf '/usr/bin/python3 /usr/bin/magnum-conductor --config-file /etc/magnum/magnum.conf --config-dir /etc/magnum/magnum.conf.d\n' > "$d/conductor.txt"
  printf '[{"Hypervisor Hostname":"openstack0","State":"up"}]\n' > "$d/hypervisors.json"
  printf '[{"Binary":"nova-compute","Host":"openstack0","Status":"enabled","State":"up"}]\n' > "$d/services.json"
  printf '[]\n' > "$d/lbs.json"
  printf 'True\n' > "$d/domain.txt"
  printf 'magnum_domain_admin\n' > "$d/user.txt"
  printf 'magnum-conductor\n' > "$d/coe.txt"
  # truthful mock: real juju 3.6 status JSON shape (line-format omits messages,
  # which is exactly the DOCFIX-087 false-negative this fixture used to hide)
  cat > "$d/ks-status.json" <<'J'
{"applications":{"keystone":{
  "application-status":{"current":"active","message":"Application Ready"},
  "units":{"keystone/0":{"workload-status":{"current":"active","message":"PO: Unit is ready"}}}}}}
J
}
run() { # run <want_rc> <regex> <label> <fixdir> [env...]
  local want="$1" rx="$2" label="$3" fd="$4"; shift 4
  local out rc
  out="$(cd "$TMP" && env "$@" FIXDIR="$fd" PATH="$BIN:$PATH" OS_AUTH_URL="https://fake:5000/v3" \
        bash "$REPO/scripts/cloud-assert.sh" 2>&1)"; rc=$?
  if [[ "$rc" == "$want" ]] && grep -qE "$rx" <<<"$out"; then
    echo "  PASS  $label"; PASS=$((PASS+1))
  else
    echo "  FAIL  $label (rc=$rc want=$want)"; grep -E '\[(FAIL|WARN)\]|CLOUD-ASSERT' <<<"$out" | head -6 | sed 's/^/        /'; FAIL=$((FAIL+1))
  fi
}

F="$TMP/good"; mkgood "$F";  run 0 'CLOUD-ASSERT: PASS' "T1 healthy cloud -> PASS" "$F"
F="$TMP/sealed"; mkgood "$F"; printf 'Initialized  true\nSealed       true\n' > "$F/vault.txt"
                              run 1 'vault SEALED' "T2 sealed vault FAILS (with unseal pointer)" "$F"
F="$TMP/rw2"; mkgood "$F"; python3 - "$F/status.json" <<'PY'
import json,sys; p=sys.argv[1]; d=json.load(open(p))
u=d["applications"]["mysql-innodb-cluster"]["units"]
u["mysql-innodb-cluster/1"]["workload-status"]["message"]="Unit is ready: Mode: R/W, Cluster is ONLINE"
json.dump(d,open(p,"w"))
PY
                              run 1 'R/W count=2' "T3 mysql split-brain (2x R/W) FAILS" "$F"
F="$TMP/chassis"; mkgood "$F"; printf 'not connected\n' > "$F/chassis.txt"
                              run 1 'chassis NOT connected' "T4 dead chassis FAILS (D-045 class)" "$F"
F="$TMP/pobroken"; mkgood "$F"; cat > "$F/ks-status.json" <<'J'
{"applications":{"keystone":{
  "application-status":{"current":"active","message":"Application Ready"},
  "units":{"keystone/0":{"workload-status":{"current":"active","message":"PO (broken): policy zip attached but unparsed"}}}}}}
J
                              run 1 'PO \(broken\)' "T5 PO(broken) FAILS (DOCFIX-071)" "$F"
F="$TMP/nodomain"; mkgood "$F"; printf 'No domain with a name or ID\n' > "$F/domain.txt"
                              run 1 "domain-setup" "T6 missing trustee domain FAILS (D-046)" "$F"
F="$TMP/graft"; mkgood "$F"; printf '/usr/bin/python3 /usr/bin/magnum-conductor --config-file /etc/magnum/magnum.conf\n' > "$F/conductor.txt"
                              run 1 'lack --config-dir' "T7 dead conductor graft FAILS (D-037)" "$F"
F="$TMP/lberr"; mkgood "$F"; printf '[{"name":"lb1","provisioning_status":"ERROR","operating_status":"OFFLINE"}]\n' > "$F/lbs.json"
                              run 1 'LBs unhealthy' "T8 LB in ERROR FAILS (with failover pointer)" "$F"
# T10 (DOCFIX-087 regression): healthy keystone but NO PO: in any message -> FAIL
F="$TMP/nopo"; mkgood "$F"; cat > "$F/ks-status.json" <<'J'
{"applications":{"keystone":{
  "application-status":{"current":"active","message":"Application Ready"},
  "units":{"keystone/0":{"workload-status":{"current":"active","message":"Unit is ready"}}}}}}
J
                              run 1 'NO policy override' "T10 missing PO: marker FAILS (DOCFIX-087)" "$F"
# T11 (DOCFIX-094): stderr deprecation noise on healthy cloud must NOT break the
# structured captures (pre-fix: merged noise killed the A5 parse -> false FAIL)
F="$TMP/noise"; mkgood "$F"; : > "$F/noise"
                              run 0 'CLOUD-ASSERT: PASS' "T11 stderr noise does not break A5/A6 parsing" "$F"
# T12 (DOCFIX-094): compute service list ERRORING must FAIL (pre-fix: silently OK)
F="$TMP/svcerr"; mkgood "$F"; : > "$F/svc-err"
                              run 1 'compute service list unreadable' "T12 erroring service-list FAILS (was fail-open)" "$F"

# T13-T15 (D-068 item 3): A9 vault-kv consumer auth section
addvault() { # add a vault app to a fixture's status.json
  python3 - "$1/status.json" <<'PY'
import json,sys; p=sys.argv[1]; d=json.load(open(p))
d["applications"]["vault"]={"units":{"vault/0":{"workload-status":{"current":"active"}}}}
json.dump(d,open(p,"w"))
PY
}
# T13: no vault app in model -> A9 skips with ok (the pre-Stage-5 VR1 shape)
F="$TMP/novault"; mkgood "$F"
                              run 0 'A9 skipped \(vault arrives Stage 5\)' "T13 no vault app -> A9 skip, still PASS" "$F"
# T14: vault present, probe driver passes -> PASS line
F="$TMP/vkpass"; mkgood "$F"; addvault "$F"
printf '#!/usr/bin/env bash\necho PASS: all consumers\nexit 0\n' > "$TMP/vkh-pass.sh"
                              run 0 'every consumer AppRole login 200' "T14 vault-kv health pass -> ok" "$F" CLOUD_ASSERT_VKH="$TMP/vkh-pass.sh"
# T15: vault present, probe driver fails -> section FAILs the sweep
F="$TMP/vkfail"; mkgood "$F"; addvault "$F"
printf '#!/usr/bin/env bash\necho "FAIL: barbican-vault login_http=403"\nexit 1\n' > "$TMP/vkh-fail.sh"
                              run 1 'vault-kv health FAILED' "T15 vault-kv health fail -> FAIL (D-068 item 3)" "$F" CLOUD_ASSERT_VKH="$TMP/vkh-fail.sh"

# ---- T16-T21 (2026-07-29): A10 HA arity + the non-vacuous A3 uniformity ----
# Before these, HA was asserted by NOTHING after deploy: this script arity-checked one
# application (mysql, A2) and A3 reported "uniform across units" over a SINGLE unit.
addha() { # addha <fixdir> <principal-units> <cluster_count-or-empty>
  python3 - "$1/status.json" "$2" <<'PY'
import json,sys
p,n=sys.argv[1],int(sys.argv[2]); d=json.load(open(p))
d["applications"]["keystone"]={"charm-name":"keystone","units":{
    "keystone/%d"%i:{"workload-status":{"current":"active"}} for i in range(n)}}
# MEASURED shape from asbuilt/20260706-224851/juju-status.json: the hacluster
# subordinate app has charm-name "hacluster", subordinate-to [principal], units {}.
d["applications"]["keystone-hacluster"]={"charm-name":"hacluster",
    "subordinate-to":["keystone"]}
json.dump(d,open(p,"w"))
PY
  mkdir -p "$1/config"
  [ -n "${3:-}" ] && printf '%s\n' "$3" > "$1/config/keystone-hacluster.cluster_count"
  return 0
}
# T16 GREEN: 3 live units, cluster_count 3 -> the check can pass, not only fail
F="$TMP/ha3"; mkgood "$F"; addha "$F" 3 3
                              run 0 'keystone: 3 live unit\(s\) == keystone-hacluster cluster_count=3' \
                                  "T16 3 units vs cluster_count=3 -> ok" "$F"
# T17 RED: the missing unit A2's mysql-only arity check could never see
F="$TMP/ha2"; mkgood "$F"; addha "$F" 2 3
                              run 1 'keystone has 2 live unit\(s\) but keystone-hacluster declares cluster_count=3' \
                                  "T17 2 live units vs cluster_count=3 FAILS" "$F"
# T18 RED the other way: a quorum left over from a scale-down
F="$TMP/hadown"; mkgood "$F"; addha "$F" 1 3
                              run 1 'HA arity broken' "T18 scale-down leaving cluster_count=3 FAILS" "$F"
# T19 REFUSAL: cluster_count unreadable -> never "verified by default"
F="$TMP/hanocc"; mkgood "$F"; addha "$F" 3 ""
                              run 1 'REFUSING to report keystone' \
                                  "T19 unreadable cluster_count REFUSES (not a pass)" "$F"
# T20 rabbitmq clusters natively, so A10's hacluster loop cannot see it: multi-unit
#     without min-cluster-size is the D-009 AMENDMENT race
F="$TMP/rabbit"; mkgood "$F"; python3 - "$F/status.json" <<'PY'
import json,sys; p=sys.argv[1]; d=json.load(open(p))
d["applications"]["rabbitmq-server"]={"charm-name":"rabbitmq-server","units":{
    "rabbitmq-server/%d"%i:{"workload-status":{"current":"active"}} for i in range(3)}}
json.dump(d,open(p,"w"))
PY
                              run 1 'min-cluster-size is unset/unreadable' \
                                  "T20 3-unit rabbitmq with no min-cluster-size FAILS (D-009 amendment)" "$F"
mkdir -p "$TMP/rabbit-ok"; mkgood "$TMP/rabbit-ok"
cp "$F/status.json" "$TMP/rabbit-ok/status.json"; mkdir -p "$TMP/rabbit-ok/config"
printf '3\n' > "$TMP/rabbit-ok/config/rabbitmq-server.min-cluster-size"
                              run 0 'rabbitmq-server: 3 live unit\(s\) == min-cluster-size=3' \
                                  "T20b 3-unit rabbitmq with min-cluster-size=3 -> ok" "$TMP/rabbit-ok"
# T21b the skip must distinguish "none present" from "discovery missed them": an app
#      NAMED *-hacluster while charm-name discovery returns nothing means the status
#      shape changed, and skipping would pass over every principal.
F="$TMP/hashape"; mkgood "$F"; python3 - "$F/status.json" <<'PY'
import json,sys; p=sys.argv[1]; d=json.load(open(p))
d["applications"]["keystone-hacluster"]={"charm-name":"SOMETHING-ELSE","subordinate-to":["keystone"]}
json.dump(d,open(p,"w"))
PY
                              run 1 'REFUSING to skip A10' \
                                  "T21b named -hacluster app but no charm-name match REFUSES the skip" "$F"

# T21 A3 must not certify uniformity over one unit -- it used to print
#     "Cluster ID uniform across units" for a single ovn-central unit.
F="$TMP/ovn1"; mkgood "$F"; python3 - "$F/status.json" <<'PY'
import json,sys; p=sys.argv[1]; d=json.load(open(p))
u=d["applications"]["ovn-central"]["units"]
for k in list(u)[1:]: del u[k]
json.dump(d,open(p,"w"))
PY
                              run 1 'uniformity NOT assertable: only 1 unit' \
                                  "T21 single-unit ovn-central is NOT 'uniform' (vacuous pass removed)" "$F"

# T9: no admin scope -> A5-A7 HELD, exit 2 (never a silent pass)
F="$TMP/held"; mkgood "$F"
out="$(cd "$TMP" && env -u OS_AUTH_URL FIXDIR="$F" PATH="$BIN:$PATH" bash "$REPO/scripts/cloud-assert.sh" 2>&1)"; rc=$?
if [[ "$rc" == "2" ]] && grep -q 'HELD: no admin scope' <<<"$out"; then echo "  PASS  T9 missing admin scope -> HELD (rc 2)"; PASS=$((PASS+1))
else echo "  FAIL  T9 (rc=$rc)"; FAIL=$((FAIL+1)); fi

echo; echo "RESULT: PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1