From c926702a292b1b15ea36eb3094b4baf5b126f12b Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Tue, 7 Jul 2026 08:55:16 +1000 Subject: [PATCH] fix(isn): flip sub-interface naming to real-gear conventions (v0.19.1) Owner review of the v0.19.0 demo + Cisco docs/real spine CLI verification: ACI spines name the VLAN-4 sub-if after the PORT (eth1/49.49, cf. Eth1/29.29-style in show ip ospf neighbors vrf overlay-1); the ISN Nexus names its dot1q-4 sub-if after the VLAN (Ethernet1/1.4). v0.19.0 had the two conventions swapped. 908 tests green. --- CHANGELOG.md | 12 ++++++++++++ aci_sim/build/fabric.py | 16 +++++++++------- aci_sim/build/underlay.py | 10 +++++++--- docs/CONTRACT.md | 2 +- docs/DESIGN.md | 8 ++++---- pyproject.toml | 2 +- tests/test_pr19_tier2.py | 11 ++++++----- tests/test_pr3b_batch1.py | 15 +++++++++------ tests/test_pr5_topology_consistency.py | 13 ++++++++----- 9 files changed, 57 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e1c7c..9b1d755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ 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.1] - 2026-07-07 + +### Fixed +- **ISN sub-interface naming conventions flipped to match real gear** (owner + review of the v0.19.0 demo, verified against Cisco docs/real spine CLI): + the ACI spine names its VLAN-4 sub-if after the PORT (`eth1/49.49`, + cf. `Eth1/29.29`-style output in `show ip ospf neighbors vrf overlay-1`), + while the ISN Nexus names its dot1q-4 sub-if after the VLAN + (`Ethernet1/1.4`). v0.19.0 had it backwards (spine `.4`, CSW bare + physical): `ospfIf`/`ospfAdjEp` ids/dns now use the port-named form and + the ISN-CSW `lldpAdjEp.portIdV` gained the `.4` suffix. + ## [0.19.0] - 2026-07-07 ### Changed diff --git a/aci_sim/build/fabric.py b/aci_sim/build/fabric.py index 82d1ce4..d7ca7ce 100644 --- a/aci_sim/build/fabric.py +++ b/aci_sim/build/fabric.py @@ -273,12 +273,14 @@ def build(topo: Topology, site: Site, store: MITStore) -> None: # 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. + # "eth1/{49+si}.{49+si}" per the ACI naming convention, but LLDP itself is + # advertised per physical port, so this adjacency targets the base port). + # The CSW-side port-id follows the IPN/ISN Nexus convention — a dot1q-4 + # routed sub-interface named after the VLAN ("Ethernet1/x.4", e.g. real + # IPN configs `interface Ethernet1/1.4` + `encapsulation dot1q 4`). + # 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. if len(topo.sites) > 1: isn_ip = f"172.16.{site.id}.254" for si, spine in enumerate(site.spine_nodes()): @@ -287,7 +289,7 @@ def build(topo: Topology, site: Site, store: MITStore) -> None: pod=pod, local_node_id=spine.id, local_port=f"eth1/{49 + si}", - remote_port=f"Ethernet1/{si + 1}", + remote_port=f"Ethernet1/{si + 1}.4", neighbor_name=f"ISN-CSW{site.id}", neighbor_mgmt_ip=isn_ip, neighbor_kind="switch", diff --git a/aci_sim/build/underlay.py b/aci_sim/build/underlay.py index e9b57dc..230ec5e 100644 --- a/aci_sim/build/underlay.py +++ b/aci_sim/build/underlay.py @@ -95,11 +95,15 @@ def build(topo: Topology, site: Site, store: MITStore) -> None: # 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 + # kind of sub-if, not the bare physical port. NAMING (verified + # against real spine CLI, e.g. "Eth1/29.29"/"Eth1/33.33" in + # `show ip ospf neighbors vrf overlay-1`): the ACI-side sub-if + # NUMBER equals the PORT number — the encap is always vlan-4 but + # only the IPN/ISN-router side names its sub-if ".4". `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_port = f"eth1/{49 + si}.{49 + si}" if_dn = f"{ospf_base}/if-[{if_port}]" store.add(MO( "ospfIf", diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index ca42530..2643145 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -312,7 +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. +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}.{49+si}` — port-named per real ACI spine CLI (Eth1/29.29-style); 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}.4` (the ISN Nexus names its dot1q-4 sub-if after the VLAN), 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 77b5dee..eceb8b4 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↔ISN, on the routed VLAN-4 sub-if `eth1/{49+si}.4` with `addr` — PR-22; multi-site only), IS-IS dom. +- `underlay.py` — bgpInst (local ASN), ospfAdjEp/**ospfIf**✅ (spine↔ISN, on the routed VLAN-4 sub-if `eth1/{49+si}.{49+si}` (port-named) 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. @@ -835,15 +835,15 @@ ACI hardware is the authoritative backward-compat gate for this PR. 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 +`eth1/49.49`, port-named per real spine CLI) 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: +`ospfIf.id`/dn now read `eth1/{49+si}.{49+si}` (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 +dn moves under the new sub-if segment (`if-[eth1/{49+si}.{49+si}]` 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 diff --git a/pyproject.toml b/pyproject.toml index baca80a..f27e097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aci-sim" -version = "0.19.0" +version = "0.19.1" 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_pr19_tier2.py b/tests/test_pr19_tier2.py index 5d2c8a0..0814112 100644 --- a/tests/test_pr19_tier2.py +++ b/tests/test_pr19_tier2.py @@ -91,8 +91,9 @@ class TestIsnDefaults: # 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 +# VLAN-4 sub-interface NAMED AFTER THE PORT (e.g. "eth1/49.49" — real spine +# CLI shows Eth1/29.29-style names; only the ISN Nexus side names its dot1q-4 +# sub-if ".4", e.g. "Ethernet1/1.4"), not the bare physical port. autoACI's Topology # "Fabric (physical)" view stitches these together: ospfIf (port + local IP) # + ospfAdjEp (remote IP) + lldpAdjEp (CSW-side port/name). # --------------------------------------------------------------------------- @@ -106,7 +107,7 @@ class TestIsnOspfSubInterfaceAndLldp: 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" + expected_id = f"eth1/{49 + si}.{49 + si}" 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}]" @@ -127,7 +128,7 @@ class TestIsnOspfSubInterfaceAndLldp: 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]" + expected_segment = f"if-[eth1/{49 + si}.{49 + si}]" adj = next( m for m in store.by_class("ospfAdjEp") if f"/node-{sid}/" in m.dn and expected_segment in m.dn @@ -146,7 +147,7 @@ class TestIsnOspfSubInterfaceAndLldp: 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["portIdV"] == f"Ethernet1/{si + 1}.4" assert adj.attrs["mgmtIp"] == f"172.16.{site.id}.254" def test_single_site_fabric_builds_no_isn_ospf_or_lldp(self) -> None: diff --git a/tests/test_pr3b_batch1.py b/tests/test_pr3b_batch1.py index 1ad12a6..769c623 100644 --- a/tests/test_pr3b_batch1.py +++ b/tests/test_pr3b_batch1.py @@ -406,10 +406,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.""" + uplink port, named after the port per real ACI convention (e.g. + "eth1/49.49" — cf. Eth1/29.29-style names in real spine CLI) — 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") @@ -418,8 +419,10 @@ 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.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] + base_port, _, sub_num = port_id.partition(".") + assert sub_num == base_port.split("/")[-1], ( + f"ospfIf {ospf_if.dn!r} id {port_id!r} is not a port-named sub-if" + ) 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" diff --git a/tests/test_pr5_topology_consistency.py b/tests/test_pr5_topology_consistency.py index cfd30fc..5462418 100644 --- a/tests/test_pr5_topology_consistency.py +++ b/tests/test_pr5_topology_consistency.py @@ -127,9 +127,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.""" + (if-[eth1/{49+si}.{49+si}], port-named per real ACI convention); 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")) @@ -138,8 +139,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.endswith(".4"), f"ospfAdjEp {adj.dn!r} port {port_id!r} is not a VLAN-4 sub-if" - base_port = port_id.split(".")[0] + base_port, _, sub_num = port_id.partition(".") + assert sub_num == base_port.split("/")[-1], ( + f"ospfAdjEp {adj.dn!r} port {port_id!r} is not a port-named sub-if" + ) 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)})"