diff --git a/tests/phase-02/jq b/tests/phase-02/jq new file mode 100644 index 0000000..2da7dc6 --- /dev/null +++ b/tests/phase-02/jq @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# fake jq for behavior-testing: implements ONLY the programs phase-02-vault-preflight.sh uses. +# Metrics are computed by an independent Python mirror of the jq logic, so the bash +# decision/exit logic runs for real against algorithmically-correct values. +import sys, json +argv = sys.argv[1:] +args = {}; prog = None; i = 0 +while i < len(argv): + a = argv[i] + if a in ("-r", "--raw-output", "-e", "--exit-status"): i += 1; continue + if a == "--arg": args[argv[i+1]] = argv[i+2]; i += 3; continue + if prog is None and not a.startswith("-"): prog = a; i += 1; continue + i += 1 +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) + +def walk(x, out): + if isinstance(x, dict): + if "workload-status" in x: out.append(x) + for v in x.values(): walk(v, out) + elif isinstance(x, list): + for v in x: walk(v, out) + +def all_units(root): + out = [] + for app in (root.get("applications") or {}).values(): + walk(app.get("units") or {}, out) + return out + +def app_units(root, a): + app = (root.get("applications") or {}).get(a) or {} + return list((app.get("units") or {}).values()) + +def ws(u): return (u.get("workload-status") or {}) +def ags(u): return (u.get("agent-status") or {}) + +if prog and ".models[]?.name" in prog: + for m in (data.get("models") or []): + if m.get("name") is not None: print(m["name"]) + sys.exit(0) + +if prog and "mach_total=" in prog: + machines = data.get("machines") or {} + db = app_units(data, args.get("dbapp")) + vt = app_units(data, args.get("vaultapp")) + u = all_units(data) + c = lambda lst, p: sum(1 for x in lst if p(x)) + kv = { + "mach_total": len(machines), + "mach_started": c(list(machines.values()), lambda m: (m.get("juju-status") or {}).get("current") == "started"), + "db_units": len(db), + "db_online": c(db, lambda x: "ONLINE" in (ws(x).get("message") or "")), + "db_rw": c(db, lambda x: "Mode: R/W" in (ws(x).get("message") or "")), + "db_active": c(db, lambda x: ws(x).get("current") == "active"), + "v_units": len(vt), + "v_fresh": c(vt, lambda x: ws(x).get("current") == "blocked" and "needs to be initialized" in (ws(x).get("message") or "").lower()), + "we": c(u, lambda x: ws(x).get("current") == "error"), + "ae": c(u, lambda x: ags(x).get("current") == "error"), + "c_blocked": c(u, lambda x: ws(x).get("current") == "blocked"), + "c_waiting": c(u, lambda x: ws(x).get("current") == "waiting"), + "c_active": c(u, lambda x: ws(x).get("current") == "active"), + "c_unknown": c(u, lambda x: ws(x).get("current") == "unknown"), + "c_total": len(u), + } + print("\n".join(f"{k}={v}" for k, v in kv.items())) + sys.exit(0) + +# cosmetic display programs -> no output (does not affect the verdict) +sys.exit(0) diff --git a/tests/phase-02/juju b/tests/phase-02/juju new file mode 100644 index 0000000..16350d1 --- /dev/null +++ b/tests/phase-02/juju @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# fake juju for behavior-testing phase-02-vault-preflight.sh +sub="${1:-}" +case "$sub" in + whoami) + echo "Controller: juju-controller" + echo "Model: ${FAKE_MODEL:-openstack}" + echo "User: jessea123" ;; + models) + printf '{"models":[{"name":"admin/%s"}]}\n' "${FAKE_MODEL:-openstack}" ;; + status) + cat "${FIXTURE:?FIXTURE env not set}" ;; + *) + echo "fake juju: unhandled subcommand: $sub" >&2; exit 1 ;; +esac diff --git a/tests/phase-02/make_fixtures.py b/tests/phase-02/make_fixtures.py new file mode 100644 index 0000000..ecb22e4 --- /dev/null +++ b/tests/phase-02/make_fixtures.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# tests/phase-02/make_fixtures.py [OUTDIR] +# Generate juju-status fixtures for the phase-02-vault-preflight regression. +# One healthy fixture + four single-fault fixtures (one broken gate each). +# ASCII + LF. +import json, copy, os, sys + +OUTDIR = sys.argv[1] if len(sys.argv) > 1 else "." + +def unit(cur, msg, agent="idle", subs=None): + u = {"workload-status": {"current": cur, "message": msg}, + "agent-status": {"current": agent}} + if subs: + u["subordinates"] = subs + return u + +base = { + "model": {"name": "openstack"}, + "machines": {str(i): {"juju-status": {"current": "started"}, "hostname": f"openstack{i}"} for i in range(4)}, + "applications": { + "mysql-innodb-cluster": {"units": { + "mysql-innodb-cluster/0": unit("active", "Unit is ready: Mode: R/W, Cluster is ONLINE and can tolerate up to ONE failure.", + subs={"mysql-router/0": unit("active", "Unit is ready")}), + "mysql-innodb-cluster/1": unit("active", "Unit is ready: Mode: R/O, Cluster is ONLINE and can tolerate up to ONE failure.", + subs={"mysql-router/1": unit("active", "Unit is ready")}), + "mysql-innodb-cluster/2": unit("active", "Unit is ready: Mode: R/O, Cluster is ONLINE and can tolerate up to ONE failure.", + subs={"mysql-router/2": unit("active", "Unit is ready")}), + }}, + "vault": {"units": { + "vault/0": unit("blocked", "Vault needs to be initialized", + subs={"vault-mysql-router/0": unit("active", "Unit is ready")}), + }}, + "octavia": {"units": {"octavia/0": unit("blocked", "Awaiting configure-resources action.")}}, + "ovn-central": {"units": {f"ovn-central/{i}": unit("waiting", "ovsdb-peer/certificates") for i in range(3)}}, + "nova-compute": {"units": { + f"nova-compute/{i}": unit("active", "Unit is ready", subs={f"ovn-chassis/{i}": unit("waiting", "certificates")}) + for i in range(3) + }}, + "neutron-api": {"units": { + "neutron-api/0": unit("active", "Unit is ready", subs={"neutron-api-plugin-ovn/0": unit("waiting", "ovsdb-cms")}), + }}, + "ceph-osd": {"units": {f"ceph-osd/{i}": unit("active", "Unit is ready (1 OSD)") for i in range(4)}}, + "ceph-mon": {"units": {f"ceph-mon/{i}": unit("active", "Unit is ready and clustered") for i in range(3)}}, + "glance-simplestreams-sync": {"units": {"glance-simplestreams-sync/0": unit("unknown", "")}}, + } +} + +def dump(name, obj): + path = os.path.join(OUTDIR, name) + with open(path, "w") as f: + json.dump(obj, f, indent=2) + print(f" wrote {path}") + +# healthy +dump("pass.json", base) + +# FAIL D: vault already initialized (not the fresh blocked-needs-init state) +f = copy.deepcopy(base) +f["applications"]["vault"]["units"]["vault/0"]["workload-status"] = {"current": "active", "message": "Unit is ready"} +dump("fail-vault-initialized.json", f) + +# FAIL C: one mysql unit OFFLINE (no ONLINE, not active) +f = copy.deepcopy(base) +f["applications"]["mysql-innodb-cluster"]["units"]["mysql-innodb-cluster/2"]["workload-status"] = \ + {"current": "blocked", "message": "Cluster is inaccessible from this instance (OFFLINE)."} +dump("fail-mysql-degraded.json", f) + +# FAIL E: a hook failure (agent-status error) on an otherwise-maintenance unit +f = copy.deepcopy(base) +f["applications"]["nova-compute"]["units"]["nova-compute/0"]["workload-status"] = {"current": "maintenance", "message": "installing"} +f["applications"]["nova-compute"]["units"]["nova-compute/0"]["agent-status"] = {"current": "error", "message": 'hook failed: "install"'} +dump("fail-hook-error.json", f) + +# FAIL B: a machine not started +f = copy.deepcopy(base) +f["machines"]["2"]["juju-status"] = {"current": "down"} +dump("fail-machine-down.json", f) diff --git a/tests/phase-02/run-tests.sh b/tests/phase-02/run-tests.sh new file mode 100644 index 0000000..c31efe8 --- /dev/null +++ b/tests/phase-02/run-tests.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# tests/phase-02/run-tests.sh +# +# Offline regression for scripts/phase-02-vault-preflight.sh. Drives the REAL +# script's decision/exit logic with juju+jq shims (fakebin/) against generated +# fixtures: one healthy (PROCEED / exit 0) and four single-fault cases the live +# healthy system cannot show -- vault-already-initialized (D), mysql OFFLINE (C), +# a hook failure (E), a down machine (B) -- each must HOLD / exit 1. +# +# Touches NO live infrastructure: fake 'juju' emits fixtures only; fake 'jq' +# mirrors the script's metrics logic in Python. fakebin/ is first on PATH so the +# real juju/jq are shadowed. Runs anywhere with python3 + bash (no real jq needed). +# +# Usage: bash tests/phase-02/run-tests.sh +# Exit: 0 all cases pass | 1 any mismatch +# ASCII + LF. + +set -euo pipefail +IFS=$'\n\t' + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="$(cd "$HERE/../../scripts" && pwd)/phase-02-vault-preflight.sh" +BIN="$HERE/fakebin" + +[ -f "$TARGET" ] || { echo "FAIL: target not found: $TARGET" >&2; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "FAIL: python3 required" >&2; exit 1; } +# Re-assert exec bits (the Windows -> git round trip can drop them). +chmod +x "$BIN/juju" "$BIN/jq" 2>/dev/null || true + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +python3 "$HERE/make_fixtures.py" "$WORK" >/dev/null + +rc_all=0 +run() { + local fix="$1" want="$2" label="$3" rc verdict + set +e + PATH="$BIN:$PATH" FIXTURE="$WORK/$fix" bash "$TARGET" openstack >"$WORK/out" 2>&1 + rc=$? + set -e + verdict="$(grep -E '^Summary:' "$WORK/out" | head -1 || true)" + if [ "$rc" -eq "$want" ]; then + printf ' [OK] %-38s exit %s | %s\n' "$label" "$rc" "$verdict" + else + printf ' [XX] %-38s exit %s (WANT %s)\n' "$label" "$rc" "$want" + sed 's/^/ /' "$WORK/out" + rc_all=1 + fi +} + +echo "=== phase-02-vault-preflight.sh regression ===" +run pass.json 0 "pass (healthy, vault fresh)" +run fail-vault-initialized.json 1 "FAIL D: vault already initialized" +run fail-mysql-degraded.json 1 "FAIL C: mysql unit OFFLINE" +run fail-hook-error.json 1 "FAIL E: hook failure (agent error)" +run fail-machine-down.json 1 "FAIL B: machine down" + +if [ "$rc_all" -eq 0 ]; then echo "ALL PASS"; else echo "FAILURES ABOVE"; fi +exit "$rc_all"