Initial public release — aci-sim v0.16.0

aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
"""Full-stack E2E: drive the REAL autoACI backend against the sim.
Proves the acceptance criterion — point local autoACI at the sim and every view
renders internally-consistent data. Unlike tests/verify_autoaci.py (which uses
TestClient in-process), this hits the running HTTP stack end to end.
Prerequisites (two servers running):
1. the sim: cd ~/aci-sim && bash scripts/run.sh
2. autoACI: cd ~/autoACI/backend && ./.venv/bin/uvicorn main:app --port 8000
Run: cd ~/autoACI/backend && ./.venv/bin/python ~/aci-sim/scripts/e2e_live.py
It logs autoACI into the sim's two APICs (:8443/:8444) + NDO (:8445), then
exercises /api/topology, ~14 real plugins, and the NDO views. Exit 0 = all pass.
"""
import json
import sys
import httpx
BASE = "http://127.0.0.1:8000"
SITE_A, SITE_B, NDO = "127.0.0.1:8443", "127.0.0.1:8444", "127.0.0.1:8445"
c = httpx.Client(base_url=BASE, timeout=90.0)
results = [] # (name, ok, detail)
def rec(name, ok, detail=""):
results.append((name, ok, detail))
print(f" {'PASS' if ok else 'FAIL'} {name} {detail}")
def csrf():
return c.cookies.get("csrf_token", "")
def post(path, body, site=None):
h = {"X-CSRF-Token": csrf(), "Content-Type": "application/json"}
if site:
h["X-Active-Site"] = site
return c.post(path, json=body, headers=h)
def get(path, site=None):
h = {}
if site:
h["X-Active-Site"] = site
return c.get(path, headers=h)
print("=== bootstrap: obtain CSRF cookie ===")
c.get("/api/health")
print(" csrf_token:", (csrf()[:12] or "<none>"))
print("\n=== APIC logins (point autoACI at the sim) ===")
for host, label in [(SITE_A, "LAB1"), (SITE_B, "LAB2")]:
r = post("/api/auth/login", {"host": host, "username": "admin", "password": "cisco", "verify_ssl": False})
ok = r.status_code == 200 and r.json().get("success")
d = r.json()
rec(f"APIC login {label} ({host})", ok,
f"fabric={d.get('fabric_name')!r} ver={d.get('apic_version')!r} role={d.get('role')!r}")
print("\n=== NDO login ===")
r = post("/api/ndo/login", {"host": NDO, "username": "admin", "password": "cisco", "verify_ssl": False})
rec("NDO login", r.status_code == 200, f"HTTP {r.status_code} {str(r.json())[:80]}")
print("\n=== /api/auth/status (multi-site) ===")
r = get("/api/auth/status")
st = r.json()
rec("auth status authenticated", bool(st.get("authenticated")),
f"sites={[s.get('host') for s in st.get('sites', [])]}")
print("\n=== /api/topology (the headline view) ===")
r = get("/api/topology", site=SITE_A)
if r.status_code != 200:
rec("GET /api/topology", False, f"HTTP {r.status_code} {r.text[:200]}")
topo = {}
else:
topo = r.json()
nodes = topo.get("nodes", [])
links = topo.get("links", [])
roles = sorted({n.get("role", "?") for n in nodes})
sites_seen = sorted({n.get("site", n.get("pod", "?")) for n in nodes})
isn = topo.get("isn") or topo.get("inter_site") or [l for l in links if str(l.get("type", "")).lower().find("isn") >= 0]
rec("GET /api/topology", len(nodes) > 0, f"top-level keys={list(topo.keys())}")
rec(" topology nodes", len(nodes) >= 6, f"{len(nodes)} nodes, roles={roles}, sites={sites_seen}")
rec(" topology links", len(links) > 0, f"{len(links)} links")
print(f" ISN/inter-site signal: {json.dumps(isn)[:160] if isn else '(inspect keys)'}")
print("\n=== prebuilt plugins (real autoACI plugin code vs the sim) ===")
# mode "rows" = expect >0 rows; mode "ran" = health/anomaly plugin, 0 rows can mean healthy → pass on HTTP 200
PLUGINS = [
("fabric_nodes", {}, "rows"), ("fabric_links", {}, "rows"), ("fabric_bgp_evpn", {}, "rows"),
("bgp_health", {}, "ran"), ("adjacency_health", {}, "ran"),
("fault_summary", {}, "rows"), ("health_score", {}, "rows"), ("fabric_node_summary", {}, "rows"),
("tenant_list", {}, "rows"), ("bd_detail", {"tenant_name": "MS-TN1"}, "rows"),
("epg_detail", {"tenant_name": "MS-TN1"}, "rows"), ("l3out_detail", {"tenant_name": "MS-TN1"}, "rows"),
("endpoint_search", {}, "rows"), ("interface_status", {"node_id": "101"}, "rows"),
]
for name, params, mode in PLUGINS:
try:
r = post(f"/api/query/prebuilt/{name}", {"plugin_name": name, "params": params}, site=SITE_A)
if r.status_code != 200:
rec(f"plugin {name}", False, f"HTTP {r.status_code} {r.text[:120]}")
continue
qr = r.json()
rows = qr.get("rows", [])
ok = (r.status_code == 200) if mode == "ran" else (len(rows) > 0)
rec(f"plugin {name}", ok, f"{len(rows)} rows · {str(qr.get('summary',''))[:60]}")
except Exception as e:
rec(f"plugin {name}", False, str(e)[:120])
print("\n=== NDO views ===")
r = get("/api/ndo/sites")
sites = r.json() if r.status_code == 200 else {}
site_names = [s.get("name") for s in (sites.get("sites", sites) if isinstance(sites, (dict, list)) else [])] if r.status_code == 200 else []
rec("NDO /sites", r.status_code == 200 and len(site_names) >= 2, f"{site_names}")
r = get("/api/query/options/schemas", site=SITE_A)
rec("NDO schemas (options)", r.status_code == 200, f"HTTP {r.status_code} {str(r.json())[:100]}")
print("\n" + "=" * 60)
passed = sum(1 for _, ok, _ in results if ok)
total = len(results)
print(f" E2E RESULT: {passed}/{total} checks passed")
fails = [n for n, ok, _ in results if not ok]
if fails:
print(" FAILED:", ", ".join(fails))
print("=" * 60)
sys.exit(0 if not fails else 1)
+93
View File
@@ -0,0 +1,93 @@
"""Sandbox E2E — replicate autoACI's exact NDO-discovery → Connect-All flow."""
import sys
from urllib.parse import urlparse
import httpx
BASE = "http://127.0.0.1:8000"
NDO_IP = "10.192.0.10"
c = httpx.Client(base_url=BASE, timeout=90.0)
results = []
def rec(n, ok, d=""):
results.append((n, ok, d))
print(f" {'PASS' if ok else 'FAIL'} {n} {d}")
def csrf():
return c.cookies.get("csrf_token", "")
def post(p, b, site=None):
h = {"X-CSRF-Token": csrf(), "Content-Type": "application/json"}
if site:
h["X-Active-Site"] = site
return c.post(p, json=b, headers=h)
def get(p, site=None):
h = {"X-Active-Site": site} if site else {}
return c.get(p, headers=h)
c.get("/api/health")
# 1. NDO login — the only thing the user types
r = post("/api/ndo/login", {"host": NDO_IP, "username": "admin", "password": "cisco", "verify_ssl": False})
rec(f"NDO login ({NDO_IP})", r.status_code == 200 and r.json().get("success"), f"HTTP {r.status_code}")
# 2. Discover sites — replicate the frontend's host extraction (new URL(url).hostname)
r = get("/api/ndo/sites")
sites = r.json().get("sites", [])
discovered = []
for s in sites:
urls = s.get("urls") or s.get("apicUrls") or []
host = ""
if urls:
try:
host = urlparse(urls[0]).hostname # == JS new URL(urls[0]).hostname
except Exception:
host = urls[0]
discovered.append({"name": s.get("name"), "apicHost": host})
rec("NDO discovered sites", len(discovered) >= 2, str([(d["name"], d["apicHost"]) for d in discovered]))
# 3. Connect All Sites — log in to each discovered APIC by IP, NO port
for d in discovered:
r = post("/api/auth/login", {"host": d["apicHost"], "username": "admin", "password": "cisco", "verify_ssl": False})
j = r.json()
rec(f"Connect {d['name']} ({d['apicHost']})", r.status_code == 200 and j.get("success"),
f"fabric={j.get('fabric_name')!r} ver={j.get('apic_version')!r}")
site_a = discovered[0]["apicHost"]
# 4. Topology (aggregates all connectors → both sites + ISN)
r = get("/api/topology", site=site_a)
topo = r.json() if r.status_code == 200 else {}
nodes, links, isn = topo.get("nodes", []), topo.get("links", []), topo.get("isnConnections", [])
rec("Topology", len(nodes) > 0,
f"{len(nodes)} nodes, {len(links)} links, {len(isn)} ISN links, roles={sorted({n.get('role') for n in nodes})}")
# 5. plugins
for name, params, mode in [
("fabric_nodes", {}, "rows"), ("fabric_bgp_evpn", {}, "rows"),
("adjacency_health", {}, "ran"), ("l3out_detail", {"tenant_name": "MS-TN1"}, "rows"),
("endpoint_search", {}, "rows"),
]:
r = post(f"/api/query/prebuilt/{name}", {"plugin_name": name, "params": params}, site=site_a)
qr = r.json() if r.status_code == 200 else {}
rows = qr.get("rows", [])
ok = (r.status_code == 200) if mode == "ran" else len(rows) > 0
rec(f"plugin {name}", ok, f"{len(rows)} rows · {str(qr.get('summary', ''))[:50]}")
# 6. NDO views
r = get("/api/ndo/sites")
rec("NDO /sites view", r.status_code == 200, str([s.get("name") for s in r.json().get("sites", [])]))
passed = sum(1 for _, ok, _ in results if ok)
print("=" * 60)
print(f" SANDBOX E2E: {passed}/{len(results)} passed")
fails = [n for n, ok, _ in results if not ok]
if fails:
print(" FAILED:", ", ".join(fails))
print("=" * 60)
sys.exit(0 if not fails else 1)
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p certs
openssl req -x509 -newkey rsa:4096 -keyout certs/sim.key -out certs/sim.crt \
-days 3650 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
echo "Certificates generated in certs/"
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Equivalent to `aci-sim run` (see README.md §5 CLI) after `pip install -e .`;
# this script remains the dependency-free entrypoint and keeps working
# unchanged.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f certs/sim.crt ] || [ ! -f certs/sim.key ]; then
bash scripts/gen_certs.sh
fi
python -m aci_sim.runtime.supervisor
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Tear down the sandbox virtual network: stop the sim and remove the loopback
# aliases (macOS: lo0 alias; Linux: ip addr del ... dev lo).
#
# sudo bash scripts/sandbox-down.sh
#
set -euo pipefail
cd "$(dirname "$0")/.."
PY="./.venv/bin/python"
OS="$(uname -s)"
IPS=$(PYTHONPATH="$PWD" "$PY" -c "from aci_sim.topology.loader import load_topology; t=load_topology('topology.yaml'); print(' '.join([t.fabric.ndo_mgmt_ip]+[s.mgmt_ip for s in t.sites if s.mgmt_ip]))")
if [ "$(id -u)" -ne 0 ]; then
echo "Needs root: sudo bash scripts/sandbox-down.sh" >&2
exit 1
fi
# Verify a recorded pid really is our supervisor process before killing it —
# a stale /tmp pid file could otherwise reference an unrelated process that
# has since reused that pid (#24).
pid_is_supervisor() {
local pid="$1"
if [ "$OS" = "Linux" ]; then
[ -r "/proc/$pid/cmdline" ] || return 1
tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
else
ps -p "$pid" -o command= 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
fi
}
if [ -f /tmp/aci-sim-sandbox.pid ]; then
old_pid="$(cat /tmp/aci-sim-sandbox.pid)"
if pid_is_supervisor "$old_pid"; then
kill "$old_pid" 2>/dev/null || true
else
echo "[sandbox] WARNING: /tmp/aci-sim-sandbox.pid ($old_pid) is not an" >&2
echo "[sandbox] aci_sim.runtime.supervisor process — refusing to kill it." >&2
fi
fi
pkill -f "aci_sim.runtime.supervisor" 2>/dev/null || true
# Wait for the supervisor to actually exit before dropping aliases, so an
# immediately-following `up` sees a clean (process-gone, :443-free) state.
for i in $(seq 1 16); do # up to ~8s
pgrep -f "aci_sim.runtime.supervisor" >/dev/null 2>&1 || break
sleep 0.5
done
rm -f /tmp/aci-sim-sandbox.pid
for ip in $IPS; do
if [ "$OS" = "Linux" ]; then
ip addr del "${ip}/32" dev lo 2>/dev/null || true
else
ifconfig lo0 -alias "$ip" 2>/dev/null || true
fi
echo "[sandbox] removed alias $ip"
done
echo "[sandbox] down."
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Sandbox virtual network — give each ACI controller a real IP on :443 via a
# loopback alias (macOS: lo0 alias; Linux: ip addr add ... dev lo), so autoACI
# connects to it by IP with NO port, exactly like real gear. autoACI is NOT
# modified in any way. Requires root (loopback alias + privileged :443).
#
# sudo bash scripts/sandbox-up.sh
#
# Safe to re-run: it stops any previous sim (verifying the recorded pid is
# actually a supervisor process before killing it — #24), waits for :443 to
# actually free up on each sandbox IP (avoids the bind race that silently
# killed old restarts), then verifies the servers really came up — failing
# loudly with the log if not.
set -euo pipefail
cd "$(dirname "$0")/.."
PY="./.venv/bin/python"
OS="$(uname -s)"
# Sandbox IPs come straight from topology.yaml (single source of truth).
IPS=$(PYTHONPATH="$PWD" "$PY" -c "from aci_sim.topology.loader import load_topology; t=load_topology('topology.yaml'); print(' '.join([t.fabric.ndo_mgmt_ip]+[s.mgmt_ip for s in t.sites if s.mgmt_ip]))")
if [ "$(id -u)" -ne 0 ]; then
echo "Needs root (loopback alias + bind :443). Re-run: sudo bash scripts/sandbox-up.sh" >&2
exit 1
fi
# Return 0 if (ip, 443) is bindable RIGHT NOW — same options uvicorn will use
# (SO_REUSEADDR), so this gate matches exactly what the supervisor is about to do.
port_free() {
"$PY" -c 'import socket,sys
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
try:
s.bind((sys.argv[1],443)); s.close()
except OSError:
sys.exit(1)' "$1"
}
# Verify a recorded pid really is our supervisor process before we kill it —
# a stale /tmp pid file could otherwise reference an unrelated (possibly
# security-sensitive) process that has since reused that pid (#24).
pid_is_supervisor() {
local pid="$1"
if [ "$OS" = "Linux" ]; then
[ -r "/proc/$pid/cmdline" ] || return 1
tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
else
ps -p "$pid" -o command= 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
fi
}
echo "[sandbox] loopback aliases ($OS): $IPS"
for ip in $IPS; do
if [ "$OS" = "Linux" ]; then
ip addr add "${ip}/32" dev lo 2>/dev/null || true
else
ifconfig lo0 alias "$ip" 255.255.255.255 up 2>/dev/null || true
fi
done
[ -f certs/sim.crt ] || bash scripts/gen_certs.sh
# Stop any previous sim, then WAIT for it to die and for :443 to actually free up
# on every sandbox IP before starting — starting while the old listener is still
# holding the socket is what made uvicorn sys.exit(1) silently.
if [ -f /tmp/aci-sim-sandbox.pid ]; then
old_pid="$(cat /tmp/aci-sim-sandbox.pid)"
if pid_is_supervisor "$old_pid"; then
kill "$old_pid" 2>/dev/null || true
else
echo "[sandbox] WARNING: /tmp/aci-sim-sandbox.pid ($old_pid) is not an" >&2
echo "[sandbox] aci_sim.runtime.supervisor process — refusing to kill it." >&2
fi
fi
pkill -f "aci_sim.runtime.supervisor" 2>/dev/null || true
for i in $(seq 1 40); do # up to ~20s
pgrep -f "aci_sim.runtime.supervisor" >/dev/null 2>&1 && { sleep 0.5; continue; }
free=1
for ip in $IPS; do port_free "$ip" || { free=0; break; }; done
[ "$free" = 1 ] && break
sleep 0.5
done
LOG=/tmp/aci-sim-sandbox.log
export SIM_SANDBOX=1 PYTHONPATH="$PWD"
nohup "$PY" -m aci_sim.runtime.supervisor >"$LOG" 2>&1 &
PID=$!
echo "$PID" >/tmp/aci-sim-sandbox.pid
ndo_ip=${IPS%% *}
# Verify the servers actually came up on :443 (loud, not silent). Any HTTP code
# (200/401/404) means the server answered; 000 means nothing is listening yet.
up=0
for i in $(seq 1 24); do # up to ~12s
kill -0 "$PID" 2>/dev/null || break # process already died → stop, dump log below
all=1
for ip in $IPS; do
code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 2 "https://${ip}:443/" 2>/dev/null || echo 000)
[ "$code" = 000 ] && { all=0; break; }
done
[ "$all" = 1 ] && { up=1; break; }
sleep 0.5
done
if [ "$up" -ne 1 ]; then
echo "[sandbox] ERROR: sim did not come up on :443 for all of: $IPS" >&2
echo "----- last 25 log lines ($LOG) -----------------------------------" >&2
tail -n 25 "$LOG" >&2 || true
echo "------------------------------------------------------------------" >&2
exit 1
fi
echo "[sandbox] up — aliases: $IPS"
echo "[sandbox] sim PID $PID serving :443 (log: $LOG)"
echo "[sandbox] autoACI → log in to NDO at $ndo_ip → Discover → Connect All Sites"
echo "[sandbox] stop with: sudo bash scripts/sandbox-down.sh"
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Save or restore the WHOLE fabric's state (every APIC site + NDO) in one
# command, by hitting each plane's /_sim/{save,load}/<name> endpoint.
#
# scripts/sim-state.sh save <name> # snapshot every plane to disk
# scripts/sim-state.sh restore <name> # restore every plane from disk
#
# Files land under SIM_STATE_DIR (default ~/.aci-sim/state) — see
# aci_sim/control/persist.py::state_dir(). Restarting the sim wipes its
# in-memory state (sandbox/port mode has no other durability), so the
# intended workflow is:
#
# scripts/sim-state.sh save mybaseline
# <restart the sim: sandbox-down.sh && sandbox-up.sh, or Ctrl-C + aci-sim run>
# scripts/sim-state.sh restore mybaseline
#
# Addressing — this script must reach ALL planes, so it derives the same
# per-device IPs scripts/sandbox-up.sh uses (straight from topology.yaml, via
# the identical `load_topology` one-liner) and falls back to port mode if
# those IPs aren't reachable:
#
# sandbox mode (real per-device IPs, :443) — from topology.yaml:
# NDO -> https://<fabric.ndo_mgmt_ip>:443
# APIC site -> https://<site.mgmt_ip>:443 (one per site)
#
# port mode (fallback, 127.0.0.1 + distinct ports — see runtime/config.py):
# APIC LAB1 -> https://127.0.0.1:8443 (APIC_A_PORT)
# APIC LAB2 -> https://127.0.0.1:8444 (APIC_B_PORT)
# NDO -> https://127.0.0.1:8445 (NDO_PORT)
#
# `restore` maps to each plane's POST /_sim/load/<name> endpoint (the sim's
# route is named "load"; this script's user-facing verb is "restore" to match
# the existing snapshot/restore vocabulary in /_sim).
set -euo pipefail
cd "$(dirname "$0")/.."
PY="./.venv/bin/python"
usage() {
echo "usage: $0 {save|restore} <name>" >&2
exit 1
}
[ $# -eq 2 ] || usage
ACTION="$1"
NAME="$2"
case "$ACTION" in
save) SIM_ACTION="save" ;;
restore) SIM_ACTION="load" ;;
*) usage ;;
esac
# Derive sandbox IPs the exact same way scripts/sandbox-up.sh does (single
# source of truth: topology.yaml). Prints "<ndo_ip> <site_ip> [<site_ip> ...]"
# or nothing if topology.yaml can't be loaded (e.g. no venv yet).
IPS=$(PYTHONPATH="$PWD" "$PY" -c "from aci_sim.topology.loader import load_topology; t=load_topology('topology.yaml'); print(' '.join([t.fabric.ndo_mgmt_ip]+[s.mgmt_ip for s in t.sites if s.mgmt_ip]))" 2>/dev/null || true)
NDO_IP=""
SITE_IPS=()
if [ -n "$IPS" ]; then
read -r -a _all <<<"$IPS"
NDO_IP="${_all[0]}"
SITE_IPS=("${_all[@]:1}")
fi
# Reachability probe: does curl get ANY response (even 4xx) from this host:443?
# 000 means nothing is listening / unreachable.
reachable() {
local ip="$1"
local code
# curl's -w '%{http_code}' already prints "000" on connect failure/timeout,
# so don't ALSO append a fallback echo on nonzero exit — that would double
# up the output (e.g. "000000") and always compare unequal to "000".
code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 2 "https://${ip}:443/" 2>/dev/null)
[ "$code" = "200" ] || [ "$code" = "401" ] || [ "$code" = "404" ]
}
USE_SANDBOX=0
if [ -n "$NDO_IP" ] && reachable "$NDO_IP"; then
USE_SANDBOX=1
fi
hit() {
local label="$1" host_port="$2"
local resp
resp=$(curl -sk -X POST "https://${host_port}/_sim/${SIM_ACTION}/${NAME}" || echo '{"status":"error","detail":"curl failed"}')
echo "[$label] $resp"
}
if [ "$USE_SANDBOX" -eq 1 ]; then
echo "[sim-state] sandbox mode — using topology.yaml mgmt IPs" >&2
hit "NDO" "${NDO_IP}:443"
i=0
for ip in "${SITE_IPS[@]}"; do
i=$((i + 1))
hit "APIC-site${i}" "${ip}:443"
done
else
echo "[sim-state] port mode fallback — 127.0.0.1:8443/8444/8445" >&2
APIC_A_PORT="${APIC_A_PORT:-8443}"
APIC_B_PORT="${APIC_B_PORT:-8444}"
NDO_PORT="${NDO_PORT:-8445}"
hit "APIC-LAB1" "127.0.0.1:${APIC_A_PORT}"
hit "APIC-LAB2" "127.0.0.1:${APIC_B_PORT}"
hit "NDO" "127.0.0.1:${NDO_PORT}"
fi
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# verify.sh — run the aci-sim verification harness
# Usage: scripts/verify.sh [pytest-options]
# Example: scripts/verify.sh -v --tb=short
set -euo pipefail
cd "$(dirname "$0")/.."
exec .venv/bin/python -m pytest tests/verify_autoaci.py -v "$@"