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.
This commit is contained in:
dtzp555-max
2026-07-07 08:55:16 +10:00
parent f6c95a0105
commit c926702a29
9 changed files with 57 additions and 32 deletions
+12
View File
@@ -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) 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). (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 ## [0.19.0] - 2026-07-07
### Changed ### Changed
+9 -7
View File
@@ -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 # 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 # 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, # 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 # "eth1/{49+si}.{49+si}" per the ACI naming convention, but LLDP itself is
# itself even when a routed sub-if carries the IP, so this adjacency # advertised per physical port, so this adjacency targets the base port).
# deliberately targets the base port, not the sub-if). autoACI's Topology # The CSW-side port-id follows the IPN/ISN Nexus convention — a dot1q-4
# "Fabric (physical)" view derives this uplink row from ospfIf (port + # routed sub-interface named after the VLAN ("Ethernet1/x.4", e.g. real
# local IP) + ospfAdjEp (remote IP) + this lldpAdjEp (CSW-side port), so # IPN configs `interface Ethernet1/1.4` + `encapsulation dot1q 4`).
# without it the neighbor port/name show up blank in that view. # 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: if len(topo.sites) > 1:
isn_ip = f"172.16.{site.id}.254" isn_ip = f"172.16.{site.id}.254"
for si, spine in enumerate(site.spine_nodes()): for si, spine in enumerate(site.spine_nodes()):
@@ -287,7 +289,7 @@ def build(topo: Topology, site: Site, store: MITStore) -> None:
pod=pod, pod=pod,
local_node_id=spine.id, local_node_id=spine.id,
local_port=f"eth1/{49 + si}", 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_name=f"ISN-CSW{site.id}",
neighbor_mgmt_ip=isn_ip, neighbor_mgmt_ip=isn_ip,
neighbor_kind="switch", neighbor_kind="switch",
+7 -3
View File
@@ -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 # is now the routed VLAN-4 sub-interface of the eth1/{49+si} ISN
# uplink port interfaces.py builds a dedicated l1PhysIf for — real # uplink port interfaces.py builds a dedicated l1PhysIf for — real
# ACI multi-site spines carry the ISN-facing OSPF address on this # 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 # kind of sub-if, not the bare physical port. NAMING (verified
# /24 as the peer ipn_ip below so the adjacency stays # 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 # subnet-consistent (consumer: autoACI's fabric_ospf.py, which
# reads ospfIf.id (falling back to ifId) + area + operSt). # 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}]" if_dn = f"{ospf_base}/if-[{if_port}]"
store.add(MO( store.add(MO(
"ospfIf", "ospfIf",
+1 -1
View File
@@ -312,7 +312,7 @@ target-subtree-class=lldpAdjEp`) returned `totalCount=0` because the root DN had
`--json` for machine-readable output). `--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-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-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) · **Overlay/BGP:** `bgpInst`(dn,asn) · `bgpPeer`(dn,asn,addr,type,peerRole) ·
`bgpPeerEntry`(dn,addr,operSt∈{established,...},rtrId,lastFlapTs,connEst,connDrop,type) · `bgpPeerEntry`(dn,addr,operSt∈{established,...},rtrId,lastFlapTs,connEst,connDrop,type) ·
+4 -4
View File
@@ -81,7 +81,7 @@ Each sub-builder is `build(topo, site, store)` and only ADDS MOs. `orchestrator.
Sub-builders (one concern each, <~200 lines): 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. - `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. - `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; - `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. **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. - `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 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=-`, 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. `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. physical port. This PR makes the sim match that reality.
### `build/underlay.py` — `ospfIf` becomes a VLAN-4 routed sub-interface ### `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 `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 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 `peerIp`/`nbrId`/`operSt`/`area` attrs are unchanged. The underlying physical port
(`l1PhysIf`/`ethpmPhysIf`, built by `build/interfaces.py`) is **untouched** — only the OSPF logical (`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 interface becomes a sub-interface; LLDP/physical port state stays on the plain port, matching real
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "aci-sim" 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)" description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
readme = "README.md" readme = "README.md"
license = "PolyForm-Noncommercial-1.0.0" license = "PolyForm-Noncommercial-1.0.0"
+6 -5
View File
@@ -91,8 +91,9 @@ class TestIsnDefaults:
# PR-22 — ISN OSPF sub-interface + ISN CSW LLDP neighbor # PR-22 — ISN OSPF sub-interface + ISN CSW LLDP neighbor
# #
# Real ACI multi-site spines carry the ISN-facing OSPF address on a routed # 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 # VLAN-4 sub-interface NAMED AFTER THE PORT (e.g. "eth1/49.49" — real spine
# the ISN switch advertises LLDP on that physical port. autoACI's Topology # 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) # "Fabric (physical)" view stitches these together: ospfIf (port + local IP)
# + ospfAdjEp (remote IP) + lldpAdjEp (CSW-side port/name). # + ospfAdjEp (remote IP) + lldpAdjEp (CSW-side port/name).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -106,7 +107,7 @@ class TestIsnOspfSubInterfaceAndLldp:
assert ospf_ifs, "expected at least one ospfIf" assert ospf_ifs, "expected at least one ospfIf"
spine_ids = sorted(n.id for n in site.spine_nodes()) spine_ids = sorted(n.id for n in site.spine_nodes())
for si, sid in enumerate(spine_ids): for si, sid in enumerate(spine_ids):
expected_id = f"eth1/{49 + si}.4" expected_id = f"eth1/{49 + si}.{49 + si}"
mo = next( mo = next(
m for m in ospf_ifs 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}]" 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) store = orchestrator.build_site(repo_topo, site)
spine_ids = sorted(n.id for n in site.spine_nodes()) spine_ids = sorted(n.id for n in site.spine_nodes())
for si, sid in enumerate(spine_ids): 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( adj = next(
m for m in store.by_class("ospfAdjEp") m for m in store.by_class("ospfAdjEp")
if f"/node-{sid}/" in m.dn and expected_segment in m.dn 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" 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["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" assert adj.attrs["mgmtIp"] == f"172.16.{site.id}.254"
def test_single_site_fabric_builds_no_isn_ospf_or_lldp(self) -> None: def test_single_site_fabric_builds_no_isn_ospf_or_lldp(self) -> None:
+9 -6
View File
@@ -406,10 +406,11 @@ def test_ospf_adj_ep_sits_on_an_ospf_if(store_name, request):
@pytest.mark.parametrize("store_name", _ALL_STORES) @pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ospf_if_references_real_port(store_name, request): def test_ospf_if_references_real_port(store_name, request):
"""ospfIf is now the routed VLAN-4 sub-interface of the spine's ISN """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 uplink port, named after the port per real ACI convention (e.g.
port state stays on the base port, so this resolves the underlying "eth1/49.49" — cf. Eth1/29.29-style names in real spine CLI) — real
l1PhysIf via the base port (ifId.split('.')[0]), same fallback autoACI's LLDP/physical port state stays on the base port, so this resolves the
topology code uses.""" underlying l1PhysIf via the base port (ifId.split('.')[0]), same
fallback autoACI's topology code uses."""
store = request.getfixturevalue(store_name) store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store) ports = _l1physif_ports(store)
ifs = store.by_class("ospfIf") 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) 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}" assert m is not None, f"unparsable ospfIf dn {ospf_if.dn!r}"
node_id, port_id = int(m.group(1)), m.group(2) 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, _, sub_num = port_id.partition(".")
base_port = port_id.split(".")[0] 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()), ( assert base_port in ports.get(node_id, set()), (
f"ospfIf {ospf_if.dn!r} references base port {base_port!r} on node " f"ospfIf {ospf_if.dn!r} references base port {base_port!r} on node "
f"{node_id}, which has no matching l1PhysIf" f"{node_id}, which has no matching l1PhysIf"
+8 -5
View File
@@ -127,9 +127,10 @@ def test_l3out_path_att_references_real_port(store_name, request):
@pytest.mark.parametrize("store_name", ["store_a", "store_b"]) @pytest.mark.parametrize("store_name", ["store_a", "store_b"])
def test_ospf_adjacency_references_real_port(store_name, request): def test_ospf_adjacency_references_real_port(store_name, request):
"""ospfAdjEp nests under the ospfIf's VLAN-4 sub-if segment """ospfAdjEp nests under the ospfIf's VLAN-4 sub-if segment
(if-[eth1/{49+si}.4]); resolve the underlying l1PhysIf via the base port (if-[eth1/{49+si}.{49+si}], port-named per real ACI convention); resolve
(ifId.split('.')[0]) — same fallback autoACI's topology code uses since the underlying l1PhysIf via the base port (ifId.split('.')[0]) — same
real LLDP/physical state stays on the base port, not the sub-if.""" 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) store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store) ports = _l1physif_ports(store)
adjs = list(store.by_class("ospfAdjEp")) 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) m = re.search(r"/node-(\d+)/sys/ospf/.*?/if-\[([^\]]+)\]", adj.dn)
assert m is not None, f"unparsable ospfAdjEp dn {adj.dn!r}" assert m is not None, f"unparsable ospfAdjEp dn {adj.dn!r}"
node_id, port_id = int(m.group(1)), m.group(2) 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, _, sub_num = port_id.partition(".")
base_port = port_id.split(".")[0] 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()), ( 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"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)})" f"which has no matching l1PhysIf (has {ports.get(node_id)})"