Files
aci-sim/tests/test_persist.py
T
taodengandClaude Fable 5 7e9a175ce6 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>
2026-07-05 20:06:36 +10:00

285 lines
9.0 KiB
Python

"""Tests for file-backed state persistence (aci_sim/control/persist.py).
Covers both planes:
(a) MITStore round-trip (APIC plane) — serialize_store/deserialize_store
(b) NdoState round-trip (NDO plane) — serialize_ndo/apply_ndo
(c) APIC app integration — /_sim/save + /_sim/load over the real REST API
(d) NDO app integration — same, on the NDO app
All tests set SIM_STATE_DIR to an isolated tmp_path so nothing is ever
written under the real ~/.aci-sim/state.
"""
from __future__ import annotations
import copy
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.control.persist import (
apply_ndo,
deserialize_store,
serialize_ndo,
serialize_store,
)
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# (a) MITStore round-trip
# ---------------------------------------------------------------------------
def test_store_round_trip_preserves_all_dns_and_subtree(monkeypatch, tmp_path):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
store = MITStore()
store.add(MO("fvTenant", dn="uni/tn-PERSIST", name="PERSIST"))
store.add(
MO(
"fvAp",
dn="uni/tn-PERSIST/ap-APP1",
name="APP1",
)
)
# Deep nested child MO under the tenant's subtree.
store.add(
MO(
"fvAEPg",
dn="uni/tn-PERSIST/ap-APP1/epg-WEB",
name="WEB",
)
)
before_dns = {mo.dn for mo in store.all()}
data = serialize_store(store)
restored = deserialize_store(data)
after_dns = {mo.dn for mo in restored.all()}
assert after_dns == before_dns
# Subtree query on the tenant must still return the deeply nested child.
subtree_dns = {mo.dn for mo in restored.subtree("uni/tn-PERSIST")}
assert "uni/tn-PERSIST/ap-APP1/epg-WEB" in subtree_dns
assert "uni/tn-PERSIST/ap-APP1" in subtree_dns
def test_store_round_trip_via_real_topology(monkeypatch, tmp_path):
"""Round-trip a store built from the real topology (not just a toy one)."""
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
before_dns = {mo.dn for mo in store.all()}
data = serialize_store(store)
restored = deserialize_store(data)
after_dns = {mo.dn for mo in restored.all()}
assert after_dns == before_dns
assert len(before_dns) > 0
# ---------------------------------------------------------------------------
# (b) NdoState round-trip
# ---------------------------------------------------------------------------
def test_ndo_state_round_trip_onto_different_state(monkeypatch, tmp_path):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
source_state = build_ndo_model(topo)
# Mutate tenants + schemas so the persisted snapshot differs from a fresh build.
source_state.tenants.append(
{"id": "tenant-extra", "name": "EXTRA", "displayName": "EXTRA", "siteAssociations": []}
)
source_state.schemas.append(
{"id": "schema-extra", "displayName": "EXTRA-schema", "name": "EXTRA-schema", "templates": []}
)
data = serialize_ndo(source_state)
# Apply onto a DIFFERENT fresh state object.
target_state = build_ndo_model(topo)
apply_ndo(target_state, data)
for f in (
"tenants",
"schemas",
"schema_details",
"template_summaries",
"tenant_policy_templates",
"policy_states",
"audit_records",
"local_users",
"remote_users",
"extra_schemas",
"sites",
"fabric_connectivity",
):
assert getattr(target_state, f) == getattr(source_state, f), f"field {f} mismatch"
# Confirm mutation actually landed (not just equal-because-untouched).
assert any(t["id"] == "tenant-extra" for t in target_state.tenants)
assert any(s["id"] == "schema-extra" for s in target_state.schemas)
def test_apply_ndo_mutates_in_place_not_a_new_object(monkeypatch, tmp_path):
"""apply_ndo must mutate the SAME object (setattr), since make_ndo_app
closes over the state object identity."""
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
state = build_ndo_model(topo)
state_id = id(state)
data = serialize_ndo(state)
data["tenants"] = data["tenants"] + [{"id": "t2", "name": "T2", "displayName": "T2", "siteAssociations": []}]
apply_ndo(state, data)
assert id(state) == state_id
assert any(t["id"] == "t2" for t in state.tenants)
# ---------------------------------------------------------------------------
# (c) APIC app integration
# ---------------------------------------------------------------------------
@pytest.fixture
def apic_client_and_state():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
c = TestClient(app)
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c, state
def test_apic_save_reset_load_round_trip(monkeypatch, tmp_path, apic_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, state = apic_client_and_state
# Create a new tenant via the REST API.
client.post(
"/api/mo/uni/tn-ITEST.json",
json={"fvTenant": {"attributes": {"name": "ITEST", "dn": "uni/tn-ITEST"}}},
)
resp = client.get("/api/class/fvTenant.json")
names = [i["fvTenant"]["attributes"]["name"] for i in resp.json()["imdata"]]
assert "ITEST" in names
# Save.
r = client.post("/_sim/save/itest")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["count"] > 0
saved_path = Path(body["file"])
assert saved_path.exists()
assert saved_path.parent == tmp_path
# Reset wipes the tenant.
client.post("/_sim/reset")
resp2 = client.get("/api/class/fvTenant.json")
names2 = [i["fvTenant"]["attributes"]["name"] for i in resp2.json()["imdata"]]
assert "ITEST" not in names2
# Load restores it.
r2 = client.post("/_sim/load/itest")
assert r2.status_code == 200
resp3 = client.get("/api/class/fvTenant.json")
names3 = [i["fvTenant"]["attributes"]["name"] for i in resp3.json()["imdata"]]
assert "ITEST" in names3
def test_apic_load_missing_state_returns_404(monkeypatch, tmp_path, apic_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, _state = apic_client_and_state
r = client.post("/_sim/load/does-not-exist")
assert r.status_code == 404
body = r.json()
assert body["imdata"][0]["error"]["attributes"]["text"]
# ---------------------------------------------------------------------------
# (d) NDO app integration
# ---------------------------------------------------------------------------
@pytest.fixture
def ndo_client_and_state():
topo = load_topology(TOPO_PATH)
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c, state
def test_ndo_save_load_round_trip(monkeypatch, tmp_path, ndo_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, state = ndo_client_and_state
# Create a new tenant via the REST API.
resp = client.post(
"/mso/api/v1/tenants",
json={"displayName": "NDO-ITEST", "name": "NDO-ITEST"},
)
assert resp.status_code == 200
new_id = resp.json()["id"]
r = client.post("/_sim/save/nditest")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
saved_path = Path(body["file"])
assert saved_path.exists()
assert saved_path.parent == tmp_path
# Mutate away the tenant to prove load actually restores state (no reset
# endpoint exists on the NDO app, so directly clear the list in place).
state.tenants.clear()
resp2 = client.get("/mso/api/v1/tenants")
ids2 = [t["id"] for t in resp2.json()["tenants"]]
assert new_id not in ids2
r2 = client.post("/_sim/load/nditest")
assert r2.status_code == 200
resp3 = client.get("/mso/api/v1/tenants")
ids3 = [t["id"] for t in resp3.json()["tenants"]]
assert new_id in ids3
def test_ndo_load_missing_state_returns_404(monkeypatch, tmp_path, ndo_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, _state = ndo_client_and_state
r = client.post("/_sim/load/does-not-exist")
assert r.status_code == 404
assert "not found" in r.json()["detail"]