Files
aci-sim/aci_sim/build/access.py
T
taodengandClaude Fable 5 7e9a175ce6 Initial public release — aci-sim v0.16.0
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

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

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:06:36 +10:00

292 lines
12 KiB
Python

"""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))