mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
+22
-10
@@ -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",
|
||||
|
||||
+2
-1
@@ -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) ·
|
||||
|
||||
+44
-1
@@ -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`).
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)})"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user