mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
feat(isn): VLAN-4 routed sub-interface + addr on spine ISN ospfIf; ISN-CSW LLDP neighbor (v0.19.0)
Real ACI multi-site spines carry the OSPF address toward the ISN on a
routed sub-interface, not the bare port: ospfIf id/dn are now
eth1/{49+si}.4 with addr=172.16.{site}.{si+1}/24 (same /24 as the
existing .254 peer); ospfAdjEp nests under the sub-if. Each spine ISN
uplink physical port also gets an ISN-CSW lldpAdjEp/cdpAdjEp
(sysName ISN-CSW{site}, portIdV Ethernet1/{si+1}, model N9K-C9364C,
locally-administered 02:1B:0D MAC hashed from site.id). Single-site
fabrics build none of this. Closes the autoACI Fabric (physical) gap
where spine uplink rows showed blank Local IP / Neighbor Port.
908 tests green.
This commit is contained in:
@@ -105,7 +105,7 @@ def test_fabric_links_reference_real_nodes(store, site_a):
|
||||
# Test: LLDP/CDP neighbor set matches cabling graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_lldp_cdp_matches_cabling(store, site_a):
|
||||
def test_lldp_cdp_matches_cabling(store, site_a, topo):
|
||||
"""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()}
|
||||
@@ -124,6 +124,12 @@ def test_lldp_cdp_matches_cabling(store, site_a):
|
||||
for leaf in site_a.leaf_nodes()[:2]:
|
||||
expected_lldp.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
|
||||
|
||||
# v0.19.0: multi-site fabrics also see the ISN CSW on each spine's
|
||||
# dedicated uplink port (fabric.py's ISN-CSW add_adjacency block).
|
||||
if len(topo.sites) > 1:
|
||||
for spine in site_a.spine_nodes():
|
||||
expected_lldp.add((spine.id, f"ISN-CSW{site_a.id}"))
|
||||
|
||||
actual_lldp: set[tuple[int, str]] = set()
|
||||
for mo in store.by_class("lldpAdjEp"):
|
||||
dn = mo.attrs["dn"]
|
||||
|
||||
@@ -285,7 +285,7 @@ def test_backward_compat_sysname_and_mgmtip_present(store, site_a):
|
||||
assert mo.attrs.get("mgmtIp"), f"{cls} missing mgmtIp: {mo.attrs['dn']}"
|
||||
|
||||
|
||||
def test_backward_compat_matches_cabling_graph(store, site_a):
|
||||
def test_backward_compat_matches_cabling_graph(store, site_a, topo):
|
||||
"""Same assertion test_build_fabric.py's test_lldp_cdp_matches_cabling
|
||||
already makes (sysName set matches the cabling graph) — re-verified here
|
||||
to prove the enrichment didn't alter the pre-existing neighbor set."""
|
||||
@@ -299,6 +299,11 @@ def test_backward_compat_matches_cabling_graph(store, site_a):
|
||||
for cid in range(1, site_a.controllers + 1):
|
||||
for leaf in site_a.leaf_nodes()[:2]:
|
||||
expected.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
|
||||
# v0.19.0: multi-site fabrics also see the ISN CSW on each spine's
|
||||
# dedicated uplink port (fabric.py's ISN-CSW add_adjacency block).
|
||||
if len(topo.sites) > 1:
|
||||
for spine in site_a.spine_nodes():
|
||||
expected.add((spine.id, f"ISN-CSW{site_a.id}"))
|
||||
|
||||
actual: set[tuple[int, str]] = set()
|
||||
for mo in store.by_class("lldpAdjEp"):
|
||||
|
||||
@@ -87,6 +87,82 @@ class TestIsnDefaults:
|
||||
assert mo.attrs["mtu"] == "9150"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PR-22 — ISN OSPF sub-interface + ISN CSW LLDP neighbor
|
||||
#
|
||||
# Real ACI multi-site spines carry the ISN-facing OSPF address on a routed
|
||||
# VLAN-4 sub-interface (e.g. "eth1/49.4"), not the bare physical port, and
|
||||
# the ISN switch advertises LLDP on that physical port. autoACI's Topology
|
||||
# "Fabric (physical)" view stitches these together: ospfIf (port + local IP)
|
||||
# + ospfAdjEp (remote IP) + lldpAdjEp (CSW-side port/name).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsnOspfSubInterfaceAndLldp:
|
||||
def test_ospfIf_id_is_vlan4_subinterface(self, repo_topo: Topology) -> None:
|
||||
site = repo_topo.site_by_name("LAB1")
|
||||
store = orchestrator.build_site(repo_topo, site)
|
||||
ospf_ifs = store.by_class("ospfIf")
|
||||
assert ospf_ifs, "expected at least one ospfIf"
|
||||
spine_ids = sorted(n.id for n in site.spine_nodes())
|
||||
for si, sid in enumerate(spine_ids):
|
||||
expected_id = f"eth1/{49 + si}.4"
|
||||
mo = next(
|
||||
m for m in ospf_ifs
|
||||
if m.dn == f"topology/pod-{site.pod}/node-{sid}/sys/ospf/inst-default/dom-overlay-1/if-[{expected_id}]"
|
||||
)
|
||||
assert mo.attrs["id"] == expected_id
|
||||
|
||||
def test_ospfIf_addr_in_site_isn_subnet(self, repo_topo: Topology) -> None:
|
||||
site = repo_topo.site_by_name("LAB1")
|
||||
store = orchestrator.build_site(repo_topo, site)
|
||||
ospf_ifs = store.by_class("ospfIf")
|
||||
assert ospf_ifs
|
||||
for mo in ospf_ifs:
|
||||
assert mo.attrs["addr"].startswith(f"172.16.{site.id}.")
|
||||
assert mo.attrs["addr"].endswith("/24")
|
||||
|
||||
def test_ospfAdjEp_dn_nests_under_subinterface_segment(self, repo_topo: Topology) -> None:
|
||||
site = repo_topo.site_by_name("LAB1")
|
||||
store = orchestrator.build_site(repo_topo, site)
|
||||
spine_ids = sorted(n.id for n in site.spine_nodes())
|
||||
for si, sid in enumerate(spine_ids):
|
||||
expected_segment = f"if-[eth1/{49 + si}.4]"
|
||||
adj = next(
|
||||
m for m in store.by_class("ospfAdjEp")
|
||||
if f"/node-{sid}/" in m.dn and expected_segment in m.dn
|
||||
)
|
||||
assert adj.attrs["peerIp"] == f"172.16.{site.id}.254"
|
||||
|
||||
def test_lldpAdjEp_exists_for_isn_csw_on_physical_port(self, repo_topo: Topology) -> None:
|
||||
site = repo_topo.site_by_name("LAB1")
|
||||
store = orchestrator.build_site(repo_topo, site)
|
||||
spine_ids = sorted(n.id for n in site.spine_nodes())
|
||||
lldp_adjs = list(store.by_class("lldpAdjEp"))
|
||||
for si, sid in enumerate(spine_ids):
|
||||
local_port = f"eth1/{49 + si}"
|
||||
adj = next(
|
||||
m for m in lldp_adjs
|
||||
if m.dn == f"topology/pod-{site.pod}/node-{sid}/sys/lldp/inst/if-[{local_port}]/adj-1"
|
||||
)
|
||||
assert adj.attrs["sysName"] == f"ISN-CSW{site.id}"
|
||||
assert adj.attrs["portIdV"] == f"Ethernet1/{si + 1}"
|
||||
assert adj.attrs["mgmtIp"] == f"172.16.{site.id}.254"
|
||||
|
||||
def test_single_site_fabric_builds_no_isn_ospf_or_lldp(self) -> None:
|
||||
"""Single-site fabrics never peer OSPF/LLDP with an ISN — no ospfIf/
|
||||
ospfAdjEp/ospfDom, and no lldpAdjEp named "ISN-CSW*", should exist."""
|
||||
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
|
||||
topo = Topology.model_validate(topo_dict)
|
||||
site = topo.sites[0]
|
||||
store = orchestrator.build_site(topo, site)
|
||||
assert store.by_class("ospfIf") == []
|
||||
assert store.by_class("ospfAdjEp") == []
|
||||
assert store.by_class("ospfDom") == []
|
||||
isn_lldp = [m for m in store.by_class("lldpAdjEp") if "ISN-CSW" in m.attrs.get("sysName", "")]
|
||||
assert isn_lldp == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ISN ospf_area / mtu — overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -405,6 +405,11 @@ def test_ospf_adj_ep_sits_on_an_ospf_if(store_name, request):
|
||||
|
||||
@pytest.mark.parametrize("store_name", _ALL_STORES)
|
||||
def test_ospf_if_references_real_port(store_name, request):
|
||||
"""ospfIf is now the routed VLAN-4 sub-interface of the spine's ISN
|
||||
uplink port (id/dn end in ".4", e.g. "eth1/49.4") — real LLDP/physical
|
||||
port state stays on the base port, so this resolves the underlying
|
||||
l1PhysIf via the base port (ifId.split('.')[0]), same fallback autoACI's
|
||||
topology code uses."""
|
||||
store = request.getfixturevalue(store_name)
|
||||
ports = _l1physif_ports(store)
|
||||
ifs = store.by_class("ospfIf")
|
||||
@@ -413,11 +418,17 @@ def test_ospf_if_references_real_port(store_name, request):
|
||||
m = re.search(r"/node-(\d+)/sys/ospf/.*?/if-\[([^\]]+)\]", ospf_if.dn)
|
||||
assert m is not None, f"unparsable ospfIf dn {ospf_if.dn!r}"
|
||||
node_id, port_id = int(m.group(1)), m.group(2)
|
||||
assert port_id in ports.get(node_id, set()), (
|
||||
f"ospfIf {ospf_if.dn!r} references {port_id!r} on node {node_id}, "
|
||||
f"which has no matching l1PhysIf"
|
||||
assert port_id.endswith(".4"), f"ospfIf {ospf_if.dn!r} id {port_id!r} is not a VLAN-4 sub-if"
|
||||
base_port = port_id.split(".")[0]
|
||||
assert base_port in ports.get(node_id, set()), (
|
||||
f"ospfIf {ospf_if.dn!r} references base port {base_port!r} on node "
|
||||
f"{node_id}, which has no matching l1PhysIf"
|
||||
)
|
||||
assert ospf_if.attrs.get("id") == port_id
|
||||
addr = ospf_if.attrs.get("addr", "")
|
||||
assert re.fullmatch(r"172\.16\.\d+\.\d+/24", addr), (
|
||||
f"ospfIf {ospf_if.dn!r} addr {addr!r} is not a 172.16.{{site}}.0/24 address"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -126,6 +126,10 @@ def test_l3out_path_att_references_real_port(store_name, request):
|
||||
|
||||
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
|
||||
def test_ospf_adjacency_references_real_port(store_name, request):
|
||||
"""ospfAdjEp nests under the ospfIf's VLAN-4 sub-if segment
|
||||
(if-[eth1/{49+si}.4]); resolve the underlying l1PhysIf via the base port
|
||||
(ifId.split('.')[0]) — same fallback autoACI's topology code uses since
|
||||
real LLDP/physical state stays on the base port, not the sub-if."""
|
||||
store = request.getfixturevalue(store_name)
|
||||
ports = _l1physif_ports(store)
|
||||
adjs = list(store.by_class("ospfAdjEp"))
|
||||
@@ -134,8 +138,10 @@ def test_ospf_adjacency_references_real_port(store_name, request):
|
||||
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}, "
|
||||
assert port_id.endswith(".4"), f"ospfAdjEp {adj.dn!r} port {port_id!r} is not a VLAN-4 sub-if"
|
||||
base_port = port_id.split(".")[0]
|
||||
assert base_port in ports.get(node_id, set()), (
|
||||
f"ospfAdjEp {adj.dn!r} references base port {base_port!r} on node {node_id}, "
|
||||
f"which has no matching l1PhysIf (has {ports.get(node_id)})"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user