mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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:
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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,
|
||||
))
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
))
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
))
|
||||
Reference in New Issue
Block a user