mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
feat: /31 point-to-point spine-ISN underlay + graph physical-links table (v0.20.0)
Spine-to-ISN links now use per-link /31 point-to-point sub-interface pairs (Cisco Multi-Site design guide) instead of a shared /24 with a .254 IPN router; ospfIf/ospfAdjEp carry the pair addresses (ISN CSW LLDP mgmt IP unchanged - device-level address). aci-sim graph follows the autoACI topology view logic: solid ISN uplinks, link-type legend entries, and a physical-links table (fabric links + ISN uplinks with /31 local/remote IPs) under the diagram. Tests updated + strengthened; suite: 908 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c926702a29
commit
e52ac1981d
@@ -6,6 +6,23 @@ 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.20.0] - 2026-07-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Spine-to-ISN underlay is now /31 point-to-point** (per the Cisco ACI
|
||||||
|
Multi-Site design guide) — each spine's ISN-facing VLAN-4 sub-interface
|
||||||
|
gets its own /31 pair (`172.16.{site}.{2*si}/31`, IPN peer `.{2*si+1}`)
|
||||||
|
instead of the previous shared `/24` LAN segment with a single `.254`
|
||||||
|
IPN router. `ospfIf.addr` and each `ospfAdjEp` peer reflect the per-link
|
||||||
|
pair; the ISN CSW's LLDP mgmt address is unchanged (a device-level
|
||||||
|
management IP, not an interface address).
|
||||||
|
- **`aci-sim graph` now mirrors the autoACI topology view logic**: ISN
|
||||||
|
uplinks are drawn solid (they are physical backbone links, dashes are for
|
||||||
|
overlays), the legend gains link-type entries (fabric link / ISN uplink /
|
||||||
|
vPC peer-link), and a **Physical links table** is rendered under the
|
||||||
|
diagram — one row per fabric link and per spine ISN uplink, the ISN rows
|
||||||
|
carrying the /31 local/remote IPs.
|
||||||
|
|
||||||
## [0.19.1] - 2026-07-07
|
## [0.19.1] - 2026-07-07
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ def build(topo: Topology, site: Site, store: MITStore) -> None:
|
|||||||
# topology code matches ospfAdjEp by ifId and falls back to the base port
|
# 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.
|
# (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
|
# Point-to-point /31 per spine uplink (RFC 3021), matching the Cisco
|
||||||
|
# ACI Multi-Site design guide: each spine's ISN/IPN-facing routed
|
||||||
|
# VLAN-4 sub-interface is its own point-to-point subnet (/31 or /30),
|
||||||
|
# NOT a shared LAN segment — the IPN router's side of each link is the
|
||||||
|
# other address of the /31 pair. Scheme: spine si gets
|
||||||
|
# 172.16.{site.id}.{2*si}/31 locally, the IPN peer is .{2*si+1}.
|
||||||
# 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
|
||||||
# a hardcoded "0.0.0.0" literal; a topology author overriding
|
# a hardcoded "0.0.0.0" literal; a topology author overriding
|
||||||
# isn.ospf_area now sees it reflected in the actual OSPF object
|
# isn.ospf_area now sees it reflected in the actual OSPF object
|
||||||
@@ -99,11 +104,13 @@ def build(topo: Topology, site: Site, store: MITStore) -> None:
|
|||||||
# against real spine CLI, e.g. "Eth1/29.29"/"Eth1/33.33" in
|
# 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
|
# `show ip ospf neighbors vrf overlay-1`): the ACI-side sub-if
|
||||||
# NUMBER equals the PORT number — the encap is always vlan-4 but
|
# 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
|
# only the IPN/ISN-router side names its sub-if ".4". `addr` is
|
||||||
# the same /24 as the peer ipn_ip below so the adjacency stays
|
# this link's /31 point-to-point address; the ospfAdjEp peer below
|
||||||
# subnet-consistent (consumer: autoACI's fabric_ospf.py, which
|
# is the other half of the pair (consumer: autoACI's fabric_ospf.py,
|
||||||
# reads ospfIf.id (falling back to ifId) + area + operSt).
|
# which reads ospfIf.id (falling back to ifId) + area + operSt).
|
||||||
if_port = f"eth1/{49 + si}.{49 + si}"
|
if_port = f"eth1/{49 + si}.{49 + si}"
|
||||||
|
local_ip = f"172.16.{site.id}.{2 * si}"
|
||||||
|
ipn_peer_ip = f"172.16.{site.id}.{2 * si + 1}"
|
||||||
if_dn = f"{ospf_base}/if-[{if_port}]"
|
if_dn = f"{ospf_base}/if-[{if_port}]"
|
||||||
store.add(MO(
|
store.add(MO(
|
||||||
"ospfIf",
|
"ospfIf",
|
||||||
@@ -113,10 +120,10 @@ 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",
|
addr=f"{local_ip}/31",
|
||||||
))
|
))
|
||||||
store.add(MO(
|
store.add(MO(
|
||||||
"ospfAdjEp",
|
"ospfAdjEp",
|
||||||
dn=f"{if_dn}/adj-[{ipn_ip}]",
|
dn=f"{if_dn}/adj-[{ipn_peer_ip}]",
|
||||||
operSt="full", peerIp=ipn_ip, nbrId=ipn_ip, area=ospf_area,
|
operSt="full", peerIp=ipn_peer_ip, nbrId=ipn_peer_ip, area=ospf_area,
|
||||||
))
|
))
|
||||||
|
|||||||
+53
-1
@@ -288,10 +288,55 @@ def _legend_svg(x: float, y: float, roles_present: list[str]) -> str:
|
|||||||
f'fill="{fill}" stroke="{border}" stroke-width="1"/>'
|
f'fill="{fill}" stroke="{border}" stroke-width="1"/>'
|
||||||
f'<text x="{lx + swatch + 6}" y="{swatch - 2}" class="legend-label">{escape(LEGEND_LABELS[role])}</text>'
|
f'<text x="{lx + swatch + 6}" y="{swatch - 2}" class="legend-label">{escape(LEGEND_LABELS[role])}</text>'
|
||||||
)
|
)
|
||||||
|
# Link-type samples (mirrors autoACI's topology legend): fabric links,
|
||||||
|
# solid ISN uplink, dashed vPC peer-link.
|
||||||
|
lx = len(roles_present) * gap + 20
|
||||||
|
line_entries = [("link-cabling", "Fabric link"), ("link-isn", "ISN uplink"), ("link-vpc", "vPC peer-link")]
|
||||||
|
for cls, label in line_entries:
|
||||||
|
parts.append(
|
||||||
|
f'<line x1="{lx}" y1="{swatch / 2:.0f}" x2="{lx + 26}" y2="{swatch / 2:.0f}" class="{cls}"/>'
|
||||||
|
f'<text x="{lx + 32}" y="{swatch - 2}" class="legend-label">{escape(label)}</text>'
|
||||||
|
)
|
||||||
|
lx += 130
|
||||||
parts.append("</g>")
|
parts.append("</g>")
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _links_table_svg(topo: Topology, x: float, y: float) -> tuple[str, int]:
|
||||||
|
"""Physical-links table under the diagram (same logic as autoACI's
|
||||||
|
Topology "Fabric (physical)" session table): one row per cabling link and
|
||||||
|
per spine ISN uplink, with the /31 point-to-point IPs on the ISN rows."""
|
||||||
|
from aci_sim.build.cabling import cabling_links
|
||||||
|
|
||||||
|
cols = [("SITE", 0), ("NODE", 110), ("PORT", 260), ("NEIGHBOR", 350), ("NBR PORT", 500), ("LOCAL IP", 610), ("REMOTE IP", 730)]
|
||||||
|
row_h = 15
|
||||||
|
rows: list[tuple[str, ...]] = []
|
||||||
|
for site in topo.sites:
|
||||||
|
names = {n.id: n.name for n in site.all_nodes()}
|
||||||
|
for n1, _s1, p1, n2, _s2, p2 in cabling_links(site):
|
||||||
|
rows.append((site.name, names.get(n1, str(n1)), f"eth1/{p1}", names.get(n2, str(n2)), f"eth1/{p2}", "\u2014", "\u2014"))
|
||||||
|
if topo.isn.enabled and len(topo.sites) > 1:
|
||||||
|
for site in topo.sites:
|
||||||
|
for si, spine in enumerate(site.spine_nodes()):
|
||||||
|
rows.append((
|
||||||
|
site.name, spine.name, f"eth1/{49 + si}.{49 + si}",
|
||||||
|
f"ISN-CSW{site.id}", f"Ethernet1/{si + 1}.4",
|
||||||
|
f"172.16.{site.id}.{2 * si}/31", f"172.16.{site.id}.{2 * si + 1}",
|
||||||
|
))
|
||||||
|
parts = [f'<g transform="translate({x:.1f},{y:.1f})">',
|
||||||
|
'<text x="0" y="0" class="tbl-title">Physical links</text>']
|
||||||
|
hy = 18
|
||||||
|
for label, cx in cols:
|
||||||
|
parts.append(f'<text x="{cx}" y="{hy}" class="tbl-head">{escape(label)}</text>')
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
ry = hy + (i + 1) * row_h
|
||||||
|
for (label, cx), val in zip(cols, row):
|
||||||
|
parts.append(f'<text x="{cx}" y="{ry}" class="tbl-cell">{escape(val)}</text>')
|
||||||
|
parts.append("</g>")
|
||||||
|
height = hy + (len(rows) + 1) * row_h + 10
|
||||||
|
return "".join(parts), height
|
||||||
|
|
||||||
|
|
||||||
def _svg_style() -> str:
|
def _svg_style() -> str:
|
||||||
return (
|
return (
|
||||||
"<style>"
|
"<style>"
|
||||||
@@ -301,9 +346,12 @@ def _svg_style() -> str:
|
|||||||
f".subtitle-text {{ font-family: {FONT}; font-size: 11px; fill: #64748b; }}"
|
f".subtitle-text {{ font-family: {FONT}; font-size: 11px; fill: #64748b; }}"
|
||||||
".node-shadow { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.25)); }"
|
".node-shadow { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.25)); }"
|
||||||
".link-cabling { stroke: #d1d5db; stroke-width: 1.5; opacity: 0.8; }"
|
".link-cabling { stroke: #d1d5db; stroke-width: 1.5; opacity: 0.8; }"
|
||||||
".link-isn { stroke: #f59e0b; stroke-width: 1.5; stroke-dasharray: 5,4; opacity: 0.85; }"
|
".link-isn { stroke: #f59e0b; stroke-width: 2; opacity: 0.9; }"
|
||||||
".link-vpc { stroke: #94a3b8; stroke-width: 1.5; stroke-dasharray: 2,3; opacity: 0.7; }"
|
".link-vpc { stroke: #94a3b8; stroke-width: 1.5; stroke-dasharray: 2,3; opacity: 0.7; }"
|
||||||
".site-label { font-family: " + FONT + "; font-size: 12px; font-weight: 600; fill: #64748b; }"
|
".site-label { font-family: " + FONT + "; font-size: 12px; font-weight: 600; fill: #64748b; }"
|
||||||
|
+ ".tbl-head { font-family: " + FONT + "; font-size: 10px; font-weight: 700; fill: #475569; }"
|
||||||
|
+ ".tbl-cell { font-family: " + FONT + "; font-size: 10px; fill: #334155; }"
|
||||||
|
+ ".tbl-title { font-family: " + FONT + "; font-size: 12px; font-weight: 700; fill: #0f172a; }"
|
||||||
"</style>"
|
"</style>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -344,6 +392,9 @@ def _svg_body(graph: Graph, topo: Topology) -> tuple[str, int, int]:
|
|||||||
legend_y = height - LEGEND_H + 16
|
legend_y = height - LEGEND_H + 16
|
||||||
legend_svg = _legend_svg(MARGIN, legend_y, roles_present)
|
legend_svg = _legend_svg(MARGIN, legend_y, roles_present)
|
||||||
|
|
||||||
|
table_svg, table_h = _links_table_svg(topo, MARGIN, height + 6)
|
||||||
|
height += table_h
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
|
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
|
||||||
f'width="{width}" height="{height}" font-family="{FONT}">'
|
f'width="{width}" height="{height}" font-family="{FONT}">'
|
||||||
@@ -353,6 +404,7 @@ def _svg_body(graph: Graph, topo: Topology) -> tuple[str, int, int]:
|
|||||||
+ links_svg
|
+ links_svg
|
||||||
+ nodes_svg
|
+ nodes_svg
|
||||||
+ legend_svg
|
+ legend_svg
|
||||||
|
+ table_svg
|
||||||
+ "</svg>"
|
+ "</svg>"
|
||||||
)
|
)
|
||||||
return body, width, height
|
return body, width, height
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "aci-sim"
|
name = "aci-sim"
|
||||||
version = "0.19.1"
|
version = "0.20.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"
|
||||||
|
|||||||
@@ -119,9 +119,12 @@ class TestIsnOspfSubInterfaceAndLldp:
|
|||||||
store = orchestrator.build_site(repo_topo, site)
|
store = orchestrator.build_site(repo_topo, site)
|
||||||
ospf_ifs = store.by_class("ospfIf")
|
ospf_ifs = store.by_class("ospfIf")
|
||||||
assert ospf_ifs
|
assert ospf_ifs
|
||||||
for mo in ospf_ifs:
|
spine_ids = sorted(n.id for n in site.spine_nodes())
|
||||||
assert mo.attrs["addr"].startswith(f"172.16.{site.id}.")
|
for si, sid in enumerate(spine_ids):
|
||||||
assert mo.attrs["addr"].endswith("/24")
|
mo = next(m for m in ospf_ifs if f"/node-{sid}/" in m.dn)
|
||||||
|
# Per-link /31 point-to-point (Cisco Multi-Site design guide):
|
||||||
|
# spine si owns 172.16.{site}.{2*si}/31, IPN peer is .{2*si+1}.
|
||||||
|
assert mo.attrs["addr"] == f"172.16.{site.id}.{2 * si}/31"
|
||||||
|
|
||||||
def test_ospfAdjEp_dn_nests_under_subinterface_segment(self, repo_topo: Topology) -> None:
|
def test_ospfAdjEp_dn_nests_under_subinterface_segment(self, repo_topo: Topology) -> None:
|
||||||
site = repo_topo.site_by_name("LAB1")
|
site = repo_topo.site_by_name("LAB1")
|
||||||
@@ -133,7 +136,7 @@ class TestIsnOspfSubInterfaceAndLldp:
|
|||||||
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
|
||||||
)
|
)
|
||||||
assert adj.attrs["peerIp"] == f"172.16.{site.id}.254"
|
assert adj.attrs["peerIp"] == f"172.16.{site.id}.{2 * si + 1}"
|
||||||
|
|
||||||
def test_lldpAdjEp_exists_for_isn_csw_on_physical_port(self, repo_topo: Topology) -> None:
|
def test_lldpAdjEp_exists_for_isn_csw_on_physical_port(self, repo_topo: Topology) -> None:
|
||||||
site = repo_topo.site_by_name("LAB1")
|
site = repo_topo.site_by_name("LAB1")
|
||||||
|
|||||||
@@ -429,8 +429,10 @@ def test_ospf_if_references_real_port(store_name, request):
|
|||||||
)
|
)
|
||||||
assert ospf_if.attrs.get("id") == port_id
|
assert ospf_if.attrs.get("id") == port_id
|
||||||
addr = ospf_if.attrs.get("addr", "")
|
addr = ospf_if.attrs.get("addr", "")
|
||||||
assert re.fullmatch(r"172\.16\.\d+\.\d+/24", addr), (
|
assert re.fullmatch(r"172\.16\.\d+\.\d+/31", addr), (
|
||||||
f"ospfIf {ospf_if.dn!r} addr {addr!r} is not a 172.16.{{site}}.0/24 address"
|
f"ospfIf {ospf_if.dn!r} addr {addr!r} is not a point-to-point "
|
||||||
|
f"172.16.{{site}}.x/31 address (Cisco Multi-Site: spine-to-IPN "
|
||||||
|
f"links are /31 or /30 p2p sub-interfaces, not a shared LAN)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user