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)
(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
+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
# 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",
+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
# 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",
+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).
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) ·
+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):
- `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
+1 -1
View File
@@ -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"
+6 -5
View File
@@ -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:
+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)
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"
+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"])
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)})"