From e52ac1981d14e8c063fbc78c18f4c347a68ea231 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Tue, 7 Jul 2026 12:18:23 +1000 Subject: [PATCH] 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 --- CHANGELOG.md | 17 ++++++++++++ aci_sim/build/underlay.py | 23 +++++++++++------ aci_sim/graph.py | 54 ++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_pr19_tier2.py | 11 +++++--- tests/test_pr3b_batch1.py | 6 +++-- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1d755..f5b0a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) (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 ### Fixed diff --git a/aci_sim/build/underlay.py b/aci_sim/build/underlay.py index 230ec5e..d1e99cd 100644 --- a/aci_sim/build/underlay.py +++ b/aci_sim/build/underlay.py @@ -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 # (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 + # 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 # a hardcoded "0.0.0.0" literal; a topology author overriding # 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 # `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). + # only the IPN/ISN-router side names its sub-if ".4". `addr` is + # this link's /31 point-to-point address; the ospfAdjEp peer below + # is the other half of the pair (consumer: autoACI's fabric_ospf.py, + # which reads ospfIf.id (falling back to ifId) + area + operSt). 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}]" store.add(MO( "ospfIf", @@ -113,10 +120,10 @@ 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", + addr=f"{local_ip}/31", )) store.add(MO( "ospfAdjEp", - dn=f"{if_dn}/adj-[{ipn_ip}]", - operSt="full", peerIp=ipn_ip, nbrId=ipn_ip, area=ospf_area, + dn=f"{if_dn}/adj-[{ipn_peer_ip}]", + operSt="full", peerIp=ipn_peer_ip, nbrId=ipn_peer_ip, area=ospf_area, )) diff --git a/aci_sim/graph.py b/aci_sim/graph.py index 7eb41fd..da409c3 100644 --- a/aci_sim/graph.py +++ b/aci_sim/graph.py @@ -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'{escape(LEGEND_LABELS[role])}' ) + # 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'' + f'{escape(label)}' + ) + lx += 130 parts.append("") 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'', + 'Physical links'] + hy = 18 + for label, cx in cols: + parts.append(f'{escape(label)}') + for i, row in enumerate(rows): + ry = hy + (i + 1) * row_h + for (label, cx), val in zip(cols, row): + parts.append(f'{escape(val)}') + parts.append("") + height = hy + (len(rows) + 1) * row_h + 10 + return "".join(parts), height + + def _svg_style() -> str: return ( "" ) @@ -344,6 +392,9 @@ def _svg_body(graph: Graph, topo: Topology) -> tuple[str, int, int]: legend_y = height - LEGEND_H + 16 legend_svg = _legend_svg(MARGIN, legend_y, roles_present) + table_svg, table_h = _links_table_svg(topo, MARGIN, height + 6) + height += table_h + body = ( f'' @@ -353,6 +404,7 @@ def _svg_body(graph: Graph, topo: Topology) -> tuple[str, int, int]: + links_svg + nodes_svg + legend_svg + + table_svg + "" ) return body, width, height diff --git a/pyproject.toml b/pyproject.toml index f27e097..5629a95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] 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)" readme = "README.md" license = "PolyForm-Noncommercial-1.0.0" diff --git a/tests/test_pr19_tier2.py b/tests/test_pr19_tier2.py index 0814112..2eee2da 100644 --- a/tests/test_pr19_tier2.py +++ b/tests/test_pr19_tier2.py @@ -119,9 +119,12 @@ class TestIsnOspfSubInterfaceAndLldp: 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") + spine_ids = sorted(n.id for n in site.spine_nodes()) + for si, sid in enumerate(spine_ids): + 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: site = repo_topo.site_by_name("LAB1") @@ -133,7 +136,7 @@ class TestIsnOspfSubInterfaceAndLldp: 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" + 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: site = repo_topo.site_by_name("LAB1") diff --git a/tests/test_pr3b_batch1.py b/tests/test_pr3b_batch1.py index 769c623..c4bb765 100644 --- a/tests/test_pr3b_batch1.py +++ b/tests/test_pr3b_batch1.py @@ -429,8 +429,10 @@ def test_ospf_if_references_real_port(store_name, request): ) 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" + assert re.fullmatch(r"172\.16\.\d+\.\d+/31", addr), ( + 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)" )