mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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>
285 lines
9.0 KiB
Python
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"]
|