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
View File
+291
View File
@@ -0,0 +1,291 @@
"""build/access.py — VLAN pools, physical/L3 domains, AAEPs, access port IPGs,
and (batch-1) the leaf interface-policy cluster: infraAccPortP/infraHPortS/
infraPortBlk/infraRsAccBaseGrp/infraAccBndlGrp.
Site-independent: call once per topology (site arg accepted for API uniformity
but not used in the selection logic).
Interface-policy cluster (CONTRACT.md §6 "Access policy") — real ACI DN/RN
conventions, verified against the existing infra tree this module already
builds (uni/infra/... prefix) and against autoACI's access_policy.py (read via
prior audit, see docs/CONTRACT.md header):
infraAccPortP uni/infra/accportprof-{name}
infraHPortS {accportp}/hports-{name}-typ-range
infraPortBlk {hports}/portblk-block{n}
infraRsAccBaseGrp {hports}/rsaccBaseGrp
infraAccBndlGrp uni/infra/funcprof/accbundle-{name}
Port ranges in infraPortBlk MUST match ports that actually exist in the
l1PhysIf inventory interfaces.py builds (eth1/1.._HOST_PORTS on leaves) — the
whole cluster is built once per border-leaf vPC pair (the topology's only vPC
domains, per fabric.py's vpcDom/vpcIf) plus one profile per regular-leaf pair,
so infraAccBndlGrp(lagT="node") lines up with a real vpcDom this same site
already has.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import AAEP, L3Domain, PhysDomain, Site, Topology, VlanPool, VMMDomain
from aci_sim.build.interfaces import _HOST_PORTS
def _build_vlan_pool(pool: VlanPool) -> MO:
"""Build fvnsVlanInstP + fvnsEncapBlk child."""
pool_dn = f"uni/infra/vlanns-[{pool.name}]-static"
blk_dn = (
f"{pool_dn}"
f"/from-[vlan-{pool.from_vlan}]-to-[vlan-{pool.to_vlan}]"
)
pool_mo = MO("fvnsVlanInstP", dn=pool_dn, name=pool.name, allocMode="static")
blk_mo = MO(
"fvnsEncapBlk",
dn=blk_dn,
allocMode="static",
**{"from": f"vlan-{pool.from_vlan}", "to": f"vlan-{pool.to_vlan}"},
)
pool_mo.add_child(blk_mo)
return pool_mo
def _build_phys_dom(phys_dom: PhysDomain, first_pool_dn: str) -> MO:
"""Build physDomP + optional infraRsVlanNs child."""
dom_mo = MO("physDomP", dn=f"uni/phys-{phys_dom.name}", name=phys_dom.name)
if first_pool_dn:
dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"uni/phys-{phys_dom.name}/rsvlanNs",
tDn=first_pool_dn,
))
return dom_mo
def _build_l3_dom(l3_dom: L3Domain, first_pool_dn: str) -> MO:
"""Build l3extDomP + optional infraRsVlanNs child."""
l3dom_mo = MO("l3extDomP", dn=f"uni/l3dom-{l3_dom.name}", name=l3_dom.name)
if first_pool_dn:
l3dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"uni/l3dom-{l3_dom.name}/rsvlanNs",
tDn=first_pool_dn,
))
return l3dom_mo
def _build_vmm_domain(vmm: VMMDomain, first_pool_dn: str) -> MO:
"""Build vmmDomP (+ vmmCtrlrP + vmmUsrAccP if vcenter_ip set, + infraRsVlanNs).
Tier-2 (PR-19). Real ACI DN scheme (verified against aci-py's
`_domain_dn_class` shim — see docs/DESIGN.md PR-19 note):
vmmDomP uni/vmmp-VMware/dom-{name}
vmmCtrlrP {vmmDomP dn}/ctrlr-{name} hostOrIp=vcenter_ip, rootContName=datacenter
vmmUsrAccP {vmmDomP dn}/usracc-{name} placeholder credential-profile ref (no
real credentials in a sim — name-only, matches the pattern
phys/l3 domains use for their own no-secret child objects)
Only "VMware" (vmmProvP=VMware) is modeled — the sole provider the
aci-py `bind_epg_to_vmm_domain` playbook chain (and the DN map in
`mso_schema_site_anp_epg_domain.py`, `dn_map["vmmDomain"] =
"uni/vmmp-VMware/dom-{0}"`) actually exercises today.
vmmCtrlrP/vmmUsrAccP are only emitted when `vcenter_ip` is set — a bare
`{name: ...}` VMM domain entry (no vCenter attrs) produces just the
vmmDomP + optional infraRsVlanNs, same shape as a phys/l3 domain with no
extra config, so a topology author who only wants a named domain isn't
forced to supply vCenter connection details.
"""
dom_dn = f"uni/vmmp-VMware/dom-{vmm.name}"
dom_mo = MO("vmmDomP", dn=dom_dn, name=vmm.name)
if vmm.vcenter_ip:
ctrlr_dn = f"{dom_dn}/ctrlr-{vmm.name}"
ctrlr_mo = MO(
"vmmCtrlrP",
dn=ctrlr_dn,
name=vmm.name,
hostOrIp=vmm.vcenter_ip,
rootContName=vmm.datacenter or vmm.name,
dvsVersion="unmanaged",
)
if vmm.vcenter_dvs:
ctrlr_mo.attrs["dvsName"] = vmm.vcenter_dvs
dom_mo.add_child(ctrlr_mo)
dom_mo.add_child(MO(
"vmmUsrAccP",
dn=f"{ctrlr_dn}/usracc-{vmm.name}",
name=vmm.name,
))
if vmm.vlan_pool and first_pool_dn:
# first_pool_dn is topo.access.vlan_pools[0] (the same convention
# phys/l3 domains use below); Tier-2 vmm.vlan_pool is validated
# against the real pool-name set in schema.py's normalize_and_validate
# but this builder still binds the module-wide first pool DN (the
# existing phys/l3-domain convention), matching a dynamic VMM pool
# binding by name rather than resolving a distinct pool DN per VMM
# domain — sufficient for the sim's single-pool-per-topology norm.
dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"{dom_dn}/rsvlanNs",
tDn=first_pool_dn,
))
return dom_mo
def _build_aaep(
aaep: AAEP,
phys_domains: list[PhysDomain],
l3_domains: list[L3Domain],
) -> MO:
"""Build infraAttEntityP + infraRsDomP children for all domains."""
aaep_dn = f"uni/infra/attentp-{aaep.name}"
aaep_mo = MO("infraAttEntityP", dn=aaep_dn, name=aaep.name)
for phys_dom in phys_domains:
tdn = f"uni/phys-{phys_dom.name}"
aaep_mo.add_child(MO(
"infraRsDomP",
dn=f"{aaep_dn}/rsdomP-[{tdn}]",
tDn=tdn,
))
for l3_dom in l3_domains:
tdn = f"uni/l3dom-{l3_dom.name}"
aaep_mo.add_child(MO(
"infraRsDomP",
dn=f"{aaep_dn}/rsdomP-[{tdn}]",
tDn=tdn,
))
return aaep_mo
def _build_port_ipg(aaep: AAEP) -> MO:
"""Build infraAccPortGrp + infraRsAttEntP child for one AAEP."""
ipg_name = f"{aaep.name}-ipg"
ipg_dn = f"uni/infra/funcprof/accportgrp-{ipg_name}"
ipg_mo = MO(
"infraAccPortGrp",
dn=ipg_dn,
name=ipg_name,
lagT="not-aggregated",
)
ipg_mo.add_child(MO(
"infraRsAttEntP",
dn=f"{ipg_dn}/rsattEntP",
tDn=f"uni/infra/attentp-{aaep.name}",
))
return ipg_mo
def _build_bndl_grp(name: str, lag_t: str) -> MO:
"""Build infraAccBndlGrp (funcprof bundle IPG) — vPC (lagT=node) or PC (lagT=link)."""
dn = f"uni/infra/funcprof/accbundle-{name}"
return MO("infraAccBndlGrp", dn=dn, name=name, lagT=lag_t)
def _build_hports(
accportp_dn: str,
sel_name: str,
from_port: int,
to_port: int,
bndl_grp_dn: str,
) -> MO:
"""Build infraHPortS (type=range) + infraPortBlk + infraRsAccBaseGrp children.
RN scheme verified against this module's own uni/infra/... conventions
(accportprof-/hports-/portblk-):
infraHPortS hports-{sel_name}-typ-range
infraPortBlk portblk-block1 (single block covering the range)
infraRsAccBaseGrp rsaccBaseGrp (fixed RN — one target per selector)
"""
hports_dn = f"{accportp_dn}/hports-{sel_name}-typ-range"
hports_mo = MO("infraHPortS", dn=hports_dn, name=sel_name, type="range")
hports_mo.add_child(MO(
"infraPortBlk",
dn=f"{hports_dn}/portblk-block1",
fromPort=str(from_port),
toPort=str(to_port),
fromCard="1",
))
hports_mo.add_child(MO(
"infraRsAccBaseGrp",
dn=f"{hports_dn}/rsaccBaseGrp",
tDn=bndl_grp_dn,
))
return hports_mo
def _build_leaf_profile(profile_name: str, sel_name: str, bndl_grp_dn: str) -> MO:
"""Build one infraAccPortP with one infraHPortS selector spanning the
leaf's host-port range (eth1/1.._HOST_PORTS — the exact inventory
interfaces.py builds), so every infraPortBlk from/to port exists as a
real l1PhysIf."""
accportp_dn = f"uni/infra/accportprof-{profile_name}"
accportp_mo = MO("infraAccPortP", dn=accportp_dn, name=profile_name)
accportp_mo.add_child(_build_hports(accportp_dn, sel_name, 1, _HOST_PORTS, bndl_grp_dn))
return accportp_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit access-policy MOs (site-independent, build once)."""
# Batch-1 fix: uni/infra previously had no MO of its own — only
# descendant DNs (uni/infra/vlanns-.../, uni/infra/attentp-.../, etc.),
# which the store's ancestor-walk (_register) links structurally but
# never materializes as a real object. That made
# GET /api/mo/uni/infra.json (any rsp-subtree mode) 404, even though the
# generic query engine's rsp-subtree=full traversal otherwise works fine
# (verified: /api/class/infraAccPortP.json?rsp-subtree=full already
# returns the nested infraHPortS/infraPortBlk/infraRsAccBaseGrp tree).
# infraInfra is the real ACI class for this container (CONTRACT.md §6
# "Infra write targets"); seeding it here is a one-line, no-behavior-
# change fix so `GET /api/mo/uni/infra.json?rsp-subtree=full` (needed to
# see the whole access-policy tree in one call, same as autoACI's
# ipg_detail/access_policy plugins would) returns 200 with a real root.
store.add(MO("infraInfra", dn="uni/infra", name=""))
first_pool_dn = (
f"uni/infra/vlanns-[{topo.access.vlan_pools[0].name}]-static"
if topo.access.vlan_pools else ""
)
for pool in topo.access.vlan_pools:
store.add(_build_vlan_pool(pool))
for phys_dom in topo.access.phys_domains:
store.add(_build_phys_dom(phys_dom, first_pool_dn))
for l3_dom in topo.access.l3_domains:
store.add(_build_l3_dom(l3_dom, first_pool_dn))
# Tier-2 (PR-19): VMM domains — optional, defaults to an empty list, so
# a topology with no `access.vmm_domains` produces zero vmmDomP MOs
# (byte-identical to pre-PR-19 output).
for vmm in topo.access.vmm_domains:
store.add(_build_vmm_domain(vmm, first_pool_dn))
for aaep in topo.access.aaeps:
store.add(_build_aaep(aaep, topo.access.phys_domains, topo.access.l3_domains))
for aaep in topo.access.aaeps:
store.add(_build_port_ipg(aaep))
# ---- Batch-1: leaf-interface-policy cluster --------------------------
# One vPC bundle group per border-leaf vPC domain (fabric.py already
# builds the matching vpcDom/vpcIf for these same pairs), one leaf
# interface-profile per leaf pair (regular leaves + each border-leaf
# vPC pair), each with a single range selector spanning the real
# host-port inventory (eth1/1.._HOST_PORTS).
seen_vpc_domains: set[str] = set()
for bl in site.border_leaves:
if bl.vpc_domain in seen_vpc_domains:
continue
seen_vpc_domains.add(bl.vpc_domain)
bndl_name = f"{bl.vpc_domain}-vpc"
store.add(_build_bndl_grp(bndl_name, lag_t="node"))
bndl_grp_dn = f"uni/infra/funcprof/accbundle-{bndl_name}"
profile_name = f"{site.name}-{bl.vpc_domain}"
store.add(_build_leaf_profile(profile_name, f"sel-{bl.vpc_domain}", bndl_grp_dn))
leaves = site.leaf_nodes()
if len(leaves) >= 2:
pair_name = f"{site.name}-leafpair"
pc_bndl_name = f"{pair_name}-pc"
store.add(_build_bndl_grp(pc_bndl_name, lag_t="link"))
pc_bndl_grp_dn = f"uni/infra/funcprof/accbundle-{pc_bndl_name}"
store.add(_build_leaf_profile(pair_name, f"sel-{pair_name}", pc_bndl_grp_dn))
+94
View File
@@ -0,0 +1,94 @@
"""build/cabling.py — fabricLink, lldpAdjEp, cdpAdjEp derived from the cabling graph.
fabricLink DN (verified against autoACI/backend/routers/topology.py lines 381-388):
topology/pod-{pod}/lnk-{n1}-{s1}-{p1}-to-{n2}-{s2}-{p2}
where "lnk-{n1}-{s1}-{p1}-to-...".split("-") = [n1, s1, p1, ...]
→ source_port = f"eth{s1}/{p1}" (spc[1], spc[2])
n1/n2 attributes carry the plain node IDs.
Port assignment (auto mode):
Spines: uplinks eth1/1, eth1/2, ... (one per downlink, in order)
Leaves/border-leaves: uplinks eth1/49, eth1/50, ... (one per spine, in order)
"""
from __future__ import annotations
from aci_sim.build.fabric import oob_ip
from aci_sim.build.neighbors import add_switch_adjacency
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Node, Site, Topology
def cabling_links(site: Site) -> list[tuple[int, int, int, int, int, int]]:
"""Return (n1_id, slot1, port1, n2_id, slot2, port2) for every fabric link.
Convention: n1=spine, n2=leaf/border-leaf.
Spine port: eth1/{leaf_idx+1}
Leaf uplink: eth1/{49+spine_idx}
"""
spines = site.spine_nodes()
downlinks = site.leaf_nodes() + site.border_leaf_nodes()
links: list[tuple[int, int, int, int, int, int]] = []
for l_idx, leaf in enumerate(downlinks):
for s_idx, spine in enumerate(spines):
links.append((
spine.id, 1, l_idx + 1, # spine eth1/(leaf_idx+1)
leaf.id, 1, 49 + s_idx, # leaf eth1/(49+spine_idx)
))
return links
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fabricLink + lldpAdjEp + cdpAdjEp derived from the cabling graph."""
pod = site.pod
# Build a lookup: node_id -> Node (for name/loopback lookups)
node_map: dict[int, Node] = {n.id: n for n in site.all_nodes()}
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
lnk_dn = (
f"topology/pod-{pod}"
f"/lnk-{n1}-{s1}-{p1}-to-{n2}-{s2}-{p2}"
)
store.add(MO(
"fabricLink",
dn=lnk_dn,
n1=str(n1),
n2=str(n2),
operSt="up",
operSpeed="100G",
linkType="leaf",
))
# --- LLDP & CDP adjacencies on BOTH endpoints ---
node1 = node_map.get(n1)
node2 = node_map.get(n2)
if node1 is None or node2 is None:
continue
port1 = f"eth{s1}/{p1}"
port2 = f"eth{s2}/{p2}"
oob1 = oob_ip(pod, n1)
oob2 = oob_ip(pod, n2)
# Node1 sees Node2 as neighbor on port1 (Node2's own port is port2)
add_switch_adjacency(
store,
pod=pod,
local_node_id=n1,
local_port=port1,
remote_port=port2,
neighbor=node2,
neighbor_mgmt_ip=oob2,
)
# Node2 sees Node1 as neighbor on port2 (Node1's own port is port1)
add_switch_adjacency(
store,
pod=pod,
local_node_id=n2,
local_port=port2,
remote_port=port1,
neighbor=node1,
neighbor_mgmt_ip=oob1,
)
+313
View File
@@ -0,0 +1,313 @@
"""build/endpoints.py — fvCEp, fvIp, epmMacEp, epmIpEp, fvIfConn.
Port allocation avoids the APIC-controller-reserved port range: fabric.py wires
each APIC-<cid> (cid 1..site.controllers) to eth1/<cid> on the first two leaves
(site.leaf_nodes()[:2]) — the same leaves endpoints are placed on. Endpoint
host ports therefore start right AFTER that reserved range so no port ever
hosts both a controller lldpAdjEp and an endpoint fabricPathDn (see
interfaces.py's _HOST_PORTS=8 host-port inventory for the ceiling).
Multi-site (stretched-tenant) endpoint semantics (see docs/DESIGN.md):
A stretched tenant (tenant.stretch=true, sites: [A, B]) is built independently
per site by this same build() function, once per site's MITStore. Real ACI
never shows the identical endpoint as locally-learned-on-a-front-panel-port at
BOTH sites simultaneously: exactly one site has it physically attached (HOME,
lcC contains "learned", fabricPathDn = a real front-panel pathep); the other
site only knows about it via the multi-site overlay/ISN tunnel (REMOTE,
lcC="learned,vxlan", fabricPathDn = a tunnelIf path toward the home site's
spine-proxy TEP). The HOME site is chosen deterministically per endpoint
(alternating over tenant.sites by the endpoint's position in the per-tenant
sequence) so both sites' independent builds agree on which one is home
without needing to share state.
"""
from __future__ import annotations
import ipaddress
import zlib
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import BD, Site, Tenant, Topology
from aci_sim.build.fabric import loopback_ip
from aci_sim.build.interfaces import _HOST_PORTS
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _vnid(name: str, base: int) -> int:
return base + (zlib.crc32(name.encode()) & 0xFFFF)
def _mac(counter: int) -> str:
aa = (counter >> 16) & 0xFF
bb = (counter >> 8) & 0xFF
cc = counter & 0xFF
return f"00:50:56:{aa:02X}:{bb:02X}:{cc:02X}"
def _host_ip(bd: BD, index: int) -> str:
"""Return host at position 10+index within the first BD subnet."""
if not bd.subnets:
return "0.0.0.0"
net = ipaddress.ip_network(bd.subnets[0], strict=False)
hosts = list(net.hosts())
pos = 10 + index
return str(hosts[pos % len(hosts)])
def _emit_epm_mos(
store: MITStore,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port_ifid: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
flags: str = "local",
) -> None:
"""Emit epmMacEp, epmIpEp, and (for real front-panel ports) fvIfConn."""
ctx_bd = f"ctx-[vxlan-{vrf_vnid}]/bd-[vxlan-{bd_vnid}]"
node_sys = f"topology/pod-{pod}/node-{leaf_id}/sys"
store.add(MO(
"epmMacEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/mac-{mac}",
addr=mac,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
encap=f"vlan-{epg_vlan}",
))
store.add(MO(
"epmIpEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/ip-[{ip}]",
addr=ip,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
))
if flags == "local":
store.add(MO(
"fvIfConn",
dn=(
f"{node_sys}/phys-[{port_ifid}]"
f"/epgconn-[{epg_ref}]"
),
encap=f"vlan-{epg_vlan}",
))
def _emit_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port: int,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp+fvIfConn for one LOCAL (home-site)
endpoint — physically attached to a real front-panel leaf port."""
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned",
fabricPathDn=(
f"topology/pod-{pod}/paths-{leaf_id}/pathep-[eth1/{port}]"
),
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, leaf_id, f"eth1/{port}",
epg_ref, vrf_vnid, bd_vnid, flags="local",
)
def _ensure_tunnel_if(
store: MITStore,
pod: int,
spine_id: int,
remote_pod: int,
remote_spine_id: int,
seen: set[tuple[int, int]],
) -> str:
"""Ensure a tunnelIf MO exists on *spine_id* toward the remote site's
spine-proxy TEP (represented by remote_spine_id's loopback); return its
interface id (e.g. "tunnel401"), used to build the fvCEp fabricPathDn.
Real ACI multi-site: remote-learned endpoints are reachable via a VXLAN
tunnel from the local spine-proxy to the remote site's spine-proxy anycast
TEP. We model one tunnelIf per (local spine, remote spine) pair, numbered
deterministically by the remote spine's id so the DN is stable and
idempotent across multiple endpoints sharing the same tunnel.
"""
key = (spine_id, remote_spine_id)
tunnel_id = f"tunnel{remote_spine_id}"
if key not in seen:
seen.add(key)
dest_tep = loopback_ip(remote_pod, remote_spine_id)
store.add(MO(
"tunnelIf",
dn=f"topology/pod-{pod}/node-{spine_id}/sys/tunnel-[{tunnel_id}]",
id=tunnel_id,
dest=dest_tep,
src=loopback_ip(pod, spine_id),
operSt="up",
type="logical-l3-multicast",
layer="l3-multicast",
))
return tunnel_id
def _emit_remote_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
tunnel_spine_id: int,
tunnel_id: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp for one REMOTE (peer-site) endpoint —
learned via the multi-site overlay tunnel, not a real front-panel port."""
fabric_path_dn = (
f"topology/pod-{pod}/paths-{tunnel_spine_id}/pathep-[{tunnel_id}]"
)
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned,vxlan",
fabricPathDn=fabric_path_dn,
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, tunnel_spine_id, tunnel_id,
epg_ref, vrf_vnid, bd_vnid, flags="learned,vxlan",
)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit endpoint + EPM MOs for every EPG deployed at *site*.
MAC/VLAN sequences are per-store (per site/APIC) and assume the intended
one-MITStore-per-APIC model; merging two site stores would collide DNs/MACs.
"""
pod = site.pod
leaves = site.leaf_nodes()
per_epg = topo.endpoints.per_epg
first_vlan = topo.access.vlan_pools[0].from_vlan if topo.access.vlan_pools else 100
# APIC-{cid} controllers (cid 1..site.controllers) are wired to eth1/{cid}
# on the first two leaves (fabric.py: apic_leaves = site.leaf_nodes()[:2]).
# Reserve that port range on those leaves; endpoints start right after it.
apic_reserved_leaf_ids = {n.id for n in site.leaf_nodes()[:2]}
apic_port_start = 1 + site.controllers
free_host_ports = max(_HOST_PORTS - site.controllers, 1)
global_epg_index = 0
ep_counter = 0
tunnels_seen: set[tuple[int, int]] = set()
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
bd_by_name = {bd.name: bd for bd in tenant.bds}
# A stretched tenant's endpoints are HOME on exactly one of its sites
# (see module docstring). Non-stretched (site-local) tenants have no
# remote counterpart — every endpoint is simply local, as before.
is_stretched = tenant.stretch and len(tenant.sites) > 1
remote_site = None
if is_stretched:
try:
remote_site = topo.other_site(site.name) if site.name in tenant.sites else None
except KeyError:
remote_site = None
for ap in tenant.aps:
for epg in ap.epgs:
bd = bd_by_name.get(epg.bd)
epg_vlan = first_vlan + global_epg_index
global_epg_index += 1
if bd is None or not bd.subnets:
continue
vrf_vnid = _vnid(f"{t}:{bd.vrf}", 2900000)
bd_vnid = _vnid(f"{t}:{bd.name}", 15000000)
epg_ref = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}"
for i in range(per_epg):
# Deterministic HOME-site assignment: alternates over
# tenant.sites by this endpoint's position in the
# per-tenant sequence, so LAB1's and LAB2's independent
# build() calls agree on which site is home without
# sharing state.
home_site_name = (
tenant.sites[ep_counter % len(tenant.sites)]
if is_stretched else site.name
)
mac = _mac(ep_counter)
ep_counter += 1
ip = _host_ip(bd, i)
cep_dn = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}/cep-{mac}"
if not is_stretched or home_site_name == site.name:
leaf = leaves[i % len(leaves)]
leaf_id = leaf.id
if leaf_id in apic_reserved_leaf_ids:
# Skip past the APIC-reserved eth1/1..eth1/{controllers}
# range so no endpoint double-books a controller port.
port = apic_port_start + (i % free_host_ports)
else:
port = (i % _HOST_PORTS) + 1
_emit_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
leaf_id, port, epg_ref, vrf_vnid, bd_vnid,
)
elif remote_site is not None:
# This site is the PEER for this endpoint: learned via
# the multi-site overlay tunnel, not a front-panel port.
local_spines = site.spine_nodes()
home_spines = remote_site.spine_nodes()
if not local_spines or not home_spines:
continue
tunnel_spine = local_spines[i % len(local_spines)]
home_spine = home_spines[i % len(home_spines)]
tunnel_id = _ensure_tunnel_if(
store, pod, tunnel_spine.id,
remote_site.pod, home_spine.id, tunnels_seen,
)
_emit_remote_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
tunnel_spine.id, tunnel_id, epg_ref, vrf_vnid, bd_vnid,
)
+343
View File
@@ -0,0 +1,343 @@
"""build/fabric.py — fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf.
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):
pcAggrIf {vpcDom}/aggr-[po{bl.id}] (port-channel details; vpc_status.py
matches it to vpcIf by (node, name==ipg_name) to get the po id)
pcRsMbrIfs {pcAggrIf}/rsmbrIfs-[{ifDn}] (member-port relation; CONTRACT.md
catalogued this as "vpcRsMbrIfs" — WRONG, corrected to pcRsMbrIfs,
the real ACI class vpc_status.py actually queries; see CONTRACT.md §6
changelog note)
infraWiNode topology/pod-{p}/node-{m}/av/node-{c} (appliance-vector: this
controller's view of cluster member {c}'s health)
fabricNodeIdentP uni/controller/nodeidentpol/nodep-{id} (boot-seeded registration
for every real fabricNode, so GETs return registered nodes without a
prior POST; consistent with writes.py's existing add-leaf reaction,
which also keys nodep-{id} on the node id, not the serial)
Address derivation (see loopback_ip/oob_ip docstrings for the full scheme).
node_id is expected in [1, 4095] (ACI node IDs are 3-4 decimal digits; this
covers every ID our topology.yaml or a hand-authored one could reasonably use).
node_id <= 255 (legacy/unchanged — BACKWARD COMPATIBLE, existing labs keep
their addresses):
loopback / topSystem.address: 10.<pod>.<node_id>.1
OOB mgmt / topSystem.oobMgmtAddr: 192.168.<pod>.<node_id>
node_id > 255 (new — needed once a site's node IDs exceed 255, e.g. LAB2's
301-402, which would otherwise overflow the last IPv4 octet
and produce invalid addresses like 10.1.401.1):
loopback: 10.<pod>.<node_id % 256>.<1 + node_id // 256>
- third octet reuses the legacy last-octet slot (node_id % 256)
- fourth octet is bumped to 2, 3, ... instead of the legacy "1", so it
can never collide with a node_id <= 255 loopback (those always end
in ".1"); node_id // 256 is <= 15 for node_id <= 4095, keeping the
fourth octet in [2, 16], well inside the valid range.
OOB mgmt: 192.<168 + node_id // 256>.<pod>.<node_id % 256>
- second octet is bumped from 168 to 169, 170, ... (never 168, which
is reserved for the legacy node_id <= 255 scheme), so it can never
collide with a legacy oob address either.
- node_id // 256 is <= 15 for node_id <= 4095, keeping the second
octet in [169, 183], still a valid unicast IPv4 octet.
Both branches are unique per (pod, node_id): within a single pod the pair
(node_id % 256, node_id // 256) is a bijection of node_id, and the fourth
octet (loopback) / second octet (oob) values used by the > 255 branch never
overlap with the values the <= 255 branch produces.
"""
from __future__ import annotations
from aci_sim.build.neighbors import add_adjacency, node_mac
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_LEGACY_MAX_NODE_ID = 255
def loopback_ip(pod: int, node_id: int) -> str:
"""Node loopback address.
node_id <= 255: 10.<pod>.<node_id>.1 (legacy, unchanged)
node_id > 255: 10.<pod>.<node_id % 256>.<1 + node_id // 256>
See module docstring for why this can never produce an invalid octet
(>255) or collide with the legacy scheme.
"""
if node_id <= _LEGACY_MAX_NODE_ID:
return f"10.{pod}.{node_id}.1"
return f"10.{pod}.{node_id % 256}.{1 + node_id // 256}"
def oob_ip(pod: int, node_id: int) -> str:
"""OOB management address.
node_id <= 255: 192.168.<pod>.<node_id> (legacy, unchanged)
node_id > 255: 192.<168 + node_id // 256>.<pod>.<node_id % 256>
See module docstring for why this can never produce an invalid octet
(>255) or collide with the legacy scheme.
"""
if node_id <= _LEGACY_MAX_NODE_ID:
return f"192.168.{pod}.{node_id}"
return f"192.{168 + node_id // 256}.{pod}.{node_id % 256}"
def default_serial(site_id: str, node_id: int) -> str:
"""Deterministic non-empty serial for an auto-generated node (PR-18).
Format: SAL{site_id}{node_id:04d} — e.g. site "1" node 101 -> "SAL10101".
Ansible's `cisco.aci.aci_fabric_node` keys node registration on serial;
prior to PR-18 an unset `Node.serial` fell back to the non-deterministic-
looking (but actually deterministic) "SIM{node.id:08d}". This helper
replaces that fallback with a scheme that also encodes the site, so two
sites' node-101 (impossible today given the 200-offset ID scheme, but a
hand-authored topology could still reuse ids across unrelated tools)
produce visibly distinct serials. Explicit YAML `serial:` values on a
Node always take precedence — this is only the fallback for `serial=""`.
"""
return f"SAL{site_id}{node_id:04d}"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf for *site*."""
pod = site.pod
fabric_name = site.fabric_name or topo.fabric.name # per-site fabricDomain (site grouping)
spine_ids = {n.id for n in site.spine_nodes()}
for node in site.all_nodes():
role = "spine" if node.id in spine_ids else "leaf"
node_dn = f"topology/pod-{pod}/node-{node.id}"
store.add(MO(
"fabricNode",
dn=node_dn,
id=str(node.id),
name=node.name,
role=role,
model=node.model,
serial=node.serial or default_serial(site.id, node.id),
version=node.version,
adSt="on",
fabricSt="active",
))
store.add(MO(
"topSystem",
dn=f"{node_dn}/sys",
id=str(node.id),
name=node.name,
role=role,
version=node.version,
address=loopback_ip(pod, node.id),
oobMgmtAddr=oob_ip(pod, node.id),
fabricDomain=fabric_name,
state="in-service",
podId=str(pod),
))
# APIC controllers — the APIC cluster (role=controller, node IDs 1..N).
# Real ACI exposes these as fabricNode + topSystem role=controller; autoACI
# reads fabricDomain + version from the controller topSystem at login and
# renders the controllers as nodes in Topology.
fabric_domain = site.fabric_name or fabric_name
# Tier-3 (PR-20): fabric.default_apic_version is a fabric-wide override
# for the APIC controller version, taking precedence over this site's
# own site.apic_version when set (None/omitted = unchanged pre-PR-20
# behavior: each site keeps using its own site.apic_version, already
# independently settable from switch-node Node.version since PR-18).
apic_version = topo.fabric.default_apic_version or site.apic_version
# PR-21: per-controller OOB mgmt IP. Site.controller_oob_ips() returns
# either the explicit `controller_ips` list, or a sequential derivation
# from `mgmt_ip` (controller 1 = mgmt_ip, controller 2 = mgmt_ip+1, ...).
# An empty list means neither is available (mgmt_ip unset) — fall back
# to the legacy per-node oob_ip(pod, cid) scheme, unchanged pre-PR-21
# behavior for topologies that never set mgmt_ip. The sim's REST server
# always binds to `mgmt_ip` regardless of `controllers` (controller-1 /
# cluster VIP) — this loop only affects the *simulated* fabricNode/
# topSystem/infraWiNode data, never which address is actually served.
controller_oob_ips = site.controller_oob_ips()
for cid in range(1, site.controllers + 1):
ctrl_dn = f"topology/pod-{pod}/node-{cid}"
apic_name = f"{site.name}-ACI-APIC{cid:02d}"
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
store.add(MO(
"fabricNode",
dn=ctrl_dn,
id=str(cid),
name=apic_name,
role="controller",
model="APIC-SERVER-M3",
serial=f"FCH00APIC{cid:03d}",
version=apic_version,
adSt="on",
fabricSt="active",
))
store.add(MO(
"topSystem",
dn=f"{ctrl_dn}/sys",
id=str(cid),
name=apic_name,
role="controller",
version=apic_version,
address=f"10.0.{pod}.{cid}",
oobMgmtAddr=oob_addr,
fabricDomain=fabric_domain,
state="in-service",
podId=str(pod),
))
# PR-9 FEATURE 5 — firmwareCtrlrRunning: cisco.aci and other clients
# read the running APIC firmware version off this class (real ACI
# RN "ctrlrfwstatuscont/ctrlrrunning", one per controller), rather
# than (or in addition to) topSystem.version. Trivially seedable
# from the same apic_version topSystem already uses above (Tier-3,
# PR-20: fabric.default_apic_version or site.apic_version), so the
# two never drift apart.
store.add(MO(
"firmwareCtrlrRunning",
dn=f"{ctrl_dn}/ctrlrfwstatuscont/ctrlrrunning",
version=apic_version,
node=str(cid),
))
# Batch-2: infraWiNode — appliance vector. Each APIC in the cluster carries
# its OWN view of every cluster member's health (real ACI: the "av"
# subtree under each controller's topSystem); health_score.py's cluster-
# fitness check reads infraWiNode.health across ALL of these, so every
# (viewer, member) pair is seeded "fully-fit" — a healthy, fully-formed
# cluster, matching this sim's other all-healthy defaults (fabricHealthTotal
# cur=100, healthInst cur=hd.node).
for viewer_cid in range(1, site.controllers + 1):
for member_cid in range(1, site.controllers + 1):
store.add(MO(
"infraWiNode",
dn=f"topology/pod-{pod}/node-{viewer_cid}/av/node-{member_cid}",
health="fully-fit",
state="in-service",
))
# APIC ↔ leaf attachments. Real APICs are dual-homed to a leaf pair; expose
# them as fabricLink (autoACI draws Topology edges from fabricLink n1/n2) plus
# a leaf-side lldpAdjEp so the leaf "sees" the controller.
apic_leaves = site.leaf_nodes()[:2]
for cid in range(1, site.controllers + 1):
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
for li, leaf in enumerate(apic_leaves):
apic_port = li + 1 # APIC NIC: eth1/1 → first leaf, eth1/2 → second leaf
leaf_port = cid # leaf downlink to APIC-<cid>: eth1/<cid>
store.add(MO(
"fabricLink",
dn=f"topology/pod-{pod}/lnk-{cid}-1-{apic_port}-to-{leaf.id}-1-{leaf_port}",
n1=str(cid),
n2=str(leaf.id),
operSt="up",
operSpeed="10G",
linkType="controller",
))
add_adjacency(
store,
pod=pod,
local_node_id=leaf.id,
local_port=f"eth1/{leaf_port}",
# The APIC's own NIC port (its "eth1/{apic_port}") — real ACI
# LLDP/CDP always reports the remote port on the neighbor's
# side, even when the neighbor is a controller rather than a
# switch. No fabricLink-style back-edge exists for the APIC
# side to look this up from, so it's derived the same way
# the fabricLink DN above already encodes it (apic_port).
remote_port=f"eth1/{apic_port}",
neighbor_name=f"{site.name}-ACI-APIC{cid:02d}",
neighbor_mgmt_ip=oob_addr,
neighbor_kind="controller",
neighbor_mac=node_mac(pod, cid),
# cdpAdjEp.ver: APIC firmware version is readily available
# here (the same `apic_version` this function already
# resolved above for the controller's own topSystem/
# firmwareCtrlrRunning), so surface it rather than "".
neighbor_version=apic_version,
)
# fabricHealthTotal — fabric-wide health summary
store.add(MO("fabricHealthTotal", dn="topology/health", cur="100"))
# vpcDom + vpcIf per border-leaf; group by vpc_domain to assign stable IDs
#
# Batch-2: pcAggrIf (port-channel details) + pcRsMbrIfs (member-port
# relation) per vPC leg, consistent with the vpcIf this loop already
# builds for the same (bl, dom_num) pair. vpc_status.py matches pcAggrIf
# to vpcIf by (node, name) — name is the IPG name, id is "poN" — so
# pcAggrIf.name reuses vpcIf's own "po{bl.id}" string as the IPG name
# (this sim has no separate infraAccBndlGrp-derived IPG name to borrow;
# access.py's vPC infraAccBndlGrp is named "{vpc_domain}-vpc", a
# different string, so pcAggrIf.name intentionally matches vpcIf.name
# instead — the field vpc_status.py actually joins on). Member ports:
# two real host-facing l1PhysIf per leg (eth1/1, eth1/2 — interfaces.py
# always builds these on every leaf/border-leaf), giving each vPC leg a
# believable 2-member port-channel without colliding with the dedicated
# L3Out port (eth1/48) or fabric uplinks.
_VPC_MEMBER_PORTS = ("eth1/1", "eth1/2")
domain_ids: dict[str, int] = {}
for bl in site.border_leaves:
vdom = bl.vpc_domain
if vdom not in domain_ids:
domain_ids[vdom] = len(domain_ids) + 1
dom_num = domain_ids[vdom]
vpc_dn = (
f"topology/pod-{pod}/node-{bl.id}"
f"/local/svc-vpc-stabdoms/domainid-{dom_num}"
)
store.add(MO(
"vpcDom",
dn=vpc_dn,
id=str(dom_num),
peerSt="peerOk",
))
po_name = f"po{bl.id}"
store.add(MO(
"vpcIf",
dn=f"{vpc_dn}/vpcif-[{po_name}]",
name=po_name,
operSt="up",
))
pc_aggr_dn = f"{vpc_dn}/aggr-[{po_name}]"
store.add(MO(
"pcAggrIf",
dn=pc_aggr_dn,
name=po_name,
id=po_name,
operSt="up",
operSpeed="100G",
descr=f"vPC {po_name} on {bl.name}",
))
for member_port in _VPC_MEMBER_PORTS:
member_if_dn = f"topology/pod-{pod}/node-{bl.id}/sys/phys-[{member_port}]"
store.add(MO(
"pcRsMbrIfs",
dn=f"{pc_aggr_dn}/rsmbrIfs-[{member_if_dn}]",
tDn=member_if_dn,
parentSKey=po_name,
))
# Batch-2: fabricNodeIdentP — boot-seeded node registration for every
# real switch fabricNode (spines/leaves/border-leaves; NOT controllers —
# real ACI's nodeidentpol/Fabric Membership registers switches joining
# the fabric, not the APICs themselves), so a GET returns the already-
# registered nodes without requiring a prior POST. RN "nodep-{id}" keys
# on the node id (not serial) to stay consistent with writes.py's
# existing _materialize_fabric_node reaction, which parses the id back
# out of this same "nodep-(\\d+)$" pattern — seeding here and the POST
# reaction in writes.py must agree on the identifier scheme or a boot-
# seeded nodep-{id} and a later POSTed nodep-{serial} would silently
# diverge into two different DNs for logically the same registration.
for node in site.all_nodes():
store.add(MO(
"fabricNodeIdentP",
dn=f"uni/controller/nodeidentpol/nodep-{node.id}",
serial=node.serial or default_serial(site.id, node.id),
nodeId=str(node.id),
name=node.name,
role="leaf" if node.id not in spine_ids else "spine",
))
+113
View File
@@ -0,0 +1,113 @@
"""build/health_faults.py — healthInst, faultInst, coopPol/coopInst, configExportP.
healthInst DN (read by topology.py line 160-163):
topology/pod-{pod}/node-{id}/sys/health
→ connector.query_dn(dn) → item.get("healthInst", {}).get("attributes", {})
faultInst DNs must:
1. Start with topology/pod-{pod}/node-{id}/ (for node-scoped queries)
2. Contain /node-{id}/ (for topology.py line 196 grouping regex)
Required attrs: severity, code, lc, type, subject, descr, created, lastTransition
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_CREATED_TS = "2026-01-01T00:00:00.000+00:00"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit health/fault/coop/config MOs for *site*."""
pod = site.pod
faults_cfg = topo.faults
hd = faults_cfg.health_defaults
# ---- Per-node healthInst --------------------------------------------------
for node in site.all_nodes():
store.add(MO(
"healthInst",
dn=f"topology/pod-{pod}/node-{node.id}/sys/health",
cur=str(hd.node),
min=str(hd.node),
max="100",
prev=str(hd.node),
))
# ---- Per-controller healthInst --------------------------------------------
# Real APIC exposes controller (role=controller, node IDs 1..N) health at
# the same .../sys/health DN shape as switch nodes; fabric.py already
# builds a topSystem at topology/pod-{pod}/node-{cid}/sys for each
# controller, so this just adds the matching healthInst there too.
for cid in range(1, site.controllers + 1):
store.add(MO(
"healthInst",
dn=f"topology/pod-{pod}/node-{cid}/sys/health",
cur=str(hd.node),
min=str(hd.node),
max="100",
prev=str(hd.node),
))
# ---- Per-tenant healthInst (for tenants deployed in this site) -----------
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
store.add(MO(
"healthInst",
dn=f"uni/tn-{tenant.name}/health",
cur=str(hd.tenant),
min=str(hd.tenant),
max="100",
prev=str(hd.tenant),
))
# ---- Seeded faultInst (node-local DNs for node-scoped query support) -----
# Map node_id → pod for this site
site_node_pods: dict[int, int] = {n.id: pod for n in site.all_nodes()}
for seed in faults_cfg.seed:
node_pod = site_node_pods.get(seed.node)
if node_pod is None:
continue # this fault belongs to a different site's node
# seed.code already carries the full ACI fault code (e.g. "F0532") —
# do NOT prepend another "F" (that produced invalid "FF0532").
fault_dn = (
f"topology/pod-{node_pod}/node-{seed.node}"
f"/local/svc-policyelem-id-0/uni/fault-{seed.code}"
)
store.add(MO(
"faultInst",
dn=fault_dn,
severity=seed.severity,
code=seed.code,
descr=seed.descr,
created=_CREATED_TS,
lastTransition=_CREATED_TS,
lc="raised",
type="operational",
subject="node",
))
# ---- coopPol + coopInst --------------------------------------------------
store.add(MO("coopPol", dn="uni/fabric/pol-coop", name="coop"))
for node in site.all_nodes():
store.add(MO(
"coopInst",
dn=f"topology/pod-{pod}/node-{node.id}/local/svc-coop-id-0/uni/epp/fv-[coop]/node-{node.id}",
operSt="formed",
))
# ---- configExportP + configJob (backup/export stubs) ---------------------
store.add(MO(
"configExportP",
dn="uni/fabric/configexp-defaultOneTime",
name="defaultOneTime",
))
store.add(MO(
"configJob",
dn="uni/fabric/configexp-defaultOneTime/job-defaultOneTime",
operSt="success",
executeTime=_CREATED_TS,
))
+191
View File
@@ -0,0 +1,191 @@
"""build/interfaces.py — l1PhysIf, ethpmPhysIf, eqptcapacityPolUsage5min per node.
Port layout:
Fabric uplink ports: same assignment as cabling.py (derived independently)
- Spines: eth1/1, eth1/2, ... (one per downlink node)
- Leaves/BLs: eth1/49, eth1/50, ... (one per spine)
Host-facing ports on leaves/border-leaves: eth1/1 eth1/8 (simulated access)
L3Out routed sub-interface port on border-leaves ONLY: eth1/48 (dedicated,
outside the host-access range — matches real ACI convention of a dedicated
front-panel port for the L3Out CSW uplink rather than sharing a host port).
ISN/IPN uplink ports on spines ONLY, multi-site fabrics only: eth1/49, 50, ...
(one per spine, dedicated — outside the fabric-uplink range eth1/1-4, which
is fully consumed by intra-fabric spine↔leaf links). Real ACI spines facing
a multi-site ISN use dedicated front-panel ports for the IPN uplink, distinct
from the leaf-facing fabric ports; underlay.py's ospfAdjEp references these.
ethpmPhysIf DN must contain "phys-[eth{s}/{p}]" — read by topology.py line 730:
port = dn.split("phys-[")[1].split("]")[0] → "eth1/1"
l1PhysIf DN is under the same sys/ subtree; id attribute carries the port name.
Batch-2 addition (CONTRACT.md §6 "Interfaces"): ethpmFcot — SFP/transceiver
info, sibling of ethpmPhysIf under the same phys-[eth{s}/{p}] port DN (own RN
"fcot", per real ACI's ethpmFcot placement one level under ethpmPhysIf's own
"phys" RN's parent). Attrs verified read-only against autoACI's
interface_comprehensive.py: typeName, guiName, vendorName, vendorSn,
actualType (decoded via that plugin's own `_decode_sfp_field`/`_port_from_dn`
helpers, which just strip a "phys-[" bracket segment off the dn — same
placement l1PhysIf/ethpmPhysIf already use). Only emitted for fabric-uplink
and L3Out routed ports (real optics-bearing ports); plain host-access ports
are left without an ethpmFcot, matching real ACI labs where copper/DAC host
ports often report no transceiver inventory while fabric/WAN-facing optical
ports do — a deliberate realism choice, not an omission.
"""
from __future__ import annotations
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.cabling import cabling_links
_UPLINK_START = 49 # first leaf-side uplink port index
_HOST_PORTS = 8 # number of host-facing ports on leaves
_L3OUT_PORT = 48 # dedicated border-leaf L3Out routed sub-interface port
def _add_port(
store: MITStore,
pod: int,
node_id: int,
slot: int,
port: int,
descr: str = "",
mode: str = "trunk",
usage: str = "fabric",
with_fcot: bool = False,
mtu: str = "9216",
) -> None:
"""Add l1PhysIf + ethpmPhysIf (+ optional ethpmFcot) for one port.
`mtu` defaults to the fabric intra-fabric MTU (9216); the ISN/IPN uplink
port (multi-site only) passes `topo.isn.mtu` instead (Tier-2, PR-19) —
real ACI's inter-pod/inter-site default is 9150, distinct from the
intra-fabric 9216 ceiling.
"""
port_id = f"eth{slot}/{port}"
sys_dn = f"topology/pod-{pod}/node-{node_id}/sys"
phys_dn = f"{sys_dn}/phys-[{port_id}]"
# l1PhysIf — administrative config
store.add(MO(
"l1PhysIf",
dn=phys_dn,
id=port_id,
adminSt="up",
descr=descr,
mtu=mtu,
mode=mode,
))
# ethpmPhysIf — operational state; DN must contain "phys-[eth…]"
store.add(MO(
"ethpmPhysIf",
dn=f"{phys_dn}/phys",
operSt="up",
operSpeed="100G",
usage=usage,
lastLinkStChg="00:00:00:00.000",
resetCtr="0",
))
# Batch-2: ethpmFcot — SFP/transceiver inventory, sibling of ethpmPhysIf
# (own "fcot" RN under the same phys-[eth…] port DN — interface_comprehensive.py's
# _port_from_dn strips the same "phys-[" bracket segment this DN shares with
# ethpmPhysIf, so both resolve to the same port string). Only real
# optics-bearing ports (fabric uplinks, ISN uplinks, L3Out routed ports)
# get one — see module docstring for the realism rationale.
if with_fcot:
store.add(MO(
"ethpmFcot",
dn=f"{phys_dn}/fcot",
typeName="QSFP-100G-SR4",
guiName="QSFP-100G-SR4",
vendorName="CISCO-FINISAR",
vendorSn=f"FNS{pod:02d}{node_id:04d}{port:02d}",
actualType="qsfp-100g-sr4",
operSt="up",
operSpeed="100G",
))
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit l1PhysIf + ethpmPhysIf for all nodes, plus eqptcapacityPolUsage5min."""
pod = site.pod
spine_ids = {n.id for n in site.spine_nodes()}
# Collect port assignments from the cabling graph
# spine_ports[node_id] = set of (slot, port) used as fabric uplinks
# leaf_ports[node_id] = set of (slot, port) used as fabric uplinks
spine_uplinks: dict[int, list[tuple[int, int]]] = {n.id: [] for n in site.spine_nodes()}
leaf_uplinks: dict[int, list[tuple[int, int]]] = {
n.id: [] for n in site.leaf_nodes() + site.border_leaf_nodes()
}
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
if n1 in spine_ids:
spine_uplinks[n1].append((s1, p1))
leaf_uplinks[n2].append((s2, p2))
else:
spine_uplinks[n2].append((s2, p2))
leaf_uplinks[n1].append((s1, p1))
# Spines — fabric uplinks, plus (multi-site only) dedicated ISN uplinks
for si, spine in enumerate(site.spine_nodes()):
for slot, port in spine_uplinks[spine.id]:
_add_port(
store, pod, spine.id, slot, port,
descr=f"Fabric uplink eth{slot}/{port}",
mode="trunk",
with_fcot=True,
)
if len(topo.sites) > 1:
isn_port = _UPLINK_START + si
_add_port(
store, pod, spine.id, 1, isn_port,
descr=f"ISN/IPN uplink eth1/{isn_port}",
mode="routed",
usage="isn",
with_fcot=True,
mtu=str(topo.isn.mtu),
)
border_leaf_ids = {bl.id for bl in site.border_leaves}
# Leaves + border-leaves — fabric uplinks + host-facing ports
for leaf in site.leaf_nodes() + site.border_leaf_nodes():
# Fabric uplink ports
for slot, port in leaf_uplinks[leaf.id]:
_add_port(
store, pod, leaf.id, slot, port,
descr="Fabric uplink to spine",
mode="trunk",
with_fcot=True,
)
# Host-facing access ports: eth1/1 … eth1/_HOST_PORTS
for hp in range(1, _HOST_PORTS + 1):
_add_port(
store, pod, leaf.id, 1, hp,
descr=f"Host port eth1/{hp}",
mode="access",
usage="access",
)
# Border-leaves only: dedicated L3Out routed sub-interface port,
# outside the host-access range — l3out.py's l3extRsPathL3OutAtt
# tDn references this exact port so it resolves against a real
# l1PhysIf (real ACI convention: dedicated front-panel port for the
# L3Out CSW uplink, not shared with regular host/EPG traffic).
if leaf.id in border_leaf_ids:
_add_port(
store, pod, leaf.id, 1, _L3OUT_PORT,
descr=f"L3Out routed uplink eth1/{_L3OUT_PORT}",
mode="routed",
usage="l3out",
with_fcot=True,
)
# eqptcapacityPolUsage5min — capacity stats (contract-rendering metrics)
store.add(MO(
"eqptcapacityPolUsage5min",
dn=f"topology/pod-{pod}/node-{leaf.id}/sys/eqptcapacity/polUsage5min",
polUsage="42",
polUsageCap="100",
polUsageCum="38",
polUsageCapCum="100",
))
+294
View File
@@ -0,0 +1,294 @@
"""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)
+122
View File
@@ -0,0 +1,122 @@
"""build/mgmt.py — mgmt tenant OOB scaffolding: fvTenant(mgmt)/mgmtMgmtP/mgmtOoB/mgmtRsOoBStNode.
Real APIC models the OOB management address + gateway for every fabric node
in the special `mgmt` tenant, NOT on the node's own topSystem (topSystem only
carries the address — `oobMgmtAddr` — never a gateway; see build/fabric.py).
The real object tree (verified against APIC's own MIT, CONTRACT.md-style):
uni/tn-mgmt fvTenant (name="mgmt")
mgmtp-default mgmtMgmtP (name="default")
oob-default mgmtOoB (name="default")
rsooBStNode-[topology/pod-<p>/node-<id>] mgmtRsOoBStNode
tDn=topology/pod-<p>/node-<id>
addr=<node's OOB IP>/<mask>
gw=<site.oob_gateway or "">
This is the ONLY place a real ACI object carries an OOB gateway attribute —
prior to this builder, `Site`/the init wizard collected an OOB gateway
(cli.py's `run_wizard`) but discarded it (no schema field, no MO); this
builder is what makes that value land on a real MO instead.
Scope (deliberately minimal/faithful): one `mgmtRsOoBStNode` per node this
site actually has (switches — spines/leaves/border-leaves — AND controllers),
matching the module list `build/fabric.py` already builds `fabricNode`/
`topSystem` for. No other mgmt-tenant children (no `mgmtInB`, no
`mgmtRsOoBCtx`, no `mgmtInstP`, etc.) — those model in-band mgmt / EPG
contract-scope wiring that no autoACI/aci-py chain audited for this sim reads
(same "store scaffolding a caller doesn't consume is scope-creep" judgment
call as `Fabric.inb_subnet`'s docstring, topology/schema.py).
Mask derivation (`addr=<ip>/<mask>`): real ACI's OOB address is configured
with an explicit prefix at first-boot (the same dialog this sim's `aci-sim
init` wizard mirrors), and is virtually always a /24 (one OOB subnet per
pod/site) even though the fabric-wide address *pool* is much larger. This
sim's own `oob_ip(pod, node_id)` (build/fabric.py) reflects exactly that
shape — every node's OOB address is `192.168.<pod>.<node_id>`, i.e. already
confined to a /24 per pod (the pod occupies the third octet; the pool's
`/16` only bounds the SECOND octet across all pods). So the mask is derived,
in priority order:
1. `fabric.oob_subnet` (Tier-3, PR-20) if the topology author explicitly
set it — their stated intent overrides the derived default, even if it
happens to be wider than /24 (e.g. a deliberately shared /16 OOB VLAN).
2. `/24` otherwise (default) — matches the real per-pod OOB subnet shape
`oob_ip()` already produces, NOT `fabric.oob_pool`'s `/16` (which
describes the whole multi-pod address space, not any single node's
subnet — using it directly would put every node in a /16 that spans
pods it was never actually part of).
`fabric.oob_subnet`, when set, is already validated (Topology.
normalize_and_validate) to be a real CIDR inside 192.168.0.0/16, so
`.prefixlen` is always safe to read.
Gateway (`gw=...`): `site.oob_gateway` verbatim if set, else the EMPTY
STRING — never a derived/invented default. A derived default (e.g. ".1" of
the OOB subnet) would look like real config an operator entered, when no
operator entered anything; an empty `gw` faithfully says "not configured",
which is exactly the real APIC behavior before its own first-boot gateway
question is answered. This also keeps the change purely additive: a
topology.yaml that never sets `oob_gateway` builds byte-identical
mgmtRsOoBStNode.gw ("") on every rebuild.
"""
from __future__ import annotations
import ipaddress
from aci_sim.build.fabric import oob_ip
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_DEFAULT_OOB_PREFIXLEN = 24 # real-ACI-shaped per-pod OOB subnet (see module docstring)
def _oob_prefixlen(topo: Topology) -> int:
"""Effective OOB subnet prefix length (see module docstring priority order)."""
if topo.fabric.oob_subnet is not None:
return ipaddress.ip_network(topo.fabric.oob_subnet, strict=False).prefixlen
return _DEFAULT_OOB_PREFIXLEN
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit the mgmt-tenant OOB scaffolding for *site*: fvTenant(mgmt) →
mgmtMgmtP(default) → mgmtOoB(default) → one mgmtRsOoBStNode per node."""
prefixlen = _oob_prefixlen(topo)
gw = site.oob_gateway or ""
tenant_mo = MO("fvTenant", dn="uni/tn-mgmt", name="mgmt", descr="")
mgmtp_mo = MO("mgmtMgmtP", dn="uni/tn-mgmt/mgmtp-default", name="default")
oob_mo = MO("mgmtOoB", dn="uni/tn-mgmt/mgmtp-default/oob-default", name="default")
pod = site.pod
# Switches (spines/leaves/border-leaves): OOB address from the same
# oob_ip(pod, node_id) derivation build/fabric.py used for topSystem.
for node in site.all_nodes():
node_dn = f"topology/pod-{pod}/node-{node.id}"
addr = f"{oob_ip(pod, node.id)}/{prefixlen}"
oob_mo.add_child(MO(
"mgmtRsOoBStNode",
dn=f"{oob_mo.dn}/rsooBStNode-[{node_dn}]",
tDn=node_dn,
addr=addr,
gw=gw,
))
# Controllers (APIC cluster, node IDs 1..N): OOB address from the same
# controller_oob_ips()/oob_ip(pod, cid) resolution build/fabric.py uses
# for the controller topSystem (explicit controller_ips, else derived
# from mgmt_ip, else legacy per-node oob_ip(pod, cid) fallback).
controller_oob_ips = site.controller_oob_ips()
for cid in range(1, site.controllers + 1):
ctrl_dn = f"topology/pod-{pod}/node-{cid}"
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
addr = f"{oob_addr}/{prefixlen}"
oob_mo.add_child(MO(
"mgmtRsOoBStNode",
dn=f"{oob_mo.dn}/rsooBStNode-[{ctrl_dn}]",
tDn=ctrl_dn,
addr=addr,
gw=gw,
))
mgmtp_mo.add_child(oob_mo)
tenant_mo.add_child(mgmtp_mo)
store.add(tenant_mo)
+230
View File
@@ -0,0 +1,230 @@
"""build/neighbors.py — shared LLDP/CDP adjacency + container-MO helper.
There are THREE call sites that construct lldpAdjEp/cdpAdjEp adjacencies:
- build/cabling.py (spine<->leaf fabric links, both directions)
- build/fabric.py (APIC<->leaf attachment, leaf-side adjacency)
- rest_aci/writes.py (dynamic leaf<->spine adjacencies for a POST-registered
node, via store.upsert on an already-built store)
All three must emit an IDENTICAL, consistent field set (real-APIC-shaped
lldpAdjEp/cdpAdjEp) AND materialize the lldpInst/lldpIf/cdpInst/cdpIf
container MOs the adjacency hangs off of — without this, the container's own
DN (".../sys/lldp/inst") has no MO of its own, and a deep-root subtree query
(`GET .../sys/lldp/inst.json?query-target=subtree&target-subtree-class=
lldpAdjEp`) returns totalCount=0 even though the adjacency itself is fully
reachable via a plain class query. See `aci_sim.query.engine.
run_mo_query`: `store.get(dn) is None` short-circuits to `([], 0)` before the
subtree walk even starts.
This module centralizes both concerns behind one function
(`add_adjacency`) so the three call sites can never drift apart (e.g. one
site adding `portIdV` and another forgetting it).
Store-method choice: every write below goes through ``store.upsert(...)``,
never ``store.add``. Verified against ``aci_sim/mit/store.py``:
``upsert`` on a DN that doesn't exist yet takes the exact same path ``add``
does (construct a fresh ``MO``, store it, call ``_register``) — the two are
behaviorally identical on an empty/fresh store. ``upsert`` additionally
merges into an existing entry instead of clobbering it, which is required
here because lldpInst/lldpIf/cdpInst/cdpIf are shared containers written
once per node/port but visited twice (once per directed adjacency at a
spine<->leaf link, per-APIC at the controller-attach site). Callers that
already exclusively use ``store.add`` (cabling.py, fabric.py boot-time build)
lose nothing by switching: the store is empty at those DNs on first visit,
so ``upsert`` behaves exactly like ``add`` there too.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Node
# APIC controllers report this fixed platform id on real hardware.
APIC_PLATFORM_ID = "APIC-SERVER-M3"
def node_mac(pod: int, node_id: int) -> str:
"""Deterministic, unique, valid MAC for *node_id* in *pod*.
Used as lldpAdjEp.chassisIdV (the neighbor's chassis MAC) and as
lldpIf.mac (the local interface's reported MAC) — real LLDP always
carries a chassis MAC, so a believable, stable value beats an empty
string. Scheme: 00:1B:0D:<pod>:<node_id high byte>:<node_id low byte>.
00:1B:0D is an arbitrary-but-fixed OUI-shaped prefix (not a real
allocation) chosen purely so the value looks like a plausible MAC;
pod/node_id are masked into single bytes so the address is always
well-formed regardless of how large either input is.
"""
return f"00:1B:0D:{pod & 0xFF:02X}:{(node_id >> 8) & 0xFF:02X}:{node_id & 0xFF:02X}"
def _lldp_inst_dn(pod: int, node_id: int) -> str:
return f"topology/pod-{pod}/node-{node_id}/sys/lldp/inst"
def _cdp_inst_dn(pod: int, node_id: int) -> str:
return f"topology/pod-{pod}/node-{node_id}/sys/cdp/inst"
def ensure_lldp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
"""Idempotently materialize lldpInst (per-node) + lldpIf (per-port).
Safe to call once per directed adjacency that lands on this node/port —
``store.upsert`` merges into the same DN instead of duplicating it.
"""
inst_dn = _lldp_inst_dn(pod, node_id)
store.upsert(MO(
"lldpInst",
dn=inst_dn,
adminSt="enabled",
name="default",
holdTime="120",
initDelayTime="2",
txFreq="30",
ctrl="rx-en,tx-en",
optTlvSel="mgmt-addr,port-desc,port-vlan,sys-cap,sys-desc,sys-name",
))
store.upsert(MO(
"lldpIf",
dn=f"{inst_dn}/if-[{local_port}]",
id=local_port,
adminRxSt="enabled",
adminTxSt="enabled",
operRxSt="up",
operTxSt="up",
mac=node_mac(pod, node_id),
portDesc="",
portVlan="0",
sysDesc="",
wiring="valid",
))
def ensure_cdp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
"""Idempotently materialize cdpInst (per-node) + cdpIf (per-port)."""
inst_dn = _cdp_inst_dn(pod, node_id)
store.upsert(MO(
"cdpInst",
dn=inst_dn,
adminSt="enabled",
name="default",
holdIntvl="180",
txFreq="60",
ver="v2",
ctrl="",
))
store.upsert(MO(
"cdpIf",
dn=f"{inst_dn}/if-[{local_port}]",
id=local_port,
adminSt="enabled",
operSt="up",
))
def add_adjacency(
store: MITStore,
*,
pod: int,
local_node_id: int,
local_port: str,
remote_port: str,
neighbor_name: str,
neighbor_mgmt_ip: str,
neighbor_kind: str,
neighbor_model: str = "",
neighbor_version: str = "",
neighbor_mac: str = "",
) -> None:
"""Emit one directed lldpAdjEp+cdpAdjEp (local node sees *neighbor* on
*local_port*) AND ensure the lldpInst/lldpIf/cdpInst/cdpIf containers for
(local_node_id, local_port) exist.
*neighbor_kind* is ``"switch"`` (spine/leaf/border-leaf) or
``"controller"`` (APIC) — drives the sysDesc/capability/platId/cap field
shapes below (real APIC neighbors don't report bridge/router capability).
*neighbor_mac* is the neighbor's chassis MAC (chassisIdV). Every call site
passes one (derived via ``node_mac`` from the neighbor's node id), so the
``""`` default is never hit in practice; it exists only so an omitted MAC
yields a syntactically valid (if uninformative) empty chassisIdV rather
than a KeyError.
"""
base = f"topology/pod-{pod}/node-{local_node_id}/sys"
if neighbor_kind == "controller":
sys_desc = "Cisco APIC"
capability = ""
plat_id = APIC_PLATFORM_ID
cap = ""
else:
sys_desc = f"{neighbor_model} running {neighbor_version}"
capability = "bridge,router"
plat_id = neighbor_model
cap = "bridge,router"
store.upsert(MO(
"lldpAdjEp",
dn=f"{base}/lldp/inst/if-[{local_port}]/adj-1",
sysName=neighbor_name,
mgmtIp=neighbor_mgmt_ip,
portIdV=remote_port,
# 802.1AB Port-ID subtype 5: portIdV carries a named interface
# (e.g. "eth1/1"), which real ACI reports as interfaceName — NOT
# locallyAssigned (subtype 7), which is for opaque port-id strings.
portIdT="interfaceName",
chassisIdT="mac",
chassisIdV=neighbor_mac,
sysDesc=sys_desc,
capability=capability,
id="1",
ttl="120",
))
store.upsert(MO(
"cdpAdjEp",
dn=f"{base}/cdp/inst/if-[{local_port}]/adj-1",
sysName=neighbor_name,
mgmtIp=neighbor_mgmt_ip,
devId=neighbor_name,
portId=remote_port,
platId=plat_id,
ver=neighbor_version,
cap=cap,
))
ensure_lldp_containers(store, pod, local_node_id, local_port)
ensure_cdp_containers(store, pod, local_node_id, local_port)
def add_switch_adjacency(
store: MITStore,
*,
pod: int,
local_node_id: int,
local_port: str,
remote_port: str,
neighbor: Node,
neighbor_mgmt_ip: str,
) -> None:
"""Convenience wrapper for the common switch<->switch case (cabling.py,
fabric.py's dynamic-registration mirror in writes.py): derives
model/version/mac from the neighbor ``Node`` directly instead of making
every call site repeat ``neighbor_model=neighbor.model,
neighbor_version=neighbor.version, neighbor_mac=node_mac(pod, neighbor.id)``.
``neighbor_mgmt_ip`` is still passed explicitly because callers derive it
via ``oob_ip()`` (or, for a dynamically-registered node, a value already
computed by the caller) rather than something derivable from ``Node``
alone.
"""
add_adjacency(
store,
pod=pod,
local_node_id=local_node_id,
local_port=local_port,
remote_port=remote_port,
neighbor_name=neighbor.name,
neighbor_mgmt_ip=neighbor_mgmt_ip,
neighbor_kind="switch",
neighbor_model=neighbor.model,
neighbor_version=neighbor.version,
neighbor_mac=node_mac(pod, neighbor.id),
)
+89
View File
@@ -0,0 +1,89 @@
"""Orchestrator — assemble a full per-site MITStore from the topology.
Every sub-builder is a pure function of (topology, site) that only ADDS MOs and
never reads another builder's output, so the order below is for readability only.
"""
from __future__ import annotations
from aci_sim.build import (
access,
cabling,
endpoints,
fabric,
health_faults,
interfaces,
l3out,
mgmt,
overlay,
routing,
tenants,
underlay,
userprofile,
zoning,
)
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
# fabric first (nodes), then wiring/underlay/overlay, then tenant config,
# then L3Out (needs border leaves), endpoints, and finally health/faults.
#
# Batch-1 note: `endpoints` now runs BEFORE `tenants` (moved from after
# `l3out`) so tenants.py's fvRsPathAtt (static binding) can read back the
# real fvCEp/fabricPathDn each EPG's endpoints already got from the store,
# instead of re-deriving port assignment logic independently (which risked
# silently drifting out of sync with endpoints.py). Every other builder
# remains a pure "only ADDS MOs, never reads another builder's output"
# function per the orchestrator's original contract — endpoints.py itself
# still only adds and doesn't read from tenants/access/l3out, so moving it
# earlier is safe (its only cross-module dependency, _HOST_PORTS, comes from
# `interfaces`, which still runs first).
#
# Batch-2 note: `zoning` runs AFTER both `tenants` (needs fvAEPg.pcTag/
# fvCtx.scope, and reuses tenants.py's pctag_for/scope_id_for helpers) and
# `endpoints` (needs fvCEp to know which leaves host each EPG's local
# endpoints) — same read-back pattern as tenants.py's fvRsPathAtt, so zoning
# is placed right after tenants in the list (endpoints already ran earlier).
# `overlay.build_vpn_routes` (bgpVpnRoute/bgpPath) has the same fvRsBd/fvCEp
# read-back dependency, so it also runs post-tenants/endpoints — as a SECOND
# call into the `overlay` module, alongside (not instead of) its early
# `overlay.build` call which only needs fabric/topology data and stays in
# its original early slot for BGP peer setup.
#
# OOB-gateway PR: `mgmt` builds the mgmt-tenant OOB scaffolding
# (fvTenant(mgmt)/mgmtMgmtP/mgmtOoB/mgmtRsOoBStNode) that carries
# site.oob_gateway on a real MO. It only needs topology data (site.all_nodes(),
# oob_ip(), site.controller_oob_ips()) — no read-back from another builder's
# store output — so, like every other builder here, it is placed for
# readability only; it is grouped right after `fabric` since both build
# node-identity/OOB data.
_BUILDERS = [
fabric,
mgmt,
cabling,
underlay,
overlay,
interfaces,
endpoints,
tenants,
zoning,
access,
l3out,
routing,
health_faults,
userprofile,
]
def build_site(topo: Topology, site: Site) -> MITStore:
"""Build and return a fully-populated MITStore for one site."""
store = MITStore()
for mod in _BUILDERS:
mod.build(topo, site, store)
overlay.build_vpn_routes(topo, site, store)
return store
def build_all(topo: Topology) -> dict[str, MITStore]:
"""Build every site → ``{site_name: MITStore}``."""
return {site.name: build_site(topo, site) for site in topo.sites}
+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)
+167
View File
@@ -0,0 +1,167 @@
"""build/routing.py — uribv4Route/uribv4Nexthop (per-leaf RIB) + arpAdjEp
(per-endpoint ARP table).
Batch-1 additions (CONTRACT.md §6 "Routing tables/control", "COOP/EPM"):
uribv4Route topology/pod-{p}/node-{n}/sys/uribv4/dom-{vrf}/db-rt/rt-[{prefix}]
uribv4Nexthop {route}/nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}]
arpAdjEp topology/pod-{p}/node-{n}/sys/arp/inst/dom-{vrf}/db-ip/adj-[{ip}]
DN/attribute shapes verified (read-only) against autoACI's
backend/plugins/route_table.py + routing_state.py (per CONTRACT.md header —
trust, don't re-explore):
- uribv4Route.attrs["prefix"] is read (NOT "ip" — that's ipRouteP/l3extSubnet/
fvSubnet's attribute name); VRF is parsed from the `dom-{name}` DN segment
via `part[4:]` after `part.startswith("dom-")`.
- uribv4Nexthop's parent-route DN is recovered via
`_RE_NH_PARENT = re.compile(r"^(.*/rt-\\[[^\\]]+\\])/nh-")` (route_table.py /
routing_state.py, both files, identical regex) — so the nexthop RN MUST
start with "nh-" immediately after the route's own "rt-[...]" segment, and
the "nh-" RN's own bracket content is opaque to this regex (any number of
"-[...]" groups after "nh-" parses fine); we use the 4-group form the
module docstring's own comment illustrates:
".../rt-[10.100.210.1/32]/nh-[direct]-[10.100.210.1/32]-[lo2]-[vrf]"
i.e. nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}].
- arpAdjEp: routing_state.py reads attrs["ifId"] and attrs["physIfId"]
(Interface / Physical Interface columns) plus ip/mac/operSt (CONTRACT.md).
VRF "dom" naming reuses the exact `{tenant}:{vrf}` convention l3out.py's
_upsert_ebgp_sessions already established for bgpDom, so uribv4Route's VRF
column lines up with the same dom name a route_table.py VRF-scoped query
would be filtered on.
"""
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
_CEP_PORT_RE = re.compile(r"/node-(\d+)/sys/.*/db-ep/mac-")
_FRONT_PANEL_IF_RE = re.compile(r"paths-\d+/pathep-\[(eth\d+/\d+)\]$")
def _owning_site(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_route(node_dn: str, vrf_dom: str, prefix: str, nh_addr: str, ifname: str) -> MO:
"""Build one uribv4Route + a single uribv4Nexthop child.
Nexthop RN uses the 4-bracket form _RE_NH_PARENT expects:
nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}].
"""
base = f"{node_dn}/uribv4/dom-{vrf_dom}/db-rt"
route_dn = f"{base}/rt-[{prefix}]"
route_mo = MO("uribv4Route", dn=route_dn, prefix=prefix, pref="1")
nh_dn = f"{route_dn}/nh-[static]-[{nh_addr}]-[{ifname}]-[{vrf_dom}]"
route_mo.add_child(MO(
"uribv4Nexthop",
dn=nh_dn,
nhAddr=nh_addr,
ifName=ifname,
))
return route_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit per-leaf uribv4Route/Nexthop RIB entries + per-endpoint arpAdjEp.
Runs after `endpoints` (orchestrator order) so fvCEp/fvIp data already in
the store can be reused for arpAdjEp instead of re-deriving MAC/IP/port
assignment independently.
"""
pod = site.pod
# ---- uribv4Route/Nexthop -------------------------------------------
# One VRF-scoped RIB per (tenant, vrf) deployed at this site, seeded on
# every leaf/border-leaf: BD subnets as directly-connected routes (nexthop
# = this node's own loopback, ifname = a real BD-facing host port so the
# interface resolves), plus (border leaves only) a 0.0.0.0/0 default route
# via the L3Out's CSW peer — matching the ipRouteP static default route
# l3out.py's _build_node_profile already seeds on the same node.
all_leaves = site.leaf_nodes() + site.border_leaf_nodes()
border_leaf_ids = {bl.id for bl in site.border_leaves}
node_loopback = {n.id: loopback_ip(pod, n.id) for n in all_leaves}
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
t = tenant.name
for vrf in tenant.vrfs:
vrf_dom = f"{t}:{vrf.name}"
bd_subnets = [cidr for bd in tenant.bds if bd.vrf == vrf.name for cidr in bd.subnets]
for leaf in all_leaves:
node_dn = f"topology/pod-{pod}/node-{leaf.id}/sys"
lb = node_loopback[leaf.id]
for cidr in bd_subnets:
store.add(_build_route(node_dn, vrf_dom, cidr, lb, "vlan1"))
if leaf.id in border_leaf_ids:
bl = next(bl for bl in site.border_leaves if bl.id == leaf.id)
for l3out in tenant.l3outs:
if l3out.vrf != vrf.name:
continue
if _owning_site(l3out, topo) != site.name:
continue
if leaf.id not in l3out.border_leaves:
continue
store.add(_build_route(
node_dn, vrf_dom, "0.0.0.0/0",
bl.csw.peer_ip, "eth1/48",
))
# ---- arpAdjEp — one per existing (locally-learned) endpoint ---------
# Reuse the real fvCEp/fvIp MAC+IP data endpoints.py already wrote for
# this site's store; only front-panel (local) endpoints get an ARP
# adjacency (a REMOTE/tunnel-learned endpoint has no local ARP entry on
# this fabric — real ACI resolves those via the overlay, not local ARP).
for cep in store.by_class("fvCEp"):
path_dn = cep.attrs.get("fabricPathDn", "")
m = _FRONT_PANEL_IF_RE.search(path_dn)
if not m:
continue
node_m = re.search(r"/paths-(\d+)/", path_dn)
if not node_m:
continue
node_id = node_m.group(1)
if int(node_id) not in {n.id for n in all_leaves}:
continue
if_name = m.group(1)
mac = cep.attrs.get("mac", "")
# fvIp children carry the endpoint's IP(s); dn = f"{cep.dn}/ip-[{ip}]"
for ip_mo in store.children(cep.dn, {"fvIp"}):
ip_addr = ip_mo.attrs.get("addr", "")
if not ip_addr:
continue
# tenant/vrf for the dom segment: derive from cep_dn's "tn-{t}" and
# this site's tenant/BD/VRF config (matches the RIB dom naming above).
tn_m = re.search(r"uni/tn-([^/]+)/", cep.dn)
tname = tn_m.group(1) if tn_m else "common"
tenant = next((tt for tt in topo.tenants if tt.name == tname), None)
vrf_name = tenant.vrfs[0].name if tenant and tenant.vrfs else "default"
vrf_dom = f"{tname}:{vrf_name}"
arp_dn = (
f"topology/pod-{pod}/node-{node_id}/sys/arp/inst"
f"/dom-{vrf_dom}/db-ip/adj-[{ip_addr}]"
)
store.add(MO(
"arpAdjEp",
dn=arp_dn,
ip=ip_addr,
mac=mac,
ifId=if_name,
physIfId=if_name,
operSt="up",
))
+379
View File
@@ -0,0 +1,379 @@
"""build/tenants.py — fvTenant, fvCtx, fvBD, fvAp/fvAEPg, vzBrCP, vzFilter.
MAC/VLAN sequences and endpoint addresses are per-store (per site/APIC); this
module assumes the intended one-MITStore-per-APIC model. Merging two site
stores would collide DNs and attribute values.
Tier-3 (PR-20): `fvBD.mac` now reflects `bd.mac` (per-BD override) falling
back to `fabric.default_bd_mac` (fabric-wide default, itself defaulting to
the real ACI default gateway MAC `00:22:BD:F8:19:FF` — the literal this
module hardcoded for every BD pre-PR-20; see `_build_bd`).
Batch-1 additions (CONTRACT.md §6 "Rels", "Contracts"):
fvRsPathAtt uni/tn-{t}/ap-{ap}/epg-{epg}/rspathAtt-[{pathDn}]
vzRsSubjGraphAtt {vzSubj}/rsSubjGraphAtt
fvRsPathAtt needs a static binding whose tDn is the SAME front-panel path an
EPG's own endpoints already use (endpoints.py assigns EP ports), so this
module reads back the real fvCEp.fabricPathDn the endpoints builder already
wrote for that EPG rather than re-deriving port-assignment logic
independently (see orchestrator.py: `endpoints` now runs before `tenants`
specifically to make this store read-back possible; a stretched EPG that has
no locally-learned (front-panel) endpoint at this site — i.e. every endpoint
here is REMOTE/tunnel-learned — gets no fvRsPathAtt, since a static binding
must reference a real local port, not a tunnel).
Batch-2 additions (CONTRACT.md §6 "Tenant (control)"): fvAEPg now carries a
stable `pcTag` (real ACI's per-EPG "sclass" used for zoning-rule matching) —
previously absent from this sim entirely (verified: no prior pcTag anywhere
in aci_sim). Assigned deterministically per (tenant, ap, epg) via
`_pctag_for` (a small CRC32-derived integer in a believable ACI sclass range,
avoiding the well-known reserved system pcTags 1/13/14/15/16384 autoACI's
zoning_rules.py hardcodes) so it is stable across rebuilds without needing
external state. `fvEpP` (uni/epp/fv-[{epg_dn}]) is seeded alongside each EPG,
carrying the SAME pcTag plus a per-VRF `scopeId` (build/zoning.py derives its
own actrlRule.scopeId from the identical per-VRF scheme, so the two line up —
see that module's docstring) and `epgPKey` = the EPG's own dn (zoning_rules.py
extracts the friendly EPG name back out of epgPKey via its trailing "epg-"
segment).
"""
from __future__ import annotations
import re
import zlib
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import (
BD,
Contract,
EPG,
Filter,
FilterEntry,
L3Out,
Site,
Tenant,
Topology,
)
_FRONT_PANEL_PATH_RE = re.compile(r"paths-\d+/pathep-\[eth\d+/\d+\]$")
# Batch-2 pcTag/scopeId scheme (shared with build/zoning.py's actrlRule —
# import these two helpers from here rather than re-deriving the formula, so
# an actrlRule's sPcTag/dPcTag/scopeId always match the fvAEPg/fvEpP/fvCtx
# values a zoning-rule consumer would resolve them against).
#
# Real ACI pcTags ("sclass") are 16-bit-ish integers; autoACI's
# zoning_rules.py hardcodes 5 well-known SYSTEM_PCTAGS (1, 13, 14, 15, 16384)
# that must never collide with a real EPG/VRF pcTag, so both helpers below
# add a fixed offset clear of that reserved set.
_PCTAG_BASE = 32770 # clear of the highest reserved system pcTag (16384)
_SCOPE_BASE = 2097152 # clear of any plausible pcTag range
def pctag_for(key: str) -> str:
"""Deterministic pcTag ("sclass") for an EPG/VRF/ExtEPG, keyed by a
stable string (e.g. the object's own dn). CRC32-derived so it's stable
across rebuilds without external state, same technique endpoints.py's
`_vnid` already uses for VNIDs."""
return str(_PCTAG_BASE + (zlib.crc32(key.encode()) & 0xFFFF))
def scope_id_for(tenant: str, vrf: str) -> str:
"""Deterministic scopeId (real ACI: the VRF's zoning-scope id) for a
(tenant, vrf) pair — every EPG/actrlRule sharing that VRF shares this
scopeId, matching real ACI's per-VRF zoning scope semantics."""
return str(_SCOPE_BASE + (zlib.crc32(f"{tenant}:{vrf}".encode()) & 0xFFFF))
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _owning_site(l3out: L3Out, topo: Topology) -> str | None:
"""Return the site name that owns this L3Out."""
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 _entry_name(entry: FilterEntry, idx: int) -> str:
if entry.from_port != "unspecified":
return f"{entry.proto}-{entry.from_port}"
return f"{entry.proto}-{idx}"
def _build_bd(topo: Topology, site: Site, t: str, bd: BD, l3outs: list[L3Out]) -> MO:
"""Build fvBD + fvRsCtx, fvSubnet, and fvRsBDToOut children."""
l2s = bd.l2stretch
# unicastRoute reflects whether the BD actually routes traffic, i.e. has
# fvSubnet children — NOT l2stretch. A stretched (l2stretch) BD can still
# carry routed subnets (this topology's stretched BDs do); NDO's schema
# side (ndo/model.py) always reports unicastRouting=True for every BD with
# subnets, so the two planes must agree. A true L2-only BD (no subnets)
# correctly stays unicastRoute="no".
routed = bool(bd.subnets)
# Tier-3 (PR-20): bd.mac (per-BD override) falls back to
# fabric.default_bd_mac, which itself defaults to the real ACI default
# gateway MAC "00:22:BD:F8:19:FF" — the literal this module hardcoded
# for every BD pre-PR-20. An unset bd.mac + unset fabric.default_bd_mac
# produces the identical "00:22:BD:F8:19:FF" as before this PR.
bd_mac = bd.mac or topo.fabric.default_bd_mac
bd_mo = MO(
"fvBD",
dn=f"uni/tn-{t}/BD-{bd.name}",
name=bd.name,
arpFlood="yes" if l2s else "no",
unicastRoute="yes" if routed else "no",
ipLearning="yes",
unkMacUcastAct="flood" if l2s else "proxy",
unkMcastAct="flood",
v6unkMcastAct="nd",
multiDstPktAct="bd-flood",
epMoveDetectMode="garp",
hostBasedRouting="no",
limitIpLearnToSubnets="yes",
mtu="9000",
mac=bd_mac,
intersiteBumTrafficAllow="yes" if l2s else "no",
type="regular",
)
bd_mo.add_child(MO(
"fvRsCtx",
dn=f"uni/tn-{t}/BD-{bd.name}/rsctx",
tnFvCtxName=bd.vrf,
))
for cidr in bd.subnets:
bd_mo.add_child(MO(
"fvSubnet",
dn=f"uni/tn-{t}/BD-{bd.name}/subnet-[{cidr}]",
ip=cidr,
scope="public,shared",
))
for l3out in l3outs:
if l3out.vrf != bd.vrf:
continue
if _owning_site(l3out, topo) != site.name:
continue
bd_mo.add_child(MO(
"fvRsBDToOut",
dn=f"uni/tn-{t}/BD-{bd.name}/rsBDToOut-{l3out.name}",
tnL3extOutName=l3out.name,
))
return bd_mo
def _find_static_binding_path(store: MITStore, epg_ref: str) -> tuple[str, str] | None:
"""Return (pathDn, encap) for one LOCAL (front-panel) endpoint already
written for *epg_ref* by endpoints.py, or None if this EPG has no
locally-learned endpoint at this site (e.g. a stretched EPG that is
entirely REMOTE/tunnel-learned here).
fvCEp dn is f"{epg_ref}/cep-{mac}"; we scan the store's fvCEp class list
(cheap — endpoint counts are small) rather than requiring endpoints.py to
expose an index, keeping the two modules loosely coupled.
"""
prefix = f"{epg_ref}/cep-"
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
path_dn = cep.attrs.get("fabricPathDn", "")
if _FRONT_PANEL_PATH_RE.search(path_dn):
return path_dn, cep.attrs.get("encap", "")
return None
def _build_epg(t: str, ap_name: str, epg: EPG, phys_dom: str, store: MITStore) -> MO:
"""Build fvAEPg + fvRsBd, fvRsDomAtt, fvRsProv, fvRsCons, fvRsPathAtt children.
Batch-2: fvAEPg.pcTag is assigned here (keyed by the EPG's own dn via
`pctag_for`) so it is available the moment the EPG exists; the matching
fvEpP (which needs the owning VRF for its scopeId) is built separately in
_build_tenant, where the BD->VRF mapping is already in scope.
"""
epg_dn = f"uni/tn-{t}/ap-{ap_name}/epg-{epg.name}"
epg_mo = MO(
"fvAEPg",
dn=epg_dn,
name=epg.name,
prefGrMemb="exclude",
pcEnfPref="unenforced",
isAttrBasedEPg="no",
floodOnEncap="disabled",
pcTag=pctag_for(epg_dn),
)
epg_mo.add_child(MO(
"fvRsBd",
dn=f"{epg_dn}/rsbd",
tnFvBDName=epg.bd,
))
epg_mo.add_child(MO(
"fvRsDomAtt",
dn=f"{epg_dn}/rsdomAtt-[uni/phys-{phys_dom}]",
tDn=f"uni/phys-{phys_dom}",
instrImedcy="lazy",
))
for c in epg.contracts.provide:
epg_mo.add_child(MO(
"fvRsProv",
dn=f"{epg_dn}/rsprov-{c}",
tnVzBrCPName=c,
))
for c in epg.contracts.consume:
epg_mo.add_child(MO(
"fvRsCons",
dn=f"{epg_dn}/rscons-{c}",
tnVzBrCPName=c,
))
# Batch-1: fvRsPathAtt — static binding to the SAME front-panel path this
# EPG's own (already-built) endpoints use.
binding = _find_static_binding_path(store, epg_dn)
if binding is not None:
path_dn, encap = binding
epg_mo.add_child(MO(
"fvRsPathAtt",
dn=f"{epg_dn}/rspathAtt-[{path_dn}]",
tDn=path_dn,
encap=encap,
mode="regular",
instrImedcy="lazy",
))
return epg_mo
def _build_contract(t: str, contract: Contract, attach_graph: bool) -> MO:
"""Build vzBrCP + vzSubj + vzRsSubjFiltAtt (+ batch-1 vzRsSubjGraphAtt) children.
*attach_graph*: attach a vzRsSubjGraphAtt to this ONE contract's subject
(per the batch-1 placement note — only one existing subject gets a
service-graph attachment; vnsAbsGraph itself is out of scope, so
tnVnsAbsGraphName references a plausible graph name only).
"""
subj_dn = f"uni/tn-{t}/brc-{contract.name}/subj-{contract.name}"
brc_mo = MO(
"vzBrCP",
dn=f"uni/tn-{t}/brc-{contract.name}",
name=contract.name,
scope=contract.scope,
prio="unspecified",
targetDscp="unspecified",
)
subj_mo = MO(
"vzSubj",
dn=subj_dn,
name=contract.name,
revFltPorts="yes",
prio="unspecified",
)
for flt in contract.filters:
subj_mo.add_child(MO(
"vzRsSubjFiltAtt",
dn=f"{subj_dn}/rssubjFiltAtt-{flt.name}",
tnVzFilterName=flt.name,
))
if attach_graph:
subj_mo.add_child(MO(
"vzRsSubjGraphAtt",
dn=f"{subj_dn}/rsSubjGraphAtt",
tnVnsAbsGraphName=f"{contract.name}_graph",
))
brc_mo.add_child(subj_mo)
return brc_mo
def _build_filter(t: str, flt: Filter) -> MO:
"""Build vzFilter + vzEntry children."""
flt_mo = MO("vzFilter", dn=f"uni/tn-{t}/flt-{flt.name}", name=flt.name)
for idx, entry in enumerate(flt.entries):
ename = _entry_name(entry, idx)
flt_mo.add_child(MO(
"vzEntry",
dn=f"uni/tn-{t}/flt-{flt.name}/e-{ename}",
name=ename,
etherT=entry.ether_type,
prot=entry.proto,
dFromPort=entry.from_port,
dToPort=entry.to_port,
sFromPort="unspecified",
sToPort="unspecified",
stateful="yes",
))
return flt_mo
def _build_tenant(topo: Topology, site: Site, tenant: Tenant, store: MITStore) -> MO:
t = tenant.name
phys_dom = topo.access.phys_domains[0].name if topo.access.phys_domains else "phys-dom-1"
tenant_mo = MO("fvTenant", dn=f"uni/tn-{t}", name=t, descr="")
# fvCtx per VRF — batch-2: pcTag (VRF-level sclass; zoning_rules.py's
# vzAny-scoped lookup falls back to this when no EPG-level pcTag matches)
# + scopeId (this VRF's own zoning scope — every actrlRule/fvEpP sharing
# this VRF shares the same scopeId, per scope_id_for's docstring).
for vrf in tenant.vrfs:
tenant_mo.add_child(MO(
"fvCtx",
dn=f"uni/tn-{t}/ctx-{vrf.name}",
name=vrf.name,
pcEnfPref="enforced",
bdEnforcedEnable="no",
ipDataPlaneLearning="enabled",
pcEnfDir="ingress",
pcTag=pctag_for(f"uni/tn-{t}/ctx-{vrf.name}"),
scope=scope_id_for(t, vrf.name),
))
# fvBD per BD
for bd in tenant.bds:
tenant_mo.add_child(_build_bd(topo, site, t, bd, tenant.l3outs))
# fvAp / fvAEPg (+ batch-2 fvEpP, a separate top-level MO under uni/epp/,
# NOT nested under the tenant tree — real ACI's EPG-profile class lives
# in its own uni/epp/ subtree, mirrored here as a sibling store.add call
# rather than a tenant_mo child).
bd_vrf = {bd.name: bd.vrf for bd in tenant.bds}
for ap in tenant.aps:
ap_mo = MO("fvAp", dn=f"uni/tn-{t}/ap-{ap.name}", name=ap.name)
for epg in ap.epgs:
epg_mo = _build_epg(t, ap.name, epg, phys_dom, store)
ap_mo.add_child(epg_mo)
vrf_name = bd_vrf.get(epg.bd, "")
store.add(MO(
"fvEpP",
dn=f"uni/epp/fv-[{epg_mo.dn}]",
epgPKey=epg_mo.dn,
pcTag=epg_mo.attrs["pcTag"],
scopeId=scope_id_for(t, vrf_name) if vrf_name else "",
))
tenant_mo.add_child(ap_mo)
# vzBrCP contracts — batch-1: attach vzRsSubjGraphAtt to exactly ONE
# existing contract subject (the tenant's first contract, if any).
graph_target = tenant.contracts[0].name if tenant.contracts else None
for contract in tenant.contracts:
tenant_mo.add_child(_build_contract(t, contract, attach_graph=(contract.name == graph_target)))
# vzFilter (deduplicated by name)
seen: set[str] = set()
for contract in tenant.contracts:
for flt in contract.filters:
if flt.name in seen:
continue
seen.add(flt.name)
tenant_mo.add_child(_build_filter(t, flt))
return tenant_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fvTenant trees for every tenant deployed at *site*."""
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
store.add(_build_tenant(topo, site, tenant, store))
+106
View File
@@ -0,0 +1,106 @@
"""build/underlay.py — bgpInst (local ASN), isisAdjEp + ospfAdjEp.
Intra-fabric underlay IGP is IS-IS: isisAdjEp is emitted between every directly
cabled spine↔leaf pair. OSPF is used ONLY by spines toward the inter-site network
(IPN/ISN) and ONLY in a multi-site fabric — leaves never peer OSPF with the ISN.
bgpInst carries the site ASN and anchors all BGP peers (overlay.py adds children).
Tier-3 (PR-20): ospfIf also carries `helloIntvl`/`deadIntvl`, fed by
`isn.ospf_hello`/`isn.ospf_dead` (real ACI defaults 10/40) — previously not
carried on this MO at all.
"""
from __future__ import annotations
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
from aci_sim.build.cabling import cabling_links
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit bgpInst per node + OSPF/IS-IS adjacencies along fabric links."""
pod = site.pod
spine_ids = {n.id for n in site.spine_nodes()}
# bgpInst per node — carries the site ASN; overlay.py adds bgpPeer children
for node in site.all_nodes():
store.add(MO(
"bgpInst",
dn=f"topology/pod-{pod}/node-{node.id}/sys/bgp/inst",
asn=str(site.asn),
))
# isisDom per node (IS-IS is the ACI underlay IGP)
for node in site.all_nodes():
store.add(MO(
"isisDom",
dn=f"topology/pod-{pod}/node-{node.id}/sys/isis/inst-default/dom-overlay-1",
name="overlay-1",
operSt="up",
))
# IS-IS adjacencies — the intra-fabric underlay, between directly-cabled
# spine↔leaf pairs only. Convention: n1=spine, n2=leaf (same as cabling.py).
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
spine_id, leaf_id = (n1, n2) if n1 in spine_ids else (n2, n1)
spine_port = f"eth{s1}/{p1}" if n1 in spine_ids else f"eth{s2}/{p2}"
leaf_port = f"eth{s2}/{p2}" if n1 in spine_ids else f"eth{s1}/{p1}"
spine_lb = loopback_ip(pod, spine_id)
leaf_lb = loopback_ip(pod, leaf_id)
spine_isis_base = f"topology/pod-{pod}/node-{spine_id}/sys/isis/inst-default/dom-overlay-1"
store.add(MO(
"isisAdjEp",
dn=f"{spine_isis_base}/if-[{spine_port}]/adj-[{leaf_lb}]",
operSt="up", sysId=leaf_lb, lastTrans="00:00:00", numAdjTrans="1",
))
leaf_isis_base = f"topology/pod-{pod}/node-{leaf_id}/sys/isis/inst-default/dom-overlay-1"
store.add(MO(
"isisAdjEp",
dn=f"{leaf_isis_base}/if-[{leaf_port}]/adj-[{spine_lb}]",
operSt="up", sysId=spine_lb, lastTrans="00:00:00", numAdjTrans="1",
))
# 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.
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
# a hardcoded "0.0.0.0" literal; a topology author overriding
# isn.ospf_area now sees it reflected in the actual OSPF object
# instead of a value the schema stored but no builder honored.
ospf_area = topo.isn.ospf_area
# Tier-3 (PR-20): isn.ospf_hello/ospf_dead (real ACI defaults 10/40)
# feed ospfIf.helloIntvl/deadIntvl — previously not carried on this
# MO at all.
ospf_hello = str(topo.isn.ospf_hello)
ospf_dead = str(topo.isn.ospf_dead)
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}"
if_dn = f"{ospf_base}/if-[{if_port}]"
store.add(MO(
"ospfIf",
dn=if_dn,
id=if_port,
area=ospf_area,
operSt="up",
helloIntvl=ospf_hello,
deadIntvl=ospf_dead,
))
store.add(MO(
"ospfAdjEp",
dn=f"{if_dn}/adj-[{ipn_ip}]",
operSt="full", peerIp=ipn_ip, nbrId=ipn_ip, area=ospf_area,
))
+42
View File
@@ -0,0 +1,42 @@
"""build/userprofile.py — aaaUserEp login-probe MO (uni/userprofile-<user>).
CONTRACT.md §1: during/after login autoACI probes
GET /api/mo/uni/userprofile-<user>.json?query-target=subtree&target-subtree-class=aaaUserDomain
This must not 404 and must return >=1 aaaUserDomain row.
Real APIC exposes a per-user aaaUserEp at uni/userprofile-<user> with an
aaaUserDomain child (name="all" is the default/full-access domain every local
user gets). The simulator only ever authenticates as "admin" (see
rest_aci/auth.py, which defaults aaaLogin's username to "admin"), so the
simplest faithful variant is a single static admin profile rather than
dynamically creating one per logged-in user — this is the same set of MOs a
real APIC would already have provisioned for the built-in admin account, no
matter who authenticates.
Site-independent: call once per topology (site arg accepted for API
uniformity but not used in the selection logic), same convention as
build/access.py.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_ADMIN_USER = "admin"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit uni/userprofile-admin (aaaUserEp) + its aaaUserDomain child."""
profile_dn = f"uni/userprofile-{_ADMIN_USER}"
profile_mo = MO(
"aaaUserEp",
dn=profile_dn,
name=_ADMIN_USER,
)
profile_mo.add_child(MO(
"aaaUserDomain",
dn=f"{profile_dn}/userdomain-all",
name="all",
))
store.add(profile_mo)
+161
View File
@@ -0,0 +1,161 @@
"""build/zoning.py — actrlRule (batch-2, CONTRACT.md §6 "Contracts").
actrlRule topology/pod-{p}/node-{n}/sys/actrl/scope-[{scopeId}]/rule-[{ruleId}]
DN/attribute shapes verified read-only against autoACI's zoning_rules.py,
contract_debug.py, and ep_connectivity.py (all three read actrlRule; see
docs/DESIGN.md "Batch-2" section for the specific attrs each parses):
- zoning_rules.py: node-scoped subtree query under `.../sys/actrl`
(target-subtree-class=actrlRule), reads scopeId/sPcTag/dPcTag/fltId/
action/direction/prio/ctrctName/operSt/dn. Also cross-references fvEpP's
(scopeId, pcTag) -> friendly-name map (tenants.py already seeds this).
- contract_debug.py: same node-scoped subtree query, filters rows whose
ctrctName contains one of the contract names on the debugged path.
- ep_connectivity.py: FABRIC-WIDE class query with an
`or(eq(actrlRule.sPcTag,...),eq(actrlRule.dPcTag,...))` filter — i.e.
actrlRule must also be reachable via a plain GET /api/class/actrlRule.json
(not only via node-scoped subtree), so this module both nests the rule
under its owning node's /sys/actrl tree (for the subtree query shape) AND
the generic class-query index (store.add already registers by class
regardless of nesting, so both access patterns are satisfied by one MO).
Rule derivation: one permit rule per (contract subject filter, provider EPG,
consumer EPG) triple, emitted on every leaf where BOTH the provider and
consumer EPG have at least one locally-learned endpoint (a real leaf only
compiles zoning rules for EPGs it actually has traffic for) — read back from
the fvCEp data endpoints.py already wrote (same read-back pattern
tenants.py's fvRsPathAtt already established; zoning.py runs after both
tenants and endpoints in the orchestrator, so both stores are populated).
sPcTag/dPcTag/scopeId reuse the exact pctag_for/scope_id_for scheme
tenants.py's fvAEPg/fvEpP/fvCtx already assign, so a zoning-rule consumer's
pcTag/scopeId cross-reference against fvEpP always resolves.
Structural-container fix (same class of gap access.py's batch-1 infraInfra
fix addressed): `.../sys/actrl` and `.../sys/actrl/scope-[{scopeId}]` are
each seeded as a real MO (classes `actrlRuleCont`/`actrlScope` — placeholder
names; no consumer queries them BY class, only actrlRule descendants
underneath, so any believable class name is faithful here) — without a real
MO at the exact `.../sys/actrl` DN, GET /api/mo/{node}/sys/actrl.json (the
precise subtree-query shape all three consumers use) 404s, because the
store's ancestor-walk only links intermediate DNs structurally in the
children index and never materializes a real MO for them on its own (see
mit/store.py's MITStore._register docstring).
"""
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, Tenant, Topology
from aci_sim.build.tenants import pctag_for, scope_id_for
_CEP_NODE_RE = re.compile(r"/paths-(\d+)/pathep-\[eth\d+/\d+\]$")
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _leaves_with_local_endpoints(store: MITStore, epg_dn: str) -> set[int]:
"""Return the set of leaf node ids that have >=1 locally-learned (real
front-panel) endpoint for *epg_dn*, read back from endpoints.py's fvCEp
data (same technique tenants.py's _find_static_binding_path uses)."""
prefix = f"{epg_dn}/cep-"
leaves: set[int] = set()
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
path_dn = cep.attrs.get("fabricPathDn", "")
m = _CEP_NODE_RE.search(path_dn)
if m:
leaves.add(int(m.group(1)))
return leaves
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit actrlRule MOs derived from existing contract/filter/EPG data."""
pod = site.pod
# Real MOs for the ".../sys/actrl" and ".../sys/actrl/scope-[...]"
# structural containers — without these, GET /api/mo/{node}/sys/actrl.json
# (the exact DN zoning_rules.py/contract_debug.py/ep_connectivity.py query
# with subtree=True) 404s: the store's ancestor-walk only links these DNs
# structurally in the children index, it does not materialize a real MO
# for them (see mit/store.py's _register docstring) — same class of gap
# access.py's batch-1 infraInfra fix addressed for uni/infra.
seen_actrl_dn: set[str] = set()
seen_scope_dn: set[str] = set()
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
# epg_dn -> EPG (only EPGs actually deployed under this tenant's APs)
epg_by_name = {
epg.name: (ap.name, epg)
for ap in tenant.aps
for epg in ap.epgs
}
for contract in tenant.contracts:
providers = [
(ap_name, epg) for name, (ap_name, epg) in epg_by_name.items()
if contract.name in epg.contracts.provide
]
consumers = [
(ap_name, epg) for name, (ap_name, epg) in epg_by_name.items()
if contract.name in epg.contracts.consume
]
if not providers or not consumers:
continue
for prov_ap, prov_epg in providers:
prov_dn = f"uni/tn-{t}/ap-{prov_ap}/epg-{prov_epg.name}"
prov_vrf = None
for bd in tenant.bds:
if bd.name == prov_epg.bd:
prov_vrf = bd.vrf
if prov_vrf is None:
continue
prov_tag = pctag_for(prov_dn)
scope_id = scope_id_for(t, prov_vrf)
prov_leaves = _leaves_with_local_endpoints(store, prov_dn)
for cons_ap, cons_epg in consumers:
cons_dn = f"uni/tn-{t}/ap-{cons_ap}/epg-{cons_epg.name}"
cons_tag = pctag_for(cons_dn)
cons_leaves = _leaves_with_local_endpoints(store, cons_dn)
# Real ACI compiles the (bidirectional-capable) zoning
# rule pair onto every leaf hosting either side's
# endpoints — the leaf enforces the policy for its own
# local traffic regardless of which side it hosts.
target_leaves = prov_leaves | cons_leaves
if not target_leaves:
continue
for flt in contract.filters:
for leaf_id in sorted(target_leaves):
actrl_dn = f"topology/pod-{pod}/node-{leaf_id}/sys/actrl"
if actrl_dn not in seen_actrl_dn:
seen_actrl_dn.add(actrl_dn)
store.add(MO("actrlRuleCont", dn=actrl_dn))
scope_dn = f"{actrl_dn}/scope-[{scope_id}]"
if scope_dn not in seen_scope_dn:
seen_scope_dn.add(scope_dn)
store.add(MO("actrlScope", dn=scope_dn, scopeId=scope_id))
rule_id = f"{contract.name}-{flt.name}"
store.add(MO(
"actrlRule",
dn=f"{scope_dn}/rule-[{rule_id}]",
scopeId=scope_id,
sPcTag=prov_tag,
dPcTag=cons_tag,
fltId=flt.name,
action="permit",
direction="bi",
prio="pol_default",
ctrctName=contract.name,
operSt="enabled",
))