Initial public release — aci-sim v0.16.0

aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
"""build/overlay.py — intra-site BGP-EVPN + ISN inter-site eBGP peers.
Intra-site: each leaf (and border-leaf) has bgpPeer/bgpPeerEntry/bgpPeerAfEntry
to EVERY spine loopback (spines are route-reflectors).
ISN (critical): on EACH spine's sys/bgp/inst, add bgpPeer for every remote-site
spine loopback with addr=<remote_loopback>/32 and asn=<remote_site.asn>.
ISN detection in autoACI topology.py (lines 493-511):
addr_raw = attrs.get("addr", "") # e.g. "10.1.401.1/32"
peer_asn = attrs.get("asn", "") # e.g. "65002" (remote site ASN)
if "/32" in addr_raw and peer_asn and peer_asn != spine_local_asn:
# → synthesize ISN cloud node + spine→ISN links
Domain used for all EVPN peers: dom-overlay-1 (no ":" → skipped by L3Out BGP table).
IMPORTANT: store.subtree() traverses via the _children index. Every intermediate
DN in the path must have its own MO registered so the parent→child link exists.
We therefore add a bgpDom MO at each "…/inst/dom-overlay-1" before adding peers.
Batch-2 additions (CONTRACT.md §6 "Overlay/BGP"): bgpVpnRoute + bgpPath — the
EVPN route family autoACI's fabric_bgp_evpn.py "vpn_routes" view two-pass
parses (verified read-only against that plugin: pass 1 collects bgpVpnRoute
by dn under a node-scoped subtree query; pass 2 matches bgpPath children back
to their parent route via `path_dn.rsplit("/path-", 1)[0]`, so a bgpPath's RN
MUST start with "path-" directly under its route's own dn — no extra bracket
segments in between). DN family:
bgpVpnRoute {spine_inst_dn}/dom-overlay-1/af-{afi}/rt-[{prefix}]
bgpPath {route_dn}/path-{nexthop-slug}
Seeded per-spine (the EVPN route-reflector role, per this module's own
`evpn_rr` convention) for each BD subnet deployed at this site, with one path
per leaf/border-leaf that actually hosts a locally-learned endpoint for that
BD (read back from endpoints.py's fvCEp — same read-back technique
tenants.py/zoning.py already use) — a spine's VPN route table only carries
paths toward leaves it has real reachability for, not a leaf with zero
endpoints in that BD. Runs regardless of ISN enablement (real ACI's
per-VRF EVPN route table exists in a single-site fabric too, unlike the
ISN-specific inter-site peer section below).
"""
from __future__ import annotations
import re
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.fabric import loopback_ip
_DOM = "overlay-1"
_CEP_NODE_RE = re.compile(r"/paths-(\d+)/pathep-\[eth\d+/\d+\]$")
def _ensure_dom(inst_dn: str, store: MITStore, seen: set[str]) -> str:
"""Add bgpDom at inst_dn/dom-{_DOM} once; return the dom DN."""
dom_dn = f"{inst_dn}/dom-{_DOM}"
if dom_dn not in seen:
store.add(MO("bgpDom", dn=dom_dn, name=_DOM))
seen.add(dom_dn)
return dom_dn
def _add_peer(
store: MITStore,
dom_dn: str,
peer_addr: str,
peer_asn: str,
peer_type: str,
rtr_id: str,
accepted_paths: str = "10",
) -> None:
"""Emit bgpPeer + bgpPeerEntry + bgpPeerAfEntry for one BGP session."""
peer_dn = f"{dom_dn}/peer-[{peer_addr}]"
entry_dn = f"{peer_dn}/ent-[{peer_addr}]"
af_dn = f"{entry_dn}/af-[l2vpn-evpn]"
store.add(MO(
"bgpPeer",
dn=peer_dn,
addr=peer_addr,
asn=peer_asn,
type=peer_type,
peerRole="internal",
))
store.add(MO(
"bgpPeerEntry",
dn=entry_dn,
addr=peer_addr,
operSt="established",
rtrId=rtr_id,
lastFlapTs="00:00:00:00.000",
connEst="1",
connDrop="0",
type=peer_type,
))
store.add(MO(
"bgpPeerAfEntry",
dn=af_dn,
type="l2vpn-evpn",
afId="l2vpn-evpn",
acceptedPaths=accepted_paths,
pfxSent="5",
tblVer="1",
))
def _route_reflectors(topo: Topology, site: Site) -> list:
"""Resolve `topo.fabric.evpn_rr` (#27) to the actual RR node list.
Only "spine" is a meaningful topology for this simulator today — Site has
no other role that could sanely act as a BGP-EVPN route-reflector (leaves
and border-leaves are always RR clients in a Clos fabric). Honoring the
field means failing fast on an unsupported value instead of silently
building spine-RR topology regardless of what the YAML says.
"""
if topo.fabric.evpn_rr == "spine":
return site.spine_nodes()
raise ValueError(
f"fabric.evpn_rr={topo.fabric.evpn_rr!r} is not supported — only "
f"'spine' is implemented as a BGP-EVPN route-reflector role."
)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit intra-site EVPN peers + ISN inter-site peers."""
pod = site.pod
spines = _route_reflectors(topo, site)
clients = site.leaf_nodes() + site.border_leaf_nodes()
seen_doms: set[str] = set()
# -------------------------------------------------------------------------
# Intra-site: each client leaf → each spine loopback
# -------------------------------------------------------------------------
for leaf in clients:
leaf_inst_dn = f"topology/pod-{pod}/node-{leaf.id}/sys/bgp/inst"
leaf_lb = loopback_ip(pod, leaf.id)
dom_dn = _ensure_dom(leaf_inst_dn, store, seen_doms)
for spine in spines:
spine_lb = loopback_ip(pod, spine.id)
_add_peer(
store, dom_dn,
peer_addr=spine_lb,
peer_asn=str(site.asn),
peer_type="internal",
rtr_id=leaf_lb,
)
# Spines also hold the reverse view of each leaf peer (RR perspective)
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
spine_lb = loopback_ip(pod, spine.id)
dom_dn = _ensure_dom(spine_inst_dn, store, seen_doms)
for leaf in clients:
leaf_lb = loopback_ip(pod, leaf.id)
_add_peer(
store, dom_dn,
peer_addr=leaf_lb,
peer_asn=str(site.asn),
peer_type="internal",
rtr_id=spine_lb,
)
# -------------------------------------------------------------------------
# ISN: each local spine → each remote-site spine loopback (/32 + remote ASN)
# -------------------------------------------------------------------------
if not topo.isn.enabled:
return
try:
remote_site = topo.other_site(site.name)
except KeyError:
return # single-site topology → nothing to do
# #27 / Tier-2 (PR-19): isn.peer_mesh is otherwise read by no builder
# (topology/schema.py's validator only checks it == "full" to gate the
# ">=2 sites" rule). Every local spine peers every remote spine below IS
# the "full" mesh. `partial` mesh (a subset of spine pairs peering,
# e.g. for large N-site fabrics wanting to limit ISN fan-out) has NO
# sensible default partition without additional per-site config this
# schema doesn't carry (which spines pair with which — that's not
# inferable from spine count alone), so PR-19 keeps this a documented
# validate-and-reject rather than inventing a partition scheme no
# topology.yaml author asked for: fail fast rather than silently
# building full-mesh (or an arbitrary subset) for an unimplemented value.
if topo.isn.peer_mesh != "full":
raise ValueError(
f"isn.peer_mesh={topo.isn.peer_mesh!r} is not supported — only "
f"'full' (every local spine peers every remote spine) is implemented. "
f"'partial' is intentionally rejected (see build/overlay.py module "
f"docs) rather than silently built as full-mesh or an undefined subset."
)
remote_asn = str(remote_site.asn)
remote_pod = remote_site.pod
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
spine_lb = loopback_ip(pod, spine.id)
# dom_dn is already ensured above (intra-site peers were added first)
dom_dn = _ensure_dom(spine_inst_dn, store, seen_doms)
for remote_spine in remote_site.spine_nodes():
remote_lb = loopback_ip(remote_pod, remote_spine.id)
# ISN peer addr MUST end with /32 — topology.py checks:
# if "/32" in addr_raw and peer_asn != spine_local_asn → ISN cloud
isn_addr = f"{remote_lb}/32"
_add_peer(
store, dom_dn,
peer_addr=isn_addr,
peer_asn=remote_asn,
peer_type="inter-site",
rtr_id=spine_lb,
)
def _leaves_hosting_bd(store: MITStore, tenant_name: str, bd_name: str) -> set[int]:
"""Return leaf node ids with >=1 locally-learned endpoint in *bd_name*,
scanning fvCEp dns for any EPG bound to that BD (fvRsBd.tnFvBDName) —
same read-back technique tenants.py/zoning.py already use against
endpoints.py's store output."""
epg_dns: set[str] = set()
tn_prefix = f"uni/tn-{tenant_name}/"
for rsbd in store.by_class("fvRsBd"):
if rsbd.attrs.get("tnFvBDName") != bd_name:
continue
if not rsbd.dn.startswith(tn_prefix):
continue
epg_dns.add(rsbd.dn.rsplit("/rsbd", 1)[0])
leaves: set[int] = set()
for epg_dn in epg_dns:
prefix = f"{epg_dn}/cep-"
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
m = _CEP_NODE_RE.search(cep.attrs.get("fabricPathDn", ""))
if m:
leaves.add(int(m.group(1)))
return leaves
def build_vpn_routes(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit bgpVpnRoute + bgpPath (batch-2) — the per-spine EVPN VPN route
table for this site's deployed BD subnets.
Runs AFTER `tenants` and `endpoints` in the orchestrator order (unlike
`build()` above, which only needs fabric/topology data and stays early)
because it reads back fvRsBd/fvCEp to find which leaves actually host
each BD's endpoints — see module docstring for the two-pass DN shape
fabric_bgp_evpn.py's "vpn_routes" view requires.
"""
pod = site.pod
spines = _route_reflectors(topo, site)
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
for bd in tenant.bds:
if not bd.subnets:
continue
target_leaves = _leaves_hosting_bd(store, tenant.name, bd.name)
if not target_leaves:
continue
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
dom_dn = f"{spine_inst_dn}/dom-{_DOM}"
for cidr in bd.subnets:
afi = "vpnv4" # IPv4-unicast EVPN route family (real ACI: af-vpnv4/af-vpnv6)
route_dn = f"{dom_dn}/af-{afi}/rt-[{cidr}]"
route_mo = MO(
"bgpVpnRoute",
dn=route_dn,
pfx=cidr,
rd=f"{site.asn}:{tenant.name}",
)
for leaf_id in sorted(target_leaves):
leaf_lb = loopback_ip(pod, leaf_id)
route_mo.add_child(MO(
"bgpPath",
dn=f"{route_dn}/path-{leaf_id}",
nh=leaf_lb,
asPath="",
localPref="100",
metric="0",
origin="igp",
type="internal",
))
store.add(route_mo)