Files
aci-sim/tests/test_pr6_control_plane.py
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

301 lines
11 KiB
Python

"""Tests for PR-6 — control-plane fixes:
1. /_sim/reload rebuilds from the FRESH post-reload Site object, not the
stale pre-reload one (finding #11).
2. fabricNodeIdentP write reaction materializes a full node-registration MO
set (fabricNode + topSystem + healthInst + fabricLink cabling + l1PhysIf
inventory), fires for a nested (not just top-level) fabricNodeIdentP, and
derives pod from the site instead of hardcoding 1 (finding #19).
3. GET /api/node/class/{cls}.json (no DN prefix) runs a fabric-wide class
query, matching /api/class/{cls}.json (finding #12).
"""
from __future__ import annotations
import copy
import ipaddress
import yaml
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml" # run from project root
def _login(client: TestClient) -> None:
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200, resp.text
def _fresh_client(site_name: str = "LAB1") -> tuple[ApicSiteState, TestClient]:
topo = load_topology(TOPO_PATH)
site = topo.site_by_name(site_name)
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
client = TestClient(make_apic_app(state))
_login(client)
return state, client
# ---------------------------------------------------------------------------
# Fix 1 — /_sim/reload uses the fresh Site, not the stale one
# ---------------------------------------------------------------------------
def test_reload_picks_up_topology_yaml_edit(tmp_path, monkeypatch):
"""Editing topology.yaml (adding a leaf) and POSTing /_sim/reload must
make the new leaf show up — the old code rebuilt against the STALE
pre-reload state.site object, so the edit was silently ignored.
"""
with open(TOPO_PATH, encoding="utf-8") as f:
raw = yaml.safe_load(f)
# Add a brand-new leaf to LAB1 in a modified copy of the topology.
lab1 = next(s for s in raw["sites"] if s["name"] == "LAB1")
lab1["leaves"].append({"id": 199, "name": "LAB1-ACI-LF199"})
modified_path = tmp_path / "topology_modified.yaml"
modified_path.write_text(yaml.safe_dump(raw), encoding="utf-8")
# Build initial state from the ORIGINAL topology (no node 199 yet).
state, client = _fresh_client("LAB1")
ids_before = [
item["fabricNode"]["attributes"]["id"]
for item in client.get("/api/class/fabricNode.json").json()["imdata"]
]
assert "199" not in ids_before
# Point TOPOLOGY_PATH at the modified file and reload.
monkeypatch.setenv("TOPOLOGY_PATH", str(modified_path))
import aci_sim.runtime.config as config_mod
monkeypatch.setattr(config_mod, "TOPOLOGY_PATH", str(modified_path))
resp = client.post("/_sim/reload")
assert resp.status_code == 200, resp.text
ids_after = [
item["fabricNode"]["attributes"]["id"]
for item in client.get("/api/class/fabricNode.json").json()["imdata"]
]
assert "199" in ids_after, f"reload did not pick up the new leaf; ids: {ids_after}"
# state.site must now be the fresh Site object (has the new leaf), not
# the stale one captured at client construction time.
leaf_ids = {n.id for n in state.site.leaf_nodes()}
assert 199 in leaf_ids
def test_reload_errors_when_site_removed(tmp_path, monkeypatch):
"""If the reloaded topology no longer contains this site, /_sim/reload
must return an APIC-style 400 error, not crash or silently keep stale
state.
"""
with open(TOPO_PATH, encoding="utf-8") as f:
raw = yaml.safe_load(f)
# Remove LAB2 entirely; also drop anything that references it so the
# remaining topology still validates.
raw["sites"] = [s for s in raw["sites"] if s["name"] != "LAB2"]
raw["isn"] = {"enabled": False}
for tenant in raw.get("tenants", []):
tenant["sites"] = [s for s in tenant.get("sites", []) if s != "LAB2"]
if tenant.get("stretch") and len(tenant["sites"]) < 2:
tenant["stretch"] = False
tenant["l3outs"] = [
l3o for l3o in tenant.get("l3outs", []) if l3o.get("site") != "LAB2"
]
modified_path = tmp_path / "topology_no_lab2.yaml"
modified_path.write_text(yaml.safe_dump(raw), encoding="utf-8")
state, client = _fresh_client("LAB2")
import aci_sim.runtime.config as config_mod
monkeypatch.setattr(config_mod, "TOPOLOGY_PATH", str(modified_path))
resp = client.post("/_sim/reload")
assert resp.status_code == 400, resp.text
data = resp.json()
assert "error" in data["imdata"][0]
# Original state must be left untouched (no crash / partial mutation).
assert state.site.name == "LAB2"
# ---------------------------------------------------------------------------
# Fix 2 — enriched fabricNodeIdentP reaction (+ nested form) + /_sim/add-leaf
# ---------------------------------------------------------------------------
def _assert_enriched_node(client: TestClient, node_id: int, pod: int = 1) -> None:
node_dn = f"topology/pod-{pod}/node-{node_id}"
# fabricNode
fn = client.get("/api/class/fabricNode.json").json()["imdata"]
ids = [item["fabricNode"]["attributes"]["id"] for item in fn]
assert str(node_id) in ids, f"fabricNode {node_id} missing; ids={ids}"
# topSystem with valid, parseable IPs
ts_resp = client.get(f"/api/mo/{node_dn}/sys.json")
assert ts_resp.status_code == 200, ts_resp.text
ts_attrs = ts_resp.json()["imdata"][0]["topSystem"]["attributes"]
ipaddress.IPv4Address(ts_attrs["address"]) # raises if invalid
ipaddress.IPv4Address(ts_attrs["oobMgmtAddr"]) # raises if invalid
# healthInst
health_resp = client.get(f"/api/mo/{node_dn}/sys/health.json")
assert health_resp.status_code == 200, health_resp.text
# At least one fabricLink to a spine
links = client.get("/api/class/fabricLink.json").json()["imdata"]
node_links = [
item["fabricLink"]["attributes"]
for item in links
if str(node_id) in (item["fabricLink"]["attributes"].get("n1"), item["fabricLink"]["attributes"].get("n2"))
]
assert node_links, f"no fabricLink found for node {node_id}"
# l1PhysIf inventory present under this node
resp = client.get(f"/api/node/class/{node_dn}/l1PhysIf.json")
assert resp.status_code == 200, resp.text
assert int(resp.json()["totalCount"]) >= 1, "expected at least one l1PhysIf for the new node"
def test_fabric_node_ident_reaction_top_level():
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-910.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-910",
"name": "leaf-910",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200, resp.text
_assert_enriched_node(client, 910, pod=1)
def test_fabric_node_ident_reaction_nested_under_parent():
"""A nested (non-top-level) fabricNodeIdentP must ALSO trigger the
reaction — previously the reaction only fired when fabricNodeIdentP was
the top-level POSTed class.
"""
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller.json",
json={
"fabricInst": {
"attributes": {"dn": "uni/controller"},
"children": [
{
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-911",
"name": "leaf-911",
"nodeType": "leaf",
}
}
}
],
}
},
)
assert resp.status_code == 200, resp.text
_assert_enriched_node(client, 911, pod=1)
def test_add_leaf_produces_same_enriched_set():
_, client = _fresh_client("LAB1")
resp = client.post("/_sim/add-leaf", json={"id": 912, "name": "leaf-912"})
assert resp.status_code == 200, resp.text
_assert_enriched_node(client, 912, pod=1)
def test_add_leaf_reaction_still_materializes_new_node_class_query():
"""Regression guard for the existing test_add_leaf_reaction /
verify_autoaci T1-09 behavior: fabricNode must still show up by class
query after the enrichment."""
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-913.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-913",
"name": "leaf-913",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200
ids = [
item["fabricNode"]["attributes"]["id"]
for item in client.get("/api/class/fabricNode.json").json()["imdata"]
]
assert "913" in ids
def test_node_registration_derives_pod_from_site_not_hardcoded():
"""Registering a node on a site whose pod != 1 must use that site's pod,
not a hardcoded 1. topology.yaml's sites both use pod=1 today, so this
test builds a synthetic site with pod=2 directly to prove the reaction
reads site.pod instead of hardcoding.
"""
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.writes import materialize_node_registration
from aci_sim.topology.loader import load_topology
topo = load_topology(TOPO_PATH)
site = topo.site_by_name("LAB1").model_copy(deep=True)
site.pod = 7
store = MITStore()
materialize_node_registration(store, topo=topo, site=site, node_id=950, name="leaf-950", role="leaf")
assert store.get("topology/pod-7/node-950") is not None
assert store.get("topology/pod-1/node-950") is None
# ---------------------------------------------------------------------------
# Fix 3 — fabric-wide node/class query (no DN prefix)
# ---------------------------------------------------------------------------
def test_fabric_wide_node_class_query_matches_class_query():
_, client = _fresh_client("LAB1")
fabric_wide = client.get("/api/node/class/topSystem.json")
assert fabric_wide.status_code == 200, fabric_wide.text
class_query = client.get("/api/class/topSystem.json")
assert class_query.status_code == 200
assert fabric_wide.json()["totalCount"] == class_query.json()["totalCount"]
fw_ids = sorted(item["topSystem"]["attributes"]["id"] for item in fabric_wide.json()["imdata"])
cq_ids = sorted(item["topSystem"]["attributes"]["id"] for item in class_query.json()["imdata"])
assert fw_ids == cq_ids
def test_node_scoped_class_query_still_works():
_, client = _fresh_client("LAB1")
resp = client.get("/api/node/class/topology/pod-1/node-101/faultInst.json")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data["imdata"], list)
assert isinstance(data["totalCount"], str)