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>
277 lines
11 KiB
Python
277 lines
11 KiB
Python
"""Regression tests for PR-5 topology-consistency findings #13-#18 (all in
|
|
aci_sim/build/):
|
|
|
|
FIX 1 (#13) — l3extRsPathL3OutAtt tDn referenced a nonexistent leaf port
|
|
(eth1/48 on border leaves, outside the eth1/1-8 host range). interfaces.py
|
|
now builds a dedicated routed l1PhysIf at that exact port on border leaves.
|
|
FIX 2 (#14) — spine ospfAdjEp referenced ports outside the spine's fabric-
|
|
uplink range (eth1/1-4). interfaces.py now builds a dedicated ISN uplink
|
|
l1PhysIf on spines (multi-site only) at the same eth1/{49+i} ports
|
|
underlay.py already used.
|
|
FIX 3 (#15) — endpoint host ports collided with APIC-controller-reserved
|
|
leaf ports (both used eth1/1..3 on the same leaves). endpoints.py now
|
|
starts endpoint port allocation right after the APIC-reserved range.
|
|
FIX 4 (#16) — a stretched tenant's endpoint appeared identically
|
|
front-panel-local on BOTH sites. endpoints.py now picks one HOME site per
|
|
endpoint (front-panel port, lcC="learned") and represents it on the peer
|
|
site as learned via a multi-site overlay tunnel (lcC="learned,vxlan",
|
|
fabricPathDn -> tunnelIf). See docs/DESIGN.md.
|
|
FIX 5 (#17) — routed BDs (has fvSubnet children) reported unicastRoute="no"
|
|
whenever l2stretch was true, disagreeing with NDO's unicastRouting=true.
|
|
tenants.py now derives unicastRoute from whether the BD has subnets.
|
|
FIX 6 (#18) — controller nodes had no healthInst (404 on GET .../sys/health).
|
|
health_faults.py now emits healthInst for controller node ids too.
|
|
|
|
Auth is enforced (PR-4): every client fixture logs in via
|
|
POST /api/aaaLogin.json (admin/cisco) before making authenticated calls,
|
|
following the existing tests/test_p5_fixes.py / test_pr3_fixes.py pattern.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import re
|
|
|
|
import pytest
|
|
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"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def topo():
|
|
return load_topology(TOPO_PATH)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def site_a(topo):
|
|
return topo.site_by_name("LAB1")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def site_b(topo):
|
|
return topo.site_by_name("LAB2")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def store_a(topo, site_a):
|
|
return build_site(topo, site_a)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def store_b(topo, site_b):
|
|
return build_site(topo, site_b)
|
|
|
|
|
|
def _make_client(topo, site, store) -> TestClient:
|
|
state = ApicSiteState(
|
|
name=site.name, site=site, topo=topo, store=store,
|
|
baseline=copy.deepcopy(store),
|
|
)
|
|
c = TestClient(make_apic_app(state))
|
|
c.post(
|
|
"/api/aaaLogin.json",
|
|
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
|
)
|
|
return c
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client_a(topo, site_a, store_a):
|
|
return _make_client(topo, site_a, store_a)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client_b(topo, site_b, store_b):
|
|
return _make_client(topo, site_b, store_b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FIX 1 (#13) — every l3extRsPathL3OutAtt tDn's port exists as a real l1PhysIf
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _l1physif_ports(store) -> dict[int, set[str]]:
|
|
"""Return {node_id: {port_id, ...}} from every l1PhysIf DN in the store."""
|
|
ports: dict[int, set[str]] = {}
|
|
for mo in store.by_class("l1PhysIf"):
|
|
m = re.search(r"/node-(\d+)/sys/phys-\[([^\]]+)\]", mo.dn)
|
|
assert m is not None, f"unparsable l1PhysIf dn {mo.dn!r}"
|
|
node_id = int(m.group(1))
|
|
ports.setdefault(node_id, set()).add(m.group(2))
|
|
return ports
|
|
|
|
|
|
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
|
|
def test_l3out_path_att_references_real_port(store_name, request):
|
|
store = request.getfixturevalue(store_name)
|
|
ports = _l1physif_ports(store)
|
|
atts = list(store.by_class("l3extRsPathL3OutAtt"))
|
|
assert atts, "no l3extRsPathL3OutAtt found"
|
|
for att in atts:
|
|
m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", att.attrs["tDn"])
|
|
assert m is not None, f"unparsable tDn {att.attrs['tDn']!r}"
|
|
node_id, port_id = int(m.group(1)), m.group(2)
|
|
assert port_id in ports.get(node_id, set()), (
|
|
f"l3extRsPathL3OutAtt {att.dn!r} references {port_id!r} on node "
|
|
f"{node_id}, which has no matching l1PhysIf (has {ports.get(node_id)})"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FIX 2 (#14) — every ospfAdjEp interface exists as a real l1PhysIf on that node
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
|
|
def test_ospf_adjacency_references_real_port(store_name, request):
|
|
store = request.getfixturevalue(store_name)
|
|
ports = _l1physif_ports(store)
|
|
adjs = list(store.by_class("ospfAdjEp"))
|
|
assert adjs, "no ospfAdjEp found (multi-site topology expected)"
|
|
for adj in adjs:
|
|
m = re.search(r"/node-(\d+)/sys/ospf/.*?/if-\[([^\]]+)\]", adj.dn)
|
|
assert m is not None, f"unparsable ospfAdjEp dn {adj.dn!r}"
|
|
node_id, port_id = int(m.group(1)), m.group(2)
|
|
assert port_id in ports.get(node_id, set()), (
|
|
f"ospfAdjEp {adj.dn!r} references {port_id!r} on node {node_id}, "
|
|
f"which has no matching l1PhysIf (has {ports.get(node_id)})"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FIX 3 (#15) — no port hosts both a controller lldpAdjEp and an endpoint path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
|
|
def test_no_port_double_booked_between_apic_and_endpoint(store_name, request):
|
|
store = request.getfixturevalue(store_name)
|
|
|
|
controller_ports: set[tuple[int, str]] = set()
|
|
for mo in store.by_class("lldpAdjEp"):
|
|
# topology/pod-1/node-101/sys/lldp/inst/if-[eth1/1]/adj-1
|
|
m = re.search(r"/node-(\d+)/sys/lldp/inst/if-\[([^\]]+)\]", mo.dn)
|
|
if not m:
|
|
continue
|
|
if "APIC" in mo.attrs.get("sysName", ""):
|
|
controller_ports.add((int(m.group(1)), m.group(2)))
|
|
assert controller_ports, "no controller lldpAdjEp found"
|
|
|
|
endpoint_ports: set[tuple[int, str]] = set()
|
|
for mo in store.by_class("fvCEp"):
|
|
path = mo.attrs.get("fabricPathDn", "")
|
|
m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", path)
|
|
if not m:
|
|
continue
|
|
endpoint_ports.add((int(m.group(1)), m.group(2)))
|
|
assert endpoint_ports, "no endpoint fabricPathDn found"
|
|
|
|
collisions = controller_ports & endpoint_ports
|
|
assert not collisions, f"port(s) double-booked between APIC and endpoint: {collisions}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FIX 4 (#16) — each stretched EP is local on exactly one site, vxlan-learned
|
|
# on the other (cross-site test: build BOTH sites' stores and compare).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_stretched_endpoint_local_on_exactly_one_site(store_a, store_b):
|
|
ceps_a = {
|
|
m.attrs["mac"]: m for m in store_a.by_class("fvCEp")
|
|
if m.dn.startswith("uni/tn-MS-TN1/")
|
|
}
|
|
ceps_b = {
|
|
m.attrs["mac"]: m for m in store_b.by_class("fvCEp")
|
|
if m.dn.startswith("uni/tn-MS-TN1/")
|
|
}
|
|
common_macs = set(ceps_a) & set(ceps_b)
|
|
assert common_macs, "no stretched (MS-TN1) endpoints found on both sites"
|
|
|
|
for mac in common_macs:
|
|
cep_a, cep_b = ceps_a[mac], ceps_b[mac]
|
|
lc_a, lc_b = cep_a.attrs.get("lcC"), cep_b.attrs.get("lcC")
|
|
|
|
# Exactly one side is HOME (front-panel-learned)...
|
|
homes = [lc for lc in (lc_a, lc_b) if lc == "learned"]
|
|
assert len(homes) == 1, (
|
|
f"MAC {mac}: expected exactly one site with lcC='learned', "
|
|
f"got A={lc_a!r} B={lc_b!r}"
|
|
)
|
|
# ...and the other is REMOTE (vxlan/tunnel-learned).
|
|
remotes = [lc for lc in (lc_a, lc_b) if lc == "learned,vxlan"]
|
|
assert len(remotes) == 1, (
|
|
f"MAC {mac}: expected exactly one site with lcC='learned,vxlan', "
|
|
f"got A={lc_a!r} B={lc_b!r}"
|
|
)
|
|
|
|
home_cep = cep_a if lc_a == "learned" else cep_b
|
|
remote_cep = cep_b if lc_a == "learned" else cep_a
|
|
|
|
# HOME: fabricPathDn is a real front-panel port (pathep-[ethX/Y]).
|
|
assert re.search(r"pathep-\[eth\d+/\d+\]", home_cep.attrs["fabricPathDn"]), (
|
|
f"HOME fvCEp {home_cep.dn!r} fabricPathDn not a front-panel port: "
|
|
f"{home_cep.attrs['fabricPathDn']!r}"
|
|
)
|
|
# REMOTE: fabricPathDn points at a tunnel interface, not a front port.
|
|
assert re.search(r"pathep-\[tunnel\d+\]", remote_cep.attrs["fabricPathDn"]), (
|
|
f"REMOTE fvCEp {remote_cep.dn!r} fabricPathDn not a tunnel path: "
|
|
f"{remote_cep.attrs['fabricPathDn']!r}"
|
|
)
|
|
|
|
|
|
def test_stretched_endpoint_remote_tunnel_if_exists(store_a, store_b):
|
|
"""The REMOTE fvCEp's tunnelIf must actually exist in that site's store."""
|
|
for store in (store_a, store_b):
|
|
for cep in store.by_class("fvCEp"):
|
|
if cep.attrs.get("lcC") != "learned,vxlan":
|
|
continue
|
|
m = re.search(r"paths-(\d+)/pathep-\[(tunnel\d+)\]", cep.attrs["fabricPathDn"])
|
|
assert m is not None
|
|
spine_id, tunnel_id = m.group(1), m.group(2)
|
|
matches = [
|
|
t for t in store.by_class("tunnelIf")
|
|
if t.attrs.get("id") == tunnel_id and f"/node-{spine_id}/" in t.dn
|
|
]
|
|
assert matches, (
|
|
f"no tunnelIf with id={tunnel_id!r} on node {spine_id} for "
|
|
f"remote fvCEp {cep.dn!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FIX 5 (#17) — every BD with fvSubnet children has unicastRoute="yes"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
|
|
def test_routed_bd_has_unicast_route_yes(store_name, request):
|
|
store = request.getfixturevalue(store_name)
|
|
bds = list(store.by_class("fvBD"))
|
|
assert bds, "no fvBD found"
|
|
checked = 0
|
|
for bd in bds:
|
|
subnets = store.children(bd.dn, {"fvSubnet"})
|
|
if not subnets:
|
|
continue
|
|
checked += 1
|
|
assert bd.attrs.get("unicastRoute") == "yes", (
|
|
f"fvBD {bd.dn!r} has {len(subnets)} fvSubnet children but "
|
|
f"unicastRoute={bd.attrs.get('unicastRoute')!r} (expected 'yes')"
|
|
)
|
|
assert checked, "no routed (subnet-bearing) BD found to check"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FIX 6 (#18) — node-1 health returns 200 on both sites' controllers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_controller_health_returns_200_both_sites(client_a, client_b):
|
|
for client in (client_a, client_b):
|
|
resp = client.get("/api/mo/topology/pod-1/node-1/sys/health.json")
|
|
assert resp.status_code == 200, (
|
|
f"controller node-1 health -> {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
data = resp.json()
|
|
assert data["imdata"], "expected a healthInst row for controller node-1"
|
|
assert "healthInst" in data["imdata"][0]
|