mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +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,288 @@
|
||||
"""Phase 3 — fabric builder tests.
|
||||
|
||||
Loads the default topology, builds SiteA into a fresh MITStore, and asserts:
|
||||
- 2 spines + 2 leaves + 2 border-leaves present as fabricNode
|
||||
- Every fabricLink n1/n2 references a real fabricNode
|
||||
- lldpAdjEp + cdpAdjEp neighbor set matches the cabling graph
|
||||
- Each spine's sys/bgp/inst subtree has ISN bgpPeer with /32 addr + remote ASN
|
||||
- healthInst per node present with correct DN
|
||||
- Seeded faultInst under node-local DNs (contain /node-{id}/)
|
||||
- topSystem has the correct loopback address formula
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aci_sim.topology.loader import load_topology
|
||||
from aci_sim.topology.schema import Topology
|
||||
from aci_sim.mit.store import MITStore
|
||||
from aci_sim.build import fabric, cabling, underlay, overlay, interfaces, health_faults
|
||||
from aci_sim.build.cabling import cabling_links
|
||||
from aci_sim.build.fabric import loopback_ip, oob_ip
|
||||
|
||||
TOPO_YAML = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def topo() -> Topology:
|
||||
return load_topology(TOPO_YAML)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def site_a(topo):
|
||||
return topo.site_by_name("LAB1")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def store(topo, site_a) -> MITStore:
|
||||
"""Build SiteA into a fresh store using all Phase-3 builders."""
|
||||
s = MITStore()
|
||||
fabric.build(topo, site_a, s)
|
||||
cabling.build(topo, site_a, s)
|
||||
underlay.build(topo, site_a, s)
|
||||
overlay.build(topo, site_a, s)
|
||||
interfaces.build(topo, site_a, s)
|
||||
health_faults.build(topo, site_a, s)
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_attrs(store: MITStore, cls: str) -> list[dict]:
|
||||
return [mo.attrs for mo in store.by_class(cls)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: node counts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_node_counts(store, site_a):
|
||||
"""SiteA default: 2 spines + 2 leaves + 2 border-leaves = 6 switches, plus a
|
||||
1-node APIC cluster (role=controller, PR-21 single-APIC default) → 7 fabricNodes."""
|
||||
nodes = get_attrs(store, "fabricNode")
|
||||
|
||||
spines = [n for n in nodes if n["role"] == "spine"]
|
||||
leaves = [n for n in nodes if n["role"] == "leaf"]
|
||||
controllers = [n for n in nodes if n["role"] == "controller"]
|
||||
assert len(spines) == 2, f"Expected 2 spines, got {len(spines)}"
|
||||
# Leaves include regular leaves (2) + border-leaves (2) — all role="leaf"
|
||||
assert len(leaves) == 4, f"Expected 4 leaf-role nodes, got {len(leaves)}"
|
||||
assert len(controllers) == 1, f"Expected 1 controller, got {len(controllers)}"
|
||||
assert len(nodes) == 7, f"Expected 7 fabricNodes (6 switch + 1 APIC), got {len(nodes)}: {[n['name'] for n in nodes]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: fabricLink n1/n2 reference real nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fabric_links_reference_real_nodes(store, site_a):
|
||||
"""Every fabricLink n1 and n2 must be a real fabricNode id in this site."""
|
||||
node_ids = {mo.attrs["id"] for mo in store.by_class("fabricNode")}
|
||||
links = store.by_class("fabricLink")
|
||||
assert len(links) > 0, "No fabricLinks found"
|
||||
|
||||
for link in links:
|
||||
n1 = link.attrs.get("n1", "")
|
||||
n2 = link.attrs.get("n2", "")
|
||||
assert n1 in node_ids, f"fabricLink n1={n1!r} not in fabricNodes {node_ids}"
|
||||
assert n2 in node_ids, f"fabricLink n2={n2!r} not in fabricNodes {node_ids}"
|
||||
|
||||
# Expected link count: (2 spines) × (2 leaves + 2 border-leaves) = 8
|
||||
# 8 spine↔leaf (2 spines × 4 downlinks) + 2 APIC↔leaf (1 controller × 2 leaves, PR-21 default)
|
||||
assert len(links) == 10, f"Expected 10 fabricLinks (8 fabric + 2 APIC), got {len(links)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: LLDP/CDP neighbor set matches cabling graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_lldp_cdp_matches_cabling(store, site_a):
|
||||
"""lldpAdjEp sysName values on each node match what the cabling graph predicts."""
|
||||
pod = site_a.pod
|
||||
node_map = {n.id: n for n in site_a.all_nodes()}
|
||||
|
||||
# Build expected neighbor pairs from cabling_links
|
||||
# For each link (n1, s1, p1, n2, s2, p2):
|
||||
# node n1 expects neighbor n2.name on port eth{s1}/{p1}
|
||||
# node n2 expects neighbor n1.name on port eth{s2}/{p2}
|
||||
expected_lldp: set[tuple[int, str]] = set() # (node_id, neighbor_name)
|
||||
for n1, s1, p1, n2, s2, p2 in cabling_links(site_a):
|
||||
expected_lldp.add((n1, node_map[n2].name))
|
||||
expected_lldp.add((n2, node_map[n1].name))
|
||||
|
||||
# APIC↔leaf: the first two leaves each see every APIC (leaf-side lldpAdjEp)
|
||||
for cid in range(1, site_a.controllers + 1):
|
||||
for leaf in site_a.leaf_nodes()[:2]:
|
||||
expected_lldp.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
|
||||
|
||||
actual_lldp: set[tuple[int, str]] = set()
|
||||
for mo in store.by_class("lldpAdjEp"):
|
||||
dn = mo.attrs["dn"]
|
||||
m = re.search(r"/node-(\d+)/", dn)
|
||||
if m:
|
||||
actual_lldp.add((int(m.group(1)), mo.attrs.get("sysName", "")))
|
||||
|
||||
assert expected_lldp == actual_lldp, (
|
||||
f"LLDP mismatch\n missing: {expected_lldp - actual_lldp}\n"
|
||||
f" extra: {actual_lldp - expected_lldp}"
|
||||
)
|
||||
|
||||
# CDP must be symmetric
|
||||
cdp_pairs: set[tuple[int, str]] = set()
|
||||
for mo in store.by_class("cdpAdjEp"):
|
||||
dn = mo.attrs["dn"]
|
||||
m = re.search(r"/node-(\d+)/", dn)
|
||||
if m:
|
||||
cdp_pairs.add((int(m.group(1)), mo.attrs.get("sysName", "")))
|
||||
assert expected_lldp == cdp_pairs, "CDP neighbor set does not match cabling"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: ISN bgpPeer — /32 addr + remote ASN on each spine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_isn_bgp_peers(store, topo, site_a):
|
||||
"""Each SiteA spine's bgp/inst subtree must contain ISN bgpPeers:
|
||||
- addr ends in /32
|
||||
- asn == remote site's ASN (not the local site's ASN)
|
||||
"""
|
||||
pod = site_a.pod
|
||||
local_asn = str(site_a.asn)
|
||||
remote_site = topo.other_site(site_a.name)
|
||||
remote_asn = str(remote_site.asn)
|
||||
remote_spines = remote_site.spine_nodes()
|
||||
remote_pod = remote_site.pod
|
||||
|
||||
for spine in site_a.spine_nodes():
|
||||
inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
|
||||
# Collect all bgpPeer descendants under this inst
|
||||
bgp_peers = store.subtree(inst_dn, {"bgpPeer"})
|
||||
assert bgp_peers, f"No bgpPeer found under {inst_dn}"
|
||||
|
||||
isn_peers = [
|
||||
p for p in bgp_peers
|
||||
if "/32" in p.attrs.get("addr", "")
|
||||
and p.attrs.get("asn", "") != local_asn
|
||||
]
|
||||
assert isn_peers, (
|
||||
f"No ISN bgpPeer (/32 + remote ASN) found on spine {spine.id}. "
|
||||
f"Peers found: {[(p.attrs.get('addr'), p.attrs.get('asn')) for p in bgp_peers]}"
|
||||
)
|
||||
|
||||
# Verify one ISN peer per remote spine
|
||||
isn_addrs = {p.attrs["addr"] for p in isn_peers}
|
||||
for rs in remote_spines:
|
||||
expected_addr = f"{loopback_ip(remote_pod, rs.id)}/32"
|
||||
assert expected_addr in isn_addrs, (
|
||||
f"Missing ISN peer to remote spine {rs.id} ({expected_addr}) "
|
||||
f"on spine {spine.id}. Found: {isn_addrs}"
|
||||
)
|
||||
# Verify the remote ASN
|
||||
for p in isn_peers:
|
||||
if p.attrs["addr"] == expected_addr:
|
||||
assert p.attrs["asn"] == remote_asn, (
|
||||
f"ISN peer {expected_addr} has asn={p.attrs['asn']!r}, "
|
||||
f"expected {remote_asn!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: healthInst per node
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_health_inst_per_node(store, site_a, topo):
|
||||
"""Every node in SiteA must have a healthInst at topology/.../sys/health."""
|
||||
pod = site_a.pod
|
||||
hd = topo.faults.health_defaults
|
||||
for node in site_a.all_nodes():
|
||||
dn = f"topology/pod-{pod}/node-{node.id}/sys/health"
|
||||
mo = store.get(dn)
|
||||
assert mo is not None, f"healthInst missing for node {node.id} at {dn}"
|
||||
assert mo.class_name == "healthInst"
|
||||
assert mo.attrs["cur"] == str(hd.node), (
|
||||
f"healthInst cur={mo.attrs['cur']!r} != {hd.node!r} for node {node.id}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: seeded faultInst under node-local DNs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_seeded_fault_insts(store, site_a, topo):
|
||||
"""Seeded faultInst MOs must exist under node-local DNs (contain /node-{id}/)."""
|
||||
site_node_ids = {n.id for n in site_a.all_nodes()}
|
||||
|
||||
for seed in topo.faults.seed:
|
||||
if seed.node not in site_node_ids:
|
||||
continue
|
||||
# Find a faultInst with the right code under the right node
|
||||
fault_dn_pattern = re.compile(rf"/node-{seed.node}/")
|
||||
found = [
|
||||
mo for mo in store.by_class("faultInst")
|
||||
if fault_dn_pattern.search(mo.attrs.get("dn", ""))
|
||||
and seed.severity == mo.attrs.get("severity", "")
|
||||
]
|
||||
assert found, (
|
||||
f"No faultInst found for seed code={seed.code} node={seed.node} "
|
||||
f"severity={seed.severity}"
|
||||
)
|
||||
# Verify the DN contains /node-{id}/ (required for topology.py grouping)
|
||||
for mo in found:
|
||||
assert f"/node-{seed.node}/" in mo.attrs["dn"], (
|
||||
f"faultInst DN {mo.attrs['dn']!r} does not contain /node-{seed.node}/"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: topSystem loopback address
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_top_system_loopback(store, site_a):
|
||||
"""topSystem.address must follow the formula 10.<pod>.<node_id>.1"""
|
||||
pod = site_a.pod
|
||||
for node in site_a.all_nodes():
|
||||
ts_dn = f"topology/pod-{pod}/node-{node.id}/sys"
|
||||
mo = store.get(ts_dn)
|
||||
assert mo is not None, f"topSystem missing for node {node.id} at {ts_dn}"
|
||||
expected_lb = loopback_ip(pod, node.id)
|
||||
assert mo.attrs["address"] == expected_lb, (
|
||||
f"topSystem.address={mo.attrs['address']!r} != {expected_lb!r} "
|
||||
f"for node {node.id}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: fabricLink DN format parseable by topology.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fabric_link_dn_format(store, site_a):
|
||||
"""fabricLink DNs must be parseable by topology.py's port-extraction logic."""
|
||||
pod = site_a.pod
|
||||
for mo in store.by_class("fabricLink"):
|
||||
dn = mo.attrs["dn"]
|
||||
source_port = target_port = ""
|
||||
for seg in dn.split("/"):
|
||||
if seg.startswith("lnk-"):
|
||||
lnk_part = seg[4:]
|
||||
if "-to-" in lnk_part:
|
||||
sp, dp = lnk_part.split("-to-", 1)
|
||||
spc = sp.split("-")
|
||||
dpc = dp.split("-")
|
||||
if len(spc) >= 3:
|
||||
source_port = f"eth{spc[1]}/{spc[2]}"
|
||||
if len(dpc) >= 3:
|
||||
target_port = f"eth{dpc[1]}/{dpc[2]}"
|
||||
assert source_port, f"Could not parse source_port from fabricLink DN: {dn}"
|
||||
assert target_port, f"Could not parse target_port from fabricLink DN: {dn}"
|
||||
# Ports must follow ethN/M format
|
||||
assert re.match(r"eth\d+/\d+", source_port), f"Bad source_port {source_port!r}"
|
||||
assert re.match(r"eth\d+/\d+", target_port), f"Bad target_port {target_port!r}"
|
||||
Reference in New Issue
Block a user