Files
aci-sim/aci_sim/build/l3out.py
T
taodengandClaude Fable 5 7e9a175ce6 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>
2026-07-05 20:06:36 +10:00

295 lines
12 KiB
Python

"""build/l3out.py — l3extOut tree + eBGP operational session MOs.
Batch-1 additions (CONTRACT.md §6 "Route-control", "L3Out (control)"):
route-control (rtctrlProfile/rtctrlCtxP/rtctrlSubjP/rtctrlMatchRtDest) +
static routes (ipRouteP/ipNexthopP) + l3extMember + ospfExtP. DN/RN shapes
verified against autoACI's plugins (read-only, prior-audit trust per
CONTRACT.md header; see docs/DESIGN.md "Batch-1" section for the specific
attrs read by route_control.py / l3out_detail.py):
rtctrlProfile uni/tn-{t}/out-{l3out}/prof-{name} (export profile, one per L3Out)
rtctrlCtxP {profile}/ctx-{name}
rtctrlSubjP uni/tn-{t}/subj-{name} (tenant-level match rule —
route_control.py queries it with wcard(dn,"tn-{tenant}"), NOT
scoped under any one L3Out)
rtctrlMatchRtDest {subjp}/dest-[{ip}]
rtctrlSetComm under the ctx (real ACI RN: "scomm")
rtctrlSetPref under the ctx (real ACI RN: "spref")
ipRouteP {rsnodeL3OutAtt}/rt-[{prefix}] (static route toward the CSW peer)
ipNexthopP {ipRouteP}/nh-[{addr}]
l3extMember {l3extRsPathL3OutAtt}/mem-{A|B} (only if the path is vPC-style;
this topology's L3Out path is a single port per border-leaf — see
_build_node_profile's path_tdn — so we emit one member, side="A",
on that existing single path, per the batch-1 placement note.)
ospfExtP uni/tn-{t}/out-{l3out}/ospfExtP (added to the SAME L3Out as
the existing bgpExtP — real ACI allows OSPF+BGP coexistence on one
L3Out, and this topology has no OSPF-only L3Out, so extending the
existing one is the simpler faithful choice; see DESIGN.md.)
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import L3Out, Site, Tenant, Topology
from aci_sim.build.fabric import loopback_ip
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _owning_site(l3out: L3Out, topo: Topology) -> str | None:
if l3out.site is not None:
return l3out.site
for s in topo.sites:
site_bl_ids = {bl.id for bl in s.border_leaves}
if any(bl_id in site_bl_ids for bl_id in l3out.border_leaves):
return s.name
return None
def _build_node_profile(l3out_dn: str, l3out: L3Out, site: Site, pod: int) -> MO:
"""Build l3extLNodeP (with node attachments) + l3extLIfP (paths + BGP peer)."""
site_bl_ids = {bl.id: bl for bl in site.border_leaves}
bl_ids_here = [bid for bid in l3out.border_leaves if bid in site_bl_ids]
np_name = f"{l3out.name}_nodeProfile"
np_dn = f"{l3out_dn}/lnodep-{np_name}"
np_mo = MO("l3extLNodeP", dn=np_dn, name=np_name)
for bl_id in bl_ids_here:
rtr_id = loopback_ip(pod, bl_id)
rsnode_dn = f"{np_dn}/rsnodeL3OutAtt-[topology/pod-{pod}/node-{bl_id}]"
rsnode_mo = MO(
"l3extRsNodeL3OutAtt",
dn=rsnode_dn,
tDn=f"topology/pod-{pod}/node-{bl_id}",
rtrId=rtr_id,
)
# Batch-1: static default route toward this border-leaf's CSW peer.
# ipRouteP.ip carries the prefix (autoACI's l3out_detail.py/route_control.py
# read attrs["ip"], not "prefix" — that's uribv4Route); ipNexthopP.nhAddr is
# the CSW interconnect IP the eBGP session builder (_upsert_ebgp_sessions)
# already peers with, so the static route is consistent with the live BGP
# session on the same border leaf.
if l3out.csw_peer:
prefix = "0.0.0.0/0"
route_dn = f"{rsnode_dn}/rt-[{prefix}]"
route_mo = MO("ipRouteP", dn=route_dn, ip=prefix, pref="1")
nh_addr = l3out.csw_peer.peer_ip
route_mo.add_child(MO(
"ipNexthopP",
dn=f"{route_dn}/nh-[{nh_addr}]",
nhAddr=nh_addr,
))
rsnode_mo.add_child(route_mo)
np_mo.add_child(rsnode_mo)
ip_name = f"{l3out.name}_ifProfile"
ip_dn = f"{np_dn}/lifp-{ip_name}"
ip_mo = MO("l3extLIfP", dn=ip_dn, name=ip_name)
for bl_id in bl_ids_here:
bl_obj = site_bl_ids[bl_id]
csw = bl_obj.csw
path_tdn = f"topology/pod-{pod}/paths-{bl_id}/pathep-[eth1/48]"
path_dn = f"{ip_dn}/rspathL3OutAtt-[{path_tdn}]"
path_mo = MO(
"l3extRsPathL3OutAtt",
dn=path_dn,
tDn=path_tdn,
encap=f"vlan-{csw.vlan}",
addr=f"{csw.local_ip}/30",
ifInstT="sub-interface",
)
# Batch-1: this L3Out's path is a single port per border-leaf (not a
# vPC-bundled path — see path_tdn above), so per the batch-1 placement
# note we emit exactly one l3extMember, side="A", on the existing path.
path_mo.add_child(MO(
"l3extMember",
dn=f"{path_dn}/mem-A",
side="A",
addr=f"{csw.local_ip}/30",
))
ip_mo.add_child(path_mo)
if l3out.csw_peer:
peer_ip = l3out.csw_peer.peer_ip
peer_dn = f"{ip_dn}/peerP-[{peer_ip}]"
# Tier-3 (PR-20): keepalive/hold — real ACI defaults 60s/180s,
# previously not carried anywhere on this MO. l3out.csw_peer's
# keepalive/hold feed bgpPeerP.privateAsCtrl-adjacent timer attrs.
peer_mo = MO(
"bgpPeerP",
dn=peer_dn,
addr=peer_ip,
peerCtrl="",
keepAliveIntvl=str(l3out.csw_peer.keepalive),
holdIntvl=str(l3out.csw_peer.hold),
)
peer_mo.add_child(MO(
"bgpAsP",
dn=f"{peer_dn}/as",
asn=str(l3out.csw_peer.asn),
))
ip_mo.add_child(peer_mo)
np_mo.add_child(ip_mo)
return np_mo
def _build_route_control(t: str, l3out: L3Out, l3out_dn: str) -> MO:
"""Build rtctrlProfile (export profile) + rtctrlCtxP + rtctrlSetComm/SetPref
children, one per L3Out. RN scheme (verified against this module's own
uni/tn-{t}/out-{l3out}/... conventions; "scomm"/"spref" are the real ACI
RNs for rtctrlSetComm/rtctrlSetPref — Cisco's fixed short-form RNs, not
name-suffixed like most other RN templates in this file):
rtctrlProfile {l3out_dn}/prof-{name}
rtctrlCtxP {profile}/ctx-{name}
rtctrlSetComm {ctx}/scomm
rtctrlSetPref {ctx}/spref
"""
prof_name = f"{l3out.name}_export"
prof_dn = f"{l3out_dn}/prof-{prof_name}"
prof_mo = MO("rtctrlProfile", dn=prof_dn, name=prof_name)
ctx_name = f"{l3out.name}_ctx"
ctx_dn = f"{prof_dn}/ctx-{ctx_name}"
ctx_mo = MO("rtctrlCtxP", dn=ctx_dn, name=ctx_name, action="permit")
ctx_mo.add_child(MO("rtctrlSetComm", dn=f"{ctx_dn}/scomm", community=f"no-advertise:{t}"))
ctx_mo.add_child(MO("rtctrlSetPref", dn=f"{ctx_dn}/spref", localPref="200"))
prof_mo.add_child(ctx_mo)
return prof_mo
def _build_tenant_subj(t: str, l3out: L3Out, bd_subnets: list[str]) -> MO | None:
"""Build one tenant-level rtctrlSubjP (match rule) + rtctrlMatchRtDest
children, referenced from this L3Out's rtctrlCtxP.
rtctrlSubjP is tenant-level (uni/tn-{t}/subj-{name}), NOT nested under any
one L3Out's DN — autoACI's route_control.py queries it with
wcard(rtctrlSubjP.dn, "tn-{tenant}") rather than scoping it to an L3Out
(verified against the plugin source). Match destinations reuse a real BD
subnet from the tenant so the redistributed prefix is believable.
"""
if not bd_subnets:
return None
subj_name = f"{l3out.name}_match"
subj_dn = f"uni/tn-{t}/subj-{subj_name}"
subj_mo = MO("rtctrlSubjP", dn=subj_dn, name=subj_name)
subj_mo.add_child(MO(
"rtctrlMatchRtDest",
dn=f"{subj_dn}/dest-[{bd_subnets[0]}]",
ip=bd_subnets[0],
))
return subj_mo
def _build_l3out_tree(t: str, l3out: L3Out, site: Site, l3_dom_name: str, bd_subnets: list[str]) -> MO:
pod = site.pod
l3out_dn = f"uni/tn-{t}/out-{l3out.name}"
l3out_mo = MO("l3extOut", dn=l3out_dn, name=l3out.name, enforceRtctrl="export")
l3out_mo.add_child(MO(
"l3extRsEctx",
dn=f"{l3out_dn}/rsectx",
tnFvCtxName=l3out.vrf,
tDn=f"uni/tn-{t}/ctx-{l3out.vrf}",
))
l3out_mo.add_child(MO(
"l3extRsL3DomAtt",
dn=f"{l3out_dn}/rsl3DomAtt",
tDn=f"uni/l3dom-{l3_dom_name}",
))
l3out_mo.add_child(MO("bgpExtP", dn=f"{l3out_dn}/bgpExtP"))
# Batch-1: ospfExtP added to the SAME L3Out (coexists with bgpExtP above —
# real ACI supports OSPF+BGP on one L3Out; see module docstring).
l3out_mo.add_child(MO(
"ospfExtP",
dn=f"{l3out_dn}/ospfExtP",
areaId="0.0.0.1",
areaType="regular",
))
l3out_mo.add_child(_build_route_control(t, l3out, l3out_dn))
l3out_mo.add_child(_build_node_profile(l3out_dn, l3out, site, pod))
for ext_epg in l3out.ext_epgs:
inst_dn = f"{l3out_dn}/instP-{ext_epg.name}"
inst_mo = MO("l3extInstP", dn=inst_dn, name=ext_epg.name, prefGrMemb="exclude")
for cidr in ext_epg.subnets:
inst_mo.add_child(MO(
"l3extSubnet",
dn=f"{inst_dn}/extsubnet-[{cidr}]",
ip=cidr,
scope="import-security",
))
l3out_mo.add_child(inst_mo)
return l3out_mo
def _upsert_ebgp_sessions(
t: str, l3out: L3Out, site: Site, store: MITStore
) -> None:
pod = site.pod
site_bl_ids = {bl.id: bl for bl in site.border_leaves}
bl_ids_here = [bid for bid in l3out.border_leaves if bid in site_bl_ids]
for bl_id in bl_ids_here:
bl_obj = site_bl_ids[bl_id]
csw = bl_obj.csw
rtr_id = loopback_ip(pod, bl_id)
inst_dn = f"topology/pod-{pod}/node-{bl_id}/sys/bgp/inst"
inst_mo = MO("bgpInst", dn=inst_dn, asn=str(site.asn))
dom_name = f"{t}:{l3out.vrf}"
dom_dn = f"{inst_dn}/dom-{dom_name}"
dom_mo = MO("bgpDom", dn=dom_dn, name=dom_name)
peer_dn = f"{dom_dn}/peer-[{csw.peer_ip}]"
peer_mo = MO(
"bgpPeer",
dn=peer_dn,
addr=csw.peer_ip,
asn=str(csw.asn),
type="ebgp",
peerRole="ce",
)
entry_dn = f"{peer_dn}/ent-[{csw.peer_ip}]"
peer_mo.add_child(MO(
"bgpPeerEntry",
dn=entry_dn,
addr=csw.peer_ip,
operSt="established",
rtrId=rtr_id,
connEst="1",
connDrop="0",
type="ebgp",
))
dom_mo.add_child(peer_mo)
inst_mo.add_child(dom_mo)
store.upsert(inst_mo)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit l3extOut trees + eBGP session MOs for L3Outs owned by *site*."""
l3_dom_name = topo.access.l3_domains[0].name if topo.access.l3_domains else "l3dom-1"
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
bd_subnets = [cidr for bd in tenant.bds for cidr in bd.subnets]
for l3out in tenant.l3outs:
if _owning_site(l3out, topo) != site.name:
continue
store.add(_build_l3out_tree(t, l3out, site, l3_dom_name, bd_subnets))
_upsert_ebgp_sessions(t, l3out, site, store)
# Batch-1: tenant-level match rule (rtctrlSubjP), added independently
# of the l3out tree (autoACI queries it as tenant-scoped, not nested
# under any one L3Out — see _build_tenant_subj docstring).
subj_mo = _build_tenant_subj(t, l3out, bd_subnets)
if subj_mo is not None:
store.add(subj_mo)