diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a0ae3..c4e1c7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (pre-1.0: minor bumps may include breaking changes to the sim's behavior). +## [0.19.0] - 2026-07-07 + +### Changed +- **Spine ISN-facing OSPF interface is now a routed VLAN-4 sub-interface** + (`build/underlay.py`) — real ACI multi-site spines carry the OSPF address + toward the IPN/ISN on a routed sub-interface (e.g. `eth1/49.4`), not the + bare physical port. `ospfIf.id`/dn now end in `.4` and carry a new `addr` + attribute (`172.16.{site.id}.{si+1}/24`, same `/24` as the existing + `172.16.{site.id}.254` peer); `ospfAdjEp` nests under the new sub-if + segment. The underlying physical port (`eth1/{49+si}`) and its + `l1PhysIf`/`ethpmPhysIf` are unchanged — LLDP/physical state stays there, + matching real hardware. +- **ISN CSW LLDP neighbor** (`build/fabric.py`) — each spine's ISN uplink + physical port now gets a simulated LLDP neighbor (`lldpAdjEp`/`cdpAdjEp`, + via the shared `add_adjacency` helper) representing the inter-site + network switch: `sysName=ISN-CSW{site.id}`, `portIdV=Ethernet1/{si+1}`, + model `N9K-C9364C`. Together with the sub-interface change above, this + closes the gap where autoACI's Topology "Fabric (physical)" view showed + `Local IP=-`/`Neighbor Port=-` for spine ISN uplinks — it now derives a + complete row from `ospfIf` (port + local IP), `ospfAdjEp` (remote IP), + and this `lldpAdjEp` (CSW-side port). Multi-site fabrics only; single-site + fabrics build none of this (unchanged: no OSPF/LLDP toward an ISN that + doesn't exist). New/updated assertions in `tests/test_pr19_tier2.py`, + `tests/test_pr3b_batch1.py`, `tests/test_pr5_topology_consistency.py`. + ## [0.18.1] - 2026-07-07 ### Changed diff --git a/README.md b/README.md index 76d5223..f2bbcf3 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,9 @@ $ aci-sim show --json | jq '.sites[0].nodes[0]' Builds the topology fresh (same builders the sim boots with) and prints a **`show lldp neighbors`-style table** of every `lldpAdjEp` adjacency — i.e. the neighbor relationships the cabling in `topology.yaml` implies -(spine<->leaf links, APIC<->leaf attachment), materialized exactly the way +(spine<->leaf links, APIC<->leaf attachment, plus — multi-site only — each +spine's ISN uplink toward the inter-site switch, `ISN-CSW{site}`), +materialized exactly the way they'd appear on a running sim (§10's LLDP/CDP fidelity). Pass `--cdp` to read `cdpAdjEp` instead. This does not start any server — it's a static, read-only view of one freshly-built site (or all sites). diff --git a/aci_sim/build/fabric.py b/aci_sim/build/fabric.py index d38200b..82d1ce4 100644 --- a/aci_sim/build/fabric.py +++ b/aci_sim/build/fabric.py @@ -1,5 +1,14 @@ """build/fabric.py — fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf. +PR-22 addition: ISN CSW lldpAdjEp/cdpAdjEp — one per spine, multi-site only, +on the spine's dedicated ISN uplink PHYSICAL port eth1/{49+si} (interfaces.py +builds the l1PhysIf; underlay.py builds the OSPF sub-interface +eth1/{49+si}.4 on the same base port). This is the third `add_adjacency` +call site the module docstring in neighbors.py already accounts for +(fabric.py) — it lives right after the existing APIC↔leaf adjacency loop in +`build()` below, not a new module, per that docstring's warning against a +fourth ad-hoc adjacency-construction site. + Batch-2 additions (CONTRACT.md §6 "Fabric/topology", "Infra write targets"; DN/ attribute shapes verified read-only against autoACI's vpc_status.py/ health_score.py — see docs/DESIGN.md "Batch-2" section): @@ -48,6 +57,8 @@ covers every ID our topology.yaml or a hand-authored one could reasonably use). """ from __future__ import annotations +import zlib + from aci_sim.build.neighbors import add_adjacency, node_mac from aci_sim.mit.mo import MO from aci_sim.mit.store import MITStore @@ -259,6 +270,44 @@ def build(topo: Topology, site: Site, store: MITStore) -> None: neighbor_version=apic_version, ) + # ISN CSW LLDP neighbor — multi-site only, one per spine's dedicated ISN + # uplink PHYSICAL port (eth1/{49+si} — see interfaces.py/underlay.py: the + # OSPF logical interface on this port is a routed VLAN-4 sub-interface, + # "eth1/{49+si}.4", but real LLDP is advertised on the physical port + # itself even when a routed sub-if carries the IP, so this adjacency + # deliberately targets the base port, not the sub-if). autoACI's Topology + # "Fabric (physical)" view derives this uplink row from ospfIf (port + + # local IP) + ospfAdjEp (remote IP) + this lldpAdjEp (CSW-side port), so + # without it the neighbor port/name show up blank in that view. + if len(topo.sites) > 1: + isn_ip = f"172.16.{site.id}.254" + for si, spine in enumerate(site.spine_nodes()): + add_adjacency( + store, + pod=pod, + local_node_id=spine.id, + local_port=f"eth1/{49 + si}", + remote_port=f"Ethernet1/{si + 1}", + neighbor_name=f"ISN-CSW{site.id}", + neighbor_mgmt_ip=isn_ip, + neighbor_kind="switch", + neighbor_model="N9K-C9364C", + neighbor_version="n9000-10.2(5)", + # Synthetic chassis MAC for the (simulated, off-fabric) ISN + # CSW device — deliberately a DIFFERENT OUI-shaped prefix + # ("02:1B:0D", locally-administered per IEEE 802 bit-1-of- + # first-octet convention) than node_mac()'s "00:1B:0D" used + # for real fabric nodes, so this can never collide with a + # spine/leaf/APIC chassis MAC regardless of site.id/node id + # overlap. Keyed by site.id (not node id — the ISN CSW is + # one device per site, not per spine). site.id is schema-typed + # as an unconstrained str, so hash it instead of int()-casting + # (a non-numeric id like "east" must not crash the build). + neighbor_mac=( + f"02:1B:0D:{zlib.crc32(str(site.id).encode()) & 0xFF:02X}:00:01" + ), + ) + # fabricHealthTotal — fabric-wide health summary store.add(MO("fabricHealthTotal", dn="topology/health", cur="100")) diff --git a/aci_sim/build/underlay.py b/aci_sim/build/underlay.py index 9aeeee5..e9b57dc 100644 --- a/aci_sim/build/underlay.py +++ b/aci_sim/build/underlay.py @@ -64,10 +64,18 @@ def build(topo: Topology, site: Site, store: MITStore) -> None: # OSPF adjacencies — ONLY spine↔IPN/ISN, and ONLY in a multi-site fabric. # Leaves never run OSPF to the ISN. One adjacency per spine toward the IPN, - # on eth1/{49+si} — interfaces.py builds a matching dedicated ISN uplink - # l1PhysIf on each spine (multi-site only) at that exact port, so this - # ospfAdjEp interface always resolves against a real port inventory entry. - # autoACI reads: iface = dn.split("/if-[")[1]; neighbor = peerIp or nbrId. + # modeled on a routed VLAN-4 SUB-INTERFACE of the dedicated ISN uplink port + # eth1/{49+si} — i.e. "eth1/{49+si}.4" — matching real ACI multi-site + # spines, which carry the ISN-facing OSPF address on a routed sub-if + # rather than the bare physical port. interfaces.py builds the underlying + # plain-port l1PhysIf/ethpmPhysIf on each spine (multi-site only) at that + # exact base port (LLDP/physical state stays there — see interfaces.py's + # module docstring); the ospfIf here is a logical child of that port, so + # it always resolves against a real port inventory entry via its base + # port (ifId.split('.')[0]). + # autoACI's fabric_ospf plugin reads ospfIf.id/area/operSt directly; its + # topology code matches ospfAdjEp by ifId and falls back to the base port + # (ifId.split('.')[0]) to resolve the LLDP neighbor on the physical port. if len(topo.sites) > 1: ipn_ip = f"172.16.{site.id}.254" # the IPN/ISN router this site's spines peer with # Tier-2 (PR-19): isn.ospf_area feeds ospfIf.area/ospfAdjEp.area — was @@ -83,12 +91,15 @@ def build(topo: Topology, site: Site, store: MITStore) -> None: for si, spine in enumerate(site.spine_nodes()): ospf_base = f"topology/pod-{pod}/node-{spine.id}/sys/ospf/inst-default/dom-overlay-1" store.add(MO("ospfDom", dn=ospf_base, name="overlay-1", operSt="up")) - # Batch-1: ospfIf nested between ospfDom and ospfAdjEp — same - # eth1/{49+si} ISN uplink port interfaces.py already builds a - # dedicated l1PhysIf for, so the existing ospfAdjEp below sits on - # a real, matching ospfIf (consumer: autoACI's fabric_ospf.py, - # which reads ospfIf.id (falling back to ifId) + area + operSt). - if_port = f"eth1/{49 + si}" + # Batch-1 (updated): ospfIf nested between ospfDom and ospfAdjEp + # is now the routed VLAN-4 sub-interface of the eth1/{49+si} ISN + # uplink port interfaces.py builds a dedicated l1PhysIf for — real + # ACI multi-site spines carry the ISN-facing OSPF address on this + # kind of sub-if, not the bare physical port. `addr` uses the same + # /24 as the peer ipn_ip below so the adjacency stays + # subnet-consistent (consumer: autoACI's fabric_ospf.py, which + # reads ospfIf.id (falling back to ifId) + area + operSt). + if_port = f"eth1/{49 + si}.4" if_dn = f"{ospf_base}/if-[{if_port}]" store.add(MO( "ospfIf", @@ -98,6 +109,7 @@ def build(topo: Topology, site: Site, store: MITStore) -> None: operSt="up", helloIntvl=ospf_hello, deadIntvl=ospf_dead, + addr=f"172.16.{site.id}.{si + 1}/24", )) store.add(MO( "ospfAdjEp", diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 3eb9e52..ca42530 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -299,7 +299,7 @@ PR-19: the ISN/IPN spine uplink `l1PhysIf.mtu` is now `isn.mtu`-driven (default `cdpAdjEp`(dn,sysName,mgmtIp,devId,portId,platId,ver,cap) · `lldpInst`🆕(dn,adminSt,name,holdTime,initDelayTime,txFreq,ctrl,optTlvSel) · `lldpIf`🆕(dn has `if-[eth{s}/{p}]`,id,adminRxSt,adminTxSt,operRxSt,operTxSt,mac,portDesc,portVlan,sysDesc,wiring) · `cdpInst`🆕(dn,adminSt,name,holdIntvl,txFreq,ver,ctrl) · `cdpIf`🆕(dn has `if-[eth{s}/{p}]`,id,adminSt,operSt) · -`isisAdjEp`(dn,operSt,sysId,lastTrans,numAdjTrans) · `isisDom` · `ospfAdjEp`(dn,operSt,id,peerIp/addr,nbrId,area) · `ospfIf`✅(name,area,state,helloIntvl,deadIntvl) · `arpAdjEp`✅(ip,mac,ifId,physIfId,operSt). +`isisAdjEp`(dn,operSt,sysId,lastTrans,numAdjTrans) · `isisDom` · `ospfAdjEp`(dn,operSt,id,peerIp/addr,nbrId,area) · `ospfIf`✅(name,area,state,helloIntvl,deadIntvl,addr) · `arpAdjEp`✅(ip,mac,ifId,physIfId,operSt). LLDP/CDP fidelity (v0.12.0): `lldpAdjEp`/`cdpAdjEp` are now enriched with real-APIC-shaped fields (`portIdV`/`portId` carry the NEIGHBOR's own port; `chassisIdV` is a deterministic per-node MAC, see `build/neighbors.py:node_mac`; `sysDesc`/`platId`/`ver` describe the neighbor's model+version, or @@ -312,6 +312,7 @@ target-subtree-class=lldpAdjEp`) returned `totalCount=0` because the root DN had `--json` for machine-readable output). PR-19: `ospfIf.area`/`ospfAdjEp.area` are now `isn.ospf_area`-driven (default `"0.0.0.0"`) instead of a hardcoded literal. PR-20: `ospfIf.helloIntvl`/`deadIntvl` are new, driven by `isn.ospf_hello`/`isn.ospf_dead` (defaults 10/40, real ACI defaults) — previously not carried on this MO at all. +PR-22: the ISN-facing `ospfIf`/`ospfAdjEp` on each multi-site spine now sit on a routed VLAN-4 sub-interface (`id`/dn `eth1/{49+si}.4`, was the bare physical port `eth1/{49+si}`) and `ospfIf` gained an `addr` attribute (`172.16.{site.id}.{si+1}/24`) — matching real ACI multi-site spines, which carry the ISN OSPF address on a sub-if rather than the physical port. The underlying physical port's `l1PhysIf`/`ethpmPhysIf` (interfaces.py) are unchanged. Each spine's ISN uplink physical port also gained a simulated `lldpAdjEp`/`cdpAdjEp` (`sysName=ISN-CSW{site.id}`, `portIdV=Ethernet1/{si+1}`, model `N9K-C9364C`) via `build/fabric.py`, the third `add_adjacency` call site — see docs/DESIGN.md's "PR-22" section. **Overlay/BGP:** `bgpInst`(dn,asn) · `bgpPeer`(dn,asn,addr,type,peerRole) · `bgpPeerEntry`(dn,addr,operSt∈{established,...},rtrId,lastFlapTs,connEst,connDrop,type) · diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3a9423d..77b5dee 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -81,7 +81,7 @@ Each sub-builder is `build(topo, site, store)` and only ADDS MOs. `orchestrator. Sub-builders (one concern each, <~200 lines): - `fabric.py` — fabricNode (roles/ids/model/serial/version from YAML), topSystem, fabricHealthTotal, vpcDom/vpcIf (border-leaf pair), infraWiNode. - `cabling.py` — fabricLink (with `/lnk-…/` DN), lldpAdjEp + cdpAdjEp derived STRICTLY from the cabling graph, isisAdjEp. -- `underlay.py` — bgpInst (local ASN), ospfAdjEp/**ospfIf**✅ (spine↔leaf), IS-IS dom. +- `underlay.py` — bgpInst (local ASN), ospfAdjEp/**ospfIf**✅ (spine↔ISN, on the routed VLAN-4 sub-if `eth1/{49+si}.4` with `addr` — PR-22; multi-site only), IS-IS dom. - `overlay.py` — intra-site BGP-EVPN (spines as RR, leaves as clients): bgpPeer/bgpPeerEntry(established)/bgpPeerAfEntry; **ISN**: on each spine's `sys/bgp/inst`, add inter-site bgpPeer with peer addr /32 + remote (other-site) ASN. - `endpoints.py` — fvCEp/fvIp distributed across leaves+EPGs (encap/fabricPathDn consistent), epmMacEp/epmIpEp, fvIfConn. @@ -827,3 +827,46 @@ regression gate this project's acceptance bar calls for (autoACI sandbox e2e, ac that gate exists to catch (a hardcoded node-count assertion inside autoACI itself, if one exists, would only surface there). The main thread's independent re-run of the real Ansible/aci-py/autoACI chains on ACI hardware is the authoritative backward-compat gate for this PR. + +## PR-22 — ISN OSPF routed sub-interface + ISN CSW LLDP neighbor + +**Design intent:** autoACI's Topology "Fabric (physical)" view derives per-spine ISN uplink rows from +`ospfIf` (port + local IP), `ospfAdjEp` (remote IP), and `lldpAdjEp` (CSW-side port). Pre-PR-22, the sim +modeled the ISN-facing `ospfIf` as a **plain interface** (`eth1/{49+si}`) with **no address**, and there +was no LLDP neighbor on that port at all — so the demo UI showed `Port=eth1/49`, `Local IP=-`, +`Neighbor Port=-`. Real ACI multi-site spines instead use a **routed sub-interface** (VLAN-4 encap, e.g. +`eth1/49.4`) that carries the OSPF address, while the ISN switch advertises LLDP on the underlying +physical port. This PR makes the sim match that reality. + +### `build/underlay.py` — `ospfIf` becomes a VLAN-4 routed sub-interface + +`ospfIf.id`/dn now read `eth1/{49+si}.4` (was `eth1/{49+si}`), and the MO gained a new `addr` attribute: +`172.16.{site.id}.{si+1}/24` — the same `/24` as the existing OSPF peer `172.16.{site.id}.254`, so the +adjacency stays subnet-consistent. `area`/`helloIntvl`/`deadIntvl`/`operSt` are unchanged. `ospfAdjEp`'s +dn moves under the new sub-if segment (`if-[eth1/{49+si}.4]` instead of `if-[eth1/{49+si}]`); its +`peerIp`/`nbrId`/`operSt`/`area` attrs are unchanged. The underlying physical port +(`l1PhysIf`/`ethpmPhysIf`, built by `build/interfaces.py`) is **untouched** — only the OSPF logical +interface becomes a sub-interface; LLDP/physical port state stays on the plain port, matching real +hardware (a routed sub-if never carries its own LLDP/physical-layer identity). Consumers resolve the +underlying port via `ifId.split('.')[0]` — the same fallback autoACI's topology code uses. + +### `build/fabric.py` — ISN CSW LLDP/CDP neighbor + +Each spine's ISN uplink **physical** port (`eth1/{49+si}`) now gets a simulated LLDP/CDP neighbor via the +shared `add_adjacency()` helper (`build/neighbors.py`) — the third of that module's three call sites, +placed directly after the existing APIC↔leaf adjacency loop in `fabric.py`'s `build()`. Fields: +`sysName=ISN-CSW{site.id}`, `portIdV=Ethernet1/{si+1}` (the CSW's own port numbering), `mgmtIp= +172.16.{site.id}.254` (the same IP the OSPF adjacency already peers with), `model=N9K-C9364C`, +`version=n9000-10.2(5)`. The neighbor's chassis MAC uses a deliberately different OUI-shaped prefix +(`02:1B:0D:...`, locally-administered per the 802 "locally administered" bit) than `node_mac()`'s +`00:1B:0D:...` used for real fabric/controller nodes, keyed by `site.id` (one ISN CSW device per site, +not per spine) — this guarantees no collision with any real node's chassis MAC regardless of ID overlap. + +Gate: both changes are multi-site only (`len(topo.sites) > 1`, the same condition `underlay.py`'s +ISN OSPF block already used) — a single-site fabric builds no `ospfDom`/`ospfIf`/`ospfAdjEp` and no +`lldpAdjEp` named `ISN-CSW*`, unchanged from pre-PR-22 behavior. + +New/updated tests: `tests/test_pr19_tier2.py` (`TestIsnOspfSubInterfaceAndLldp` — sub-if id/addr shape, +`ospfAdjEp` nesting, `lldpAdjEp` fields, single-site negative case); +`tests/test_pr3b_batch1.py`/`tests/test_pr5_topology_consistency.py` (`ospfIf`/`ospfAdjEp` port-resolution +assertions updated to strip the `.4` suffix before matching against `l1PhysIf`). diff --git a/pyproject.toml b/pyproject.toml index 6ec6f75..baca80a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aci-sim" -version = "0.18.1" +version = "0.19.0" description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)" readme = "README.md" license = "PolyForm-Noncommercial-1.0.0" diff --git a/tests/test_build_fabric.py b/tests/test_build_fabric.py index 244adb9..93195e8 100644 --- a/tests/test_build_fabric.py +++ b/tests/test_build_fabric.py @@ -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"] diff --git a/tests/test_lldp_cdp_fidelity.py b/tests/test_lldp_cdp_fidelity.py index 90aacbb..80a8a92 100644 --- a/tests/test_lldp_cdp_fidelity.py +++ b/tests/test_lldp_cdp_fidelity.py @@ -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"): diff --git a/tests/test_pr19_tier2.py b/tests/test_pr19_tier2.py index 183c92e..5d2c8a0 100644 --- a/tests/test_pr19_tier2.py +++ b/tests/test_pr19_tier2.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_pr3b_batch1.py b/tests/test_pr3b_batch1.py index b2a99db..1ad12a6 100644 --- a/tests/test_pr3b_batch1.py +++ b/tests/test_pr3b_batch1.py @@ -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" + ) # --------------------------------------------------------------------------- diff --git a/tests/test_pr5_topology_consistency.py b/tests/test_pr5_topology_consistency.py index 31de0e0..cfd30fc 100644 --- a/tests/test_pr5_topology_consistency.py +++ b/tests/test_pr5_topology_consistency.py @@ -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)})" )