mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-22 21:45:10 +00:00
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:
@@ -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)
|
||||
@@ -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)
|
||||
Executable
+8
@@ -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
@@ -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
|
||||
Executable
+56
@@ -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."
|
||||
Executable
+116
@@ -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"
|
||||
Executable
+106
@@ -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
|
||||
Executable
+7
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user