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:
dtzp555-max
2026-07-07 08:17:23 +10:00
parent ff838e2467
commit f6c95a0105
12 changed files with 257 additions and 21 deletions
+25
View File
@@ -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) 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.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 ## [0.18.1] - 2026-07-07
### Changed ### Changed
+3 -1
View File
@@ -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 Builds the topology fresh (same builders the sim boots with) and prints a
**`show lldp neighbors`-style table** of every `lldpAdjEp` adjacency — **`show lldp neighbors`-style table** of every `lldpAdjEp` adjacency —
i.e. the neighbor relationships the cabling in `topology.yaml` implies 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 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 `cdpAdjEp` instead. This does not start any server — it's a static,
read-only view of one freshly-built site (or all sites). read-only view of one freshly-built site (or all sites).
+49
View File
@@ -1,5 +1,14 @@
"""build/fabric.py — fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf. """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/ Batch-2 additions (CONTRACT.md §6 "Fabric/topology", "Infra write targets"; DN/
attribute shapes verified read-only against autoACI's vpc_status.py/ attribute shapes verified read-only against autoACI's vpc_status.py/
health_score.py — see docs/DESIGN.md "Batch-2" section): 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 from __future__ import annotations
import zlib
from aci_sim.build.neighbors import add_adjacency, node_mac from aci_sim.build.neighbors import add_adjacency, node_mac
from aci_sim.mit.mo import MO from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore from aci_sim.mit.store import MITStore
@@ -259,6 +270,44 @@ def build(topo: Topology, site: Site, store: MITStore) -> None:
neighbor_version=apic_version, 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 # fabricHealthTotal — fabric-wide health summary
store.add(MO("fabricHealthTotal", dn="topology/health", cur="100")) store.add(MO("fabricHealthTotal", dn="topology/health", cur="100"))
+22 -10
View File
@@ -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. # 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, # 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 # modeled on a routed VLAN-4 SUB-INTERFACE of the dedicated ISN uplink port
# l1PhysIf on each spine (multi-site only) at that exact port, so this # eth1/{49+si} — i.e. "eth1/{49+si}.4" — matching real ACI multi-site
# ospfAdjEp interface always resolves against a real port inventory entry. # spines, which carry the ISN-facing OSPF address on a routed sub-if
# autoACI reads: iface = dn.split("/if-[")[1]; neighbor = peerIp or nbrId. # 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: if len(topo.sites) > 1:
ipn_ip = f"172.16.{site.id}.254" # the IPN/ISN router this site's spines peer with 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 # 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()): for si, spine in enumerate(site.spine_nodes()):
ospf_base = f"topology/pod-{pod}/node-{spine.id}/sys/ospf/inst-default/dom-overlay-1" 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")) store.add(MO("ospfDom", dn=ospf_base, name="overlay-1", operSt="up"))
# Batch-1: ospfIf nested between ospfDom and ospfAdjEp — same # Batch-1 (updated): ospfIf nested between ospfDom and ospfAdjEp
# eth1/{49+si} ISN uplink port interfaces.py already builds a # is now the routed VLAN-4 sub-interface of the eth1/{49+si} ISN
# dedicated l1PhysIf for, so the existing ospfAdjEp below sits on # uplink port interfaces.py builds a dedicated l1PhysIf for — real
# a real, matching ospfIf (consumer: autoACI's fabric_ospf.py, # ACI multi-site spines carry the ISN-facing OSPF address on this
# which reads ospfIf.id (falling back to ifId) + area + operSt). # kind of sub-if, not the bare physical port. `addr` uses the same
if_port = f"eth1/{49 + si}" # /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}]" if_dn = f"{ospf_base}/if-[{if_port}]"
store.add(MO( store.add(MO(
"ospfIf", "ospfIf",
@@ -98,6 +109,7 @@ def build(topo: Topology, site: Site, store: MITStore) -> None:
operSt="up", operSt="up",
helloIntvl=ospf_hello, helloIntvl=ospf_hello,
deadIntvl=ospf_dead, deadIntvl=ospf_dead,
addr=f"172.16.{site.id}.{si + 1}/24",
)) ))
store.add(MO( store.add(MO(
"ospfAdjEp", "ospfAdjEp",
+2 -1
View File
@@ -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) · `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) · `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) · `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 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, (`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 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). `--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.
**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) ·
+44 -1
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↔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; - `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.
@@ -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 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 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. 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`).
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "aci-sim" 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)" 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"
+7 -1
View File
@@ -105,7 +105,7 @@ def test_fabric_links_reference_real_nodes(store, site_a):
# Test: LLDP/CDP neighbor set matches cabling graph # 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.""" """lldpAdjEp sysName values on each node match what the cabling graph predicts."""
pod = site_a.pod pod = site_a.pod
node_map = {n.id: n for n in site_a.all_nodes()} 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]: for leaf in site_a.leaf_nodes()[:2]:
expected_lldp.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}")) 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() actual_lldp: set[tuple[int, str]] = set()
for mo in store.by_class("lldpAdjEp"): for mo in store.by_class("lldpAdjEp"):
dn = mo.attrs["dn"] dn = mo.attrs["dn"]
+6 -1
View File
@@ -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']}" 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 """Same assertion test_build_fabric.py's test_lldp_cdp_matches_cabling
already makes (sysName set matches the cabling graph) — re-verified here already makes (sysName set matches the cabling graph) — re-verified here
to prove the enrichment didn't alter the pre-existing neighbor set.""" 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 cid in range(1, site_a.controllers + 1):
for leaf in site_a.leaf_nodes()[:2]: for leaf in site_a.leaf_nodes()[:2]:
expected.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}")) 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() actual: set[tuple[int, str]] = set()
for mo in store.by_class("lldpAdjEp"): for mo in store.by_class("lldpAdjEp"):
+76
View File
@@ -87,6 +87,82 @@ class TestIsnDefaults:
assert mo.attrs["mtu"] == "9150" 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 # ISN ospf_area / mtu — overrides
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+14 -3
View File
@@ -405,6 +405,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
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) store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store) ports = _l1physif_ports(store)
ifs = store.by_class("ospfIf") 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) 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 in ports.get(node_id, set()), ( assert port_id.endswith(".4"), f"ospfIf {ospf_if.dn!r} id {port_id!r} is not a VLAN-4 sub-if"
f"ospfIf {ospf_if.dn!r} references {port_id!r} on node {node_id}, " base_port = port_id.split(".")[0]
f"which has no matching l1PhysIf" 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 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"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8 -2
View File
@@ -126,6 +126,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
(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) 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"))
@@ -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) 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 in ports.get(node_id, set()), ( assert port_id.endswith(".4"), f"ospfAdjEp {adj.dn!r} port {port_id!r} is not a VLAN-4 sub-if"
f"ospfAdjEp {adj.dn!r} references {port_id!r} on node {node_id}, " 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)})" f"which has no matching l1PhysIf (has {ports.get(node_id)})"
) )