Initial public release — aci-sim v0.16.0

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

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

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
View File
View File
+291
View File
@@ -0,0 +1,291 @@
"""build/access.py — VLAN pools, physical/L3 domains, AAEPs, access port IPGs,
and (batch-1) the leaf interface-policy cluster: infraAccPortP/infraHPortS/
infraPortBlk/infraRsAccBaseGrp/infraAccBndlGrp.
Site-independent: call once per topology (site arg accepted for API uniformity
but not used in the selection logic).
Interface-policy cluster (CONTRACT.md §6 "Access policy") — real ACI DN/RN
conventions, verified against the existing infra tree this module already
builds (uni/infra/... prefix) and against autoACI's access_policy.py (read via
prior audit, see docs/CONTRACT.md header):
infraAccPortP uni/infra/accportprof-{name}
infraHPortS {accportp}/hports-{name}-typ-range
infraPortBlk {hports}/portblk-block{n}
infraRsAccBaseGrp {hports}/rsaccBaseGrp
infraAccBndlGrp uni/infra/funcprof/accbundle-{name}
Port ranges in infraPortBlk MUST match ports that actually exist in the
l1PhysIf inventory interfaces.py builds (eth1/1.._HOST_PORTS on leaves) — the
whole cluster is built once per border-leaf vPC pair (the topology's only vPC
domains, per fabric.py's vpcDom/vpcIf) plus one profile per regular-leaf pair,
so infraAccBndlGrp(lagT="node") lines up with a real vpcDom this same site
already has.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import AAEP, L3Domain, PhysDomain, Site, Topology, VlanPool, VMMDomain
from aci_sim.build.interfaces import _HOST_PORTS
def _build_vlan_pool(pool: VlanPool) -> MO:
"""Build fvnsVlanInstP + fvnsEncapBlk child."""
pool_dn = f"uni/infra/vlanns-[{pool.name}]-static"
blk_dn = (
f"{pool_dn}"
f"/from-[vlan-{pool.from_vlan}]-to-[vlan-{pool.to_vlan}]"
)
pool_mo = MO("fvnsVlanInstP", dn=pool_dn, name=pool.name, allocMode="static")
blk_mo = MO(
"fvnsEncapBlk",
dn=blk_dn,
allocMode="static",
**{"from": f"vlan-{pool.from_vlan}", "to": f"vlan-{pool.to_vlan}"},
)
pool_mo.add_child(blk_mo)
return pool_mo
def _build_phys_dom(phys_dom: PhysDomain, first_pool_dn: str) -> MO:
"""Build physDomP + optional infraRsVlanNs child."""
dom_mo = MO("physDomP", dn=f"uni/phys-{phys_dom.name}", name=phys_dom.name)
if first_pool_dn:
dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"uni/phys-{phys_dom.name}/rsvlanNs",
tDn=first_pool_dn,
))
return dom_mo
def _build_l3_dom(l3_dom: L3Domain, first_pool_dn: str) -> MO:
"""Build l3extDomP + optional infraRsVlanNs child."""
l3dom_mo = MO("l3extDomP", dn=f"uni/l3dom-{l3_dom.name}", name=l3_dom.name)
if first_pool_dn:
l3dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"uni/l3dom-{l3_dom.name}/rsvlanNs",
tDn=first_pool_dn,
))
return l3dom_mo
def _build_vmm_domain(vmm: VMMDomain, first_pool_dn: str) -> MO:
"""Build vmmDomP (+ vmmCtrlrP + vmmUsrAccP if vcenter_ip set, + infraRsVlanNs).
Tier-2 (PR-19). Real ACI DN scheme (verified against aci-py's
`_domain_dn_class` shim — see docs/DESIGN.md PR-19 note):
vmmDomP uni/vmmp-VMware/dom-{name}
vmmCtrlrP {vmmDomP dn}/ctrlr-{name} hostOrIp=vcenter_ip, rootContName=datacenter
vmmUsrAccP {vmmDomP dn}/usracc-{name} placeholder credential-profile ref (no
real credentials in a sim — name-only, matches the pattern
phys/l3 domains use for their own no-secret child objects)
Only "VMware" (vmmProvP=VMware) is modeled — the sole provider the
aci-py `bind_epg_to_vmm_domain` playbook chain (and the DN map in
`mso_schema_site_anp_epg_domain.py`, `dn_map["vmmDomain"] =
"uni/vmmp-VMware/dom-{0}"`) actually exercises today.
vmmCtrlrP/vmmUsrAccP are only emitted when `vcenter_ip` is set — a bare
`{name: ...}` VMM domain entry (no vCenter attrs) produces just the
vmmDomP + optional infraRsVlanNs, same shape as a phys/l3 domain with no
extra config, so a topology author who only wants a named domain isn't
forced to supply vCenter connection details.
"""
dom_dn = f"uni/vmmp-VMware/dom-{vmm.name}"
dom_mo = MO("vmmDomP", dn=dom_dn, name=vmm.name)
if vmm.vcenter_ip:
ctrlr_dn = f"{dom_dn}/ctrlr-{vmm.name}"
ctrlr_mo = MO(
"vmmCtrlrP",
dn=ctrlr_dn,
name=vmm.name,
hostOrIp=vmm.vcenter_ip,
rootContName=vmm.datacenter or vmm.name,
dvsVersion="unmanaged",
)
if vmm.vcenter_dvs:
ctrlr_mo.attrs["dvsName"] = vmm.vcenter_dvs
dom_mo.add_child(ctrlr_mo)
dom_mo.add_child(MO(
"vmmUsrAccP",
dn=f"{ctrlr_dn}/usracc-{vmm.name}",
name=vmm.name,
))
if vmm.vlan_pool and first_pool_dn:
# first_pool_dn is topo.access.vlan_pools[0] (the same convention
# phys/l3 domains use below); Tier-2 vmm.vlan_pool is validated
# against the real pool-name set in schema.py's normalize_and_validate
# but this builder still binds the module-wide first pool DN (the
# existing phys/l3-domain convention), matching a dynamic VMM pool
# binding by name rather than resolving a distinct pool DN per VMM
# domain — sufficient for the sim's single-pool-per-topology norm.
dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"{dom_dn}/rsvlanNs",
tDn=first_pool_dn,
))
return dom_mo
def _build_aaep(
aaep: AAEP,
phys_domains: list[PhysDomain],
l3_domains: list[L3Domain],
) -> MO:
"""Build infraAttEntityP + infraRsDomP children for all domains."""
aaep_dn = f"uni/infra/attentp-{aaep.name}"
aaep_mo = MO("infraAttEntityP", dn=aaep_dn, name=aaep.name)
for phys_dom in phys_domains:
tdn = f"uni/phys-{phys_dom.name}"
aaep_mo.add_child(MO(
"infraRsDomP",
dn=f"{aaep_dn}/rsdomP-[{tdn}]",
tDn=tdn,
))
for l3_dom in l3_domains:
tdn = f"uni/l3dom-{l3_dom.name}"
aaep_mo.add_child(MO(
"infraRsDomP",
dn=f"{aaep_dn}/rsdomP-[{tdn}]",
tDn=tdn,
))
return aaep_mo
def _build_port_ipg(aaep: AAEP) -> MO:
"""Build infraAccPortGrp + infraRsAttEntP child for one AAEP."""
ipg_name = f"{aaep.name}-ipg"
ipg_dn = f"uni/infra/funcprof/accportgrp-{ipg_name}"
ipg_mo = MO(
"infraAccPortGrp",
dn=ipg_dn,
name=ipg_name,
lagT="not-aggregated",
)
ipg_mo.add_child(MO(
"infraRsAttEntP",
dn=f"{ipg_dn}/rsattEntP",
tDn=f"uni/infra/attentp-{aaep.name}",
))
return ipg_mo
def _build_bndl_grp(name: str, lag_t: str) -> MO:
"""Build infraAccBndlGrp (funcprof bundle IPG) — vPC (lagT=node) or PC (lagT=link)."""
dn = f"uni/infra/funcprof/accbundle-{name}"
return MO("infraAccBndlGrp", dn=dn, name=name, lagT=lag_t)
def _build_hports(
accportp_dn: str,
sel_name: str,
from_port: int,
to_port: int,
bndl_grp_dn: str,
) -> MO:
"""Build infraHPortS (type=range) + infraPortBlk + infraRsAccBaseGrp children.
RN scheme verified against this module's own uni/infra/... conventions
(accportprof-/hports-/portblk-):
infraHPortS hports-{sel_name}-typ-range
infraPortBlk portblk-block1 (single block covering the range)
infraRsAccBaseGrp rsaccBaseGrp (fixed RN — one target per selector)
"""
hports_dn = f"{accportp_dn}/hports-{sel_name}-typ-range"
hports_mo = MO("infraHPortS", dn=hports_dn, name=sel_name, type="range")
hports_mo.add_child(MO(
"infraPortBlk",
dn=f"{hports_dn}/portblk-block1",
fromPort=str(from_port),
toPort=str(to_port),
fromCard="1",
))
hports_mo.add_child(MO(
"infraRsAccBaseGrp",
dn=f"{hports_dn}/rsaccBaseGrp",
tDn=bndl_grp_dn,
))
return hports_mo
def _build_leaf_profile(profile_name: str, sel_name: str, bndl_grp_dn: str) -> MO:
"""Build one infraAccPortP with one infraHPortS selector spanning the
leaf's host-port range (eth1/1.._HOST_PORTS — the exact inventory
interfaces.py builds), so every infraPortBlk from/to port exists as a
real l1PhysIf."""
accportp_dn = f"uni/infra/accportprof-{profile_name}"
accportp_mo = MO("infraAccPortP", dn=accportp_dn, name=profile_name)
accportp_mo.add_child(_build_hports(accportp_dn, sel_name, 1, _HOST_PORTS, bndl_grp_dn))
return accportp_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit access-policy MOs (site-independent, build once)."""
# Batch-1 fix: uni/infra previously had no MO of its own — only
# descendant DNs (uni/infra/vlanns-.../, uni/infra/attentp-.../, etc.),
# which the store's ancestor-walk (_register) links structurally but
# never materializes as a real object. That made
# GET /api/mo/uni/infra.json (any rsp-subtree mode) 404, even though the
# generic query engine's rsp-subtree=full traversal otherwise works fine
# (verified: /api/class/infraAccPortP.json?rsp-subtree=full already
# returns the nested infraHPortS/infraPortBlk/infraRsAccBaseGrp tree).
# infraInfra is the real ACI class for this container (CONTRACT.md §6
# "Infra write targets"); seeding it here is a one-line, no-behavior-
# change fix so `GET /api/mo/uni/infra.json?rsp-subtree=full` (needed to
# see the whole access-policy tree in one call, same as autoACI's
# ipg_detail/access_policy plugins would) returns 200 with a real root.
store.add(MO("infraInfra", dn="uni/infra", name=""))
first_pool_dn = (
f"uni/infra/vlanns-[{topo.access.vlan_pools[0].name}]-static"
if topo.access.vlan_pools else ""
)
for pool in topo.access.vlan_pools:
store.add(_build_vlan_pool(pool))
for phys_dom in topo.access.phys_domains:
store.add(_build_phys_dom(phys_dom, first_pool_dn))
for l3_dom in topo.access.l3_domains:
store.add(_build_l3_dom(l3_dom, first_pool_dn))
# Tier-2 (PR-19): VMM domains — optional, defaults to an empty list, so
# a topology with no `access.vmm_domains` produces zero vmmDomP MOs
# (byte-identical to pre-PR-19 output).
for vmm in topo.access.vmm_domains:
store.add(_build_vmm_domain(vmm, first_pool_dn))
for aaep in topo.access.aaeps:
store.add(_build_aaep(aaep, topo.access.phys_domains, topo.access.l3_domains))
for aaep in topo.access.aaeps:
store.add(_build_port_ipg(aaep))
# ---- Batch-1: leaf-interface-policy cluster --------------------------
# One vPC bundle group per border-leaf vPC domain (fabric.py already
# builds the matching vpcDom/vpcIf for these same pairs), one leaf
# interface-profile per leaf pair (regular leaves + each border-leaf
# vPC pair), each with a single range selector spanning the real
# host-port inventory (eth1/1.._HOST_PORTS).
seen_vpc_domains: set[str] = set()
for bl in site.border_leaves:
if bl.vpc_domain in seen_vpc_domains:
continue
seen_vpc_domains.add(bl.vpc_domain)
bndl_name = f"{bl.vpc_domain}-vpc"
store.add(_build_bndl_grp(bndl_name, lag_t="node"))
bndl_grp_dn = f"uni/infra/funcprof/accbundle-{bndl_name}"
profile_name = f"{site.name}-{bl.vpc_domain}"
store.add(_build_leaf_profile(profile_name, f"sel-{bl.vpc_domain}", bndl_grp_dn))
leaves = site.leaf_nodes()
if len(leaves) >= 2:
pair_name = f"{site.name}-leafpair"
pc_bndl_name = f"{pair_name}-pc"
store.add(_build_bndl_grp(pc_bndl_name, lag_t="link"))
pc_bndl_grp_dn = f"uni/infra/funcprof/accbundle-{pc_bndl_name}"
store.add(_build_leaf_profile(pair_name, f"sel-{pair_name}", pc_bndl_grp_dn))
+94
View File
@@ -0,0 +1,94 @@
"""build/cabling.py — fabricLink, lldpAdjEp, cdpAdjEp derived from the cabling graph.
fabricLink DN (verified against autoACI/backend/routers/topology.py lines 381-388):
topology/pod-{pod}/lnk-{n1}-{s1}-{p1}-to-{n2}-{s2}-{p2}
where "lnk-{n1}-{s1}-{p1}-to-...".split("-") = [n1, s1, p1, ...]
→ source_port = f"eth{s1}/{p1}" (spc[1], spc[2])
n1/n2 attributes carry the plain node IDs.
Port assignment (auto mode):
Spines: uplinks eth1/1, eth1/2, ... (one per downlink, in order)
Leaves/border-leaves: uplinks eth1/49, eth1/50, ... (one per spine, in order)
"""
from __future__ import annotations
from aci_sim.build.fabric import oob_ip
from aci_sim.build.neighbors import add_switch_adjacency
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Node, Site, Topology
def cabling_links(site: Site) -> list[tuple[int, int, int, int, int, int]]:
"""Return (n1_id, slot1, port1, n2_id, slot2, port2) for every fabric link.
Convention: n1=spine, n2=leaf/border-leaf.
Spine port: eth1/{leaf_idx+1}
Leaf uplink: eth1/{49+spine_idx}
"""
spines = site.spine_nodes()
downlinks = site.leaf_nodes() + site.border_leaf_nodes()
links: list[tuple[int, int, int, int, int, int]] = []
for l_idx, leaf in enumerate(downlinks):
for s_idx, spine in enumerate(spines):
links.append((
spine.id, 1, l_idx + 1, # spine eth1/(leaf_idx+1)
leaf.id, 1, 49 + s_idx, # leaf eth1/(49+spine_idx)
))
return links
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fabricLink + lldpAdjEp + cdpAdjEp derived from the cabling graph."""
pod = site.pod
# Build a lookup: node_id -> Node (for name/loopback lookups)
node_map: dict[int, Node] = {n.id: n for n in site.all_nodes()}
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
lnk_dn = (
f"topology/pod-{pod}"
f"/lnk-{n1}-{s1}-{p1}-to-{n2}-{s2}-{p2}"
)
store.add(MO(
"fabricLink",
dn=lnk_dn,
n1=str(n1),
n2=str(n2),
operSt="up",
operSpeed="100G",
linkType="leaf",
))
# --- LLDP & CDP adjacencies on BOTH endpoints ---
node1 = node_map.get(n1)
node2 = node_map.get(n2)
if node1 is None or node2 is None:
continue
port1 = f"eth{s1}/{p1}"
port2 = f"eth{s2}/{p2}"
oob1 = oob_ip(pod, n1)
oob2 = oob_ip(pod, n2)
# Node1 sees Node2 as neighbor on port1 (Node2's own port is port2)
add_switch_adjacency(
store,
pod=pod,
local_node_id=n1,
local_port=port1,
remote_port=port2,
neighbor=node2,
neighbor_mgmt_ip=oob2,
)
# Node2 sees Node1 as neighbor on port2 (Node1's own port is port1)
add_switch_adjacency(
store,
pod=pod,
local_node_id=n2,
local_port=port2,
remote_port=port1,
neighbor=node1,
neighbor_mgmt_ip=oob1,
)
+313
View File
@@ -0,0 +1,313 @@
"""build/endpoints.py — fvCEp, fvIp, epmMacEp, epmIpEp, fvIfConn.
Port allocation avoids the APIC-controller-reserved port range: fabric.py wires
each APIC-<cid> (cid 1..site.controllers) to eth1/<cid> on the first two leaves
(site.leaf_nodes()[:2]) — the same leaves endpoints are placed on. Endpoint
host ports therefore start right AFTER that reserved range so no port ever
hosts both a controller lldpAdjEp and an endpoint fabricPathDn (see
interfaces.py's _HOST_PORTS=8 host-port inventory for the ceiling).
Multi-site (stretched-tenant) endpoint semantics (see docs/DESIGN.md):
A stretched tenant (tenant.stretch=true, sites: [A, B]) is built independently
per site by this same build() function, once per site's MITStore. Real ACI
never shows the identical endpoint as locally-learned-on-a-front-panel-port at
BOTH sites simultaneously: exactly one site has it physically attached (HOME,
lcC contains "learned", fabricPathDn = a real front-panel pathep); the other
site only knows about it via the multi-site overlay/ISN tunnel (REMOTE,
lcC="learned,vxlan", fabricPathDn = a tunnelIf path toward the home site's
spine-proxy TEP). The HOME site is chosen deterministically per endpoint
(alternating over tenant.sites by the endpoint's position in the per-tenant
sequence) so both sites' independent builds agree on which one is home
without needing to share state.
"""
from __future__ import annotations
import ipaddress
import zlib
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import BD, Site, Tenant, Topology
from aci_sim.build.fabric import loopback_ip
from aci_sim.build.interfaces import _HOST_PORTS
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _vnid(name: str, base: int) -> int:
return base + (zlib.crc32(name.encode()) & 0xFFFF)
def _mac(counter: int) -> str:
aa = (counter >> 16) & 0xFF
bb = (counter >> 8) & 0xFF
cc = counter & 0xFF
return f"00:50:56:{aa:02X}:{bb:02X}:{cc:02X}"
def _host_ip(bd: BD, index: int) -> str:
"""Return host at position 10+index within the first BD subnet."""
if not bd.subnets:
return "0.0.0.0"
net = ipaddress.ip_network(bd.subnets[0], strict=False)
hosts = list(net.hosts())
pos = 10 + index
return str(hosts[pos % len(hosts)])
def _emit_epm_mos(
store: MITStore,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port_ifid: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
flags: str = "local",
) -> None:
"""Emit epmMacEp, epmIpEp, and (for real front-panel ports) fvIfConn."""
ctx_bd = f"ctx-[vxlan-{vrf_vnid}]/bd-[vxlan-{bd_vnid}]"
node_sys = f"topology/pod-{pod}/node-{leaf_id}/sys"
store.add(MO(
"epmMacEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/mac-{mac}",
addr=mac,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
encap=f"vlan-{epg_vlan}",
))
store.add(MO(
"epmIpEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/ip-[{ip}]",
addr=ip,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
))
if flags == "local":
store.add(MO(
"fvIfConn",
dn=(
f"{node_sys}/phys-[{port_ifid}]"
f"/epgconn-[{epg_ref}]"
),
encap=f"vlan-{epg_vlan}",
))
def _emit_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port: int,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp+fvIfConn for one LOCAL (home-site)
endpoint — physically attached to a real front-panel leaf port."""
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned",
fabricPathDn=(
f"topology/pod-{pod}/paths-{leaf_id}/pathep-[eth1/{port}]"
),
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, leaf_id, f"eth1/{port}",
epg_ref, vrf_vnid, bd_vnid, flags="local",
)
def _ensure_tunnel_if(
store: MITStore,
pod: int,
spine_id: int,
remote_pod: int,
remote_spine_id: int,
seen: set[tuple[int, int]],
) -> str:
"""Ensure a tunnelIf MO exists on *spine_id* toward the remote site's
spine-proxy TEP (represented by remote_spine_id's loopback); return its
interface id (e.g. "tunnel401"), used to build the fvCEp fabricPathDn.
Real ACI multi-site: remote-learned endpoints are reachable via a VXLAN
tunnel from the local spine-proxy to the remote site's spine-proxy anycast
TEP. We model one tunnelIf per (local spine, remote spine) pair, numbered
deterministically by the remote spine's id so the DN is stable and
idempotent across multiple endpoints sharing the same tunnel.
"""
key = (spine_id, remote_spine_id)
tunnel_id = f"tunnel{remote_spine_id}"
if key not in seen:
seen.add(key)
dest_tep = loopback_ip(remote_pod, remote_spine_id)
store.add(MO(
"tunnelIf",
dn=f"topology/pod-{pod}/node-{spine_id}/sys/tunnel-[{tunnel_id}]",
id=tunnel_id,
dest=dest_tep,
src=loopback_ip(pod, spine_id),
operSt="up",
type="logical-l3-multicast",
layer="l3-multicast",
))
return tunnel_id
def _emit_remote_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
tunnel_spine_id: int,
tunnel_id: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp for one REMOTE (peer-site) endpoint —
learned via the multi-site overlay tunnel, not a real front-panel port."""
fabric_path_dn = (
f"topology/pod-{pod}/paths-{tunnel_spine_id}/pathep-[{tunnel_id}]"
)
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned,vxlan",
fabricPathDn=fabric_path_dn,
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, tunnel_spine_id, tunnel_id,
epg_ref, vrf_vnid, bd_vnid, flags="learned,vxlan",
)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit endpoint + EPM MOs for every EPG deployed at *site*.
MAC/VLAN sequences are per-store (per site/APIC) and assume the intended
one-MITStore-per-APIC model; merging two site stores would collide DNs/MACs.
"""
pod = site.pod
leaves = site.leaf_nodes()
per_epg = topo.endpoints.per_epg
first_vlan = topo.access.vlan_pools[0].from_vlan if topo.access.vlan_pools else 100
# APIC-{cid} controllers (cid 1..site.controllers) are wired to eth1/{cid}
# on the first two leaves (fabric.py: apic_leaves = site.leaf_nodes()[:2]).
# Reserve that port range on those leaves; endpoints start right after it.
apic_reserved_leaf_ids = {n.id for n in site.leaf_nodes()[:2]}
apic_port_start = 1 + site.controllers
free_host_ports = max(_HOST_PORTS - site.controllers, 1)
global_epg_index = 0
ep_counter = 0
tunnels_seen: set[tuple[int, int]] = set()
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
bd_by_name = {bd.name: bd for bd in tenant.bds}
# A stretched tenant's endpoints are HOME on exactly one of its sites
# (see module docstring). Non-stretched (site-local) tenants have no
# remote counterpart — every endpoint is simply local, as before.
is_stretched = tenant.stretch and len(tenant.sites) > 1
remote_site = None
if is_stretched:
try:
remote_site = topo.other_site(site.name) if site.name in tenant.sites else None
except KeyError:
remote_site = None
for ap in tenant.aps:
for epg in ap.epgs:
bd = bd_by_name.get(epg.bd)
epg_vlan = first_vlan + global_epg_index
global_epg_index += 1
if bd is None or not bd.subnets:
continue
vrf_vnid = _vnid(f"{t}:{bd.vrf}", 2900000)
bd_vnid = _vnid(f"{t}:{bd.name}", 15000000)
epg_ref = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}"
for i in range(per_epg):
# Deterministic HOME-site assignment: alternates over
# tenant.sites by this endpoint's position in the
# per-tenant sequence, so LAB1's and LAB2's independent
# build() calls agree on which site is home without
# sharing state.
home_site_name = (
tenant.sites[ep_counter % len(tenant.sites)]
if is_stretched else site.name
)
mac = _mac(ep_counter)
ep_counter += 1
ip = _host_ip(bd, i)
cep_dn = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}/cep-{mac}"
if not is_stretched or home_site_name == site.name:
leaf = leaves[i % len(leaves)]
leaf_id = leaf.id
if leaf_id in apic_reserved_leaf_ids:
# Skip past the APIC-reserved eth1/1..eth1/{controllers}
# range so no endpoint double-books a controller port.
port = apic_port_start + (i % free_host_ports)
else:
port = (i % _HOST_PORTS) + 1
_emit_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
leaf_id, port, epg_ref, vrf_vnid, bd_vnid,
)
elif remote_site is not None:
# This site is the PEER for this endpoint: learned via
# the multi-site overlay tunnel, not a front-panel port.
local_spines = site.spine_nodes()
home_spines = remote_site.spine_nodes()
if not local_spines or not home_spines:
continue
tunnel_spine = local_spines[i % len(local_spines)]
home_spine = home_spines[i % len(home_spines)]
tunnel_id = _ensure_tunnel_if(
store, pod, tunnel_spine.id,
remote_site.pod, home_spine.id, tunnels_seen,
)
_emit_remote_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
tunnel_spine.id, tunnel_id, epg_ref, vrf_vnid, bd_vnid,
)
+343
View File
@@ -0,0 +1,343 @@
"""build/fabric.py — fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf.
Batch-2 additions (CONTRACT.md §6 "Fabric/topology", "Infra write targets"; DN/
attribute shapes verified read-only against autoACI's vpc_status.py/
health_score.py — see docs/DESIGN.md "Batch-2" section):
pcAggrIf {vpcDom}/aggr-[po{bl.id}] (port-channel details; vpc_status.py
matches it to vpcIf by (node, name==ipg_name) to get the po id)
pcRsMbrIfs {pcAggrIf}/rsmbrIfs-[{ifDn}] (member-port relation; CONTRACT.md
catalogued this as "vpcRsMbrIfs" — WRONG, corrected to pcRsMbrIfs,
the real ACI class vpc_status.py actually queries; see CONTRACT.md §6
changelog note)
infraWiNode topology/pod-{p}/node-{m}/av/node-{c} (appliance-vector: this
controller's view of cluster member {c}'s health)
fabricNodeIdentP uni/controller/nodeidentpol/nodep-{id} (boot-seeded registration
for every real fabricNode, so GETs return registered nodes without a
prior POST; consistent with writes.py's existing add-leaf reaction,
which also keys nodep-{id} on the node id, not the serial)
Address derivation (see loopback_ip/oob_ip docstrings for the full scheme).
node_id is expected in [1, 4095] (ACI node IDs are 3-4 decimal digits; this
covers every ID our topology.yaml or a hand-authored one could reasonably use).
node_id <= 255 (legacy/unchanged — BACKWARD COMPATIBLE, existing labs keep
their addresses):
loopback / topSystem.address: 10.<pod>.<node_id>.1
OOB mgmt / topSystem.oobMgmtAddr: 192.168.<pod>.<node_id>
node_id > 255 (new — needed once a site's node IDs exceed 255, e.g. LAB2's
301-402, which would otherwise overflow the last IPv4 octet
and produce invalid addresses like 10.1.401.1):
loopback: 10.<pod>.<node_id % 256>.<1 + node_id // 256>
- third octet reuses the legacy last-octet slot (node_id % 256)
- fourth octet is bumped to 2, 3, ... instead of the legacy "1", so it
can never collide with a node_id <= 255 loopback (those always end
in ".1"); node_id // 256 is <= 15 for node_id <= 4095, keeping the
fourth octet in [2, 16], well inside the valid range.
OOB mgmt: 192.<168 + node_id // 256>.<pod>.<node_id % 256>
- second octet is bumped from 168 to 169, 170, ... (never 168, which
is reserved for the legacy node_id <= 255 scheme), so it can never
collide with a legacy oob address either.
- node_id // 256 is <= 15 for node_id <= 4095, keeping the second
octet in [169, 183], still a valid unicast IPv4 octet.
Both branches are unique per (pod, node_id): within a single pod the pair
(node_id % 256, node_id // 256) is a bijection of node_id, and the fourth
octet (loopback) / second octet (oob) values used by the > 255 branch never
overlap with the values the <= 255 branch produces.
"""
from __future__ import annotations
from aci_sim.build.neighbors import add_adjacency, node_mac
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_LEGACY_MAX_NODE_ID = 255
def loopback_ip(pod: int, node_id: int) -> str:
"""Node loopback address.
node_id <= 255: 10.<pod>.<node_id>.1 (legacy, unchanged)
node_id > 255: 10.<pod>.<node_id % 256>.<1 + node_id // 256>
See module docstring for why this can never produce an invalid octet
(>255) or collide with the legacy scheme.
"""
if node_id <= _LEGACY_MAX_NODE_ID:
return f"10.{pod}.{node_id}.1"
return f"10.{pod}.{node_id % 256}.{1 + node_id // 256}"
def oob_ip(pod: int, node_id: int) -> str:
"""OOB management address.
node_id <= 255: 192.168.<pod>.<node_id> (legacy, unchanged)
node_id > 255: 192.<168 + node_id // 256>.<pod>.<node_id % 256>
See module docstring for why this can never produce an invalid octet
(>255) or collide with the legacy scheme.
"""
if node_id <= _LEGACY_MAX_NODE_ID:
return f"192.168.{pod}.{node_id}"
return f"192.{168 + node_id // 256}.{pod}.{node_id % 256}"
def default_serial(site_id: str, node_id: int) -> str:
"""Deterministic non-empty serial for an auto-generated node (PR-18).
Format: SAL{site_id}{node_id:04d} — e.g. site "1" node 101 -> "SAL10101".
Ansible's `cisco.aci.aci_fabric_node` keys node registration on serial;
prior to PR-18 an unset `Node.serial` fell back to the non-deterministic-
looking (but actually deterministic) "SIM{node.id:08d}". This helper
replaces that fallback with a scheme that also encodes the site, so two
sites' node-101 (impossible today given the 200-offset ID scheme, but a
hand-authored topology could still reuse ids across unrelated tools)
produce visibly distinct serials. Explicit YAML `serial:` values on a
Node always take precedence — this is only the fallback for `serial=""`.
"""
return f"SAL{site_id}{node_id:04d}"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf for *site*."""
pod = site.pod
fabric_name = site.fabric_name or topo.fabric.name # per-site fabricDomain (site grouping)
spine_ids = {n.id for n in site.spine_nodes()}
for node in site.all_nodes():
role = "spine" if node.id in spine_ids else "leaf"
node_dn = f"topology/pod-{pod}/node-{node.id}"
store.add(MO(
"fabricNode",
dn=node_dn,
id=str(node.id),
name=node.name,
role=role,
model=node.model,
serial=node.serial or default_serial(site.id, node.id),
version=node.version,
adSt="on",
fabricSt="active",
))
store.add(MO(
"topSystem",
dn=f"{node_dn}/sys",
id=str(node.id),
name=node.name,
role=role,
version=node.version,
address=loopback_ip(pod, node.id),
oobMgmtAddr=oob_ip(pod, node.id),
fabricDomain=fabric_name,
state="in-service",
podId=str(pod),
))
# APIC controllers — the APIC cluster (role=controller, node IDs 1..N).
# Real ACI exposes these as fabricNode + topSystem role=controller; autoACI
# reads fabricDomain + version from the controller topSystem at login and
# renders the controllers as nodes in Topology.
fabric_domain = site.fabric_name or fabric_name
# Tier-3 (PR-20): fabric.default_apic_version is a fabric-wide override
# for the APIC controller version, taking precedence over this site's
# own site.apic_version when set (None/omitted = unchanged pre-PR-20
# behavior: each site keeps using its own site.apic_version, already
# independently settable from switch-node Node.version since PR-18).
apic_version = topo.fabric.default_apic_version or site.apic_version
# PR-21: per-controller OOB mgmt IP. Site.controller_oob_ips() returns
# either the explicit `controller_ips` list, or a sequential derivation
# from `mgmt_ip` (controller 1 = mgmt_ip, controller 2 = mgmt_ip+1, ...).
# An empty list means neither is available (mgmt_ip unset) — fall back
# to the legacy per-node oob_ip(pod, cid) scheme, unchanged pre-PR-21
# behavior for topologies that never set mgmt_ip. The sim's REST server
# always binds to `mgmt_ip` regardless of `controllers` (controller-1 /
# cluster VIP) — this loop only affects the *simulated* fabricNode/
# topSystem/infraWiNode data, never which address is actually served.
controller_oob_ips = site.controller_oob_ips()
for cid in range(1, site.controllers + 1):
ctrl_dn = f"topology/pod-{pod}/node-{cid}"
apic_name = f"{site.name}-ACI-APIC{cid:02d}"
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
store.add(MO(
"fabricNode",
dn=ctrl_dn,
id=str(cid),
name=apic_name,
role="controller",
model="APIC-SERVER-M3",
serial=f"FCH00APIC{cid:03d}",
version=apic_version,
adSt="on",
fabricSt="active",
))
store.add(MO(
"topSystem",
dn=f"{ctrl_dn}/sys",
id=str(cid),
name=apic_name,
role="controller",
version=apic_version,
address=f"10.0.{pod}.{cid}",
oobMgmtAddr=oob_addr,
fabricDomain=fabric_domain,
state="in-service",
podId=str(pod),
))
# PR-9 FEATURE 5 — firmwareCtrlrRunning: cisco.aci and other clients
# read the running APIC firmware version off this class (real ACI
# RN "ctrlrfwstatuscont/ctrlrrunning", one per controller), rather
# than (or in addition to) topSystem.version. Trivially seedable
# from the same apic_version topSystem already uses above (Tier-3,
# PR-20: fabric.default_apic_version or site.apic_version), so the
# two never drift apart.
store.add(MO(
"firmwareCtrlrRunning",
dn=f"{ctrl_dn}/ctrlrfwstatuscont/ctrlrrunning",
version=apic_version,
node=str(cid),
))
# Batch-2: infraWiNode — appliance vector. Each APIC in the cluster carries
# its OWN view of every cluster member's health (real ACI: the "av"
# subtree under each controller's topSystem); health_score.py's cluster-
# fitness check reads infraWiNode.health across ALL of these, so every
# (viewer, member) pair is seeded "fully-fit" — a healthy, fully-formed
# cluster, matching this sim's other all-healthy defaults (fabricHealthTotal
# cur=100, healthInst cur=hd.node).
for viewer_cid in range(1, site.controllers + 1):
for member_cid in range(1, site.controllers + 1):
store.add(MO(
"infraWiNode",
dn=f"topology/pod-{pod}/node-{viewer_cid}/av/node-{member_cid}",
health="fully-fit",
state="in-service",
))
# APIC ↔ leaf attachments. Real APICs are dual-homed to a leaf pair; expose
# them as fabricLink (autoACI draws Topology edges from fabricLink n1/n2) plus
# a leaf-side lldpAdjEp so the leaf "sees" the controller.
apic_leaves = site.leaf_nodes()[:2]
for cid in range(1, site.controllers + 1):
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
for li, leaf in enumerate(apic_leaves):
apic_port = li + 1 # APIC NIC: eth1/1 → first leaf, eth1/2 → second leaf
leaf_port = cid # leaf downlink to APIC-<cid>: eth1/<cid>
store.add(MO(
"fabricLink",
dn=f"topology/pod-{pod}/lnk-{cid}-1-{apic_port}-to-{leaf.id}-1-{leaf_port}",
n1=str(cid),
n2=str(leaf.id),
operSt="up",
operSpeed="10G",
linkType="controller",
))
add_adjacency(
store,
pod=pod,
local_node_id=leaf.id,
local_port=f"eth1/{leaf_port}",
# The APIC's own NIC port (its "eth1/{apic_port}") — real ACI
# LLDP/CDP always reports the remote port on the neighbor's
# side, even when the neighbor is a controller rather than a
# switch. No fabricLink-style back-edge exists for the APIC
# side to look this up from, so it's derived the same way
# the fabricLink DN above already encodes it (apic_port).
remote_port=f"eth1/{apic_port}",
neighbor_name=f"{site.name}-ACI-APIC{cid:02d}",
neighbor_mgmt_ip=oob_addr,
neighbor_kind="controller",
neighbor_mac=node_mac(pod, cid),
# cdpAdjEp.ver: APIC firmware version is readily available
# here (the same `apic_version` this function already
# resolved above for the controller's own topSystem/
# firmwareCtrlrRunning), so surface it rather than "".
neighbor_version=apic_version,
)
# fabricHealthTotal — fabric-wide health summary
store.add(MO("fabricHealthTotal", dn="topology/health", cur="100"))
# vpcDom + vpcIf per border-leaf; group by vpc_domain to assign stable IDs
#
# Batch-2: pcAggrIf (port-channel details) + pcRsMbrIfs (member-port
# relation) per vPC leg, consistent with the vpcIf this loop already
# builds for the same (bl, dom_num) pair. vpc_status.py matches pcAggrIf
# to vpcIf by (node, name) — name is the IPG name, id is "poN" — so
# pcAggrIf.name reuses vpcIf's own "po{bl.id}" string as the IPG name
# (this sim has no separate infraAccBndlGrp-derived IPG name to borrow;
# access.py's vPC infraAccBndlGrp is named "{vpc_domain}-vpc", a
# different string, so pcAggrIf.name intentionally matches vpcIf.name
# instead — the field vpc_status.py actually joins on). Member ports:
# two real host-facing l1PhysIf per leg (eth1/1, eth1/2 — interfaces.py
# always builds these on every leaf/border-leaf), giving each vPC leg a
# believable 2-member port-channel without colliding with the dedicated
# L3Out port (eth1/48) or fabric uplinks.
_VPC_MEMBER_PORTS = ("eth1/1", "eth1/2")
domain_ids: dict[str, int] = {}
for bl in site.border_leaves:
vdom = bl.vpc_domain
if vdom not in domain_ids:
domain_ids[vdom] = len(domain_ids) + 1
dom_num = domain_ids[vdom]
vpc_dn = (
f"topology/pod-{pod}/node-{bl.id}"
f"/local/svc-vpc-stabdoms/domainid-{dom_num}"
)
store.add(MO(
"vpcDom",
dn=vpc_dn,
id=str(dom_num),
peerSt="peerOk",
))
po_name = f"po{bl.id}"
store.add(MO(
"vpcIf",
dn=f"{vpc_dn}/vpcif-[{po_name}]",
name=po_name,
operSt="up",
))
pc_aggr_dn = f"{vpc_dn}/aggr-[{po_name}]"
store.add(MO(
"pcAggrIf",
dn=pc_aggr_dn,
name=po_name,
id=po_name,
operSt="up",
operSpeed="100G",
descr=f"vPC {po_name} on {bl.name}",
))
for member_port in _VPC_MEMBER_PORTS:
member_if_dn = f"topology/pod-{pod}/node-{bl.id}/sys/phys-[{member_port}]"
store.add(MO(
"pcRsMbrIfs",
dn=f"{pc_aggr_dn}/rsmbrIfs-[{member_if_dn}]",
tDn=member_if_dn,
parentSKey=po_name,
))
# Batch-2: fabricNodeIdentP — boot-seeded node registration for every
# real switch fabricNode (spines/leaves/border-leaves; NOT controllers —
# real ACI's nodeidentpol/Fabric Membership registers switches joining
# the fabric, not the APICs themselves), so a GET returns the already-
# registered nodes without requiring a prior POST. RN "nodep-{id}" keys
# on the node id (not serial) to stay consistent with writes.py's
# existing _materialize_fabric_node reaction, which parses the id back
# out of this same "nodep-(\\d+)$" pattern — seeding here and the POST
# reaction in writes.py must agree on the identifier scheme or a boot-
# seeded nodep-{id} and a later POSTed nodep-{serial} would silently
# diverge into two different DNs for logically the same registration.
for node in site.all_nodes():
store.add(MO(
"fabricNodeIdentP",
dn=f"uni/controller/nodeidentpol/nodep-{node.id}",
serial=node.serial or default_serial(site.id, node.id),
nodeId=str(node.id),
name=node.name,
role="leaf" if node.id not in spine_ids else "spine",
))
+113
View File
@@ -0,0 +1,113 @@
"""build/health_faults.py — healthInst, faultInst, coopPol/coopInst, configExportP.
healthInst DN (read by topology.py line 160-163):
topology/pod-{pod}/node-{id}/sys/health
→ connector.query_dn(dn) → item.get("healthInst", {}).get("attributes", {})
faultInst DNs must:
1. Start with topology/pod-{pod}/node-{id}/ (for node-scoped queries)
2. Contain /node-{id}/ (for topology.py line 196 grouping regex)
Required attrs: severity, code, lc, type, subject, descr, created, lastTransition
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_CREATED_TS = "2026-01-01T00:00:00.000+00:00"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit health/fault/coop/config MOs for *site*."""
pod = site.pod
faults_cfg = topo.faults
hd = faults_cfg.health_defaults
# ---- Per-node healthInst --------------------------------------------------
for node in site.all_nodes():
store.add(MO(
"healthInst",
dn=f"topology/pod-{pod}/node-{node.id}/sys/health",
cur=str(hd.node),
min=str(hd.node),
max="100",
prev=str(hd.node),
))
# ---- Per-controller healthInst --------------------------------------------
# Real APIC exposes controller (role=controller, node IDs 1..N) health at
# the same .../sys/health DN shape as switch nodes; fabric.py already
# builds a topSystem at topology/pod-{pod}/node-{cid}/sys for each
# controller, so this just adds the matching healthInst there too.
for cid in range(1, site.controllers + 1):
store.add(MO(
"healthInst",
dn=f"topology/pod-{pod}/node-{cid}/sys/health",
cur=str(hd.node),
min=str(hd.node),
max="100",
prev=str(hd.node),
))
# ---- Per-tenant healthInst (for tenants deployed in this site) -----------
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
store.add(MO(
"healthInst",
dn=f"uni/tn-{tenant.name}/health",
cur=str(hd.tenant),
min=str(hd.tenant),
max="100",
prev=str(hd.tenant),
))
# ---- Seeded faultInst (node-local DNs for node-scoped query support) -----
# Map node_id → pod for this site
site_node_pods: dict[int, int] = {n.id: pod for n in site.all_nodes()}
for seed in faults_cfg.seed:
node_pod = site_node_pods.get(seed.node)
if node_pod is None:
continue # this fault belongs to a different site's node
# seed.code already carries the full ACI fault code (e.g. "F0532") —
# do NOT prepend another "F" (that produced invalid "FF0532").
fault_dn = (
f"topology/pod-{node_pod}/node-{seed.node}"
f"/local/svc-policyelem-id-0/uni/fault-{seed.code}"
)
store.add(MO(
"faultInst",
dn=fault_dn,
severity=seed.severity,
code=seed.code,
descr=seed.descr,
created=_CREATED_TS,
lastTransition=_CREATED_TS,
lc="raised",
type="operational",
subject="node",
))
# ---- coopPol + coopInst --------------------------------------------------
store.add(MO("coopPol", dn="uni/fabric/pol-coop", name="coop"))
for node in site.all_nodes():
store.add(MO(
"coopInst",
dn=f"topology/pod-{pod}/node-{node.id}/local/svc-coop-id-0/uni/epp/fv-[coop]/node-{node.id}",
operSt="formed",
))
# ---- configExportP + configJob (backup/export stubs) ---------------------
store.add(MO(
"configExportP",
dn="uni/fabric/configexp-defaultOneTime",
name="defaultOneTime",
))
store.add(MO(
"configJob",
dn="uni/fabric/configexp-defaultOneTime/job-defaultOneTime",
operSt="success",
executeTime=_CREATED_TS,
))
+191
View File
@@ -0,0 +1,191 @@
"""build/interfaces.py — l1PhysIf, ethpmPhysIf, eqptcapacityPolUsage5min per node.
Port layout:
Fabric uplink ports: same assignment as cabling.py (derived independently)
- Spines: eth1/1, eth1/2, ... (one per downlink node)
- Leaves/BLs: eth1/49, eth1/50, ... (one per spine)
Host-facing ports on leaves/border-leaves: eth1/1 eth1/8 (simulated access)
L3Out routed sub-interface port on border-leaves ONLY: eth1/48 (dedicated,
outside the host-access range — matches real ACI convention of a dedicated
front-panel port for the L3Out CSW uplink rather than sharing a host port).
ISN/IPN uplink ports on spines ONLY, multi-site fabrics only: eth1/49, 50, ...
(one per spine, dedicated — outside the fabric-uplink range eth1/1-4, which
is fully consumed by intra-fabric spine↔leaf links). Real ACI spines facing
a multi-site ISN use dedicated front-panel ports for the IPN uplink, distinct
from the leaf-facing fabric ports; underlay.py's ospfAdjEp references these.
ethpmPhysIf DN must contain "phys-[eth{s}/{p}]" — read by topology.py line 730:
port = dn.split("phys-[")[1].split("]")[0] → "eth1/1"
l1PhysIf DN is under the same sys/ subtree; id attribute carries the port name.
Batch-2 addition (CONTRACT.md §6 "Interfaces"): ethpmFcot — SFP/transceiver
info, sibling of ethpmPhysIf under the same phys-[eth{s}/{p}] port DN (own RN
"fcot", per real ACI's ethpmFcot placement one level under ethpmPhysIf's own
"phys" RN's parent). Attrs verified read-only against autoACI's
interface_comprehensive.py: typeName, guiName, vendorName, vendorSn,
actualType (decoded via that plugin's own `_decode_sfp_field`/`_port_from_dn`
helpers, which just strip a "phys-[" bracket segment off the dn — same
placement l1PhysIf/ethpmPhysIf already use). Only emitted for fabric-uplink
and L3Out routed ports (real optics-bearing ports); plain host-access ports
are left without an ethpmFcot, matching real ACI labs where copper/DAC host
ports often report no transceiver inventory while fabric/WAN-facing optical
ports do — a deliberate realism choice, not an omission.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.cabling import cabling_links
_UPLINK_START = 49 # first leaf-side uplink port index
_HOST_PORTS = 8 # number of host-facing ports on leaves
_L3OUT_PORT = 48 # dedicated border-leaf L3Out routed sub-interface port
def _add_port(
store: MITStore,
pod: int,
node_id: int,
slot: int,
port: int,
descr: str = "",
mode: str = "trunk",
usage: str = "fabric",
with_fcot: bool = False,
mtu: str = "9216",
) -> None:
"""Add l1PhysIf + ethpmPhysIf (+ optional ethpmFcot) for one port.
`mtu` defaults to the fabric intra-fabric MTU (9216); the ISN/IPN uplink
port (multi-site only) passes `topo.isn.mtu` instead (Tier-2, PR-19) —
real ACI's inter-pod/inter-site default is 9150, distinct from the
intra-fabric 9216 ceiling.
"""
port_id = f"eth{slot}/{port}"
sys_dn = f"topology/pod-{pod}/node-{node_id}/sys"
phys_dn = f"{sys_dn}/phys-[{port_id}]"
# l1PhysIf — administrative config
store.add(MO(
"l1PhysIf",
dn=phys_dn,
id=port_id,
adminSt="up",
descr=descr,
mtu=mtu,
mode=mode,
))
# ethpmPhysIf — operational state; DN must contain "phys-[eth…]"
store.add(MO(
"ethpmPhysIf",
dn=f"{phys_dn}/phys",
operSt="up",
operSpeed="100G",
usage=usage,
lastLinkStChg="00:00:00:00.000",
resetCtr="0",
))
# Batch-2: ethpmFcot — SFP/transceiver inventory, sibling of ethpmPhysIf
# (own "fcot" RN under the same phys-[eth…] port DN — interface_comprehensive.py's
# _port_from_dn strips the same "phys-[" bracket segment this DN shares with
# ethpmPhysIf, so both resolve to the same port string). Only real
# optics-bearing ports (fabric uplinks, ISN uplinks, L3Out routed ports)
# get one — see module docstring for the realism rationale.
if with_fcot:
store.add(MO(
"ethpmFcot",
dn=f"{phys_dn}/fcot",
typeName="QSFP-100G-SR4",
guiName="QSFP-100G-SR4",
vendorName="CISCO-FINISAR",
vendorSn=f"FNS{pod:02d}{node_id:04d}{port:02d}",
actualType="qsfp-100g-sr4",
operSt="up",
operSpeed="100G",
))
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit l1PhysIf + ethpmPhysIf for all nodes, plus eqptcapacityPolUsage5min."""
pod = site.pod
spine_ids = {n.id for n in site.spine_nodes()}
# Collect port assignments from the cabling graph
# spine_ports[node_id] = set of (slot, port) used as fabric uplinks
# leaf_ports[node_id] = set of (slot, port) used as fabric uplinks
spine_uplinks: dict[int, list[tuple[int, int]]] = {n.id: [] for n in site.spine_nodes()}
leaf_uplinks: dict[int, list[tuple[int, int]]] = {
n.id: [] for n in site.leaf_nodes() + site.border_leaf_nodes()
}
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
if n1 in spine_ids:
spine_uplinks[n1].append((s1, p1))
leaf_uplinks[n2].append((s2, p2))
else:
spine_uplinks[n2].append((s2, p2))
leaf_uplinks[n1].append((s1, p1))
# Spines — fabric uplinks, plus (multi-site only) dedicated ISN uplinks
for si, spine in enumerate(site.spine_nodes()):
for slot, port in spine_uplinks[spine.id]:
_add_port(
store, pod, spine.id, slot, port,
descr=f"Fabric uplink eth{slot}/{port}",
mode="trunk",
with_fcot=True,
)
if len(topo.sites) > 1:
isn_port = _UPLINK_START + si
_add_port(
store, pod, spine.id, 1, isn_port,
descr=f"ISN/IPN uplink eth1/{isn_port}",
mode="routed",
usage="isn",
with_fcot=True,
mtu=str(topo.isn.mtu),
)
border_leaf_ids = {bl.id for bl in site.border_leaves}
# Leaves + border-leaves — fabric uplinks + host-facing ports
for leaf in site.leaf_nodes() + site.border_leaf_nodes():
# Fabric uplink ports
for slot, port in leaf_uplinks[leaf.id]:
_add_port(
store, pod, leaf.id, slot, port,
descr="Fabric uplink to spine",
mode="trunk",
with_fcot=True,
)
# Host-facing access ports: eth1/1 … eth1/_HOST_PORTS
for hp in range(1, _HOST_PORTS + 1):
_add_port(
store, pod, leaf.id, 1, hp,
descr=f"Host port eth1/{hp}",
mode="access",
usage="access",
)
# Border-leaves only: dedicated L3Out routed sub-interface port,
# outside the host-access range — l3out.py's l3extRsPathL3OutAtt
# tDn references this exact port so it resolves against a real
# l1PhysIf (real ACI convention: dedicated front-panel port for the
# L3Out CSW uplink, not shared with regular host/EPG traffic).
if leaf.id in border_leaf_ids:
_add_port(
store, pod, leaf.id, 1, _L3OUT_PORT,
descr=f"L3Out routed uplink eth1/{_L3OUT_PORT}",
mode="routed",
usage="l3out",
with_fcot=True,
)
# eqptcapacityPolUsage5min — capacity stats (contract-rendering metrics)
store.add(MO(
"eqptcapacityPolUsage5min",
dn=f"topology/pod-{pod}/node-{leaf.id}/sys/eqptcapacity/polUsage5min",
polUsage="42",
polUsageCap="100",
polUsageCum="38",
polUsageCapCum="100",
))
+294
View File
@@ -0,0 +1,294 @@
"""build/l3out.py — l3extOut tree + eBGP operational session MOs.
Batch-1 additions (CONTRACT.md §6 "Route-control", "L3Out (control)"):
route-control (rtctrlProfile/rtctrlCtxP/rtctrlSubjP/rtctrlMatchRtDest) +
static routes (ipRouteP/ipNexthopP) + l3extMember + ospfExtP. DN/RN shapes
verified against autoACI's plugins (read-only, prior-audit trust per
CONTRACT.md header; see docs/DESIGN.md "Batch-1" section for the specific
attrs read by route_control.py / l3out_detail.py):
rtctrlProfile uni/tn-{t}/out-{l3out}/prof-{name} (export profile, one per L3Out)
rtctrlCtxP {profile}/ctx-{name}
rtctrlSubjP uni/tn-{t}/subj-{name} (tenant-level match rule —
route_control.py queries it with wcard(dn,"tn-{tenant}"), NOT
scoped under any one L3Out)
rtctrlMatchRtDest {subjp}/dest-[{ip}]
rtctrlSetComm under the ctx (real ACI RN: "scomm")
rtctrlSetPref under the ctx (real ACI RN: "spref")
ipRouteP {rsnodeL3OutAtt}/rt-[{prefix}] (static route toward the CSW peer)
ipNexthopP {ipRouteP}/nh-[{addr}]
l3extMember {l3extRsPathL3OutAtt}/mem-{A|B} (only if the path is vPC-style;
this topology's L3Out path is a single port per border-leaf — see
_build_node_profile's path_tdn — so we emit one member, side="A",
on that existing single path, per the batch-1 placement note.)
ospfExtP uni/tn-{t}/out-{l3out}/ospfExtP (added to the SAME L3Out as
the existing bgpExtP — real ACI allows OSPF+BGP coexistence on one
L3Out, and this topology has no OSPF-only L3Out, so extending the
existing one is the simpler faithful choice; see DESIGN.md.)
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import L3Out, Site, Tenant, Topology
from aci_sim.build.fabric import loopback_ip
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _owning_site(l3out: L3Out, topo: Topology) -> str | None:
if l3out.site is not None:
return l3out.site
for s in topo.sites:
site_bl_ids = {bl.id for bl in s.border_leaves}
if any(bl_id in site_bl_ids for bl_id in l3out.border_leaves):
return s.name
return None
def _build_node_profile(l3out_dn: str, l3out: L3Out, site: Site, pod: int) -> MO:
"""Build l3extLNodeP (with node attachments) + l3extLIfP (paths + BGP peer)."""
site_bl_ids = {bl.id: bl for bl in site.border_leaves}
bl_ids_here = [bid for bid in l3out.border_leaves if bid in site_bl_ids]
np_name = f"{l3out.name}_nodeProfile"
np_dn = f"{l3out_dn}/lnodep-{np_name}"
np_mo = MO("l3extLNodeP", dn=np_dn, name=np_name)
for bl_id in bl_ids_here:
rtr_id = loopback_ip(pod, bl_id)
rsnode_dn = f"{np_dn}/rsnodeL3OutAtt-[topology/pod-{pod}/node-{bl_id}]"
rsnode_mo = MO(
"l3extRsNodeL3OutAtt",
dn=rsnode_dn,
tDn=f"topology/pod-{pod}/node-{bl_id}",
rtrId=rtr_id,
)
# Batch-1: static default route toward this border-leaf's CSW peer.
# ipRouteP.ip carries the prefix (autoACI's l3out_detail.py/route_control.py
# read attrs["ip"], not "prefix" — that's uribv4Route); ipNexthopP.nhAddr is
# the CSW interconnect IP the eBGP session builder (_upsert_ebgp_sessions)
# already peers with, so the static route is consistent with the live BGP
# session on the same border leaf.
if l3out.csw_peer:
prefix = "0.0.0.0/0"
route_dn = f"{rsnode_dn}/rt-[{prefix}]"
route_mo = MO("ipRouteP", dn=route_dn, ip=prefix, pref="1")
nh_addr = l3out.csw_peer.peer_ip
route_mo.add_child(MO(
"ipNexthopP",
dn=f"{route_dn}/nh-[{nh_addr}]",
nhAddr=nh_addr,
))
rsnode_mo.add_child(route_mo)
np_mo.add_child(rsnode_mo)
ip_name = f"{l3out.name}_ifProfile"
ip_dn = f"{np_dn}/lifp-{ip_name}"
ip_mo = MO("l3extLIfP", dn=ip_dn, name=ip_name)
for bl_id in bl_ids_here:
bl_obj = site_bl_ids[bl_id]
csw = bl_obj.csw
path_tdn = f"topology/pod-{pod}/paths-{bl_id}/pathep-[eth1/48]"
path_dn = f"{ip_dn}/rspathL3OutAtt-[{path_tdn}]"
path_mo = MO(
"l3extRsPathL3OutAtt",
dn=path_dn,
tDn=path_tdn,
encap=f"vlan-{csw.vlan}",
addr=f"{csw.local_ip}/30",
ifInstT="sub-interface",
)
# Batch-1: this L3Out's path is a single port per border-leaf (not a
# vPC-bundled path — see path_tdn above), so per the batch-1 placement
# note we emit exactly one l3extMember, side="A", on the existing path.
path_mo.add_child(MO(
"l3extMember",
dn=f"{path_dn}/mem-A",
side="A",
addr=f"{csw.local_ip}/30",
))
ip_mo.add_child(path_mo)
if l3out.csw_peer:
peer_ip = l3out.csw_peer.peer_ip
peer_dn = f"{ip_dn}/peerP-[{peer_ip}]"
# Tier-3 (PR-20): keepalive/hold — real ACI defaults 60s/180s,
# previously not carried anywhere on this MO. l3out.csw_peer's
# keepalive/hold feed bgpPeerP.privateAsCtrl-adjacent timer attrs.
peer_mo = MO(
"bgpPeerP",
dn=peer_dn,
addr=peer_ip,
peerCtrl="",
keepAliveIntvl=str(l3out.csw_peer.keepalive),
holdIntvl=str(l3out.csw_peer.hold),
)
peer_mo.add_child(MO(
"bgpAsP",
dn=f"{peer_dn}/as",
asn=str(l3out.csw_peer.asn),
))
ip_mo.add_child(peer_mo)
np_mo.add_child(ip_mo)
return np_mo
def _build_route_control(t: str, l3out: L3Out, l3out_dn: str) -> MO:
"""Build rtctrlProfile (export profile) + rtctrlCtxP + rtctrlSetComm/SetPref
children, one per L3Out. RN scheme (verified against this module's own
uni/tn-{t}/out-{l3out}/... conventions; "scomm"/"spref" are the real ACI
RNs for rtctrlSetComm/rtctrlSetPref — Cisco's fixed short-form RNs, not
name-suffixed like most other RN templates in this file):
rtctrlProfile {l3out_dn}/prof-{name}
rtctrlCtxP {profile}/ctx-{name}
rtctrlSetComm {ctx}/scomm
rtctrlSetPref {ctx}/spref
"""
prof_name = f"{l3out.name}_export"
prof_dn = f"{l3out_dn}/prof-{prof_name}"
prof_mo = MO("rtctrlProfile", dn=prof_dn, name=prof_name)
ctx_name = f"{l3out.name}_ctx"
ctx_dn = f"{prof_dn}/ctx-{ctx_name}"
ctx_mo = MO("rtctrlCtxP", dn=ctx_dn, name=ctx_name, action="permit")
ctx_mo.add_child(MO("rtctrlSetComm", dn=f"{ctx_dn}/scomm", community=f"no-advertise:{t}"))
ctx_mo.add_child(MO("rtctrlSetPref", dn=f"{ctx_dn}/spref", localPref="200"))
prof_mo.add_child(ctx_mo)
return prof_mo
def _build_tenant_subj(t: str, l3out: L3Out, bd_subnets: list[str]) -> MO | None:
"""Build one tenant-level rtctrlSubjP (match rule) + rtctrlMatchRtDest
children, referenced from this L3Out's rtctrlCtxP.
rtctrlSubjP is tenant-level (uni/tn-{t}/subj-{name}), NOT nested under any
one L3Out's DN — autoACI's route_control.py queries it with
wcard(rtctrlSubjP.dn, "tn-{tenant}") rather than scoping it to an L3Out
(verified against the plugin source). Match destinations reuse a real BD
subnet from the tenant so the redistributed prefix is believable.
"""
if not bd_subnets:
return None
subj_name = f"{l3out.name}_match"
subj_dn = f"uni/tn-{t}/subj-{subj_name}"
subj_mo = MO("rtctrlSubjP", dn=subj_dn, name=subj_name)
subj_mo.add_child(MO(
"rtctrlMatchRtDest",
dn=f"{subj_dn}/dest-[{bd_subnets[0]}]",
ip=bd_subnets[0],
))
return subj_mo
def _build_l3out_tree(t: str, l3out: L3Out, site: Site, l3_dom_name: str, bd_subnets: list[str]) -> MO:
pod = site.pod
l3out_dn = f"uni/tn-{t}/out-{l3out.name}"
l3out_mo = MO("l3extOut", dn=l3out_dn, name=l3out.name, enforceRtctrl="export")
l3out_mo.add_child(MO(
"l3extRsEctx",
dn=f"{l3out_dn}/rsectx",
tnFvCtxName=l3out.vrf,
tDn=f"uni/tn-{t}/ctx-{l3out.vrf}",
))
l3out_mo.add_child(MO(
"l3extRsL3DomAtt",
dn=f"{l3out_dn}/rsl3DomAtt",
tDn=f"uni/l3dom-{l3_dom_name}",
))
l3out_mo.add_child(MO("bgpExtP", dn=f"{l3out_dn}/bgpExtP"))
# Batch-1: ospfExtP added to the SAME L3Out (coexists with bgpExtP above —
# real ACI supports OSPF+BGP on one L3Out; see module docstring).
l3out_mo.add_child(MO(
"ospfExtP",
dn=f"{l3out_dn}/ospfExtP",
areaId="0.0.0.1",
areaType="regular",
))
l3out_mo.add_child(_build_route_control(t, l3out, l3out_dn))
l3out_mo.add_child(_build_node_profile(l3out_dn, l3out, site, pod))
for ext_epg in l3out.ext_epgs:
inst_dn = f"{l3out_dn}/instP-{ext_epg.name}"
inst_mo = MO("l3extInstP", dn=inst_dn, name=ext_epg.name, prefGrMemb="exclude")
for cidr in ext_epg.subnets:
inst_mo.add_child(MO(
"l3extSubnet",
dn=f"{inst_dn}/extsubnet-[{cidr}]",
ip=cidr,
scope="import-security",
))
l3out_mo.add_child(inst_mo)
return l3out_mo
def _upsert_ebgp_sessions(
t: str, l3out: L3Out, site: Site, store: MITStore
) -> None:
pod = site.pod
site_bl_ids = {bl.id: bl for bl in site.border_leaves}
bl_ids_here = [bid for bid in l3out.border_leaves if bid in site_bl_ids]
for bl_id in bl_ids_here:
bl_obj = site_bl_ids[bl_id]
csw = bl_obj.csw
rtr_id = loopback_ip(pod, bl_id)
inst_dn = f"topology/pod-{pod}/node-{bl_id}/sys/bgp/inst"
inst_mo = MO("bgpInst", dn=inst_dn, asn=str(site.asn))
dom_name = f"{t}:{l3out.vrf}"
dom_dn = f"{inst_dn}/dom-{dom_name}"
dom_mo = MO("bgpDom", dn=dom_dn, name=dom_name)
peer_dn = f"{dom_dn}/peer-[{csw.peer_ip}]"
peer_mo = MO(
"bgpPeer",
dn=peer_dn,
addr=csw.peer_ip,
asn=str(csw.asn),
type="ebgp",
peerRole="ce",
)
entry_dn = f"{peer_dn}/ent-[{csw.peer_ip}]"
peer_mo.add_child(MO(
"bgpPeerEntry",
dn=entry_dn,
addr=csw.peer_ip,
operSt="established",
rtrId=rtr_id,
connEst="1",
connDrop="0",
type="ebgp",
))
dom_mo.add_child(peer_mo)
inst_mo.add_child(dom_mo)
store.upsert(inst_mo)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit l3extOut trees + eBGP session MOs for L3Outs owned by *site*."""
l3_dom_name = topo.access.l3_domains[0].name if topo.access.l3_domains else "l3dom-1"
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
bd_subnets = [cidr for bd in tenant.bds for cidr in bd.subnets]
for l3out in tenant.l3outs:
if _owning_site(l3out, topo) != site.name:
continue
store.add(_build_l3out_tree(t, l3out, site, l3_dom_name, bd_subnets))
_upsert_ebgp_sessions(t, l3out, site, store)
# Batch-1: tenant-level match rule (rtctrlSubjP), added independently
# of the l3out tree (autoACI queries it as tenant-scoped, not nested
# under any one L3Out — see _build_tenant_subj docstring).
subj_mo = _build_tenant_subj(t, l3out, bd_subnets)
if subj_mo is not None:
store.add(subj_mo)
+122
View File
@@ -0,0 +1,122 @@
"""build/mgmt.py — mgmt tenant OOB scaffolding: fvTenant(mgmt)/mgmtMgmtP/mgmtOoB/mgmtRsOoBStNode.
Real APIC models the OOB management address + gateway for every fabric node
in the special `mgmt` tenant, NOT on the node's own topSystem (topSystem only
carries the address — `oobMgmtAddr` — never a gateway; see build/fabric.py).
The real object tree (verified against APIC's own MIT, CONTRACT.md-style):
uni/tn-mgmt fvTenant (name="mgmt")
mgmtp-default mgmtMgmtP (name="default")
oob-default mgmtOoB (name="default")
rsooBStNode-[topology/pod-<p>/node-<id>] mgmtRsOoBStNode
tDn=topology/pod-<p>/node-<id>
addr=<node's OOB IP>/<mask>
gw=<site.oob_gateway or "">
This is the ONLY place a real ACI object carries an OOB gateway attribute —
prior to this builder, `Site`/the init wizard collected an OOB gateway
(cli.py's `run_wizard`) but discarded it (no schema field, no MO); this
builder is what makes that value land on a real MO instead.
Scope (deliberately minimal/faithful): one `mgmtRsOoBStNode` per node this
site actually has (switches — spines/leaves/border-leaves — AND controllers),
matching the module list `build/fabric.py` already builds `fabricNode`/
`topSystem` for. No other mgmt-tenant children (no `mgmtInB`, no
`mgmtRsOoBCtx`, no `mgmtInstP`, etc.) — those model in-band mgmt / EPG
contract-scope wiring that no autoACI/aci-py chain audited for this sim reads
(same "store scaffolding a caller doesn't consume is scope-creep" judgment
call as `Fabric.inb_subnet`'s docstring, topology/schema.py).
Mask derivation (`addr=<ip>/<mask>`): real ACI's OOB address is configured
with an explicit prefix at first-boot (the same dialog this sim's `aci-sim
init` wizard mirrors), and is virtually always a /24 (one OOB subnet per
pod/site) even though the fabric-wide address *pool* is much larger. This
sim's own `oob_ip(pod, node_id)` (build/fabric.py) reflects exactly that
shape — every node's OOB address is `192.168.<pod>.<node_id>`, i.e. already
confined to a /24 per pod (the pod occupies the third octet; the pool's
`/16` only bounds the SECOND octet across all pods). So the mask is derived,
in priority order:
1. `fabric.oob_subnet` (Tier-3, PR-20) if the topology author explicitly
set it — their stated intent overrides the derived default, even if it
happens to be wider than /24 (e.g. a deliberately shared /16 OOB VLAN).
2. `/24` otherwise (default) — matches the real per-pod OOB subnet shape
`oob_ip()` already produces, NOT `fabric.oob_pool`'s `/16` (which
describes the whole multi-pod address space, not any single node's
subnet — using it directly would put every node in a /16 that spans
pods it was never actually part of).
`fabric.oob_subnet`, when set, is already validated (Topology.
normalize_and_validate) to be a real CIDR inside 192.168.0.0/16, so
`.prefixlen` is always safe to read.
Gateway (`gw=...`): `site.oob_gateway` verbatim if set, else the EMPTY
STRING — never a derived/invented default. A derived default (e.g. ".1" of
the OOB subnet) would look like real config an operator entered, when no
operator entered anything; an empty `gw` faithfully says "not configured",
which is exactly the real APIC behavior before its own first-boot gateway
question is answered. This also keeps the change purely additive: a
topology.yaml that never sets `oob_gateway` builds byte-identical
mgmtRsOoBStNode.gw ("") on every rebuild.
"""
from __future__ import annotations
import ipaddress
from aci_sim.build.fabric import oob_ip
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_DEFAULT_OOB_PREFIXLEN = 24 # real-ACI-shaped per-pod OOB subnet (see module docstring)
def _oob_prefixlen(topo: Topology) -> int:
"""Effective OOB subnet prefix length (see module docstring priority order)."""
if topo.fabric.oob_subnet is not None:
return ipaddress.ip_network(topo.fabric.oob_subnet, strict=False).prefixlen
return _DEFAULT_OOB_PREFIXLEN
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit the mgmt-tenant OOB scaffolding for *site*: fvTenant(mgmt) →
mgmtMgmtP(default) → mgmtOoB(default) → one mgmtRsOoBStNode per node."""
prefixlen = _oob_prefixlen(topo)
gw = site.oob_gateway or ""
tenant_mo = MO("fvTenant", dn="uni/tn-mgmt", name="mgmt", descr="")
mgmtp_mo = MO("mgmtMgmtP", dn="uni/tn-mgmt/mgmtp-default", name="default")
oob_mo = MO("mgmtOoB", dn="uni/tn-mgmt/mgmtp-default/oob-default", name="default")
pod = site.pod
# Switches (spines/leaves/border-leaves): OOB address from the same
# oob_ip(pod, node_id) derivation build/fabric.py used for topSystem.
for node in site.all_nodes():
node_dn = f"topology/pod-{pod}/node-{node.id}"
addr = f"{oob_ip(pod, node.id)}/{prefixlen}"
oob_mo.add_child(MO(
"mgmtRsOoBStNode",
dn=f"{oob_mo.dn}/rsooBStNode-[{node_dn}]",
tDn=node_dn,
addr=addr,
gw=gw,
))
# Controllers (APIC cluster, node IDs 1..N): OOB address from the same
# controller_oob_ips()/oob_ip(pod, cid) resolution build/fabric.py uses
# for the controller topSystem (explicit controller_ips, else derived
# from mgmt_ip, else legacy per-node oob_ip(pod, cid) fallback).
controller_oob_ips = site.controller_oob_ips()
for cid in range(1, site.controllers + 1):
ctrl_dn = f"topology/pod-{pod}/node-{cid}"
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
addr = f"{oob_addr}/{prefixlen}"
oob_mo.add_child(MO(
"mgmtRsOoBStNode",
dn=f"{oob_mo.dn}/rsooBStNode-[{ctrl_dn}]",
tDn=ctrl_dn,
addr=addr,
gw=gw,
))
mgmtp_mo.add_child(oob_mo)
tenant_mo.add_child(mgmtp_mo)
store.add(tenant_mo)
+230
View File
@@ -0,0 +1,230 @@
"""build/neighbors.py — shared LLDP/CDP adjacency + container-MO helper.
There are THREE call sites that construct lldpAdjEp/cdpAdjEp adjacencies:
- build/cabling.py (spine<->leaf fabric links, both directions)
- build/fabric.py (APIC<->leaf attachment, leaf-side adjacency)
- rest_aci/writes.py (dynamic leaf<->spine adjacencies for a POST-registered
node, via store.upsert on an already-built store)
All three must emit an IDENTICAL, consistent field set (real-APIC-shaped
lldpAdjEp/cdpAdjEp) AND materialize the lldpInst/lldpIf/cdpInst/cdpIf
container MOs the adjacency hangs off of — without this, the container's own
DN (".../sys/lldp/inst") has no MO of its own, and a deep-root subtree query
(`GET .../sys/lldp/inst.json?query-target=subtree&target-subtree-class=
lldpAdjEp`) returns totalCount=0 even though the adjacency itself is fully
reachable via a plain class query. See `aci_sim.query.engine.
run_mo_query`: `store.get(dn) is None` short-circuits to `([], 0)` before the
subtree walk even starts.
This module centralizes both concerns behind one function
(`add_adjacency`) so the three call sites can never drift apart (e.g. one
site adding `portIdV` and another forgetting it).
Store-method choice: every write below goes through ``store.upsert(...)``,
never ``store.add``. Verified against ``aci_sim/mit/store.py``:
``upsert`` on a DN that doesn't exist yet takes the exact same path ``add``
does (construct a fresh ``MO``, store it, call ``_register``) — the two are
behaviorally identical on an empty/fresh store. ``upsert`` additionally
merges into an existing entry instead of clobbering it, which is required
here because lldpInst/lldpIf/cdpInst/cdpIf are shared containers written
once per node/port but visited twice (once per directed adjacency at a
spine<->leaf link, per-APIC at the controller-attach site). Callers that
already exclusively use ``store.add`` (cabling.py, fabric.py boot-time build)
lose nothing by switching: the store is empty at those DNs on first visit,
so ``upsert`` behaves exactly like ``add`` there too.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Node
# APIC controllers report this fixed platform id on real hardware.
APIC_PLATFORM_ID = "APIC-SERVER-M3"
def node_mac(pod: int, node_id: int) -> str:
"""Deterministic, unique, valid MAC for *node_id* in *pod*.
Used as lldpAdjEp.chassisIdV (the neighbor's chassis MAC) and as
lldpIf.mac (the local interface's reported MAC) — real LLDP always
carries a chassis MAC, so a believable, stable value beats an empty
string. Scheme: 00:1B:0D:<pod>:<node_id high byte>:<node_id low byte>.
00:1B:0D is an arbitrary-but-fixed OUI-shaped prefix (not a real
allocation) chosen purely so the value looks like a plausible MAC;
pod/node_id are masked into single bytes so the address is always
well-formed regardless of how large either input is.
"""
return f"00:1B:0D:{pod & 0xFF:02X}:{(node_id >> 8) & 0xFF:02X}:{node_id & 0xFF:02X}"
def _lldp_inst_dn(pod: int, node_id: int) -> str:
return f"topology/pod-{pod}/node-{node_id}/sys/lldp/inst"
def _cdp_inst_dn(pod: int, node_id: int) -> str:
return f"topology/pod-{pod}/node-{node_id}/sys/cdp/inst"
def ensure_lldp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
"""Idempotently materialize lldpInst (per-node) + lldpIf (per-port).
Safe to call once per directed adjacency that lands on this node/port —
``store.upsert`` merges into the same DN instead of duplicating it.
"""
inst_dn = _lldp_inst_dn(pod, node_id)
store.upsert(MO(
"lldpInst",
dn=inst_dn,
adminSt="enabled",
name="default",
holdTime="120",
initDelayTime="2",
txFreq="30",
ctrl="rx-en,tx-en",
optTlvSel="mgmt-addr,port-desc,port-vlan,sys-cap,sys-desc,sys-name",
))
store.upsert(MO(
"lldpIf",
dn=f"{inst_dn}/if-[{local_port}]",
id=local_port,
adminRxSt="enabled",
adminTxSt="enabled",
operRxSt="up",
operTxSt="up",
mac=node_mac(pod, node_id),
portDesc="",
portVlan="0",
sysDesc="",
wiring="valid",
))
def ensure_cdp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
"""Idempotently materialize cdpInst (per-node) + cdpIf (per-port)."""
inst_dn = _cdp_inst_dn(pod, node_id)
store.upsert(MO(
"cdpInst",
dn=inst_dn,
adminSt="enabled",
name="default",
holdIntvl="180",
txFreq="60",
ver="v2",
ctrl="",
))
store.upsert(MO(
"cdpIf",
dn=f"{inst_dn}/if-[{local_port}]",
id=local_port,
adminSt="enabled",
operSt="up",
))
def add_adjacency(
store: MITStore,
*,
pod: int,
local_node_id: int,
local_port: str,
remote_port: str,
neighbor_name: str,
neighbor_mgmt_ip: str,
neighbor_kind: str,
neighbor_model: str = "",
neighbor_version: str = "",
neighbor_mac: str = "",
) -> None:
"""Emit one directed lldpAdjEp+cdpAdjEp (local node sees *neighbor* on
*local_port*) AND ensure the lldpInst/lldpIf/cdpInst/cdpIf containers for
(local_node_id, local_port) exist.
*neighbor_kind* is ``"switch"`` (spine/leaf/border-leaf) or
``"controller"`` (APIC) — drives the sysDesc/capability/platId/cap field
shapes below (real APIC neighbors don't report bridge/router capability).
*neighbor_mac* is the neighbor's chassis MAC (chassisIdV). Every call site
passes one (derived via ``node_mac`` from the neighbor's node id), so the
``""`` default is never hit in practice; it exists only so an omitted MAC
yields a syntactically valid (if uninformative) empty chassisIdV rather
than a KeyError.
"""
base = f"topology/pod-{pod}/node-{local_node_id}/sys"
if neighbor_kind == "controller":
sys_desc = "Cisco APIC"
capability = ""
plat_id = APIC_PLATFORM_ID
cap = ""
else:
sys_desc = f"{neighbor_model} running {neighbor_version}"
capability = "bridge,router"
plat_id = neighbor_model
cap = "bridge,router"
store.upsert(MO(
"lldpAdjEp",
dn=f"{base}/lldp/inst/if-[{local_port}]/adj-1",
sysName=neighbor_name,
mgmtIp=neighbor_mgmt_ip,
portIdV=remote_port,
# 802.1AB Port-ID subtype 5: portIdV carries a named interface
# (e.g. "eth1/1"), which real ACI reports as interfaceName — NOT
# locallyAssigned (subtype 7), which is for opaque port-id strings.
portIdT="interfaceName",
chassisIdT="mac",
chassisIdV=neighbor_mac,
sysDesc=sys_desc,
capability=capability,
id="1",
ttl="120",
))
store.upsert(MO(
"cdpAdjEp",
dn=f"{base}/cdp/inst/if-[{local_port}]/adj-1",
sysName=neighbor_name,
mgmtIp=neighbor_mgmt_ip,
devId=neighbor_name,
portId=remote_port,
platId=plat_id,
ver=neighbor_version,
cap=cap,
))
ensure_lldp_containers(store, pod, local_node_id, local_port)
ensure_cdp_containers(store, pod, local_node_id, local_port)
def add_switch_adjacency(
store: MITStore,
*,
pod: int,
local_node_id: int,
local_port: str,
remote_port: str,
neighbor: Node,
neighbor_mgmt_ip: str,
) -> None:
"""Convenience wrapper for the common switch<->switch case (cabling.py,
fabric.py's dynamic-registration mirror in writes.py): derives
model/version/mac from the neighbor ``Node`` directly instead of making
every call site repeat ``neighbor_model=neighbor.model,
neighbor_version=neighbor.version, neighbor_mac=node_mac(pod, neighbor.id)``.
``neighbor_mgmt_ip`` is still passed explicitly because callers derive it
via ``oob_ip()`` (or, for a dynamically-registered node, a value already
computed by the caller) rather than something derivable from ``Node``
alone.
"""
add_adjacency(
store,
pod=pod,
local_node_id=local_node_id,
local_port=local_port,
remote_port=remote_port,
neighbor_name=neighbor.name,
neighbor_mgmt_ip=neighbor_mgmt_ip,
neighbor_kind="switch",
neighbor_model=neighbor.model,
neighbor_version=neighbor.version,
neighbor_mac=node_mac(pod, neighbor.id),
)
+89
View File
@@ -0,0 +1,89 @@
"""Orchestrator — assemble a full per-site MITStore from the topology.
Every sub-builder is a pure function of (topology, site) that only ADDS MOs and
never reads another builder's output, so the order below is for readability only.
"""
from __future__ import annotations
from aci_sim.build import (
access,
cabling,
endpoints,
fabric,
health_faults,
interfaces,
l3out,
mgmt,
overlay,
routing,
tenants,
underlay,
userprofile,
zoning,
)
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
# fabric first (nodes), then wiring/underlay/overlay, then tenant config,
# then L3Out (needs border leaves), endpoints, and finally health/faults.
#
# Batch-1 note: `endpoints` now runs BEFORE `tenants` (moved from after
# `l3out`) so tenants.py's fvRsPathAtt (static binding) can read back the
# real fvCEp/fabricPathDn each EPG's endpoints already got from the store,
# instead of re-deriving port assignment logic independently (which risked
# silently drifting out of sync with endpoints.py). Every other builder
# remains a pure "only ADDS MOs, never reads another builder's output"
# function per the orchestrator's original contract — endpoints.py itself
# still only adds and doesn't read from tenants/access/l3out, so moving it
# earlier is safe (its only cross-module dependency, _HOST_PORTS, comes from
# `interfaces`, which still runs first).
#
# Batch-2 note: `zoning` runs AFTER both `tenants` (needs fvAEPg.pcTag/
# fvCtx.scope, and reuses tenants.py's pctag_for/scope_id_for helpers) and
# `endpoints` (needs fvCEp to know which leaves host each EPG's local
# endpoints) — same read-back pattern as tenants.py's fvRsPathAtt, so zoning
# is placed right after tenants in the list (endpoints already ran earlier).
# `overlay.build_vpn_routes` (bgpVpnRoute/bgpPath) has the same fvRsBd/fvCEp
# read-back dependency, so it also runs post-tenants/endpoints — as a SECOND
# call into the `overlay` module, alongside (not instead of) its early
# `overlay.build` call which only needs fabric/topology data and stays in
# its original early slot for BGP peer setup.
#
# OOB-gateway PR: `mgmt` builds the mgmt-tenant OOB scaffolding
# (fvTenant(mgmt)/mgmtMgmtP/mgmtOoB/mgmtRsOoBStNode) that carries
# site.oob_gateway on a real MO. It only needs topology data (site.all_nodes(),
# oob_ip(), site.controller_oob_ips()) — no read-back from another builder's
# store output — so, like every other builder here, it is placed for
# readability only; it is grouped right after `fabric` since both build
# node-identity/OOB data.
_BUILDERS = [
fabric,
mgmt,
cabling,
underlay,
overlay,
interfaces,
endpoints,
tenants,
zoning,
access,
l3out,
routing,
health_faults,
userprofile,
]
def build_site(topo: Topology, site: Site) -> MITStore:
"""Build and return a fully-populated MITStore for one site."""
store = MITStore()
for mod in _BUILDERS:
mod.build(topo, site, store)
overlay.build_vpn_routes(topo, site, store)
return store
def build_all(topo: Topology) -> dict[str, MITStore]:
"""Build every site → ``{site_name: MITStore}``."""
return {site.name: build_site(topo, site) for site in topo.sites}
+286
View File
@@ -0,0 +1,286 @@
"""build/overlay.py — intra-site BGP-EVPN + ISN inter-site eBGP peers.
Intra-site: each leaf (and border-leaf) has bgpPeer/bgpPeerEntry/bgpPeerAfEntry
to EVERY spine loopback (spines are route-reflectors).
ISN (critical): on EACH spine's sys/bgp/inst, add bgpPeer for every remote-site
spine loopback with addr=<remote_loopback>/32 and asn=<remote_site.asn>.
ISN detection in autoACI topology.py (lines 493-511):
addr_raw = attrs.get("addr", "") # e.g. "10.1.401.1/32"
peer_asn = attrs.get("asn", "") # e.g. "65002" (remote site ASN)
if "/32" in addr_raw and peer_asn and peer_asn != spine_local_asn:
# → synthesize ISN cloud node + spine→ISN links
Domain used for all EVPN peers: dom-overlay-1 (no ":" → skipped by L3Out BGP table).
IMPORTANT: store.subtree() traverses via the _children index. Every intermediate
DN in the path must have its own MO registered so the parent→child link exists.
We therefore add a bgpDom MO at each "…/inst/dom-overlay-1" before adding peers.
Batch-2 additions (CONTRACT.md §6 "Overlay/BGP"): bgpVpnRoute + bgpPath — the
EVPN route family autoACI's fabric_bgp_evpn.py "vpn_routes" view two-pass
parses (verified read-only against that plugin: pass 1 collects bgpVpnRoute
by dn under a node-scoped subtree query; pass 2 matches bgpPath children back
to their parent route via `path_dn.rsplit("/path-", 1)[0]`, so a bgpPath's RN
MUST start with "path-" directly under its route's own dn — no extra bracket
segments in between). DN family:
bgpVpnRoute {spine_inst_dn}/dom-overlay-1/af-{afi}/rt-[{prefix}]
bgpPath {route_dn}/path-{nexthop-slug}
Seeded per-spine (the EVPN route-reflector role, per this module's own
`evpn_rr` convention) for each BD subnet deployed at this site, with one path
per leaf/border-leaf that actually hosts a locally-learned endpoint for that
BD (read back from endpoints.py's fvCEp — same read-back technique
tenants.py/zoning.py already use) — a spine's VPN route table only carries
paths toward leaves it has real reachability for, not a leaf with zero
endpoints in that BD. Runs regardless of ISN enablement (real ACI's
per-VRF EVPN route table exists in a single-site fabric too, unlike the
ISN-specific inter-site peer section below).
"""
from __future__ import annotations
import re
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.fabric import loopback_ip
_DOM = "overlay-1"
_CEP_NODE_RE = re.compile(r"/paths-(\d+)/pathep-\[eth\d+/\d+\]$")
def _ensure_dom(inst_dn: str, store: MITStore, seen: set[str]) -> str:
"""Add bgpDom at inst_dn/dom-{_DOM} once; return the dom DN."""
dom_dn = f"{inst_dn}/dom-{_DOM}"
if dom_dn not in seen:
store.add(MO("bgpDom", dn=dom_dn, name=_DOM))
seen.add(dom_dn)
return dom_dn
def _add_peer(
store: MITStore,
dom_dn: str,
peer_addr: str,
peer_asn: str,
peer_type: str,
rtr_id: str,
accepted_paths: str = "10",
) -> None:
"""Emit bgpPeer + bgpPeerEntry + bgpPeerAfEntry for one BGP session."""
peer_dn = f"{dom_dn}/peer-[{peer_addr}]"
entry_dn = f"{peer_dn}/ent-[{peer_addr}]"
af_dn = f"{entry_dn}/af-[l2vpn-evpn]"
store.add(MO(
"bgpPeer",
dn=peer_dn,
addr=peer_addr,
asn=peer_asn,
type=peer_type,
peerRole="internal",
))
store.add(MO(
"bgpPeerEntry",
dn=entry_dn,
addr=peer_addr,
operSt="established",
rtrId=rtr_id,
lastFlapTs="00:00:00:00.000",
connEst="1",
connDrop="0",
type=peer_type,
))
store.add(MO(
"bgpPeerAfEntry",
dn=af_dn,
type="l2vpn-evpn",
afId="l2vpn-evpn",
acceptedPaths=accepted_paths,
pfxSent="5",
tblVer="1",
))
def _route_reflectors(topo: Topology, site: Site) -> list:
"""Resolve `topo.fabric.evpn_rr` (#27) to the actual RR node list.
Only "spine" is a meaningful topology for this simulator today — Site has
no other role that could sanely act as a BGP-EVPN route-reflector (leaves
and border-leaves are always RR clients in a Clos fabric). Honoring the
field means failing fast on an unsupported value instead of silently
building spine-RR topology regardless of what the YAML says.
"""
if topo.fabric.evpn_rr == "spine":
return site.spine_nodes()
raise ValueError(
f"fabric.evpn_rr={topo.fabric.evpn_rr!r} is not supported — only "
f"'spine' is implemented as a BGP-EVPN route-reflector role."
)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit intra-site EVPN peers + ISN inter-site peers."""
pod = site.pod
spines = _route_reflectors(topo, site)
clients = site.leaf_nodes() + site.border_leaf_nodes()
seen_doms: set[str] = set()
# -------------------------------------------------------------------------
# Intra-site: each client leaf → each spine loopback
# -------------------------------------------------------------------------
for leaf in clients:
leaf_inst_dn = f"topology/pod-{pod}/node-{leaf.id}/sys/bgp/inst"
leaf_lb = loopback_ip(pod, leaf.id)
dom_dn = _ensure_dom(leaf_inst_dn, store, seen_doms)
for spine in spines:
spine_lb = loopback_ip(pod, spine.id)
_add_peer(
store, dom_dn,
peer_addr=spine_lb,
peer_asn=str(site.asn),
peer_type="internal",
rtr_id=leaf_lb,
)
# Spines also hold the reverse view of each leaf peer (RR perspective)
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
spine_lb = loopback_ip(pod, spine.id)
dom_dn = _ensure_dom(spine_inst_dn, store, seen_doms)
for leaf in clients:
leaf_lb = loopback_ip(pod, leaf.id)
_add_peer(
store, dom_dn,
peer_addr=leaf_lb,
peer_asn=str(site.asn),
peer_type="internal",
rtr_id=spine_lb,
)
# -------------------------------------------------------------------------
# ISN: each local spine → each remote-site spine loopback (/32 + remote ASN)
# -------------------------------------------------------------------------
if not topo.isn.enabled:
return
try:
remote_site = topo.other_site(site.name)
except KeyError:
return # single-site topology → nothing to do
# #27 / Tier-2 (PR-19): isn.peer_mesh is otherwise read by no builder
# (topology/schema.py's validator only checks it == "full" to gate the
# ">=2 sites" rule). Every local spine peers every remote spine below IS
# the "full" mesh. `partial` mesh (a subset of spine pairs peering,
# e.g. for large N-site fabrics wanting to limit ISN fan-out) has NO
# sensible default partition without additional per-site config this
# schema doesn't carry (which spines pair with which — that's not
# inferable from spine count alone), so PR-19 keeps this a documented
# validate-and-reject rather than inventing a partition scheme no
# topology.yaml author asked for: fail fast rather than silently
# building full-mesh (or an arbitrary subset) for an unimplemented value.
if topo.isn.peer_mesh != "full":
raise ValueError(
f"isn.peer_mesh={topo.isn.peer_mesh!r} is not supported — only "
f"'full' (every local spine peers every remote spine) is implemented. "
f"'partial' is intentionally rejected (see build/overlay.py module "
f"docs) rather than silently built as full-mesh or an undefined subset."
)
remote_asn = str(remote_site.asn)
remote_pod = remote_site.pod
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
spine_lb = loopback_ip(pod, spine.id)
# dom_dn is already ensured above (intra-site peers were added first)
dom_dn = _ensure_dom(spine_inst_dn, store, seen_doms)
for remote_spine in remote_site.spine_nodes():
remote_lb = loopback_ip(remote_pod, remote_spine.id)
# ISN peer addr MUST end with /32 — topology.py checks:
# if "/32" in addr_raw and peer_asn != spine_local_asn → ISN cloud
isn_addr = f"{remote_lb}/32"
_add_peer(
store, dom_dn,
peer_addr=isn_addr,
peer_asn=remote_asn,
peer_type="inter-site",
rtr_id=spine_lb,
)
def _leaves_hosting_bd(store: MITStore, tenant_name: str, bd_name: str) -> set[int]:
"""Return leaf node ids with >=1 locally-learned endpoint in *bd_name*,
scanning fvCEp dns for any EPG bound to that BD (fvRsBd.tnFvBDName) —
same read-back technique tenants.py/zoning.py already use against
endpoints.py's store output."""
epg_dns: set[str] = set()
tn_prefix = f"uni/tn-{tenant_name}/"
for rsbd in store.by_class("fvRsBd"):
if rsbd.attrs.get("tnFvBDName") != bd_name:
continue
if not rsbd.dn.startswith(tn_prefix):
continue
epg_dns.add(rsbd.dn.rsplit("/rsbd", 1)[0])
leaves: set[int] = set()
for epg_dn in epg_dns:
prefix = f"{epg_dn}/cep-"
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
m = _CEP_NODE_RE.search(cep.attrs.get("fabricPathDn", ""))
if m:
leaves.add(int(m.group(1)))
return leaves
def build_vpn_routes(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit bgpVpnRoute + bgpPath (batch-2) — the per-spine EVPN VPN route
table for this site's deployed BD subnets.
Runs AFTER `tenants` and `endpoints` in the orchestrator order (unlike
`build()` above, which only needs fabric/topology data and stays early)
because it reads back fvRsBd/fvCEp to find which leaves actually host
each BD's endpoints — see module docstring for the two-pass DN shape
fabric_bgp_evpn.py's "vpn_routes" view requires.
"""
pod = site.pod
spines = _route_reflectors(topo, site)
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
for bd in tenant.bds:
if not bd.subnets:
continue
target_leaves = _leaves_hosting_bd(store, tenant.name, bd.name)
if not target_leaves:
continue
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
dom_dn = f"{spine_inst_dn}/dom-{_DOM}"
for cidr in bd.subnets:
afi = "vpnv4" # IPv4-unicast EVPN route family (real ACI: af-vpnv4/af-vpnv6)
route_dn = f"{dom_dn}/af-{afi}/rt-[{cidr}]"
route_mo = MO(
"bgpVpnRoute",
dn=route_dn,
pfx=cidr,
rd=f"{site.asn}:{tenant.name}",
)
for leaf_id in sorted(target_leaves):
leaf_lb = loopback_ip(pod, leaf_id)
route_mo.add_child(MO(
"bgpPath",
dn=f"{route_dn}/path-{leaf_id}",
nh=leaf_lb,
asPath="",
localPref="100",
metric="0",
origin="igp",
type="internal",
))
store.add(route_mo)
+167
View File
@@ -0,0 +1,167 @@
"""build/routing.py — uribv4Route/uribv4Nexthop (per-leaf RIB) + arpAdjEp
(per-endpoint ARP table).
Batch-1 additions (CONTRACT.md §6 "Routing tables/control", "COOP/EPM"):
uribv4Route topology/pod-{p}/node-{n}/sys/uribv4/dom-{vrf}/db-rt/rt-[{prefix}]
uribv4Nexthop {route}/nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}]
arpAdjEp topology/pod-{p}/node-{n}/sys/arp/inst/dom-{vrf}/db-ip/adj-[{ip}]
DN/attribute shapes verified (read-only) against autoACI's
backend/plugins/route_table.py + routing_state.py (per CONTRACT.md header —
trust, don't re-explore):
- uribv4Route.attrs["prefix"] is read (NOT "ip" — that's ipRouteP/l3extSubnet/
fvSubnet's attribute name); VRF is parsed from the `dom-{name}` DN segment
via `part[4:]` after `part.startswith("dom-")`.
- uribv4Nexthop's parent-route DN is recovered via
`_RE_NH_PARENT = re.compile(r"^(.*/rt-\\[[^\\]]+\\])/nh-")` (route_table.py /
routing_state.py, both files, identical regex) — so the nexthop RN MUST
start with "nh-" immediately after the route's own "rt-[...]" segment, and
the "nh-" RN's own bracket content is opaque to this regex (any number of
"-[...]" groups after "nh-" parses fine); we use the 4-group form the
module docstring's own comment illustrates:
".../rt-[10.100.210.1/32]/nh-[direct]-[10.100.210.1/32]-[lo2]-[vrf]"
i.e. nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}].
- arpAdjEp: routing_state.py reads attrs["ifId"] and attrs["physIfId"]
(Interface / Physical Interface columns) plus ip/mac/operSt (CONTRACT.md).
VRF "dom" naming reuses the exact `{tenant}:{vrf}` convention l3out.py's
_upsert_ebgp_sessions already established for bgpDom, so uribv4Route's VRF
column lines up with the same dom name a route_table.py VRF-scoped query
would be filtered on.
"""
from __future__ import annotations
import re
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.fabric import loopback_ip
_CEP_PORT_RE = re.compile(r"/node-(\d+)/sys/.*/db-ep/mac-")
_FRONT_PANEL_IF_RE = re.compile(r"paths-\d+/pathep-\[(eth\d+/\d+)\]$")
def _owning_site(l3out, topo: Topology) -> str | None:
if l3out.site is not None:
return l3out.site
for s in topo.sites:
site_bl_ids = {bl.id for bl in s.border_leaves}
if any(bl_id in site_bl_ids for bl_id in l3out.border_leaves):
return s.name
return None
def _build_route(node_dn: str, vrf_dom: str, prefix: str, nh_addr: str, ifname: str) -> MO:
"""Build one uribv4Route + a single uribv4Nexthop child.
Nexthop RN uses the 4-bracket form _RE_NH_PARENT expects:
nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}].
"""
base = f"{node_dn}/uribv4/dom-{vrf_dom}/db-rt"
route_dn = f"{base}/rt-[{prefix}]"
route_mo = MO("uribv4Route", dn=route_dn, prefix=prefix, pref="1")
nh_dn = f"{route_dn}/nh-[static]-[{nh_addr}]-[{ifname}]-[{vrf_dom}]"
route_mo.add_child(MO(
"uribv4Nexthop",
dn=nh_dn,
nhAddr=nh_addr,
ifName=ifname,
))
return route_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit per-leaf uribv4Route/Nexthop RIB entries + per-endpoint arpAdjEp.
Runs after `endpoints` (orchestrator order) so fvCEp/fvIp data already in
the store can be reused for arpAdjEp instead of re-deriving MAC/IP/port
assignment independently.
"""
pod = site.pod
# ---- uribv4Route/Nexthop -------------------------------------------
# One VRF-scoped RIB per (tenant, vrf) deployed at this site, seeded on
# every leaf/border-leaf: BD subnets as directly-connected routes (nexthop
# = this node's own loopback, ifname = a real BD-facing host port so the
# interface resolves), plus (border leaves only) a 0.0.0.0/0 default route
# via the L3Out's CSW peer — matching the ipRouteP static default route
# l3out.py's _build_node_profile already seeds on the same node.
all_leaves = site.leaf_nodes() + site.border_leaf_nodes()
border_leaf_ids = {bl.id for bl in site.border_leaves}
node_loopback = {n.id: loopback_ip(pod, n.id) for n in all_leaves}
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
t = tenant.name
for vrf in tenant.vrfs:
vrf_dom = f"{t}:{vrf.name}"
bd_subnets = [cidr for bd in tenant.bds if bd.vrf == vrf.name for cidr in bd.subnets]
for leaf in all_leaves:
node_dn = f"topology/pod-{pod}/node-{leaf.id}/sys"
lb = node_loopback[leaf.id]
for cidr in bd_subnets:
store.add(_build_route(node_dn, vrf_dom, cidr, lb, "vlan1"))
if leaf.id in border_leaf_ids:
bl = next(bl for bl in site.border_leaves if bl.id == leaf.id)
for l3out in tenant.l3outs:
if l3out.vrf != vrf.name:
continue
if _owning_site(l3out, topo) != site.name:
continue
if leaf.id not in l3out.border_leaves:
continue
store.add(_build_route(
node_dn, vrf_dom, "0.0.0.0/0",
bl.csw.peer_ip, "eth1/48",
))
# ---- arpAdjEp — one per existing (locally-learned) endpoint ---------
# Reuse the real fvCEp/fvIp MAC+IP data endpoints.py already wrote for
# this site's store; only front-panel (local) endpoints get an ARP
# adjacency (a REMOTE/tunnel-learned endpoint has no local ARP entry on
# this fabric — real ACI resolves those via the overlay, not local ARP).
for cep in store.by_class("fvCEp"):
path_dn = cep.attrs.get("fabricPathDn", "")
m = _FRONT_PANEL_IF_RE.search(path_dn)
if not m:
continue
node_m = re.search(r"/paths-(\d+)/", path_dn)
if not node_m:
continue
node_id = node_m.group(1)
if int(node_id) not in {n.id for n in all_leaves}:
continue
if_name = m.group(1)
mac = cep.attrs.get("mac", "")
# fvIp children carry the endpoint's IP(s); dn = f"{cep.dn}/ip-[{ip}]"
for ip_mo in store.children(cep.dn, {"fvIp"}):
ip_addr = ip_mo.attrs.get("addr", "")
if not ip_addr:
continue
# tenant/vrf for the dom segment: derive from cep_dn's "tn-{t}" and
# this site's tenant/BD/VRF config (matches the RIB dom naming above).
tn_m = re.search(r"uni/tn-([^/]+)/", cep.dn)
tname = tn_m.group(1) if tn_m else "common"
tenant = next((tt for tt in topo.tenants if tt.name == tname), None)
vrf_name = tenant.vrfs[0].name if tenant and tenant.vrfs else "default"
vrf_dom = f"{tname}:{vrf_name}"
arp_dn = (
f"topology/pod-{pod}/node-{node_id}/sys/arp/inst"
f"/dom-{vrf_dom}/db-ip/adj-[{ip_addr}]"
)
store.add(MO(
"arpAdjEp",
dn=arp_dn,
ip=ip_addr,
mac=mac,
ifId=if_name,
physIfId=if_name,
operSt="up",
))
+379
View File
@@ -0,0 +1,379 @@
"""build/tenants.py — fvTenant, fvCtx, fvBD, fvAp/fvAEPg, vzBrCP, vzFilter.
MAC/VLAN sequences and endpoint addresses are per-store (per site/APIC); this
module assumes the intended one-MITStore-per-APIC model. Merging two site
stores would collide DNs and attribute values.
Tier-3 (PR-20): `fvBD.mac` now reflects `bd.mac` (per-BD override) falling
back to `fabric.default_bd_mac` (fabric-wide default, itself defaulting to
the real ACI default gateway MAC `00:22:BD:F8:19:FF` — the literal this
module hardcoded for every BD pre-PR-20; see `_build_bd`).
Batch-1 additions (CONTRACT.md §6 "Rels", "Contracts"):
fvRsPathAtt uni/tn-{t}/ap-{ap}/epg-{epg}/rspathAtt-[{pathDn}]
vzRsSubjGraphAtt {vzSubj}/rsSubjGraphAtt
fvRsPathAtt needs a static binding whose tDn is the SAME front-panel path an
EPG's own endpoints already use (endpoints.py assigns EP ports), so this
module reads back the real fvCEp.fabricPathDn the endpoints builder already
wrote for that EPG rather than re-deriving port-assignment logic
independently (see orchestrator.py: `endpoints` now runs before `tenants`
specifically to make this store read-back possible; a stretched EPG that has
no locally-learned (front-panel) endpoint at this site — i.e. every endpoint
here is REMOTE/tunnel-learned — gets no fvRsPathAtt, since a static binding
must reference a real local port, not a tunnel).
Batch-2 additions (CONTRACT.md §6 "Tenant (control)"): fvAEPg now carries a
stable `pcTag` (real ACI's per-EPG "sclass" used for zoning-rule matching) —
previously absent from this sim entirely (verified: no prior pcTag anywhere
in aci_sim). Assigned deterministically per (tenant, ap, epg) via
`_pctag_for` (a small CRC32-derived integer in a believable ACI sclass range,
avoiding the well-known reserved system pcTags 1/13/14/15/16384 autoACI's
zoning_rules.py hardcodes) so it is stable across rebuilds without needing
external state. `fvEpP` (uni/epp/fv-[{epg_dn}]) is seeded alongside each EPG,
carrying the SAME pcTag plus a per-VRF `scopeId` (build/zoning.py derives its
own actrlRule.scopeId from the identical per-VRF scheme, so the two line up —
see that module's docstring) and `epgPKey` = the EPG's own dn (zoning_rules.py
extracts the friendly EPG name back out of epgPKey via its trailing "epg-"
segment).
"""
from __future__ import annotations
import re
import zlib
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import (
BD,
Contract,
EPG,
Filter,
FilterEntry,
L3Out,
Site,
Tenant,
Topology,
)
_FRONT_PANEL_PATH_RE = re.compile(r"paths-\d+/pathep-\[eth\d+/\d+\]$")
# Batch-2 pcTag/scopeId scheme (shared with build/zoning.py's actrlRule —
# import these two helpers from here rather than re-deriving the formula, so
# an actrlRule's sPcTag/dPcTag/scopeId always match the fvAEPg/fvEpP/fvCtx
# values a zoning-rule consumer would resolve them against).
#
# Real ACI pcTags ("sclass") are 16-bit-ish integers; autoACI's
# zoning_rules.py hardcodes 5 well-known SYSTEM_PCTAGS (1, 13, 14, 15, 16384)
# that must never collide with a real EPG/VRF pcTag, so both helpers below
# add a fixed offset clear of that reserved set.
_PCTAG_BASE = 32770 # clear of the highest reserved system pcTag (16384)
_SCOPE_BASE = 2097152 # clear of any plausible pcTag range
def pctag_for(key: str) -> str:
"""Deterministic pcTag ("sclass") for an EPG/VRF/ExtEPG, keyed by a
stable string (e.g. the object's own dn). CRC32-derived so it's stable
across rebuilds without external state, same technique endpoints.py's
`_vnid` already uses for VNIDs."""
return str(_PCTAG_BASE + (zlib.crc32(key.encode()) & 0xFFFF))
def scope_id_for(tenant: str, vrf: str) -> str:
"""Deterministic scopeId (real ACI: the VRF's zoning-scope id) for a
(tenant, vrf) pair — every EPG/actrlRule sharing that VRF shares this
scopeId, matching real ACI's per-VRF zoning scope semantics."""
return str(_SCOPE_BASE + (zlib.crc32(f"{tenant}:{vrf}".encode()) & 0xFFFF))
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _owning_site(l3out: L3Out, topo: Topology) -> str | None:
"""Return the site name that owns this L3Out."""
if l3out.site is not None:
return l3out.site
for s in topo.sites:
site_bl_ids = {bl.id for bl in s.border_leaves}
if any(bl_id in site_bl_ids for bl_id in l3out.border_leaves):
return s.name
return None
def _entry_name(entry: FilterEntry, idx: int) -> str:
if entry.from_port != "unspecified":
return f"{entry.proto}-{entry.from_port}"
return f"{entry.proto}-{idx}"
def _build_bd(topo: Topology, site: Site, t: str, bd: BD, l3outs: list[L3Out]) -> MO:
"""Build fvBD + fvRsCtx, fvSubnet, and fvRsBDToOut children."""
l2s = bd.l2stretch
# unicastRoute reflects whether the BD actually routes traffic, i.e. has
# fvSubnet children — NOT l2stretch. A stretched (l2stretch) BD can still
# carry routed subnets (this topology's stretched BDs do); NDO's schema
# side (ndo/model.py) always reports unicastRouting=True for every BD with
# subnets, so the two planes must agree. A true L2-only BD (no subnets)
# correctly stays unicastRoute="no".
routed = bool(bd.subnets)
# Tier-3 (PR-20): bd.mac (per-BD override) falls back to
# fabric.default_bd_mac, which itself defaults to the real ACI default
# gateway MAC "00:22:BD:F8:19:FF" — the literal this module hardcoded
# for every BD pre-PR-20. An unset bd.mac + unset fabric.default_bd_mac
# produces the identical "00:22:BD:F8:19:FF" as before this PR.
bd_mac = bd.mac or topo.fabric.default_bd_mac
bd_mo = MO(
"fvBD",
dn=f"uni/tn-{t}/BD-{bd.name}",
name=bd.name,
arpFlood="yes" if l2s else "no",
unicastRoute="yes" if routed else "no",
ipLearning="yes",
unkMacUcastAct="flood" if l2s else "proxy",
unkMcastAct="flood",
v6unkMcastAct="nd",
multiDstPktAct="bd-flood",
epMoveDetectMode="garp",
hostBasedRouting="no",
limitIpLearnToSubnets="yes",
mtu="9000",
mac=bd_mac,
intersiteBumTrafficAllow="yes" if l2s else "no",
type="regular",
)
bd_mo.add_child(MO(
"fvRsCtx",
dn=f"uni/tn-{t}/BD-{bd.name}/rsctx",
tnFvCtxName=bd.vrf,
))
for cidr in bd.subnets:
bd_mo.add_child(MO(
"fvSubnet",
dn=f"uni/tn-{t}/BD-{bd.name}/subnet-[{cidr}]",
ip=cidr,
scope="public,shared",
))
for l3out in l3outs:
if l3out.vrf != bd.vrf:
continue
if _owning_site(l3out, topo) != site.name:
continue
bd_mo.add_child(MO(
"fvRsBDToOut",
dn=f"uni/tn-{t}/BD-{bd.name}/rsBDToOut-{l3out.name}",
tnL3extOutName=l3out.name,
))
return bd_mo
def _find_static_binding_path(store: MITStore, epg_ref: str) -> tuple[str, str] | None:
"""Return (pathDn, encap) for one LOCAL (front-panel) endpoint already
written for *epg_ref* by endpoints.py, or None if this EPG has no
locally-learned endpoint at this site (e.g. a stretched EPG that is
entirely REMOTE/tunnel-learned here).
fvCEp dn is f"{epg_ref}/cep-{mac}"; we scan the store's fvCEp class list
(cheap — endpoint counts are small) rather than requiring endpoints.py to
expose an index, keeping the two modules loosely coupled.
"""
prefix = f"{epg_ref}/cep-"
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
path_dn = cep.attrs.get("fabricPathDn", "")
if _FRONT_PANEL_PATH_RE.search(path_dn):
return path_dn, cep.attrs.get("encap", "")
return None
def _build_epg(t: str, ap_name: str, epg: EPG, phys_dom: str, store: MITStore) -> MO:
"""Build fvAEPg + fvRsBd, fvRsDomAtt, fvRsProv, fvRsCons, fvRsPathAtt children.
Batch-2: fvAEPg.pcTag is assigned here (keyed by the EPG's own dn via
`pctag_for`) so it is available the moment the EPG exists; the matching
fvEpP (which needs the owning VRF for its scopeId) is built separately in
_build_tenant, where the BD->VRF mapping is already in scope.
"""
epg_dn = f"uni/tn-{t}/ap-{ap_name}/epg-{epg.name}"
epg_mo = MO(
"fvAEPg",
dn=epg_dn,
name=epg.name,
prefGrMemb="exclude",
pcEnfPref="unenforced",
isAttrBasedEPg="no",
floodOnEncap="disabled",
pcTag=pctag_for(epg_dn),
)
epg_mo.add_child(MO(
"fvRsBd",
dn=f"{epg_dn}/rsbd",
tnFvBDName=epg.bd,
))
epg_mo.add_child(MO(
"fvRsDomAtt",
dn=f"{epg_dn}/rsdomAtt-[uni/phys-{phys_dom}]",
tDn=f"uni/phys-{phys_dom}",
instrImedcy="lazy",
))
for c in epg.contracts.provide:
epg_mo.add_child(MO(
"fvRsProv",
dn=f"{epg_dn}/rsprov-{c}",
tnVzBrCPName=c,
))
for c in epg.contracts.consume:
epg_mo.add_child(MO(
"fvRsCons",
dn=f"{epg_dn}/rscons-{c}",
tnVzBrCPName=c,
))
# Batch-1: fvRsPathAtt — static binding to the SAME front-panel path this
# EPG's own (already-built) endpoints use.
binding = _find_static_binding_path(store, epg_dn)
if binding is not None:
path_dn, encap = binding
epg_mo.add_child(MO(
"fvRsPathAtt",
dn=f"{epg_dn}/rspathAtt-[{path_dn}]",
tDn=path_dn,
encap=encap,
mode="regular",
instrImedcy="lazy",
))
return epg_mo
def _build_contract(t: str, contract: Contract, attach_graph: bool) -> MO:
"""Build vzBrCP + vzSubj + vzRsSubjFiltAtt (+ batch-1 vzRsSubjGraphAtt) children.
*attach_graph*: attach a vzRsSubjGraphAtt to this ONE contract's subject
(per the batch-1 placement note — only one existing subject gets a
service-graph attachment; vnsAbsGraph itself is out of scope, so
tnVnsAbsGraphName references a plausible graph name only).
"""
subj_dn = f"uni/tn-{t}/brc-{contract.name}/subj-{contract.name}"
brc_mo = MO(
"vzBrCP",
dn=f"uni/tn-{t}/brc-{contract.name}",
name=contract.name,
scope=contract.scope,
prio="unspecified",
targetDscp="unspecified",
)
subj_mo = MO(
"vzSubj",
dn=subj_dn,
name=contract.name,
revFltPorts="yes",
prio="unspecified",
)
for flt in contract.filters:
subj_mo.add_child(MO(
"vzRsSubjFiltAtt",
dn=f"{subj_dn}/rssubjFiltAtt-{flt.name}",
tnVzFilterName=flt.name,
))
if attach_graph:
subj_mo.add_child(MO(
"vzRsSubjGraphAtt",
dn=f"{subj_dn}/rsSubjGraphAtt",
tnVnsAbsGraphName=f"{contract.name}_graph",
))
brc_mo.add_child(subj_mo)
return brc_mo
def _build_filter(t: str, flt: Filter) -> MO:
"""Build vzFilter + vzEntry children."""
flt_mo = MO("vzFilter", dn=f"uni/tn-{t}/flt-{flt.name}", name=flt.name)
for idx, entry in enumerate(flt.entries):
ename = _entry_name(entry, idx)
flt_mo.add_child(MO(
"vzEntry",
dn=f"uni/tn-{t}/flt-{flt.name}/e-{ename}",
name=ename,
etherT=entry.ether_type,
prot=entry.proto,
dFromPort=entry.from_port,
dToPort=entry.to_port,
sFromPort="unspecified",
sToPort="unspecified",
stateful="yes",
))
return flt_mo
def _build_tenant(topo: Topology, site: Site, tenant: Tenant, store: MITStore) -> MO:
t = tenant.name
phys_dom = topo.access.phys_domains[0].name if topo.access.phys_domains else "phys-dom-1"
tenant_mo = MO("fvTenant", dn=f"uni/tn-{t}", name=t, descr="")
# fvCtx per VRF — batch-2: pcTag (VRF-level sclass; zoning_rules.py's
# vzAny-scoped lookup falls back to this when no EPG-level pcTag matches)
# + scopeId (this VRF's own zoning scope — every actrlRule/fvEpP sharing
# this VRF shares the same scopeId, per scope_id_for's docstring).
for vrf in tenant.vrfs:
tenant_mo.add_child(MO(
"fvCtx",
dn=f"uni/tn-{t}/ctx-{vrf.name}",
name=vrf.name,
pcEnfPref="enforced",
bdEnforcedEnable="no",
ipDataPlaneLearning="enabled",
pcEnfDir="ingress",
pcTag=pctag_for(f"uni/tn-{t}/ctx-{vrf.name}"),
scope=scope_id_for(t, vrf.name),
))
# fvBD per BD
for bd in tenant.bds:
tenant_mo.add_child(_build_bd(topo, site, t, bd, tenant.l3outs))
# fvAp / fvAEPg (+ batch-2 fvEpP, a separate top-level MO under uni/epp/,
# NOT nested under the tenant tree — real ACI's EPG-profile class lives
# in its own uni/epp/ subtree, mirrored here as a sibling store.add call
# rather than a tenant_mo child).
bd_vrf = {bd.name: bd.vrf for bd in tenant.bds}
for ap in tenant.aps:
ap_mo = MO("fvAp", dn=f"uni/tn-{t}/ap-{ap.name}", name=ap.name)
for epg in ap.epgs:
epg_mo = _build_epg(t, ap.name, epg, phys_dom, store)
ap_mo.add_child(epg_mo)
vrf_name = bd_vrf.get(epg.bd, "")
store.add(MO(
"fvEpP",
dn=f"uni/epp/fv-[{epg_mo.dn}]",
epgPKey=epg_mo.dn,
pcTag=epg_mo.attrs["pcTag"],
scopeId=scope_id_for(t, vrf_name) if vrf_name else "",
))
tenant_mo.add_child(ap_mo)
# vzBrCP contracts — batch-1: attach vzRsSubjGraphAtt to exactly ONE
# existing contract subject (the tenant's first contract, if any).
graph_target = tenant.contracts[0].name if tenant.contracts else None
for contract in tenant.contracts:
tenant_mo.add_child(_build_contract(t, contract, attach_graph=(contract.name == graph_target)))
# vzFilter (deduplicated by name)
seen: set[str] = set()
for contract in tenant.contracts:
for flt in contract.filters:
if flt.name in seen:
continue
seen.add(flt.name)
tenant_mo.add_child(_build_filter(t, flt))
return tenant_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fvTenant trees for every tenant deployed at *site*."""
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
store.add(_build_tenant(topo, site, tenant, store))
+106
View File
@@ -0,0 +1,106 @@
"""build/underlay.py — bgpInst (local ASN), isisAdjEp + ospfAdjEp.
Intra-fabric underlay IGP is IS-IS: isisAdjEp is emitted between every directly
cabled spine↔leaf pair. OSPF is used ONLY by spines toward the inter-site network
(IPN/ISN) and ONLY in a multi-site fabric — leaves never peer OSPF with the ISN.
bgpInst carries the site ASN and anchors all BGP peers (overlay.py adds children).
Tier-3 (PR-20): ospfIf also carries `helloIntvl`/`deadIntvl`, fed by
`isn.ospf_hello`/`isn.ospf_dead` (real ACI defaults 10/40) — previously not
carried on this MO at all.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.fabric import loopback_ip
from aci_sim.build.cabling import cabling_links
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit bgpInst per node + OSPF/IS-IS adjacencies along fabric links."""
pod = site.pod
spine_ids = {n.id for n in site.spine_nodes()}
# bgpInst per node — carries the site ASN; overlay.py adds bgpPeer children
for node in site.all_nodes():
store.add(MO(
"bgpInst",
dn=f"topology/pod-{pod}/node-{node.id}/sys/bgp/inst",
asn=str(site.asn),
))
# isisDom per node (IS-IS is the ACI underlay IGP)
for node in site.all_nodes():
store.add(MO(
"isisDom",
dn=f"topology/pod-{pod}/node-{node.id}/sys/isis/inst-default/dom-overlay-1",
name="overlay-1",
operSt="up",
))
# IS-IS adjacencies — the intra-fabric underlay, between directly-cabled
# spine↔leaf pairs only. Convention: n1=spine, n2=leaf (same as cabling.py).
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
spine_id, leaf_id = (n1, n2) if n1 in spine_ids else (n2, n1)
spine_port = f"eth{s1}/{p1}" if n1 in spine_ids else f"eth{s2}/{p2}"
leaf_port = f"eth{s2}/{p2}" if n1 in spine_ids else f"eth{s1}/{p1}"
spine_lb = loopback_ip(pod, spine_id)
leaf_lb = loopback_ip(pod, leaf_id)
spine_isis_base = f"topology/pod-{pod}/node-{spine_id}/sys/isis/inst-default/dom-overlay-1"
store.add(MO(
"isisAdjEp",
dn=f"{spine_isis_base}/if-[{spine_port}]/adj-[{leaf_lb}]",
operSt="up", sysId=leaf_lb, lastTrans="00:00:00", numAdjTrans="1",
))
leaf_isis_base = f"topology/pod-{pod}/node-{leaf_id}/sys/isis/inst-default/dom-overlay-1"
store.add(MO(
"isisAdjEp",
dn=f"{leaf_isis_base}/if-[{leaf_port}]/adj-[{spine_lb}]",
operSt="up", sysId=spine_lb, lastTrans="00:00:00", numAdjTrans="1",
))
# OSPF adjacencies — ONLY spine↔IPN/ISN, and ONLY in a multi-site fabric.
# Leaves never run OSPF to the ISN. One adjacency per spine toward the IPN,
# on eth1/{49+si} — interfaces.py builds a matching dedicated ISN uplink
# l1PhysIf on each spine (multi-site only) at that exact port, so this
# ospfAdjEp interface always resolves against a real port inventory entry.
# autoACI reads: iface = dn.split("/if-[")[1]; neighbor = peerIp or nbrId.
if len(topo.sites) > 1:
ipn_ip = f"172.16.{site.id}.254" # the IPN/ISN router this site's spines peer with
# Tier-2 (PR-19): isn.ospf_area feeds ospfIf.area/ospfAdjEp.area — was
# a hardcoded "0.0.0.0" literal; a topology author overriding
# isn.ospf_area now sees it reflected in the actual OSPF object
# instead of a value the schema stored but no builder honored.
ospf_area = topo.isn.ospf_area
# Tier-3 (PR-20): isn.ospf_hello/ospf_dead (real ACI defaults 10/40)
# feed ospfIf.helloIntvl/deadIntvl — previously not carried on this
# MO at all.
ospf_hello = str(topo.isn.ospf_hello)
ospf_dead = str(topo.isn.ospf_dead)
for si, spine in enumerate(site.spine_nodes()):
ospf_base = f"topology/pod-{pod}/node-{spine.id}/sys/ospf/inst-default/dom-overlay-1"
store.add(MO("ospfDom", dn=ospf_base, name="overlay-1", operSt="up"))
# Batch-1: ospfIf nested between ospfDom and ospfAdjEp — same
# eth1/{49+si} ISN uplink port interfaces.py already builds a
# dedicated l1PhysIf for, so the existing ospfAdjEp below sits on
# a real, matching ospfIf (consumer: autoACI's fabric_ospf.py,
# which reads ospfIf.id (falling back to ifId) + area + operSt).
if_port = f"eth1/{49 + si}"
if_dn = f"{ospf_base}/if-[{if_port}]"
store.add(MO(
"ospfIf",
dn=if_dn,
id=if_port,
area=ospf_area,
operSt="up",
helloIntvl=ospf_hello,
deadIntvl=ospf_dead,
))
store.add(MO(
"ospfAdjEp",
dn=f"{if_dn}/adj-[{ipn_ip}]",
operSt="full", peerIp=ipn_ip, nbrId=ipn_ip, area=ospf_area,
))
+42
View File
@@ -0,0 +1,42 @@
"""build/userprofile.py — aaaUserEp login-probe MO (uni/userprofile-<user>).
CONTRACT.md §1: during/after login autoACI probes
GET /api/mo/uni/userprofile-<user>.json?query-target=subtree&target-subtree-class=aaaUserDomain
This must not 404 and must return >=1 aaaUserDomain row.
Real APIC exposes a per-user aaaUserEp at uni/userprofile-<user> with an
aaaUserDomain child (name="all" is the default/full-access domain every local
user gets). The simulator only ever authenticates as "admin" (see
rest_aci/auth.py, which defaults aaaLogin's username to "admin"), so the
simplest faithful variant is a single static admin profile rather than
dynamically creating one per logged-in user — this is the same set of MOs a
real APIC would already have provisioned for the built-in admin account, no
matter who authenticates.
Site-independent: call once per topology (site arg accepted for API
uniformity but not used in the selection logic), same convention as
build/access.py.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_ADMIN_USER = "admin"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit uni/userprofile-admin (aaaUserEp) + its aaaUserDomain child."""
profile_dn = f"uni/userprofile-{_ADMIN_USER}"
profile_mo = MO(
"aaaUserEp",
dn=profile_dn,
name=_ADMIN_USER,
)
profile_mo.add_child(MO(
"aaaUserDomain",
dn=f"{profile_dn}/userdomain-all",
name="all",
))
store.add(profile_mo)
+161
View File
@@ -0,0 +1,161 @@
"""build/zoning.py — actrlRule (batch-2, CONTRACT.md §6 "Contracts").
actrlRule topology/pod-{p}/node-{n}/sys/actrl/scope-[{scopeId}]/rule-[{ruleId}]
DN/attribute shapes verified read-only against autoACI's zoning_rules.py,
contract_debug.py, and ep_connectivity.py (all three read actrlRule; see
docs/DESIGN.md "Batch-2" section for the specific attrs each parses):
- zoning_rules.py: node-scoped subtree query under `.../sys/actrl`
(target-subtree-class=actrlRule), reads scopeId/sPcTag/dPcTag/fltId/
action/direction/prio/ctrctName/operSt/dn. Also cross-references fvEpP's
(scopeId, pcTag) -> friendly-name map (tenants.py already seeds this).
- contract_debug.py: same node-scoped subtree query, filters rows whose
ctrctName contains one of the contract names on the debugged path.
- ep_connectivity.py: FABRIC-WIDE class query with an
`or(eq(actrlRule.sPcTag,...),eq(actrlRule.dPcTag,...))` filter — i.e.
actrlRule must also be reachable via a plain GET /api/class/actrlRule.json
(not only via node-scoped subtree), so this module both nests the rule
under its owning node's /sys/actrl tree (for the subtree query shape) AND
the generic class-query index (store.add already registers by class
regardless of nesting, so both access patterns are satisfied by one MO).
Rule derivation: one permit rule per (contract subject filter, provider EPG,
consumer EPG) triple, emitted on every leaf where BOTH the provider and
consumer EPG have at least one locally-learned endpoint (a real leaf only
compiles zoning rules for EPGs it actually has traffic for) — read back from
the fvCEp data endpoints.py already wrote (same read-back pattern
tenants.py's fvRsPathAtt already established; zoning.py runs after both
tenants and endpoints in the orchestrator, so both stores are populated).
sPcTag/dPcTag/scopeId reuse the exact pctag_for/scope_id_for scheme
tenants.py's fvAEPg/fvEpP/fvCtx already assign, so a zoning-rule consumer's
pcTag/scopeId cross-reference against fvEpP always resolves.
Structural-container fix (same class of gap access.py's batch-1 infraInfra
fix addressed): `.../sys/actrl` and `.../sys/actrl/scope-[{scopeId}]` are
each seeded as a real MO (classes `actrlRuleCont`/`actrlScope` — placeholder
names; no consumer queries them BY class, only actrlRule descendants
underneath, so any believable class name is faithful here) — without a real
MO at the exact `.../sys/actrl` DN, GET /api/mo/{node}/sys/actrl.json (the
precise subtree-query shape all three consumers use) 404s, because the
store's ancestor-walk only links intermediate DNs structurally in the
children index and never materializes a real MO for them on its own (see
mit/store.py's MITStore._register docstring).
"""
from __future__ import annotations
import re
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Tenant, Topology
from aci_sim.build.tenants import pctag_for, scope_id_for
_CEP_NODE_RE = re.compile(r"/paths-(\d+)/pathep-\[eth\d+/\d+\]$")
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _leaves_with_local_endpoints(store: MITStore, epg_dn: str) -> set[int]:
"""Return the set of leaf node ids that have >=1 locally-learned (real
front-panel) endpoint for *epg_dn*, read back from endpoints.py's fvCEp
data (same technique tenants.py's _find_static_binding_path uses)."""
prefix = f"{epg_dn}/cep-"
leaves: set[int] = set()
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
path_dn = cep.attrs.get("fabricPathDn", "")
m = _CEP_NODE_RE.search(path_dn)
if m:
leaves.add(int(m.group(1)))
return leaves
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit actrlRule MOs derived from existing contract/filter/EPG data."""
pod = site.pod
# Real MOs for the ".../sys/actrl" and ".../sys/actrl/scope-[...]"
# structural containers — without these, GET /api/mo/{node}/sys/actrl.json
# (the exact DN zoning_rules.py/contract_debug.py/ep_connectivity.py query
# with subtree=True) 404s: the store's ancestor-walk only links these DNs
# structurally in the children index, it does not materialize a real MO
# for them (see mit/store.py's _register docstring) — same class of gap
# access.py's batch-1 infraInfra fix addressed for uni/infra.
seen_actrl_dn: set[str] = set()
seen_scope_dn: set[str] = set()
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
# epg_dn -> EPG (only EPGs actually deployed under this tenant's APs)
epg_by_name = {
epg.name: (ap.name, epg)
for ap in tenant.aps
for epg in ap.epgs
}
for contract in tenant.contracts:
providers = [
(ap_name, epg) for name, (ap_name, epg) in epg_by_name.items()
if contract.name in epg.contracts.provide
]
consumers = [
(ap_name, epg) for name, (ap_name, epg) in epg_by_name.items()
if contract.name in epg.contracts.consume
]
if not providers or not consumers:
continue
for prov_ap, prov_epg in providers:
prov_dn = f"uni/tn-{t}/ap-{prov_ap}/epg-{prov_epg.name}"
prov_vrf = None
for bd in tenant.bds:
if bd.name == prov_epg.bd:
prov_vrf = bd.vrf
if prov_vrf is None:
continue
prov_tag = pctag_for(prov_dn)
scope_id = scope_id_for(t, prov_vrf)
prov_leaves = _leaves_with_local_endpoints(store, prov_dn)
for cons_ap, cons_epg in consumers:
cons_dn = f"uni/tn-{t}/ap-{cons_ap}/epg-{cons_epg.name}"
cons_tag = pctag_for(cons_dn)
cons_leaves = _leaves_with_local_endpoints(store, cons_dn)
# Real ACI compiles the (bidirectional-capable) zoning
# rule pair onto every leaf hosting either side's
# endpoints — the leaf enforces the policy for its own
# local traffic regardless of which side it hosts.
target_leaves = prov_leaves | cons_leaves
if not target_leaves:
continue
for flt in contract.filters:
for leaf_id in sorted(target_leaves):
actrl_dn = f"topology/pod-{pod}/node-{leaf_id}/sys/actrl"
if actrl_dn not in seen_actrl_dn:
seen_actrl_dn.add(actrl_dn)
store.add(MO("actrlRuleCont", dn=actrl_dn))
scope_dn = f"{actrl_dn}/scope-[{scope_id}]"
if scope_dn not in seen_scope_dn:
seen_scope_dn.add(scope_dn)
store.add(MO("actrlScope", dn=scope_dn, scopeId=scope_id))
rule_id = f"{contract.name}-{flt.name}"
store.add(MO(
"actrlRule",
dn=f"{scope_dn}/rule-[{rule_id}]",
scopeId=scope_id,
sPcTag=prov_tag,
dPcTag=cons_tag,
fltId=flt.name,
action="permit",
direction="bi",
prio="pol_default",
ctrctName=contract.name,
operSt="enabled",
))
+1768
View File
File diff suppressed because it is too large Load Diff
View File
+166
View File
@@ -0,0 +1,166 @@
"""Control-plane admin router mounted under /_sim on each APIC app."""
from __future__ import annotations
import copy
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from aci_sim.control.persist import (
deserialize_store,
load_json,
save_json,
serialize_store,
state_dir,
)
from aci_sim.mit.mo import MO
def _apic_error(text: str, code: str = "103", status_code: int = 400) -> JSONResponse:
"""Return an APIC error envelope as a JSONResponse.
Duplicated (rather than imported) from rest_aci.app to avoid a circular
import: app.py imports make_admin_router from this module.
"""
body = {
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
"totalCount": "1",
}
return JSONResponse(status_code=status_code, content=body)
def make_admin_router(state) -> APIRouter:
"""Build the /_sim admin router.
state is an ApicSiteState dataclass; we mutate state.store directly.
"""
router = APIRouter(prefix="/_sim")
_snapshots: dict[str, object] = {} # name → MITStore deepcopy
@router.post("/reset")
async def reset():
state.store = copy.deepcopy(state.baseline)
return {"status": "ok", "action": "reset"}
@router.post("/snapshot/{name}")
async def snapshot(name: str):
_snapshots[name] = copy.deepcopy(state.store)
return {"status": "ok", "snapshot": name}
@router.post("/restore/{name}")
async def restore(name: str):
if name not in _snapshots:
return JSONResponse(
status_code=404,
content={"status": "error", "detail": f"snapshot '{name}' not found"},
)
state.store = copy.deepcopy(_snapshots[name])
return {"status": "ok", "restored": name}
def _find_site(topo):
"""Look up the site matching state.site in a freshly-loaded topology.
Matches by the stable Site.id first (the schema's documented stable
key), falling back to Site.name for topologies that only vary node
content but keep the same id. Returns None if the site no longer
exists in the new topology at all.
"""
for candidate in topo.sites:
if candidate.id == state.site.id:
return candidate
for candidate in topo.sites:
if candidate.name == state.site.name:
return candidate
return None
@router.post("/reload")
async def reload(request: Request):
from aci_sim.topology.loader import load_topology
from aci_sim.build.orchestrator import build_site
from aci_sim.runtime.config import TOPOLOGY_PATH
topo = load_topology(TOPOLOGY_PATH)
fresh_site = _find_site(topo)
if fresh_site is None:
return _apic_error(
text=(
f"Site '{state.site.name}' (id={state.site.id!r}) no longer "
"exists in the reloaded topology"
),
code="103",
status_code=400,
)
# Use the FRESH site object from the just-loaded topology, not the
# stale pre-reload state.site — otherwise edits to this site's nodes/
# leaves in topology.yaml are silently ignored (finding #11).
state.topo = topo
state.site = fresh_site
new_store = build_site(topo, fresh_site)
state.store = new_store
state.baseline = copy.deepcopy(new_store)
return {"status": "ok", "action": "reload"}
@router.post("/save/{name}")
async def save(name: str):
"""Persist this site's MITStore to disk under *name*.
File is keyed by both *name* and ``state.site.id`` so a single
SIM_STATE_DIR can hold saves for multiple sites without collision
(mirrors the wrapper script's per-plane file naming).
"""
path = state_dir() / f"{name}.{state.site.id}.apic.json"
data = serialize_store(state.store)
save_json(path, data)
return {"status": "ok", "file": str(path), "count": len(data)}
@router.post("/load/{name}")
async def load(name: str):
"""Restore this site's MITStore from a prior :func:`save`."""
path = state_dir() / f"{name}.{state.site.id}.apic.json"
if not path.exists():
return _apic_error(
f"state '{name}' not found for site {state.site.id}",
status_code=404,
)
data = load_json(path)
state.store = deserialize_store(data)
return {"status": "ok", "count": len(data)}
@router.post("/add-leaf")
async def add_leaf(request: Request):
from aci_sim.rest_aci.writes import materialize_node_registration
body = await request.json()
node_id = body.get("id")
if node_id is None:
existing = [int(mo.attrs.get("id", 0)) for mo in state.store.by_class("fabricNode")]
node_id = max(existing, default=100) + 1
name = body.get("name", f"leaf-{node_id}")
# Shares the fabricNodeIdentP write-reaction's enrichment (finding #19):
# fabricNode + topSystem + healthInst + fabricLink cabling to the
# spines + l1PhysIf inventory, not just a bare fabricNode.
materialize_node_registration(
state.store,
topo=state.topo,
site=state.site,
node_id=int(node_id),
name=name,
role=body.get("role", "leaf"),
)
return {"status": "ok", "node_id": node_id, "name": name}
@router.post("/remove-leaf")
async def remove_leaf(request: Request):
body = await request.json()
node_id = body.get("id")
if node_id is None:
return JSONResponse(status_code=400, content={"status": "error", "detail": "id required"})
pod = state.site.pod
dn = f"topology/pod-{pod}/node-{node_id}"
mo = MO("fabricNode", dn=dn, status="deleted")
state.store.upsert(mo)
return {"status": "ok", "removed": node_id}
return router
+122
View File
@@ -0,0 +1,122 @@
"""File-backed state persistence for the sim's MITStore and NdoState.
Covers ALL planes — per-site APIC MITStores and the NDO model — so a whole
fabric can be saved to disk and restored across a sim restart (sandbox/port
mode has no other durability: everything else lives in memory only).
Design notes
------------
- ``MO`` instances stored in a :class:`~aci_sim.mit.store.MITStore`
are always childless (the store keeps the parent/child hierarchy only in
its own ``_children`` index — see ``MITStore.add``'s docstring). So a
faithful store round-trip only needs each MO's ``class_name`` + ``attrs``;
replaying them through ``MITStore.add`` rebuilds the ancestor index and
therefore all subtree queries.
- ``NdoState`` is a plain ``@dataclass`` of JSON-friendly fields (lists/dicts
of str/bool/int). ``apply_ndo`` mutates the SAME state object in place via
``setattr`` — ``make_ndo_app`` closes over the ``state`` object identity,
so replacing it with a new instance would leave the running app's routes
pointed at stale data.
"""
from __future__ import annotations
import copy
import json
import os
from pathlib import Path
from typing import Any
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.ndo.model import NdoState
#: NdoState fields that are persisted (mutable, JSON-friendly). Excludes
#: nothing from the dataclass — every field NdoState carries is saved.
FIELDS: list[str] = [
"tenants",
"schemas",
"schema_details",
"template_summaries",
"tenant_policy_templates",
"policy_states",
"audit_records",
"local_users",
"remote_users",
"extra_schemas",
"sites",
"fabric_connectivity",
]
def state_dir() -> Path:
"""Return the base directory for persisted state, creating it if needed.
Defaults to ``~/.aci-sim/state``; override with ``SIM_STATE_DIR``
(tests set this to an isolated ``tmp_path`` so nothing touches the
real home directory).
"""
base = Path(os.environ.get("SIM_STATE_DIR", os.path.expanduser("~/.aci-sim/state")))
base.mkdir(parents=True, exist_ok=True)
return base
def save_json(path: Path, obj: Any) -> None:
"""Write *obj* to *path* as indented JSON."""
with open(path, "w") as f:
json.dump(obj, f, indent=2)
def load_json(path: Path) -> Any:
"""Read and return the JSON document at *path*."""
with open(path) as f:
return json.load(f)
# ---------------------------------------------------------------------------
# MITStore (APIC plane) serialization
# ---------------------------------------------------------------------------
def serialize_store(store: MITStore) -> list[dict[str, Any]]:
"""Flatten *store* to a JSON-friendly list of ``{class, attrs}`` dicts.
Stored MOs are childless (hierarchy lives only in the store's parent
index — see ``MITStore.add``), so this list alone is sufficient to
reconstruct the store via :func:`deserialize_store`.
"""
return [{"class": mo.class_name, "attrs": dict(mo.attrs)} for mo in store.all()]
def deserialize_store(data: list[dict[str, Any]]) -> MITStore:
"""Rebuild a :class:`MITStore` from :func:`serialize_store` output.
Replays each entry through ``MITStore.add``, which rebuilds the
ancestor/``_children`` index as it goes — so subtree queries against the
restored store behave exactly as they did before serialization.
"""
s = MITStore()
for d in data:
s.add(MO(d["class"], **d["attrs"]))
return s
# ---------------------------------------------------------------------------
# NdoState (NDO plane) serialization
# ---------------------------------------------------------------------------
def serialize_ndo(state: NdoState) -> dict[str, Any]:
"""Return a deep-copied, JSON-friendly dict of *state*'s persisted fields."""
return {f: copy.deepcopy(getattr(state, f)) for f in FIELDS}
def apply_ndo(state: NdoState, data: dict[str, Any]) -> None:
"""Apply *data* (from :func:`serialize_ndo`) onto *state* IN PLACE.
Mutates the same object via ``setattr`` rather than returning a new
``NdoState`` — ``make_ndo_app`` closes over this exact object, so
replacing it would leave the running app's routes reading stale state.
"""
for f, v in data.items():
setattr(state, f, copy.deepcopy(v))
+389
View File
@@ -0,0 +1,389 @@
"""aci_sim.graph — self-contained SVG/HTML topology diagram renderer.
Renders the BUILT fabric (spines, leaves, border-leaves, controllers, the
spine<->leaf cabling mesh, vPC pairs, and — for multi-site topologies — the
ISN cloud) as a single, dependency-free SVG. No CDN, no server, no npm: the
output is plain hand-generated SVG markup, optionally wrapped in a minimal
`<html><style>...</style><body><svg>...</svg></body></html>` shell.
This is a STANDALONE Python reimplementation of the visual language used by
autoACI's Vue+cytoscape topology view (frontend/src/composables/
useTopologyStyles.js + backend/routers/topology.py) — same role->color
palette and tiered spine/leaf/border-leaf/controller hierarchy with an ISN
cloud between sites — but it imports nothing from autoACI and does not
touch this repo's schema or builders. It only reads the already-validated
`Topology` model (topology/schema.py) and re-derives the same spine<->leaf
mesh build/cabling.py's `cabling_links()` produces.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from xml.sax.saxutils import escape
from aci_sim.build.cabling import cabling_links
from aci_sim.topology.schema import Site, Topology
# ---------------------------------------------------------------------------
# Role -> color palette, matched to autoACI's useTopologyStyles.js
# ---------------------------------------------------------------------------
ROLE_COLORS: dict[str, tuple[str, str]] = {
# role: (fill, border) — matches autoACI's cytoscape stylesheet exactly.
"spine": ("#2563eb", "#1d4ed8"),
"leaf": ("#16a34a", "#15803d"),
"border-leaf": ("#16a34a", "#15803d"), # same green family as leaf in autoACI (role="leaf")
"controller": ("#d97706", "#b45309"),
"isn": ("#475569", "#334155"),
}
LEGEND_ORDER = ["spine", "leaf", "border-leaf", "controller", "isn"]
LEGEND_LABELS = {
"spine": "Spine",
"leaf": "Leaf",
"border-leaf": "Border Leaf",
"controller": "Controller (APIC)",
"isn": "ISN Cloud",
}
FONT = "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif"
NODE_W = 96
NODE_H = 40
H_GAP = 130 # horizontal spacing between nodes in the same tier
V_GAP = 130 # vertical spacing between tiers
SITE_GAP = 220 # extra horizontal gap between sites
MARGIN = 60
LEGEND_H = 46
TITLE_H = 56
@dataclass
class GNode:
id: str
label: str
role: str
site: str | None
x: float = 0.0
y: float = 0.0
@dataclass
class GLink:
source: str
target: str
kind: str = "cabling" # cabling | isn | vpc
@dataclass
class Graph:
nodes: list[GNode] = field(default_factory=list)
links: list[GLink] = field(default_factory=list)
fabric_name: str = ""
site_count: int = 0
def node_count(self) -> int:
return len(self.nodes)
# ---------------------------------------------------------------------------
# Graph construction (pure data — no rendering here)
# ---------------------------------------------------------------------------
def _site_nodes(site: Site) -> tuple[list[GNode], list[GNode], list[GNode], list[GNode]]:
"""Return (spines, leaves, border_leaves, controllers) as GNode lists for a site."""
spines = [
GNode(id=f"n{n.id}", label=f"{n.name}\n({n.id})", role="spine", site=site.name)
for n in site.spine_nodes()
]
leaves = [
GNode(id=f"n{n.id}", label=f"{n.name}\n({n.id})", role="leaf", site=site.name)
for n in site.leaf_nodes()
]
border_leaves = [
GNode(id=f"n{bl.id}", label=f"{bl.name}\n({bl.id})", role="border-leaf", site=site.name)
for bl in site.border_leaves
]
controllers = [
GNode(
id=f"ctrl-{site.name}-{i + 1}",
label=f"APIC{i + 1}\n{site.name}",
role="controller",
site=site.name,
)
for i in range(site.controllers)
]
return spines, leaves, border_leaves, controllers
def build_graph(topo: Topology) -> Graph:
"""Derive the node/link graph from a validated Topology.
Node IDs use the fabric's real node id (`n{id}`) for spines/leaves/
border-leaves, so cabling links (which are keyed by real node id) join up
directly; controllers get a synthetic id since they have no fabricLink.
"""
graph = Graph(fabric_name=topo.fabric.name, site_count=len(topo.sites))
isn_enabled = topo.isn.enabled and len(topo.sites) > 1
isn_node = GNode(id="isn-cloud", label="ISN", role="isn", site=None) if isn_enabled else None
for site in topo.sites:
spines, leaves, border_leaves, controllers = _site_nodes(site)
graph.nodes.extend(spines + leaves + border_leaves + controllers)
# Spine<->leaf/border-leaf cabling mesh — reuse the exact same
# derivation build/cabling.py's fabricLink builder uses, so the
# diagram always matches the built fabric's real cabling graph.
for n1, _s1, _p1, n2, _s2, _p2 in cabling_links(site):
graph.links.append(GLink(source=f"n{n1}", target=f"n{n2}", kind="cabling"))
# vPC pairs: border leaves sharing vpc_domain get a dashed peer-link
# for visual grouping (mirrors autoACI's edge.vpc-link).
by_domain: dict[str, list[int]] = {}
for bl in site.border_leaves:
by_domain.setdefault(bl.vpc_domain, []).append(bl.id)
for ids in by_domain.values():
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
graph.links.append(GLink(source=f"n{ids[i]}", target=f"n{ids[j]}", kind="vpc"))
# Spine -> ISN cloud uplinks (autoACI: role="isn" node with spine
# source links, backend/routers/topology.py isn_connections).
if isn_node is not None:
for spine in spines:
graph.links.append(GLink(source=spine.id, target=isn_node.id, kind="isn"))
if isn_node is not None:
graph.nodes.append(isn_node)
_layout(graph, topo)
return graph
def _layout(graph: Graph, topo: Topology) -> None:
"""Hand-computed tiered ACI hierarchy layout (mirrors autoACI's preset layout).
Tiers (top to bottom): ISN cloud (multi-site only) -> spine -> leaf ->
border-leaf -> controller. Sites are spread side by side; each tier's
nodes are centered within their site's horizontal band.
"""
site_names = [s.name for s in topo.sites]
multi_site = len(site_names) > 1
by_site: dict[str, dict[str, list[GNode]]] = {
name: {"spine": [], "leaf": [], "border-leaf": [], "controller": []} for name in site_names
}
isn_node = None
for n in graph.nodes:
if n.role == "isn":
isn_node = n
continue
by_site[n.site][n.role].append(n)
def place_row(nodes: list[GNode], site_x: float, y: float) -> None:
if not nodes:
return
width = (len(nodes) - 1) * H_GAP
start_x = site_x - width / 2
for i, n in enumerate(nodes):
n.x = start_x + i * H_GAP
n.y = y
y_isn = 0.0
y_spine = V_GAP * 1 if not multi_site else V_GAP * 1.6
y_leaf = y_spine + V_GAP
y_border = y_leaf + V_GAP
y_ctrl = y_border + V_GAP
for idx, name in enumerate(site_names):
site_x = idx * SITE_GAP + idx * H_GAP * 2 # extra spread accounts for wide tiers
tiers = by_site[name]
# Recompute site_x based on the widest tier so sites don't overlap.
place_row(tiers["spine"], site_x, y_spine)
place_row(tiers["leaf"], site_x, y_leaf)
place_row(tiers["border-leaf"], site_x, y_border)
place_row(tiers["controller"], site_x, y_ctrl)
# Second pass: re-center each site block using the actual widest tier so
# multi-site layouts don't visually collide when leaf counts differ.
site_extents: dict[str, tuple[float, float]] = {}
for name in site_names:
tiers = by_site[name]
xs = [n.x for row in tiers.values() for n in row]
if xs:
site_extents[name] = (min(xs) - NODE_W, max(xs) + NODE_W)
else:
site_extents[name] = (0.0, 0.0)
cursor = 0.0
for name in site_names:
lo, hi = site_extents[name]
shift = cursor - lo
for row in by_site[name].values():
for n in row:
n.x += shift
cursor = hi + shift + SITE_GAP
if isn_node is not None:
all_x = [n.x for name in site_names for row in by_site[name].values() for n in row]
isn_node.x = (min(all_x) + max(all_x)) / 2 if all_x else 0.0
isn_node.y = y_isn
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _wrap_label(label: str) -> list[str]:
return label.split("\n")
def _node_svg(n: GNode) -> str:
fill, border = ROLE_COLORS.get(n.role, ("#64748b", "#334155"))
x = n.x - NODE_W / 2
y = n.y - NODE_H / 2
lines = _wrap_label(n.label)
text_parts = []
line_h = 13
start_y = n.y - (len(lines) - 1) * line_h / 2
for i, line in enumerate(lines):
text_parts.append(
f'<text x="{n.x:.1f}" y="{start_y + i * line_h:.1f}" text-anchor="middle" '
f'dominant-baseline="middle" class="node-label">{escape(line)}</text>'
)
return (
f'<g class="node node-{n.role}" data-id="{escape(n.id)}">'
f'<rect x="{x:.1f}" y="{y:.1f}" width="{NODE_W}" height="{NODE_H}" rx="8" ry="8" '
f'fill="{fill}" stroke="{border}" stroke-width="1.5" class="node-shadow"/>'
+ "".join(text_parts)
+ "</g>"
)
def _link_svg(link: GLink, pos: dict[str, GNode]) -> str:
a = pos.get(link.source)
b = pos.get(link.target)
if a is None or b is None:
return ""
cls = {
"cabling": "link-cabling",
"isn": "link-isn",
"vpc": "link-vpc",
}.get(link.kind, "link-cabling")
return f'<line x1="{a.x:.1f}" y1="{a.y:.1f}" x2="{b.x:.1f}" y2="{b.y:.1f}" class="{cls}"/>'
def _legend_svg(x: float, y: float, roles_present: list[str]) -> str:
parts = [f'<g class="legend" transform="translate({x:.1f},{y:.1f})">']
swatch = 14
gap = 150
for i, role in enumerate(roles_present):
fill, border = ROLE_COLORS[role]
lx = i * gap
parts.append(
f'<rect x="{lx}" y="0" width="{swatch}" height="{swatch}" rx="3" ry="3" '
f'fill="{fill}" stroke="{border}" stroke-width="1"/>'
f'<text x="{lx + swatch + 6}" y="{swatch - 2}" class="legend-label">{escape(LEGEND_LABELS[role])}</text>'
)
parts.append("</g>")
return "".join(parts)
def _svg_style() -> str:
return (
"<style>"
f".node-label {{ font-family: {FONT}; font-size: 9px; font-weight: 600; fill: #ffffff; }}"
f".legend-label {{ font-family: {FONT}; font-size: 11px; fill: #334155; }}"
f".title-text {{ font-family: {FONT}; font-size: 16px; font-weight: 700; fill: #0f172a; }}"
f".subtitle-text {{ font-family: {FONT}; font-size: 11px; fill: #64748b; }}"
".node-shadow { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.25)); }"
".link-cabling { stroke: #d1d5db; stroke-width: 1.5; opacity: 0.8; }"
".link-isn { stroke: #f59e0b; stroke-width: 1.5; stroke-dasharray: 5,4; opacity: 0.85; }"
".link-vpc { stroke: #94a3b8; stroke-width: 1.5; stroke-dasharray: 2,3; opacity: 0.7; }"
".site-label { font-family: " + FONT + "; font-size: 12px; font-weight: 600; fill: #64748b; }"
"</style>"
)
def _svg_body(graph: Graph, topo: Topology) -> tuple[str, int, int]:
pos = {n.id: n for n in graph.nodes}
if graph.nodes:
min_x = min(n.x for n in graph.nodes) - NODE_W
max_x = max(n.x for n in graph.nodes) + NODE_W
min_y = min(n.y for n in graph.nodes) - NODE_H
max_y = max(n.y for n in graph.nodes) + NODE_H
else:
min_x = min_y = 0.0
max_x = max_y = 0.0
width = int(max_x - min_x + 2 * MARGIN)
height = int(max_y - min_y + 2 * MARGIN + TITLE_H + LEGEND_H)
# Shift everything so the drawing starts at (MARGIN, MARGIN + TITLE_H).
offset_x = MARGIN - min_x
offset_y = MARGIN + TITLE_H - min_y
for n in graph.nodes:
n.x += offset_x
n.y += offset_y
roles_present = [r for r in LEGEND_ORDER if any(n.role == r for n in graph.nodes)]
links_svg = "".join(_link_svg(link, pos) for link in graph.links)
nodes_svg = "".join(_node_svg(n) for n in graph.nodes)
node_count = graph.node_count()
subtitle = f"{graph.site_count} site(s), {node_count} node(s)"
title_svg = (
f'<text x="{MARGIN}" y="28" class="title-text">{escape(graph.fabric_name)} — Topology</text>'
f'<text x="{MARGIN}" y="46" class="subtitle-text">{escape(subtitle)}</text>'
)
legend_y = height - LEGEND_H + 16
legend_svg = _legend_svg(MARGIN, legend_y, roles_present)
body = (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
f'width="{width}" height="{height}" font-family="{FONT}">'
+ _svg_style()
+ f'<rect x="0" y="0" width="{width}" height="{height}" fill="#f8fafc"/>'
+ title_svg
+ links_svg
+ nodes_svg
+ legend_svg
+ "</svg>"
)
return body, width, height
def render_topology(topo: Topology, fmt: str = "html") -> str:
"""Render *topo* as a self-contained SVG or HTML string.
fmt="svg" -> raw `<svg>...</svg>` markup.
fmt="html" -> `<html><body><svg>...</svg></body></html>` with inline CSS.
No network access, no external assets — the returned string is fully
self-contained and safe to open directly in any browser.
"""
graph = build_graph(topo)
svg, width, _height = _svg_body(graph, topo)
if fmt == "svg":
return svg
if fmt != "html":
raise ValueError(f"Unsupported format {fmt!r}; expected 'svg' or 'html'")
title = escape(f"{topo.fabric.name} — Topology")
return (
f'<html lang="en"><head><meta charset="utf-8"/><title>{title}</title>'
"<style>"
"body { margin: 0; padding: 24px; background: #eef2f7; "
"font-family: -apple-system, 'Segoe UI', system-ui, sans-serif; }"
f"svg {{ display: block; margin: 0 auto; max-width: 100%; height: auto; background: #f8fafc; "
"border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }"
"</style>"
f"</head><body>{svg}</body></html>"
)
View File
+115
View File
@@ -0,0 +1,115 @@
"""Bracket-aware DN utilities.
ACI Distinguished Names are slash-separated, but path components inside
square brackets must never be split on the slash. Examples:
uni/tn-T/BD-b/subnet-[10.0.0.1/24]
uni/tn-T/pathep-[eth1/9]
uni/phys-X/rsdomAtt-[uni/phys-X]
uni/infra/.../rslldpIfAtt-[pathep-[eth1/9]] (nested brackets)
"""
from __future__ import annotations
import re
def dn_split(dn: str) -> list[str]:
"""Split a DN into RN components, never splitting inside [...].
Returns a list of RN strings; returns [] for an empty DN.
"""
parts: list[str] = []
depth = 0
current: list[str] = []
for ch in dn:
if ch == "[":
depth += 1
current.append(ch)
elif ch == "]":
depth -= 1
current.append(ch)
elif ch == "/" and depth == 0:
parts.append("".join(current))
current = []
else:
current.append(ch)
if current:
parts.append("".join(current))
return parts
def parent_dn(dn: str) -> str:
"""Return the parent DN (everything except the last RN).
Returns "" for top-level DNs (no parent).
"""
parts = dn_split(dn)
if len(parts) <= 1:
return ""
return "/".join(parts[:-1])
def rn_of(dn: str) -> str:
"""Return the last RN of a DN."""
parts = dn_split(dn)
return parts[-1] if parts else ""
def name_from_dn(dn: str) -> str:
"""Extract the name from the last RN (part after the first '-').
For bracket RNs like `subnet-[10.0.0.1/24]`, returns `10.0.0.1/24`.
For plain RNs like `tn-T`, returns `T`.
For RNs with no dash (e.g. `uni`), returns the whole RN.
"""
rn = rn_of(dn)
# Bracket form: foo-[...] → extract content of innermost [...] span
m = re.match(r"[^-]+-\[(.+)\]$", rn)
if m:
return m.group(1)
# Plain dash: foo-bar → bar
idx = rn.find("-")
if idx >= 0:
return rn[idx + 1:]
return rn
def pod_from_dn(dn: str) -> str:
"""Extract the pod number from a DN.
Searches for `/pod-N` in the DN; returns "1" as the default when absent.
"""
m = re.search(r"/pod-(\d+)(?:/|$)", dn)
if m:
return m.group(1)
# Also handle DN that starts with "pod-N"
m2 = re.match(r"pod-(\d+)(?:/|$)", dn)
if m2:
return m2.group(1)
return "1"
def dn_class_hint(rn: str) -> str | None:
"""Guess the ACI class from an RN prefix (best-effort).
Used by the REST layer to build default class names from path components.
Returns None if the prefix is not recognised.
"""
_PREFIX_MAP: dict[str, str] = {
"uni": "polUni",
"tn-": "fvTenant",
"ctx-": "fvCtx",
"BD-": "fvBD",
"subnet-": "fvSubnet",
"ap-": "fvAp",
"epg-": "fvAEPg",
"cep-": "fvCEp",
"node-": "fabricNode",
"sys": "topSystem",
"health": "healthInst",
"fault-": "faultInst",
"lnk-": "fabricLink",
}
for prefix, cls in _PREFIX_MAP.items():
if rn == prefix or (prefix.endswith("-") and rn.startswith(prefix)):
return cls
return None
+50
View File
@@ -0,0 +1,50 @@
"""MO (Managed Object) — building block of the ACI Managed Information Tree."""
from __future__ import annotations
from typing import Any
class MO:
"""A single node in the ACI MIT.
Attributes
----------
class_name : str e.g. ``"fvTenant"``
attrs : dict[str, str] must include ``"dn"``
children : list[MO] direct children in this MO's subtree
"""
def __init__(self, class_name: str, **attrs: str) -> None:
self.class_name = class_name
self.attrs: dict[str, str] = dict(attrs)
self.children: list[MO] = []
@property
def dn(self) -> str:
return self.attrs.get("dn", "")
def add_child(self, mo: MO) -> None:
"""Append *mo* to this MO's children list."""
self.children.append(mo)
def flat(self) -> list[MO]:
"""Return self + all descendants in depth-first order."""
result: list[MO] = [self]
for child in self.children:
result.extend(child.flat())
return result
def to_imdata(self) -> dict[str, Any]:
"""Produce the APIC wire shape for this MO.
``{"class_name": {"attributes": {...}, "children": [...]}}``
The ``children`` key is omitted when the MO has no children.
"""
body: dict[str, Any] = {"attributes": dict(self.attrs)}
if self.children:
body["children"] = [c.to_imdata() for c in self.children]
return {self.class_name: body}
def __repr__(self) -> str:
return f"MO({self.class_name!r}, dn={self.dn!r})"
+174
View File
@@ -0,0 +1,174 @@
"""MITStore — DN-keyed in-memory object store for ACI MOs."""
from __future__ import annotations
import copy
from aci_sim.mit.mo import MO
from aci_sim.mit.dn import parent_dn
class MITStore:
"""Maintains a ``dn -> MO`` map and a ``parent_dn -> [child dn]`` index.
Iteration order is deterministic (insertion order of DNs).
The store is deep-copyable for baseline/snapshot workflows.
"""
def __init__(self) -> None:
self._mos: dict[str, MO] = {} # dn → MO
self._children: dict[str, list[str]] = {} # parent_dn → [child dn]
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _register(self, dn: str) -> None:
"""Register *dn* AND ensure its full ancestor chain is linked (idempotent).
Walking the whole chain (not just the immediate parent) lets :meth:`subtree`
traverse through structural intermediates that were never added as MOs — e.g.
a deep ``epmMacEp`` at ``…/sys/ctx-[…]/bd-[…]/db-ep/mac-…`` whose ctx/bd/db-ep
containers have no MO of their own. Without this, ``subtree(node/sys)`` could
not reach it.
"""
self._children.setdefault(dn, [])
cur = dn
while True:
pdn = parent_dn(cur)
if not pdn or pdn == cur:
break
siblings = self._children.setdefault(pdn, [])
if cur not in siblings:
siblings.append(cur)
cur = pdn
def _remove_subtree(self, dn: str) -> None:
"""Remove *dn* and all its descendants from both maps."""
for child_dn in list(self._children.get(dn, [])):
self._remove_subtree(child_dn)
self._mos.pop(dn, None)
self._children.pop(dn, None)
pdn = parent_dn(dn)
siblings = self._children.get(pdn)
if siblings is not None:
try:
siblings.remove(dn)
except ValueError:
pass
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def add(self, mo: MO) -> None:
"""Insert *mo* (and recursively its ``.children``) without merging.
Stores a *childless* copy — hierarchy lives only in the parent index
(same invariant as :meth:`upsert`). Otherwise a stored tree MO would
carry a second copy of its subtree via ``MO.to_imdata()``, leaking
``children`` onto non-subtree queries and duplicating descendants on
subtree queries.
"""
dn = mo.dn
self._mos[dn] = MO(mo.class_name, **mo.attrs)
self._register(dn)
for child in mo.children:
self.add(child)
def upsert(self, mo: MO) -> None:
"""Merge *mo*'s attrs into an existing entry or insert it.
Recurses into ``mo.children``.
If ``mo.attrs["status"] == "deleted"``, removes the DN and its entire
subtree instead of merging.
"""
dn = mo.dn
if mo.attrs.get("status") == "deleted":
self._remove_subtree(dn)
return
existing = self._mos.get(dn)
if existing is None:
new_mo = MO(mo.class_name, **mo.attrs)
self._mos[dn] = new_mo
self._register(dn)
else:
existing.attrs.update(mo.attrs)
for child in mo.children:
self.upsert(child)
def get(self, dn: str) -> MO | None:
"""Return the MO at *dn*, or ``None`` if not present."""
return self._mos.get(dn)
def delete(self, dn: str) -> None:
"""Remove *dn* and its entire subtree from the store.
Idempotent: deleting a *dn* that is not present (or was already
removed) is a silent no-op, matching ``upsert``'s ``status="deleted"``
branch (also unconditional — no existence check) and real APIC's
DELETE /api/mo/{dn}.json used by cisco.aci's ``state=absent`` path
(PR-9). Public wrapper around the same ``_remove_subtree`` helper
``upsert`` already uses, so callers outside this module (the DELETE
HTTP route) don't need to reach into a private method.
"""
self._remove_subtree(dn)
def children(self, dn: str, classes: set[str] | None = None) -> list[MO]:
"""Return direct children of *dn*, optionally filtered by class names."""
result: list[MO] = []
for cdn in self._children.get(dn, []):
mo = self._mos.get(cdn)
if mo is None:
continue
if classes is None or mo.class_name in classes:
result.append(mo)
return result
def child_dns(self, dn: str) -> list[str]:
"""Return the raw child-DN list of *dn* in insertion order.
Includes structural intermediates that have no MO of their own (see
:meth:`_register`). Used by the query engine to reconstruct the nested
subtree for ``rsp-subtree=full`` without flattening the hierarchy.
"""
return list(self._children.get(dn, []))
def subtree(self, dn: str, classes: set[str] | None = None) -> list[MO]:
"""Return all descendants of *dn* (not *dn* itself), depth-first.
If *classes* is given, only include MOs whose class_name is in the set;
but always recurse into ALL children regardless (mirrors APIC behaviour
where a class filter on rsp-subtree-class traverses the full tree).
"""
acc: list[MO] = []
self._collect_subtree(dn, classes, acc)
return acc
def _collect_subtree(self, dn: str, classes: set[str] | None, acc: list[MO]) -> None:
for cdn in self._children.get(dn, []):
mo = self._mos.get(cdn)
if mo is not None and (classes is None or mo.class_name in classes):
acc.append(mo)
# Recurse even through MO-less structural intermediates so deeply
# nested descendants (e.g. epm* under uninstantiated containers) are reached.
self._collect_subtree(cdn, classes, acc)
def by_class(self, cls: str) -> list[MO]:
"""Return all MOs whose ``class_name == cls``, in insertion order."""
return [mo for mo in self._mos.values() if mo.class_name == cls]
def all(self) -> list[MO]:
"""Return all MOs in insertion order."""
return list(self._mos.values())
# ------------------------------------------------------------------
# Deep-copy support (for baseline / snapshot)
# ------------------------------------------------------------------
def __deepcopy__(self, memo: dict) -> MITStore:
new = MITStore.__new__(MITStore)
new._mos = copy.deepcopy(self._mos, memo)
new._children = copy.deepcopy(self._children, memo)
return new
View File
+785
View File
@@ -0,0 +1,785 @@
"""
NDO FastAPI application — Phase 6.
`make_ndo_app(state)` returns a FastAPI app that implements every §7 endpoint
from CONTRACT.md. Bearer auth is lenient: tokens are issued on login but
never validated on subsequent requests (accept present-or-absent).
"""
from __future__ import annotations
import hashlib
import uuid
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from aci_sim.control.persist import apply_ndo, load_json, save_json, serialize_ndo, state_dir
from .deploy_mirror import mirror_template_to_sites
from .model import NdoState
from .patch import PatchError, apply_json_patch, normalize_site, normalize_template
def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) -> FastAPI:
"""Build and return the NDO FastAPI application backed by *state*.
*apic_states* (optional — defaults to ``None``, keeping every existing
single-arg call site valid) maps site_id -> ``ApicSiteState``. When
provided, ``POST /mso/api/v1/task`` (deploy/undeploy) mirrors the
deployed template's VRFs/BDs/ANPs/EPGs into the target sites' APIC
MITStores via ``deploy_mirror.mirror_template_to_sites`` — see that
module's docstring for the SIM GAP this closes. Typed as a plain
``dict[str, Any]`` (not ``dict[str, ApicSiteState]``) to avoid importing
``aci_sim.rest_aci.app`` here, which would risk a circular import
(that module doesn't currently import ``ndo.app``, but the two live in
sibling packages wired together only by ``runtime/supervisor.py`` — this
keeps ``ndo/app.py`` decoupled from the APIC app module's import graph).
"""
app = FastAPI(title="aci-sim NDO", version="4.2.2")
# ------------------------------------------------------------------
# Authentication
# ------------------------------------------------------------------
@app.post("/api/v1/auth/login")
async def login(request: Request):
"""Accept any credentials and return a bearer token."""
body = await request.json()
token = f"sim-ndo-{uuid.uuid4().hex[:16]}"
return {
"token": token,
"userName": body.get("userName", "admin"),
"domain": body.get("domain", "local"),
}
@app.post("/login")
async def nd_bare_login(request: Request):
"""Classic Nexus Dashboard bare-login endpoint.
Real ND serves ``POST /login`` (body ``{userName, userPasswd, domain}``)
alongside the newer ``/api/v1/auth/login``. Clients that use the ND
platform login directly (aci-py's NDO connector, cisco.nd httpapi's
session bookkeeping) POST here and read the JWT from ``token`` /
``jwttoken``.
"""
body = await request.json()
token = f"sim-ndo-{uuid.uuid4().hex[:16]}"
return {
"token": token,
"jwttoken": token,
"userName": body.get("userName", "admin"),
"domain": body.get("domain", "local"),
}
@app.post("/logout")
async def nd_bare_logout():
"""Classic ND bare-logout — mirrors POST /login."""
return {"status": "ok"}
# ------------------------------------------------------------------
# Platform version
# ------------------------------------------------------------------
@app.get("/api/v1/platform/version")
async def platform_version():
# Dotted form per CONTRACT.md §7 — matches real Nexus Dashboard;
# autoACI's ndo_connector.py treats this as an opaque passthrough string.
return {"version": "4.2.2"}
# ------------------------------------------------------------------
# Sites
# ------------------------------------------------------------------
@app.get("/mso/api/v1/sites/fabric-connectivity")
async def get_fabric_connectivity():
"""Per-site connectivity status.
Must be declared BEFORE the generic /{site_id} route (if any)
so FastAPI's literal-first match picks it up correctly.
"""
return state.fabric_connectivity
@app.get("/mso/api/v1/sites")
async def get_sites():
return {"sites": state.sites}
# ------------------------------------------------------------------
# Tenants
# ------------------------------------------------------------------
@app.get("/mso/api/v1/tenants")
async def get_tenants():
return {"tenants": state.tenants}
# PR-10: cisco.mso.ndo_template's `lookup_tenant()` prereq lookup (used
# by TenantPol tenant-policy templates) resolves through this bare
# /api/v1/tenants path in addition to the /mso-prefixed form above —
# backs both with the same tenant list so a caller using either shape
# sees identical data (see docs/CONTRACT.md §7).
@app.get("/api/v1/tenants")
async def get_tenants_bare():
return {"tenants": state.tenants}
def _find_tenant(tenant_id: str) -> dict | None:
for t in state.tenants:
if t["id"] == tenant_id:
return t
return None
@app.put("/mso/api/v1/tenants/{tenant_id}")
async def update_tenant(tenant_id: str, request: Request):
"""Update an existing tenant in place — cisco.mso.mso_tenant's
"tenant already exists" branch (PR-11).
Every tenant this sim seeds comes straight from the topology (see
build_ndo_model), so a real E2E run's `mso_tenant` task always finds
the tenant already present via `GET /mso/api/v1/tenants` and takes
this PUT-to-update path rather than POST-to-create — confirmed on a
real hardware run: `PUT https://.../mso/api/v1/tenants/{id}` → sim 404
(route didn't exist) → "MSO Error:". Accept the module's full
payload (description/displayName/siteAssociations/userAssociations)
and merge it into the stored tenant dict, then echo it back per
MSOModule.request()'s 200/201/202 "parse and return the body" path.
"""
tenant = _find_tenant(tenant_id)
if tenant is None:
raise HTTPException(
status_code=404, detail=f"Tenant '{tenant_id}' not found"
)
body = await request.json()
tenant.update(body)
tenant["id"] = tenant_id
return tenant
@app.post("/mso/api/v1/tenants")
async def create_tenant(request: Request):
"""Create a brand-new tenant not already in the seeded topology."""
body = await request.json()
new_id = body.get("id") or f"tenant-{uuid.uuid4().hex[:16]}"
tenant = dict(body)
tenant["id"] = new_id
tenant.setdefault("siteAssociations", [])
state.tenants.append(tenant)
return tenant
# PR-11: `cisco.mso.ndo_template`'s prereq lookup for TenantPol templates
# (`prereq_tenantpol.yml`'s `ndo_template` task) resolves sites through
# this BARE path too — confirmed against a real hardware run that hit
# `GET https://.../api/v1/sites` (no `/mso` prefix) → 404. Same data as
# the canonical `/mso/api/v1/sites` route above.
@app.get("/api/v1/sites")
async def get_sites_bare():
return {"sites": state.sites}
# ------------------------------------------------------------------
# ND-platform user class queries — PR-11
# ------------------------------------------------------------------
# cisco.mso's MSOModule.lookup_users()/lookup_remote_users() query the
# modern `/nexus/infra/api/aaa/v4/{local,remote}users` endpoints first
# (ignore_not_found_error=True) and, only when BOTH come back as an empty
# dict, fall back to these legacy `/api/config/class/*` routes — see
# ansible-mso plugins/module_utils/mso.py. The sim doesn't implement the
# v4 aaa routes, so httpapi's connection plugin treats their absence as
# "not found" → empty dict → the fallback below is exactly what's hit.
# A real ACI-hardware E2E run confirmed the failure signature: GET
# /api/config/class/remoteusers → sim 404 {"detail":"Not Found"} → nd_request()
# has no "code"/"messages" in that body → "ND Error: Unknown error no
# error code in decoded payload", aborting mso_tenant's Create a tenant task.
@app.get("/api/config/class/remoteusers")
async def get_remote_users_class():
return state.remote_users
@app.get("/api/config/class/localusers")
async def get_local_users_class():
return state.local_users
# ------------------------------------------------------------------
# Schemas — list and detail
# ------------------------------------------------------------------
@app.get("/mso/api/v1/schemas")
async def get_schemas():
"""Return the FULL schema detail list (not the `list-identity`
lightweight summary below).
PR-13: real NDO's plain `GET /schemas` returns every schema's
complete nested doc (`templates[].{bds,anps,serviceGraphs,...}`,
top-level `sites[]`) — this is precisely why `schemas/list-identity`
exists as a separate, lighter-weight enumeration endpoint (per its
own PR-10 docstring below: callers that only need id/displayName
use that one instead of paying for the full payload here). Several
OLDER `cisco.mso` community modules that predate the `MSOTemplate`/
`MSOSchema` module_utils classes — e.g. `mso-model`'s
`custom_mso_schema_service_graph.py` (`mso.get_obj('schemas',
displayName=schema)`) — bare-subscript straight into this response
(`schema_obj.get('templates')[idx]['serviceGraphs']`,
`schema_obj.get('sites')[idx]['serviceGraphs']`), so a trimmed
`{name}`-only projection here would KeyError/IndexError on those
exact modules even with every other collection-default fix in
place; a real hardware MS-TN2 `create_tenant` pass-2 run confirmed
this class of failure (`KeyError: 'serviceGraphs'`).
"""
all_ids = [s["id"] for s in state.schemas] + [s["id"] for s in state.extra_schemas]
full = [state.schema_details[sid] for sid in all_ids if sid in state.schema_details]
return {"schemas": full}
# PR-10: cisco.mso's MSOModule.lookup_schema() calls this lightweight
# enumeration endpoint FIRST to resolve a schema displayName -> id
# (verified against ansible-mso plugins/module_utils/mso.py:
# `self.query_objs("schemas/list-identity", key="schemas", displayName=schema)`,
# which reads json["schemas"][] entries with at least id + displayName).
# CRITICAL ORDERING: this MUST be declared before the parameterized
# GET /mso/api/v1/schemas/{schema_id} route below, or FastAPI/Starlette
# matches "list-identity" as a schema_id path param first (that bug was
# observed as a 404 body {"detail":"Schema 'list-identity' not found"}).
# Same store as GET /mso/api/v1/schemas — every schema this sim knows
# about (seeded + runtime-POSTed) is enumerable here too.
@app.get("/mso/api/v1/schemas/list-identity")
async def get_schemas_list_identity():
all_schemas = list(state.schemas) + list(state.extra_schemas)
return {
"schemas": [
{"id": s["id"], "displayName": s.get("displayName", s.get("name", ""))}
for s in all_schemas
]
}
# PR-13: `custom_mso_schema_service_graph.py` (mso-model role's "Create
# service graph" task) resolves a service node's display type
# (Firewall/Load Balancer/Other) to a stable id via
# `mso.get_obj('schemas/service-node-types', key='serviceNodeTypes',
# displayName=node_type)` before adding the service-graph node. Same
# routing-order requirement as `list-identity` above: this literal path
# segment MUST be declared before the parameterized `/schemas/{schema_id}`
# route below or FastAPI/Starlette matches "service-node-types" as a
# schema id instead (observed as a 404 body {"detail":"Schema
# 'service-node-types' not found"}).
@app.get("/mso/api/v1/schemas/service-node-types")
@app.get("/api/v1/schemas/service-node-types")
async def get_service_node_types():
return {
"serviceNodeTypes": [
{"id": "svc-node-type-firewall", "displayName": "Firewall"},
{"id": "svc-node-type-adc", "displayName": "Load Balancer"},
{"id": "svc-node-type-other", "displayName": "Other"},
]
}
@app.get("/mso/api/v1/schemas/{schema_id}/policy-states")
async def get_policy_states(schema_id: str):
"""Per-schema deployment / drift state.
Declared before the bare `/{schema_id}` route so FastAPI routes
`/schemas/X/policy-states` here rather than treating `policy-states`
as part of the schema id.
"""
ps = state.policy_states.get(schema_id)
if ps is None:
# Caller may have POSTed a schema whose id we stored in policy_states;
# fall back to a healthy default rather than 404.
ps = [{"status": "synced", "drift": False}]
return {"policyStates": ps}
# PR-11: both `cisco.mso.mso_schema_template_deploy` (legacy) and
# `cisco.mso.ndo_schema_template_deploy` (the one aci-ansible's mso-model
# role actually calls, confirmed against the collection installed on
# real ACI hardware) call `mso.validate_schema(schema_id)` — `GET
# schemas/{id}/validate` — before every deploy/redeploy. The return
# value is discarded by the caller, only the status code matters; a real
# hardware run 404'd here because the route didn't exist at all. Declared
# before the bare `/{schema_id}` route for the same routing-order reason
# as policy-states above.
@app.get("/mso/api/v1/schemas/{schema_id}/validate")
async def validate_schema(schema_id: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"errors": [], "warnings": []}
@app.get("/mso/api/v1/schemas/{schema_id}")
async def get_schema(schema_id: str):
"""Full schema detail — templates with VRFs/BDs/EPGs/contracts/filters."""
detail = state.schema_details.get(schema_id)
if detail is None:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return detail
# PR-11: legacy `mso_schema_template_deploy`'s deploy/undeploy request —
# `GET execute/schema/{id}/template/{name}` (deploy/undeploy) or
# `GET status/schema/{id}/template/{name}` (state=status). The response
# is splatted straight into `mso.exit_json(**status)`, so it must be a
# JSON object (dict), not a list/scalar. Kept alongside the POST /task
# route below since both modules ship in the same collection and either
# could be used by a given playbook.
@app.get("/mso/api/v1/execute/schema/{schema_id}/template/{template_name}")
async def execute_schema_template(schema_id: str, template_name: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"status": "success", "schemaId": schema_id, "templateName": template_name}
@app.get("/mso/api/v1/status/schema/{schema_id}/template/{template_name}")
async def status_schema_template(schema_id: str, template_name: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"status": "success", "schemaId": schema_id, "templateName": template_name}
# PR-11: `cisco.mso.ndo_schema_template_deploy` — the module aci-ansible's
# mso-model/tasks/template_deploy.yml role actually invokes (confirmed
# against the collection installed on real ACI hardware, which differs slightly from
# the legacy mso_schema_template_deploy.py) — sends deploy/redeploy/
# undeploy as `POST /mso/api/v1/task` with body
# `{"schemaId":...,"templateName":...,"isRedeploy":bool}` (or `undeploy:
# [siteId,...]`), and `state=query` as `GET status/schema/{id}/template/
# {name}` (same route as the legacy module's status query above). A real
# hardware run 404'd on `POST .../mso/api/v1/task` — the route didn't exist.
@app.post("/mso/api/v1/task")
async def post_task(request: Request):
body = await request.json()
schema_id = body.get("schemaId")
if schema_id is not None and schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
task_id = f"task-{uuid.uuid4().hex[:12]}"
template_name = body.get("templateName")
# Deploy mirror (confirmed SIM GAP): `ndo_schema_template_deploy`
# sends `undeploy: [siteId, ...]` (a non-empty list) to tear a
# template down from those sites; every other shape (deploy /
# `isRedeploy: true` redeploy) is a create/refresh. `apic_states`
# is None in every pre-existing test/call site (default arg), so
# this stays a strict no-op there — unchanged behavior.
if apic_states is not None and template_name:
undeploy = bool(body.get("undeploy"))
mirror_template_to_sites(
state, template_name, apic_states, schema_id=schema_id, undeploy=undeploy,
)
return {
"id": task_id,
"schemaId": schema_id,
"templateName": template_name,
"status": "success",
}
# ------------------------------------------------------------------
# Template summaries and detail — PR-13 generalized template store
# ------------------------------------------------------------------
# `cisco.mso.ndo_template`'s CREATE flow (`prereq_tenantpol.yml`'s
# `Create TenantPol-<tenant> tenant policy template` task) needs a real
# POST + PATCH round trip against a persistent template store, not just
# the single fixed tenantPolicy template PR-10/PR-11 seeded. And because
# that task carries `delegate_to: localhost` while every OTHER
# `cisco.mso`/`ndo_*` task in the same playbook run does not, the two
# code paths hit DIFFERENT URL shapes for the identical logical
# endpoint:
# - `delegate_to: localhost` tasks build `MSOModule` with no
# persistent httpapi connection (`module._socket_path is None`), so
# `MSOModule.request()` takes its direct-HTTP branch and never adds
# the `/mso` prefix at all: `GET/POST https://host/api/v1/templates*`
# (confirmed on a real hardware run: `prereq_tenantpol.yml`'s
# `ndo_template` task 404'd on exactly this bare path).
# - every other task on this same playbook host runs over the
# persistent `ansible.netcommon.httpapi` connection
# (`ansible_network_os: cisco.nd.nd`), whose platform tag routes
# through `NDO_API_VERSION_PATH_FORMAT = "/mso/api/{v}/{path}"` —
# e.g. `create_dhcp_relay`'s `ndo_dhcp_relay_policy` task.
# Both shapes must therefore back the SAME mutable store so a template
# created via the bare path is visible to a later mso-prefixed lookup
# (and vice versa) — registered via a shared handler, matching the
# existing `/api/v1/tenants` ↔ `/mso/api/v1/tenants` / `/api/v1/sites`
# ↔ `/mso/api/v1/sites` pattern PR-10/PR-11 already established.
def _template_summary_view(doc: dict) -> dict:
return {
"templateId": doc.get("templateId", ""),
"templateName": doc.get("displayName", ""),
"templateType": doc.get("templateType", ""),
}
def _sync_template_summary(doc: dict) -> None:
"""Keep `state.template_summaries` in sync with a stored template
doc — mutate the existing summary entry in place if present, else
append. Mirrors `_schema_summary`/`_find_summary`'s schema-side
pattern below."""
view = _template_summary_view(doc)
for existing in state.template_summaries:
if existing.get("templateId") == view["templateId"]:
existing.update(view)
return
state.template_summaries.append(view)
async def _get_template_summaries():
"""Return a list of {templateId, templateName, templateType}.
ndo_connector.get_dhcp_policy_map() handles both a plain list and
{"templates": [...]} — we return the list directly. Query params
(templateName/templateType/schemaName/schemaId) are accepted but
filtering happens client-side in `MSOTemplate`/`query_objs()`, so
this route always returns the full list — same as every other
`query_objs()`-backed enumeration endpoint in this file.
"""
return state.template_summaries
async def _get_template(template_id: str):
"""Return a specific template detail (e.g. the tenantPolicy DHCP template)."""
doc = state.tenant_policy_templates.get(template_id)
if doc is None:
raise HTTPException(
status_code=404, detail=f"Template '{template_id}' not found"
)
return doc
def _backfill_policy_uuids(doc: dict, template_id: str) -> None:
"""Assign a stable `uuid` to every dhcpRelayPolicies/
dhcpOptionPolicies entry that lacks one.
`ndo_dhcp_relay_policy`'s add payload is only `{name, providers,
description?}` (no `uuid` — real NDO assigns that server-side).
`ndo_schema_template_bd_dhcp_policy`'s `get_dhcp_relay_policy_uuid()`/
`get_dhcp_option_policy_uuid()` (the `bind_dhcp_relay_to_bd`
playbook step) then requires a non-empty `uuid` on the matched
entry — `mso.fail_json()`s otherwise — so a query-back immediately
after the add must already see one.
"""
container = doc.get("tenantPolicyTemplate", {}).get("template", {})
tenant_id = container.get("tenantId", "")
for list_key in ("dhcpRelayPolicies", "dhcpOptionPolicies"):
for item in container.get(list_key, []) or []:
if isinstance(item, dict) and not item.get("uuid"):
seed = "{}-uuid-{}-{}-{}".format(list_key, template_id, tenant_id, item.get("name", ""))
item["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
async def _create_template(request: Request):
"""Create a new template (`ndo_template`'s "does not exist yet"
branch — POST payload shape: `{displayName, templateType,
<typeContainer>: {template:{...}, sites:[{siteId}]}}`, e.g.
`tenantPolicyTemplate: {template:{tenantId}, sites:[{siteId}]}}`).
Assigns a stable-looking id, stores the full doc (so a later
`ndo_dhcp_relay_policy` PATCH against `templates/{id}` finds it),
and mirrors a summary entry into `template_summaries` so the next
`templates/summaries?templateName=...` lookup (by any caller,
bare-path or mso-prefixed) resolves it.
"""
body = await request.json()
new_id = f"template-{uuid.uuid4().hex[:20]}"
doc = dict(body)
doc["templateId"] = new_id
doc.setdefault("displayName", body.get("displayName", ""))
doc.setdefault("templateType", body.get("templateType", ""))
_backfill_policy_uuids(doc, new_id)
state.tenant_policy_templates[new_id] = doc
_sync_template_summary(doc)
return doc
async def _patch_template(template_id: str, request: Request):
"""Apply a JSON-Patch op list to a stored template (e.g.
`ndo_dhcp_relay_policy`'s `add /tenantPolicyTemplate/template/
dhcpRelayPolicies/-`, or `ndo_template`'s site add/remove ops)."""
doc = state.tenant_policy_templates.get(template_id)
if doc is None:
raise HTTPException(
status_code=404, detail=f"Template '{template_id}' not found"
)
body = await request.json()
ops = body if isinstance(body, list) else body.get("ops", [])
if not ops:
return doc
try:
apply_json_patch(doc, ops)
except PatchError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
doc["templateId"] = template_id
_backfill_policy_uuids(doc, template_id)
_sync_template_summary(doc)
return doc
async def _delete_template(template_id: str):
state.tenant_policy_templates.pop(template_id, None)
state.template_summaries[:] = [
s for s in state.template_summaries if s.get("templateId") != template_id
]
return {}
#: `type`→(store-path-segment, name-key) for the generic cross-template
#: object lookup below.
_TEMPLATE_OBJECT_TYPES: dict[str, tuple[str, str]] = {
"dhcpRelay": ("dhcpRelayPolicies", "name"),
"dhcpOption": ("dhcpOptionPolicies", "name"),
}
async def _get_template_objects(request: Request):
"""Cross-template object lookup — `GET templates/objects?type=
{dhcpRelay|dhcpOption|epg|externalEpg}&{uuid=...|name=...}`.
Real NDO exposes this as a flat cross-cutting search over every
template's policy objects; `ansible-mso`'s `MSOTemplate.
get_template_object_by_uuid()` (`plugins/module_utils/template.py`)
and `ndo_schema_template_bd_dhcp_policy.py`'s
`get_dhcp_relay_policy_uuid()`/`get_dhcp_relay_label_name()` both
call this exact route — the former to resolve a DHCP relay/option
policy's UUID by name (`create_dhcp_relay`'s NDO 4.x path), the
latter to resolve a stored `dhcpLabels[].ref` UUID back to a name
(`bind_dhcp_relay_to_bd`'s query-back-after-PATCH step). Confirmed
against a real hardware run: both hit `GET /mso/api/v1/templates/
objects?type=dhcpRelay&name=...` mid-playbook.
`type=epg`/`type=externalEpg` additionally search every SCHEMA
template's `anps[].epgs[]` / `externalEpgs[]` (not just the
tenantPolicy templates) — `ndo_dhcp_relay_policy`'s
`insert_dhcp_relay_policy_relation_name()` resolves a stored
`epgRef`/`externalEpgRef` UUID back to a display name this way on
every query/present round trip.
"""
obj_type = request.query_params.get("type", "")
uuid_q = request.query_params.get("uuid")
name_q = request.query_params.get("name")
results: list[dict] = []
if obj_type in _TEMPLATE_OBJECT_TYPES:
list_key, name_key = _TEMPLATE_OBJECT_TYPES[obj_type]
for tmpl_doc in state.tenant_policy_templates.values():
tenant_id = (
tmpl_doc.get("tenantPolicyTemplate", {}).get("template", {}).get("tenantId")
)
container = tmpl_doc.get("tenantPolicyTemplate", {}).get("template", {})
for item in container.get(list_key, []) or []:
if not isinstance(item, dict):
continue
entry = dict(item)
entry.setdefault("tenantId", tenant_id)
results.append(entry)
elif obj_type in ("epg", "externalEpg"):
array_key = "externalEpgs" if obj_type == "externalEpg" else None
for detail in list(state.schema_details.values()):
for tmpl in detail.get("templates", []):
if array_key:
for item in tmpl.get("externalEpgs", []) or []:
if isinstance(item, dict):
results.append(item)
else:
for anp in tmpl.get("anps", []) or []:
for epg in anp.get("epgs", []) or []:
if isinstance(epg, dict):
results.append(epg)
if uuid_q is not None:
match = next((r for r in results if r.get("uuid") == uuid_q), None)
return match or {}
if name_q is not None:
matches = [r for r in results if r.get(_TEMPLATE_OBJECT_TYPES.get(obj_type, ("", "name"))[1]) == name_q]
return matches
return results
for prefix in ("/mso/api/v1", "/api/v1"):
app.add_api_route(f"{prefix}/templates/summaries", _get_template_summaries, methods=["GET"])
app.add_api_route(f"{prefix}/templates/objects", _get_template_objects, methods=["GET"])
app.add_api_route(f"{prefix}/templates", _create_template, methods=["POST"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _get_template, methods=["GET"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _patch_template, methods=["PATCH"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _delete_template, methods=["DELETE"])
# ------------------------------------------------------------------
# Audit records (both canonical and fallback paths)
# ------------------------------------------------------------------
async def _audit_records(count: int = 50):
return {"auditRecords": state.audit_records[:count]}
app.add_api_route(
"/api/v1/audit-records",
_audit_records,
methods=["GET"],
)
app.add_api_route(
"/mso/api/v1/audit-records",
_audit_records,
methods=["GET"],
)
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def _schema_summary(detail: dict) -> dict:
"""Project a full schema detail dict down to the list/GET-many shape.
Kept in sync on every write (POST create, PATCH mutate) so GET
/mso/api/v1/schemas and GET /mso/api/v1/schemas/list-identity always
reflect the current templates[].name set — mso_schema_template.py's
`get_obj("schemas", displayName=schema)` inspects exactly this.
"""
return {
"id": detail["id"],
"displayName": detail.get("displayName", detail.get("name", "")),
"name": detail.get("name", detail.get("displayName", "")),
"templates": [
{"name": t.get("name", "")} for t in detail.get("templates", [])
],
}
def _find_summary(schema_id: str) -> dict | None:
"""Locate a schema's summary dict in whichever list holds it."""
for s in state.schemas:
if s["id"] == schema_id:
return s
for s in state.extra_schemas:
if s["id"] == schema_id:
return s
return None
@app.post("/mso/api/v1/schemas")
async def create_schema(request: Request):
"""Store a new schema and return its assigned id + status.
Two shapes reach this route:
- `mso_schema.py` (state=present, no templates): POST
`{"displayName":..., "description":...}` — an empty schema, no
`id`/`templates` in the body.
- `mso_schema_template.py`'s "schema does not exist yet" branch:
POST `{"displayName": schema, "templates": [{name, displayName,
tenantId}], "sites": []}` — the schema is born already carrying
its first template. This is the path a real hardware run takes
for the per-tenant-region `<tenant>-<region>` schemas (e.g.
"MS-TN1-LAB0") — the schema must round-trip through GET
/schemas/list-identity (by displayName) and GET /schemas/{id}
for the subsequent mso_schema_template_bd/_anp/... PATCH calls
to find it (PR-11).
"""
body = await request.json()
new_id = f"schema-{uuid.uuid4().hex[:16]}"
display_name = body.get("displayName", body.get("name", "unnamed"))
schema_name = body.get("name", display_name)
# Full detail (returned by GET /schemas/{id}) — store the POSTed
# body verbatim (templates/sites and all) plus the assigned id, so
# every field a later PATCH op path might reference already exists.
detail = dict(body)
detail["id"] = new_id
detail["displayName"] = display_name
detail["name"] = schema_name
detail.setdefault("templates", [])
detail.setdefault("sites", [])
for tmpl in detail["templates"]:
normalize_template(tmpl, new_id)
for site in detail["sites"]:
if isinstance(site, dict):
site.setdefault("bds", [])
site.setdefault("anps", [])
normalize_site(site)
summary = _schema_summary(detail)
state.extra_schemas.append(summary)
state.schema_details[new_id] = detail
state.policy_states[new_id] = [{"status": "synced", "drift": False}]
return {"id": new_id, "displayName": display_name, "status": "active"}
@app.patch("/mso/api/v1/schemas/{schema_id}")
async def patch_schema(schema_id: str, request: Request):
"""Apply a JSON-Patch op list to the stored schema and return the doc.
Every cisco.mso schema-object module (mso_schema_template,
mso_schema_template_bd/_anp/_anp_epg, mso_schema_site, ...) mutates a
schema this way: look it up (list-identity → id, then GET by id),
then PATCH with a small list of `{op, path, value}` entries such as
`{"op":"add","path":"/templates/LAB1/bds/-","value":{...}}`. See
aci_sim/ndo/patch.py for the applier and docs/CONTRACT.md §7
for the full request-sequence writeup (PR-11).
"""
detail = state.schema_details.get(schema_id)
if detail is None:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
body = await request.json()
ops = body if isinstance(body, list) else body.get("ops", [])
if not ops:
# cisco.mso's own MSOModule.request() short-circuits an empty-ops
# PATCH client-side and never sends it, but stay tolerant.
return detail
try:
apply_json_patch(detail, ops)
except PatchError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
detail["id"] = schema_id # ops must never be able to clobber the id
# An "add" op can introduce a brand-new template dict (mso_schema_
# template.py's "template does not exist" branch PATCHes
# {"op":"add","path":"/templates/-","value":{name,displayName,
# tenantId}} — no vrfs/bds/... keys). Re-normalize every template
# after each PATCH so downstream MSOSchema.set_template_vrf() etc.
# never meets a missing collection key (same rule as create_schema).
for tmpl in detail.get("templates", []):
normalize_template(tmpl, schema_id)
# Same rule for the top-level `sites[]` array — mso_schema_site_bd.py
# et al PATCH child objects into `/sites/{siteId-templateName}/bds/-`
# etc. without every collection default (PR-11).
for site in detail.get("sites", []):
if isinstance(site, dict):
site.setdefault("bds", [])
site.setdefault("anps", [])
normalize_site(site)
# Keep the summary (GET /schemas, /schemas/list-identity) in sync —
# template adds/removes and displayName/name replace ops all need to
# be visible to the next lookup_schema() call in the same playbook.
# Mutate whichever summary list already holds this id in place, so
# GET /schemas never double-lists or drops the entry.
updated_summary = _schema_summary(detail)
existing_summary = _find_summary(schema_id)
if existing_summary is not None:
existing_summary.update(updated_summary)
else:
# Shouldn't normally happen (every schema_details entry is
# created alongside a summary), but degrade gracefully.
state.extra_schemas.append(updated_summary)
state.policy_states.setdefault(schema_id, [{"status": "synced", "drift": False}])
return detail
# ------------------------------------------------------------------
# State persistence (/_sim) — mirrors the APIC plane's admin router
# (aci_sim/control/admin.py), which the NDO app doesn't otherwise
# mount, so it's registered directly here.
# ------------------------------------------------------------------
@app.post("/_sim/save/{name}")
async def sim_save(name: str):
"""Persist the NDO state to disk under *name*."""
path = state_dir() / f"{name}.ndo.json"
save_json(path, serialize_ndo(state))
return {"status": "ok", "file": str(path)}
@app.post("/_sim/load/{name}")
async def sim_load(name: str):
"""Restore the NDO state from a prior :func:`sim_save`."""
path = state_dir() / f"{name}.ndo.json"
if not path.exists():
raise HTTPException(status_code=404, detail=f"state '{name}' not found")
apply_ndo(state, load_json(path))
return {"status": "ok"}
@app.post("/mso/api/v1/deploy")
async def deploy(request: Request):
"""Acknowledge a deploy request and return an in-progress record."""
deploy_id = f"deploy-{uuid.uuid4().hex[:12]}"
return {"id": deploy_id, "status": "in-progress"}
return app
+435
View File
@@ -0,0 +1,435 @@
"""NDO -> APIC deploy mirror.
Confirmed SIM GAP: `POST /mso/api/v1/task` (the deploy/undeploy request
`cisco.mso.ndo_schema_template_deploy` sends — see ndo/app.py's docstring on
that route) has always been a pure ack no-op: it never materializes the
deployed schema template's VRFs/BDs/ANPs/EPGs/binds into the TARGET SITES'
APIC MITStores. A real E2E Ansible run never direct-POSTs those MOs to the
APIC for a multi-site tenant either — it relies entirely on NDO's deploy to
push them down. So today, multi-site EPGs exist in the NDO schema doc but
never appear on the APIC side of this sim, which breaks any playbook/test
that reads EPGs back from the APIC after an NDO-driven multi-site deploy.
`mirror_template_to_sites()` closes that gap: given the NDO state, a
template name, and a site_id -> ApicSiteState map, it walks the owning
schema's template (vrfs/bds/anps/epgs) plus each associated SITE entry
(hostBasedRouting overlay, domainAssociations, staticPorts) and upserts the
equivalent fvCtx/fvBD/fvAp/fvAEPg (+ children) MOs into every target site's
MITStore — reusing the exact DN/attribute conventions build/tenants.py's
boot-time builders and rest_aci/writes.py's POST-write path already
establish, so a mirrored MO is indistinguishable from a boot-built or
directly-POSTed one.
Pure and HTTP-free: no FastAPI/Request dependency, so it's unit-testable
without spinning up either app.
"""
from __future__ import annotations
from typing import Any
from aci_sim.build.tenants import pctag_for
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.writes import _CLASS_DEFAULTS
from .model import NdoState
#: Leaf object-name keys inside a DICT-form NDO ref, in priority order. An
#: epgRef carries BOTH anpName and epgName, so epgName is checked first (the
#: EPG's own name wins); every other ref dict carries only its own *Name key.
#: schemaId/templateName are deliberately excluded.
_REF_NAME_KEYS = ("epgName", "bdName", "vrfName", "contractName", "anpName", "name")
def _basename(ref: Any) -> str:
"""Return the object name for an NDO `*Ref`, handling BOTH forms it uses.
- STRING ref `/schemas/<id>/templates/<tmpl>/bds/<bdName>` -> last segment.
- DICT ref `{"vrfName": ..., "schemaId": ..., "templateName": ...}` -> the
leaf object-name key (see `_REF_NAME_KEYS`). aci-ansible's cisco.mso
modules store the dict shape; aci-py stringifies its refs — so the mirror
must accept either or it AttributeErrors (`'dict'.rsplit`) on a redeploy
of an aci-ansible multi-site tenant.
The last segment / leaf name already matches the APIC MO name (see
ndo/model.py's docstring: NDO names are derived verbatim from the APIC
fvBD/fvAp/fvAEPg names at build time).
"""
if isinstance(ref, dict):
for key in _REF_NAME_KEYS:
value = ref.get(key)
if isinstance(value, str) and value:
return value
return ""
if not ref:
return ""
return ref.rsplit("/", 1)[-1]
def _tenant_name(state: NdoState, tenant_id: str) -> str:
"""Resolve a template's `tenantId` to the tenant's APIC-facing name."""
for tenant in state.tenants:
if tenant.get("id") == tenant_id:
return tenant.get("name", "")
return ""
def _find_template(
state: NdoState, template_name: str, schema_id: str | None = None
) -> tuple[dict | None, dict | None]:
"""Locate (schema_detail, template_doc) for a template named *template_name*.
If *schema_id* is given, resolve ONLY within that schema — template names
are NOT unique across schemas (every multi-site tenant's schema commonly
has templates literally named e.g. 'LAB1-LAB2'/'LAB1'/'LAB2'), so a
cross-schema scan risks mirroring the WRONG tenant. When *schema_id* is
None, fall back to the legacy scan-every-schema behavior (kept for
existing callers/tests that don't have a schema id handy).
"""
if schema_id is not None:
sd = state.schema_details.get(schema_id)
if sd is None:
return None, None
for tmpl in sd.get("templates", []):
if tmpl.get("name") == template_name:
return sd, tmpl
return sd, None
for schema_detail in state.schema_details.values():
for tmpl in schema_detail.get("templates", []):
if tmpl.get("name") == template_name:
return schema_detail, tmpl
return None, None
def _site_entries_for_template(schema_detail: dict, template_name: str) -> list[dict]:
"""Return every top-level `sites[]` entry belonging to *template_name*."""
return [
site
for site in schema_detail.get("sites", [])
if isinstance(site, dict) and site.get("templateName") == template_name
]
def _find_site_bd(site_entry: dict, bd_name: str) -> dict | None:
for bd in site_entry.get("bds", []) or []:
if isinstance(bd, dict) and _basename(bd.get("bdRef")) == bd_name:
return bd
return None
def _find_site_epg(site_entry: dict, anp_name: str, epg_name: str) -> dict | None:
for anp in site_entry.get("anps", []) or []:
if not isinstance(anp, dict) or _basename(anp.get("anpRef")) != anp_name:
continue
for epg in anp.get("epgs", []) or []:
if isinstance(epg, dict) and _basename(epg.get("epgRef")) == epg_name:
return epg
return None
def _mirror_bd(store: MITStore, tenant: str, bd: dict, site_bd: dict | None) -> None:
"""Upsert fvCtx-referencing fvBD (+ fvRsCtx/fvSubnet children) for one
template BD, overlaid with the SITE bd's hostBasedRouting."""
bd_name = bd.get("name", "")
vrf_name = _basename(bd.get("vrfRef"))
bd_dn = f"uni/tn-{tenant}/BD-{bd_name}"
l2_unknown_ucast = bd.get("l2UnknownUnicast", "flood")
unicast_routing = bool(bd.get("unicastRouting", True))
host_based_routing = bool(site_bd.get("hostBasedRouting")) if site_bd else False
attrs: dict[str, Any] = dict(_CLASS_DEFAULTS.get("fvBD", {}))
attrs.update(
{
"dn": bd_dn,
"name": bd_name,
"epMoveDetectMode": bd.get("epMoveDetectMode", ""),
"arpFlood": "yes" if bd.get("arpFlood") else "no",
"unkMacUcastAct": l2_unknown_ucast,
"unicastRoute": "yes" if unicast_routing else "no",
"hostBasedRouting": "yes" if host_based_routing else "no",
"intersiteBumTrafficAllow": "yes" if bd.get("intersiteBumTrafficAllow") else "no",
}
)
bd_mo = MO("fvBD", **attrs)
if vrf_name:
bd_mo.add_child(MO(
"fvRsCtx",
dn=f"{bd_dn}/rsctx",
tnFvCtxName=vrf_name,
))
for subnet in bd.get("subnets", []) or []:
if not isinstance(subnet, dict):
continue
ip = subnet.get("ip")
if not ip:
continue
scope = subnet.get("scope") or "public,shared"
bd_mo.add_child(MO(
"fvSubnet",
dn=f"{bd_dn}/subnet-[{ip}]",
ip=ip,
scope=scope,
preferred="yes" if subnet.get("primary") else "no",
))
# Merge-upsert (NOT delete-then-recreate) on purpose: the BD may carry an
# externally-POSTed `epClear` attr (the clear_remote_mac stub a playbook
# posts directly to the APIC) that a delete-then-recreate would drop.
# store.upsert() merges attrs/children onto any existing MO at this dn,
# so that attr survives a redeploy. Contrast with _mirror_epg below,
# which deletes-then-recreates because EPGs have no such externally-set
# state to preserve.
store.upsert(bd_mo)
def _mirror_epg(store: MITStore, tenant: str, anp_name: str, epg: dict, site_epg: dict | None) -> None:
"""Upsert fvAEPg (+ fvRsBd/fvRsProv/fvRsCons/fvSubnet/fvRsDomAtt/
fvRsPathAtt children) for one template EPG, overlaid with the SITE epg's
domainAssociations/staticPorts (bind data — usually empty until a bind
playbook runs)."""
epg_name = epg.get("name", "")
epg_dn = f"uni/tn-{tenant}/ap-{anp_name}/epg-{epg_name}"
epg_mo = MO(
"fvAEPg",
dn=epg_dn,
name=epg_name,
prefGrMemb="include" if epg.get("preferredGroup") else "exclude",
pcTag=pctag_for(epg_dn),
pcEnfPref="unenforced",
isAttrBasedEPg="no",
floodOnEncap="disabled",
)
bd_name = _basename(epg.get("bdRef"))
if bd_name:
epg_mo.add_child(MO(
"fvRsBd",
dn=f"{epg_dn}/rsbd",
tnFvBDName=bd_name,
))
for rel in epg.get("contractRelationships", []) or []:
if not isinstance(rel, dict):
continue
contract = _basename(rel.get("contractRef"))
if not contract:
continue
if rel.get("relationshipType") == "provider":
epg_mo.add_child(MO(
"fvRsProv",
dn=f"{epg_dn}/rsprov-{contract}",
tnVzBrCPName=contract,
))
elif rel.get("relationshipType") == "consumer":
epg_mo.add_child(MO(
"fvRsCons",
dn=f"{epg_dn}/rscons-{contract}",
tnVzBrCPName=contract,
))
for subnet in epg.get("subnets", []) or []:
if not isinstance(subnet, dict):
continue
ip = subnet.get("ip")
if not ip:
continue
epg_mo.add_child(MO(
"fvSubnet",
dn=f"{epg_dn}/subnet-[{ip}]",
ip=ip,
scope=subnet.get("scope") or "public,shared",
preferred="yes" if subnet.get("primary") else "no",
))
if site_epg is not None:
for dom in site_epg.get("domainAssociations", []) or []:
if not isinstance(dom, dict):
continue
t_dn = dom.get("dn") or dom.get("domainRef") or dom.get("tDn")
if not t_dn:
continue
epg_mo.add_child(MO(
"fvRsDomAtt",
dn=f"{epg_dn}/rsdomAtt-[{t_dn}]",
tDn=t_dn,
instrImedcy="lazy",
))
for path in site_epg.get("staticPorts", []) or []:
if not isinstance(path, dict):
continue
path_dn = path.get("path") or path.get("dn") or path.get("tDn")
if not path_dn:
continue
epg_mo.add_child(MO(
"fvRsPathAtt",
dn=f"{epg_dn}/rspathAtt-[{path_dn}]",
tDn=path_dn,
encap=path.get("encap", ""),
mode=path.get("mode", "regular"),
instrImedcy=path.get("deploymentImmediacy", "lazy"),
))
# Redeploy idempotency: delete-then-recreate (NOT a merge-upsert like
# _mirror_bd). store.upsert() merges attrs/children onto an existing MO,
# so a contract/bind removed in NDO since the last deploy would otherwise
# leave a stale fvRsProv/fvRsDomAtt/etc. child behind forever. This is
# safe because multi-site EPGs have NO externally-posted children of
# their own — every child under this dn comes from this mirror, so
# wiping the subtree first and rebuilding it clean can't drop anything
# an outside POST relied on (contrast with the BD's epClear, above).
store.upsert(MO("fvAEPg", dn=epg_dn, status="deleted"))
store.upsert(epg_mo)
def _template_object_dns(tenant: str, template: dict) -> list[tuple[str, str]]:
"""Every tenant-scoped (dn, class_name) pair this template owns (its
VRFs/BDs/ANPs/EPGs) — used to scope an undeploy's delete to just this
template's objects, never the whole `uni/tn-<T>` tenant (a schema may
hold more than one template against the same tenant).
Deletion in the store is DN-based (status="deleted" removes by dn
regardless of the class passed), so the class here only matters for
fidelity — the emitted delete MO now carries the object's real class
instead of a hardcoded 'fvBD' for everything.
"""
dns: list[tuple[str, str]] = []
for vrf in template.get("vrfs", []) or []:
name = vrf.get("name") if isinstance(vrf, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/ctx-{name}", "fvCtx"))
for bd in template.get("bds", []) or []:
name = bd.get("name") if isinstance(bd, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/BD-{name}", "fvBD"))
for anp in template.get("anps", []) or []:
if not isinstance(anp, dict):
continue
anp_name = anp.get("name")
if not anp_name:
continue
dns.append((f"uni/tn-{tenant}/ap-{anp_name}", "fvAp"))
for epg in anp.get("epgs", []) or []:
epg_name = epg.get("name") if isinstance(epg, dict) else None
if epg_name:
dns.append((f"uni/tn-{tenant}/ap-{anp_name}/epg-{epg_name}", "fvAEPg"))
return dns
def mirror_template_to_sites(
state: NdoState,
template_name: str,
apic_states: dict,
*,
schema_id: str | None = None,
undeploy: bool = False,
) -> int:
"""Materialize (or, if *undeploy*, tear down) *template_name*'s
VRFs/BDs/ANPs/EPGs into every associated site's APIC MITStore.
*schema_id*, when known (the real `POST /mso/api/v1/task` body always
carries one), scopes template resolution to that ONE schema — template
names collide across schemas (e.g. every multi-site tenant's schema has
templates literally named 'LAB1-LAB2'/'LAB1'/'LAB2'), so resolving by
name alone across ALL schemas risks mirroring the WRONG tenant. Pass
None only for legacy callers/tests that don't have a schema id handy.
*apic_states* maps site_id -> an object exposing a `.store` (MITStore) —
typically `aci_sim.rest_aci.app.ApicSiteState`. Only sites that
are BOTH associated with this template (schema `sites[].templateName ==
template_name`) AND present in *apic_states* are touched; every other
site's store is left completely untouched.
Returns the number of MOs upserted/deleted (0 if the template or none of
its sites could be resolved — a safe no-op).
"""
schema_detail, template = _find_template(state, template_name, schema_id)
if template is None or schema_detail is None:
return 0
tenant = _tenant_name(state, template.get("tenantId", ""))
if not tenant:
return 0
site_entries = _site_entries_for_template(schema_detail, template_name)
if not site_entries:
return 0
written = 0
if undeploy:
object_dns = _template_object_dns(tenant, template)
for site_entry in site_entries:
site_id = site_entry.get("siteId")
apic_state = apic_states.get(str(site_id)) if apic_states else None
if apic_state is None:
continue
store = apic_state.store
for dn, cls in object_dns:
store.upsert(MO(cls, dn=dn, status="deleted"))
written += 1
return written
for site_entry in site_entries:
site_id = site_entry.get("siteId")
apic_state = apic_states.get(str(site_id)) if apic_states else None
if apic_state is None:
continue
store = apic_state.store
# Materialize the tenant shadow on this site FIRST. An NDO deploy
# creates the tenant on each target site's APIC; without the fvTenant
# root MO, a subtree/mo query on `uni/tn-<T>` returns empty even though
# the mirrored children exist (real F1 root cause: the direct-POST path
# only creates fvTenant on the --apic site, so the OTHER site had the
# mirrored VRF/BD/EPG children but no tenant root to query them under).
store.upsert(MO("fvTenant", dn=f"uni/tn-{tenant}", name=tenant))
written += 1
for vrf in template.get("vrfs", []) or []:
if not isinstance(vrf, dict):
continue
vrf_name = vrf.get("name", "")
if not vrf_name:
continue
store.upsert(MO(
"fvCtx",
dn=f"uni/tn-{tenant}/ctx-{vrf_name}",
name=vrf_name,
))
written += 1
for bd in template.get("bds", []) or []:
if not isinstance(bd, dict):
continue
bd_name = bd.get("name", "")
if not bd_name:
continue
site_bd = _find_site_bd(site_entry, bd_name)
_mirror_bd(store, tenant, bd, site_bd)
written += 1
for anp in template.get("anps", []) or []:
if not isinstance(anp, dict):
continue
anp_name = anp.get("name", "")
if not anp_name:
continue
store.upsert(MO(
"fvAp",
dn=f"uni/tn-{tenant}/ap-{anp_name}",
name=anp_name,
))
written += 1
for epg in anp.get("epgs", []) or []:
if not isinstance(epg, dict):
continue
epg_name = epg.get("name", "")
if not epg_name:
continue
site_epg = _find_site_epg(site_entry, anp_name, epg_name)
_mirror_epg(store, tenant, anp_name, epg, site_epg)
written += 1
return written
+530
View File
@@ -0,0 +1,530 @@
"""
NDO state model builder — Phase 6.
`build_ndo_model(topo)` derives a complete NdoState from the same Topology
object used for the APIC MIT builders, so every NDO tenant/VRF/BD/EPG/contract
name *exactly* matches the APIC MIT (cross-plane consistency, DESIGN.md rule).
The state is a plain dataclass — no FastAPI dependency here.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass, field
from aci_sim.topology.schema import Topology
from .patch import _new_site_bd, _new_site_epg, normalize_site, normalize_template
# ---------------------------------------------------------------------------
# Fixed deterministic artefacts (stable across test runs — no datetime.now())
# ---------------------------------------------------------------------------
#: UUID used as the dhcpRelayPolicy reference in every BD's dhcpLabels.
#: The tenantPolicy template exposes this UUID so ndo_bd_view can resolve it.
DHCP_RELAY_UUID: str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
#: UUID for the DHCP option policy exposed in the tenantPolicy template.
DHCP_OPTION_UUID: str = "f9e8d7c6-b5a4-3210-fedc-ba9876543210"
#: Stable template-id for the single tenantPolicy template.
TENANT_POLICY_TEMPLATE_ID: str = "tenantpolicy000000000001" # 24 chars
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hex_id(seed: str) -> str:
"""Return a 24-char lower-hex deterministic id (Mongo ObjectId style)."""
return hashlib.sha256(seed.encode()).hexdigest()[:24]
# ---------------------------------------------------------------------------
# State container
# ---------------------------------------------------------------------------
@dataclass
class NdoState:
"""All NDO-plane state derived from the topology, plus a mutable POST bucket."""
#: [{id, name, platform, urls}] — from topo.sites
sites: list
#: [{id, name, displayName, siteAssociations}] — one per topo.tenants
tenants: list
#: Summary shape [{id, displayName, name, templates:[{name}]}]
schemas: list
#: schema_id → full schema detail dict (returned by GET /schemas/{id})
schema_details: dict
#: [{templateId, templateType}] — includes one "tenantPolicy" entry
template_summaries: list
#: templateId → full template doc (returned by GET /templates/{id})
tenant_policy_templates: dict
#: {"sites": [{id, siteId, status, connectivityStatus}]}
fabric_connectivity: dict
#: schema_id → [{status, drift}]
policy_states: dict
#: [{id, timestamp, user, action, description, details}] — fixed set
audit_records: list
#: ND-platform local users, ND-shaped `{"items":[{"spec":{...}}]}` — PR-11.
#: `cisco.mso.mso_tenant`'s `lookup_users()`/`lookup_remote_users()` query
#: this (and the sibling `remote_users`) as part of every tenant create.
local_users: dict = field(default_factory=dict)
#: ND-platform remote (AAA/TACACS/LDAP) users, same ND aggregate shape.
#: Seeded with the sandbox's own `admin` login so `mso_tenant`'s implicit
#: `users: [ansible_user]` (defaulted to admin if unset) resolves.
remote_users: dict = field(default_factory=dict)
#: Accumulates schemas POSTed at runtime (mutable — shared with app closures)
extra_schemas: list = field(default_factory=list)
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
def build_ndo_model(topo: Topology, sandbox: bool = False) -> NdoState: # noqa: C901
"""Derive an NdoState from *topo* with names matching the APIC MIT.
In sandbox mode each APIC is served on its own ``mgmt_ip`` at :443, so NDO
advertises the portless ``https://<mgmt_ip>`` URL — exactly how real NDO
reports controller URLs, and what autoACI's discovery expects. Otherwise it
advertises ``https://<apic_host>`` (the 127.0.0.1:<port> loopback form).
"""
# ------------------------------------------------------------------
# Sites
# ------------------------------------------------------------------
# PR-10: real NDO registers sites under their fabric name (e.g.
# "LAB1-IT-ACI"), not the short topology-internal site name ("LAB1") —
# verified against a real-gear cisco.mso.mso_tenant run (see
# docs/CONTRACT.md §7 / CHANGELOG 0.2.1): the tenant's `sites` list only
# accepted the fabric-name form, and `mso_tenant` failed with
# "Site 'LAB1-IT-ACI' is not a valid site name" when only the short name
# was exposed here. `Site.fabric_name` (topSystem.fabricDomain) already
# carries this value; fall back to the short `site.name` only if a
# topology omits fabric_name (keeps this builder tolerant of minimal
# topologies used by unit tests that don't set it).
sites: list = []
site_id_map: dict[str, str] = {} # site.name (short) → site.id — internal join key
for site in topo.sites:
apic_url = f"https://{site.mgmt_ip}" if (sandbox and site.mgmt_ip) else f"https://{site.apic_host}"
ndo_site_name = site.fabric_name or site.name
sites.append(
{
"id": site.id,
"name": ndo_site_name,
"platform": "APIC",
"urls": [apic_url],
}
)
site_id_map[site.name] = site.id
# ------------------------------------------------------------------
# Tenants
# ------------------------------------------------------------------
tenants: list = []
for tenant in topo.tenants:
t_id = _hex_id(f"tenant-{tenant.name}")
site_assocs = [
{"siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
tenants.append(
{
"id": t_id,
"name": tenant.name,
"displayName": tenant.name,
"siteAssociations": site_assocs,
}
)
# ------------------------------------------------------------------
# Schemas — one per tenant
# ------------------------------------------------------------------
schemas: list = []
schema_details: dict = {}
policy_states: dict = {}
for tenant in topo.tenants:
schema_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
tmpl_name = f"{tenant.name}-template"
# VRFs — name/displayName/vrfRef match APIC fvCtx names
vrfs = [
{
"name": vrf.name,
"displayName": vrf.name,
"vrfRef": f"/vrfs/{vrf.name}",
"vzAnyEnabled": False,
"ipDataPlaneLearning": "enabled",
}
for vrf in tenant.vrfs
]
# BDs — name/vrfRef match APIC fvBD names; every BD carries a DHCP
# relay label so ndo_bd_view can exercise uuid→name resolution.
bds = []
for bd in tenant.bds:
bds.append(
{
"name": bd.name,
"vrfRef": f"/vrfs/{bd.vrf}",
"subnets": [{"ip": s} for s in bd.subnets],
"l2Stretch": bd.l2stretch,
"intersiteBumTraffic": bd.l2stretch,
"l2UnknownUnicast": "flood",
"arpFlood": True,
"unicastRouting": True,
"epMoveDetectMode": "garp",
"dhcpLabels": [{"ref": DHCP_RELAY_UUID}],
}
)
# ANPs / EPGs — names match APIC fvAp / fvAEPg names
anps = []
for ap in tenant.aps:
epgs = []
for epg in ap.epgs:
contract_rels = [
{
"relationshipType": "provider",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.provide
] + [
{
"relationshipType": "consumer",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.consume
]
epgs.append(
{
"name": epg.name,
"bdRef": f"/bds/{epg.bd}",
"preferredGroup": False,
"proxyArp": False,
"uSegEpg": False,
"intraEpg": "unenforced",
"contractRelationships": contract_rels,
}
)
anps.append({"name": ap.name, "epgs": epgs})
# Contracts — names match APIC vzBrCP names
contracts = []
for contract in tenant.contracts:
filter_rels = [
{"filterRef": f"/filters/{flt.name}"}
for flt in contract.filters
]
contracts.append(
{
"name": contract.name,
"scope": contract.scope,
"filterRelationships": filter_rels,
}
)
# Filters — deduplicated; entries use CONTRACT §7 field names
# (ipProtocol / dFromPort / dToPort / etherType)
seen_filters: set[str] = set()
filters: list = []
for contract in tenant.contracts:
for flt in contract.filters:
if flt.name in seen_filters:
continue
seen_filters.add(flt.name)
entries = [
{
"name": f"{e.proto}-{e.from_port}",
"ipProtocol": e.proto,
"dFromPort": e.from_port,
"dToPort": e.to_port,
"etherType": e.ether_type,
}
for e in flt.entries
]
filters.append({"name": flt.name, "entries": entries})
# External EPGs from L3Out ext_epgs — vrfRef / l3outRef match APIC names
external_epgs: list = []
for l3out in tenant.l3outs:
for ext_epg in l3out.ext_epgs:
external_epgs.append(
{
"name": ext_epg.name,
"vrfRef": f"/vrfs/{l3out.vrf}",
"l3outRef": f"/l3outs/{l3out.name}",
"type": "on-premise",
"subnets": [{"ip": s} for s in ext_epg.subnets],
}
)
# Schema-level site associations (template ↔ site mappings)
schema_sites = [
{"templateName": tmpl_name, "siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
template = {
"name": tmpl_name,
"templateType": tmpl_type,
# PR-13: `custom_mso_schema_service_graph.py`'s "service graph
# already exists, add a node to it" branch (invoked the SECOND
# time the module PATCHes the same service-graph name — e.g.
# once per site in a multi-site service-graph bind) reads
# `schema_obj.get('templates')[template_idx]['tenantId']` with
# a bare subscript to resolve the L4-L7 device's owning tenant.
# Same deterministic id `build_ndo_model`'s tenants[] list
# already assigns this tenant (`_hex_id(f"tenant-{name}")`) —
# recomputed here rather than threaded through because it's
# derived purely from the tenant name.
"tenantId": _hex_id(f"tenant-{tenant.name}"),
"vrfs": vrfs,
"bds": bds,
"anps": anps,
"contracts": contracts,
"filters": filters,
"externalEpgs": external_epgs,
}
# PR-12: backfill self-referencing anpRef/epgRef on every template
# anp/epg (real NDO carries these — see aci_sim/ndo/patch.py
# normalize_template's docstring), then mirror each template ANP/EPG
# into every site already associated with this template, matching
# real NDO 4.x's auto-population of sites[].anps[].epgs[] the moment
# a template with sites attached gains an ANP/EPG — otherwise a
# topology.yaml tenant that ships with ANPs/EPGs already configured
# would boot into the same `set_site_anp()`-returns-None crash this
# PR fixes for the PATCH-built case.
normalize_template(template, schema_id)
for site in schema_sites:
if site.get("templateName") != tmpl_name:
continue
# PR-15: mirror each template BD into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring below (and `_mirror_template_bd_to_sites` in
# patch.py, which does the equivalent for a runtime PATCH `add`):
# real NDO 4.x auto-creates the site BDDelta shadow the moment a
# template BD is added to a template with sites attached, and
# `mso_schema_site_bd.py` always PATCHes that shadow with `op:
# replace` (never `add`) — a topology.yaml tenant that boots with
# BDs already configured must start with the shadow already
# present, or the FIRST site-BD PATCH 400s with "'bd-X' not found
# at '/sites/{siteId}-{t}/bds/bd-X'" (confirmed on a real hardware
# aci-py create_tenant/create_bd run against a freshly-booted sim).
site.setdefault("bds", [])
for bd in template["bds"]:
bd_ref = "/schemas/{}/templates/{}/bds/{}".format(schema_id, tmpl_name, bd["name"])
site["bds"].append(_new_site_bd(bd_ref))
site.setdefault("anps", [])
for anp in template["anps"]:
site["anps"].append(
{"anpRef": anp["anpRef"], "epgs": [_new_site_epg(epg["epgRef"]) for epg in anp["epgs"]]}
)
# PR-13: mirror each template contract into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring above, for the site-local `contracts[]`
# array a raw mso_rest-driven PATCH (e.g. the mso-model role's
# per-fabric service-graph redirect bind) addresses by bare
# contract name (`/sites/{siteId}-{t}/contracts/{c}/
# serviceGraphRelationship`).
site.setdefault("contracts", [])
for contract in template["contracts"]:
contract_ref = "/schemas/{}/templates/{}/contracts/{}".format(schema_id, tmpl_name, contract["name"])
site["contracts"].append({"contractRef": contract_ref})
normalize_site(site)
schema_name = f"{tenant.name}-schema"
schemas.append(
{
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
# Summary shape: only template names (no inner lists)
"templates": [{"name": tmpl_name}],
}
)
schema_details[schema_id] = {
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
"templates": [template],
"sites": schema_sites,
}
policy_states[schema_id] = [{"status": "synced", "drift": False}]
# ------------------------------------------------------------------
# Template summaries
# ------------------------------------------------------------------
# One tenantPolicy template (carries DHCP policies), plus one summary
# entry per schema template so ndo_deployment_status / stretch-analysis
# can enumerate them.
#
# PR-13: `MSOTemplate.__init__`'s name-based lookup
# (`ansible-mso`'s `plugins/module_utils/template.py`) filters
# `templates/summaries` entries by `templateName`+`templateType` (and
# tolerates absent `schemaName`/`schemaId` kwargs — `query_objs()` skips
# `None`-valued filter kwargs). Every summary entry must therefore carry
# a `templateName` field, not just `templateId`/`templateType`, or a
# by-name lookup (`ndo_template`/`ndo_dhcp_relay_policy`'s `template:
# "TenantPol-MS-TN1"` param) never matches anything.
template_summaries: list = [
{
"templateId": TENANT_POLICY_TEMPLATE_ID,
"templateName": "tenantpolicy-default",
"templateType": "tenantPolicy",
}
]
for tenant in topo.tenants:
s_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
template_summaries.append(
{
"templateId": f"{s_id}-{tenant.name}",
"templateName": f"{tenant.name}-template",
"templateType": tmpl_type,
}
)
# ------------------------------------------------------------------
# Tenant policy template detail (DHCP relay + option policies)
# ------------------------------------------------------------------
# PR-13: keyed by templateId, one entry per `GET /templates/{id}` a
# caller might ask for. Every entry mirrors real NDO's generic template
# envelope shape: `{templateId, displayName, templateType,
# tenantPolicyTemplate:{template:{...}, sites:[{siteId}]}}` — the
# `sites` array (not just the inner `template` dict) is required because
# `ndo_template`'s "template already exists" PATCH branch
# (`append_site_config_to_ops`) reads
# `config.get(template_type_container, {}).get("sites", [])` directly.
tenant_policy_templates: dict = {
TENANT_POLICY_TEMPLATE_ID: {
"templateId": TENANT_POLICY_TEMPLATE_ID,
"displayName": "tenantpolicy-default",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {
"dhcpRelayPolicies": [
{"uuid": DHCP_RELAY_UUID, "name": "dhcp-relay-1"},
],
"dhcpOptionPolicies": [
{"uuid": DHCP_OPTION_UUID, "name": "dhcp-option-1"},
],
},
"sites": [],
},
}
}
# ------------------------------------------------------------------
# Fabric connectivity
# ------------------------------------------------------------------
fabric_connectivity: dict = {
"sites": [
{
"id": site.id,
"siteId": site.id,
"status": "up",
"connectivityStatus": "up",
}
for site in topo.sites
]
}
# ------------------------------------------------------------------
# Audit records (fixed timestamps — no Date.now() / datetime.now())
# ------------------------------------------------------------------
audit_records: list = [
{
"id": "audit-001",
"timestamp": "2026-06-01T10:00:00Z",
"user": "admin",
"action": "create",
"description": "Schema Corp-schema created",
"details": "Initial schema creation via NDO",
},
{
"id": "audit-002",
"timestamp": "2026-06-01T10:05:00Z",
"user": "admin",
"action": "deploy",
"description": "Template Corp-template deployed to SiteA, SiteB",
"details": "Full deployment: 1 VRF, 2 BDs, 2 EPGs, 1 contract",
},
{
"id": "audit-003",
"timestamp": "2026-06-02T09:00:00Z",
"user": "operator",
"action": "update",
"description": "BD app-bd updated",
"details": "Changed ARP flood setting to enabled",
},
]
# ------------------------------------------------------------------
# ND local/remote users — PR-11
# ------------------------------------------------------------------
# `cisco.mso.mso_tenant`'s state=present flow always resolves the tenant's
# `users` list (defaulted to ["admin"] when unset) via `lookup_users()`,
# which on ND platforms queries `/nexus/infra/api/aaa/v4/localusers` +
# `/remoteusers` first, falling back to the legacy `/api/config/class/*`
# form when both come back empty (see ansible-mso module_utils/mso.py).
# The sim only serves the legacy `/api/config/class/remoteusers` route
# (see ndo/app.py), so seed a single "admin" local user in ND's
# `{"items":[{"spec":{...}}]}` aggregate shape — `get_user_from_list_of_
# users()` reads `item["spec"]["loginID"]` and needs `id`/`userID` on
# that same spec dict to build the `userAssociations` id list.
local_users: dict = {
"items": [
{
"spec": {
"loginID": "admin",
"loginDomain": None,
"id": "admin-local-0000000000000001",
"userID": "admin-local-0000000000000001",
}
}
]
}
# Remote (AAA/TACACS/LDAP) users start empty: `nd_request()` returns the
# raw JSON body untouched (see module_utils/mso.py `nd_request()`), and
# `get_user_from_list_of_users()` only iterates `body["items"]` when body
# is a dict — so any 200 response with an `"items"` key (even empty)
# short-circuits the "ND Error: Unknown error" 404 failure the real
# playbook run hit. No remote users are configured in the tenants under
# test, only the local "admin" ships above.
remote_users: dict = {"items": []}
return NdoState(
sites=sites,
tenants=tenants,
schemas=schemas,
schema_details=schema_details,
template_summaries=template_summaries,
tenant_policy_templates=tenant_policy_templates,
fabric_connectivity=fabric_connectivity,
policy_states=policy_states,
audit_records=audit_records,
local_users=local_users,
remote_users=remote_users,
)
+785
View File
@@ -0,0 +1,785 @@
"""Minimal JSON-Patch (RFC 6902) applier for the NDO schema PATCH surface — PR-11.
`cisco.mso`'s schema-object modules (mso_schema_template, mso_schema_template_bd,
mso_schema_template_anp, mso_schema_template_anp_epg, mso_schema_site, ...) all
mutate a schema the same way: look the schema up by displayName/id, then send
`PATCH /mso/api/v1/schemas/{id}` with a JSON body that is a *list* of ops:
[{"op": "add", "path": "/templates/LAB1/bds/-", "value": {...}}]
[{"op": "replace", "path": "/templates/LAB1/anps/0/epgs/2", "value": {...}}]
[{"op": "remove", "path": "/templates/LAB1/bds/bd-App1_LAB1"}]
Two path shapes appear in ansible-mso source:
- numeric array index / "-" (append) — standard RFC 6902.
- a *named* segment used as a dict-list lookup key ("bds/bd-App1_LAB1",
"anps/AP1"): the module resolves those to a numeric index in Python and
sends the resolved path in the common case (see mso_schema_template_bd.py
`bd_path = "/templates/{0}/bds/{1}".format(template, bd)` used directly in
ops when `mso.existing` was truthy), but every module also computes
`template` as a *name*, not an index — "/templates/LAB1/..." — so the verb
is really "the templates list, resolved by matching name field", not
"index 0". We replicate that: any path segment that isn't a valid array
index and isn't "-" is resolved by scanning the current list for a dict
whose `name` (or `displayName`) attribute equals that segment.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
#: Every collection key a schema template can carry. Real NDO always serves
#: these back as (at minimum) empty lists even when a caller's create/add
#: payload omitted them — e.g. `mso_schema_template.py`'s "schema does not
#: exist yet" POST payload is only `{name, displayName, tenantId}`, no
#: `vrfs`/`bds`/... . `MSOSchema.set_template_vrf()` (ansible-mso
#: module_utils/schema.py) then does
#: `enumerate(self.schema_objects["template"].details.get("vrfs"))`
#: unconditionally — a missing key there is `None`, not `[]`, and blows up
#: with "'NoneType' object is not iterable" (PR-11, observed on a real
#: ACI-hardware create_tenant → create VRF run). Normalize on every
#: template create so every reader sees a real list.
TEMPLATE_COLLECTION_KEYS: tuple[str, ...] = (
"vrfs",
"bds",
"anps",
"contracts",
"filters",
"externalEpgs",
# PR-11: mso_schema_template_l3out.py / _service_graph.py subscript
# these directly (`schema_obj.get("templates")[idx]["intersiteL3outs"]`,
# no `.get()` fallback) — a missing key is a hard KeyError, confirmed on
# a real hardware create_tenant run's "Create L3Out" task.
"intersiteL3outs",
"serviceGraphs",
)
#: Child-object collection defaults, one level down from a template's own
#: arrays. Every cisco.mso "add a child object" module writes a payload with
#: only the fields *it* cares about (e.g. mso_schema_template_external_epg.py
#: never sets `subnets`), then a *sibling* module
#: (mso_schema_template_external_epg_subnet.py) reads
#: `externalEpgs[idx]["subnets"]` with a bare subscript — a real-hardware run
#: hit `KeyError: 'subnets'` on exactly this path. Real NDO must default
#: these server-side; replicate that here so every object born via PATCH
#: carries the collection keys its sibling modules expect.
#: Maps: template-array-key -> {default-key: default-value}.
CHILD_OBJECT_DEFAULTS: dict[str, dict[str, Any]] = {
"bds": {"subnets": [], "dhcpLabels": []},
"anps": {"epgs": []},
"epgs": {"subnets": [], "contractRelationships": [], "staticPorts": [], "staticLeafs": [], "domains": []},
"externalEpgs": {"subnets": [], "contractRelationships": [], "selectors": []},
"contracts": {"filterRelationships": []},
"filters": {"entries": []},
"intersiteL3outs": {},
"serviceGraphs": {},
"vrfs": {"rpConfigs": []},
}
class PatchError(Exception):
"""Raised when a JSON-Patch op cannot be applied to the stored document."""
def _normalize_object(obj: dict, array_key: str, schema_id: str, template_name: str, anp_name: str = "") -> None:
"""Backfill *obj*'s missing collection-default keys for its container
array (e.g. array_key="bds" -> obj gets subnets/dhcpLabels defaults),
then recurse into any nested arrays this object type carries (anps.epgs).
PR-12: template-level `anps[]`/`epgs[]` entries also get a
self-referencing `anpRef`/`epgRef` string field backfilled — real NDO
stores one on every template ANP/EPG object itself (confirmed by
`ansible-mso`'s `mso_schema_template_anp.py`/`_anp_epg.py` explicitly
stripping it client-side before an idempotency comparison: `if "anpRef"
in mso.previous: del mso.previous["anpRef"]` — dead code unless the
server actually sends one back). `MSOSchema.set_site_anp()`/
`set_site_anp_epg()` (`module_utils/schema.py`) key their site-local
lookup off exactly this field (`template_anp.details.get("anpRef")`) —
without it those lookups compare against `None` and never match,
crashing `mso_schema_site_anp_epg_staticport.py` with `'NoneType'
object has no attribute 'details'` (confirmed on a real MS-TN1
bind_epg_to_static_port run on ACI hardware before this fix).
PR-13: `epgs`/`externalEpgs` entries also get a stable `uuid` field.
Real NDO assigns one to every schema-template EPG/external-EPG; the
NDO-plane `cisco.mso.ndo_dhcp_relay_policy` module reads it as
`schema_objects["template_anp_epg"].details.get("uuid")` (via
`MSOSchema.set_template_anp_epg()`) to build a DHCP relay policy
provider's `epgRef` — without it the sim always hands back `None`,
and the relay-policy-add PATCH round-trips a provider that can never
resolve back to a real EPG (confirmed against `ansible-mso`'s
`plugins/modules/ndo_dhcp_relay_policy.py::get_providers_payload()`).
"""
for key, default in CHILD_OBJECT_DEFAULTS.get(array_key, {}).items():
if obj.get(key) is None:
obj[key] = [] if isinstance(default, list) else default
if array_key == "anps":
anp_name = obj.get("name", anp_name)
if not obj.get("anpRef"):
obj["anpRef"] = "/schemas/{}/templates/{}/anps/{}".format(schema_id, template_name, anp_name)
for epg in obj.get("epgs", []):
if isinstance(epg, dict):
_normalize_object(epg, "epgs", schema_id, template_name, anp_name)
elif array_key == "epgs":
epg_name = obj.get("name", "")
if not obj.get("epgRef"):
obj["epgRef"] = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(
schema_id, template_name, anp_name, epg_name
)
if not obj.get("uuid"):
seed = "epg-uuid-{}-{}-{}-{}".format(schema_id, template_name, anp_name, epg_name)
obj["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
elif array_key == "externalEpgs":
if not obj.get("uuid"):
ext_epg_name = obj.get("name", "")
seed = "extepg-uuid-{}-{}-{}".format(schema_id, template_name, ext_epg_name)
obj["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
def normalize_template(template: dict, schema_id: str = "") -> dict:
"""Backfill missing collection keys + templateID on a template dict, and
recursively normalize every child object already present in its arrays.
Mutates and returns *template* in place. Safe to call repeatedly
(idempotent — only fills keys that are absent or None). *schema_id* is
used to build the self-referencing anpRef/epgRef strings PR-12 backfills
on every anps[]/epgs[] entry — see _normalize_object's docstring.
"""
template_name = template.get("name", "")
for key in TEMPLATE_COLLECTION_KEYS:
if template.get(key) is None:
template[key] = []
for obj in template[key]:
if isinstance(obj, dict):
_normalize_object(obj, key, schema_id, template_name)
# `MSOSchema.set_template()` reads `match.details.get("templateID")` —
# a capital-ID sibling of the lowercase `tenantId`/`id` keys the create
# payload sends. Real NDO assigns this server-side; derive a stable one
# from (schema-scoped) template name so repeated GETs are consistent.
if not template.get("templateID"):
seed = f"template-{template.get('name', '')}"
template["templateID"] = hashlib.sha256(seed.encode()).hexdigest()[:24]
return template
#: Site-array (top-level schema `sites[]`) collection defaults — a distinct
#: namespace from TEMPLATE_COLLECTION_KEYS/CHILD_OBJECT_DEFAULTS because
#: `mso_schema_site_bd.py`'s add payload is only `{bdRef, hostBasedRouting}`
#: (no `subnets`), yet `mso_schema_site_bd_subnet.py` reads
#: `site_bd.details.get("subnets")` and iterates it — a missing key there is
#: `None`, not `[]` (PR-11, same "iterate a None" family of bug as the
#: template-level fix above).
#:
#: The `epgs` entry is the canonical *full* site-local EPG collection shape.
#: A site-local EPG is created by `mso_schema_site_anp_epg.py` (or the
#: staticport/domain modules' own inline fallback) with a payload of only
#: `{epgRef}` — real NDO backfills every child collection array server-side,
#: and every sibling `mso_schema_site_anp_epg_*` module then reads its own
#: array with a **bare subscript** (not `.get()`), so a missing key is a hard
#: `KeyError`, not a 4xx:
#: - `staticPorts` — `mso_schema_site_anp_epg_staticport.py`
#: (`set_existing_static_ports`: `...["epgs"][idx].get("staticPorts")` /
#: bulk module iterates it).
#: - `staticLeafs` — `mso_schema_site_anp_epg_staticleaf.py`
#: (`...["epgs"][epg_idx]["staticLeafs"]`).
#: - `domainAssociations` — `mso_schema_site_anp_epg_domain.py`
#: (`domains = [dom.get("dn") for dom in ...["epgs"][epg_idx]
#: ["domainAssociations"]]`) — a real MS-TN1 hardware
#: `bind_epg_to_physical_domain`/`_vmm_domain` run hit
#: `KeyError: 'domainAssociations'` on exactly this line once PR-12
#: started auto-creating the site-EPG (pre-PR-12 no site-EPG existed at
#: all, so the module took a non-crashing branch).
#: - `subnets` — `mso_schema_site_anp_epg_subnet.py`.
SITE_OBJECT_DEFAULTS: dict[str, dict[str, Any]] = {
"bds": {"subnets": []},
"anps": {"epgs": []},
"epgs": {"subnets": [], "staticPorts": [], "staticLeafs": [], "domainAssociations": []},
}
#: Top-level collection keys on a schema `sites[]` ENTRY ITSELF (a sibling
#: of `bds`/`anps`, not a nested child-object default like
#: SITE_OBJECT_DEFAULTS above) — PR-13. `custom_mso_schema_service_graph.py`
#: (the "Create service graph" task's module, `mso-model/library/`) reads
#: `schema_obj.get('sites')[site_idx]['serviceGraphs']` with a **bare
#: subscript** to find/append the site-local service-graph-to-device
#: binding; a schema born via `POST /schemas` or PATCHed via
#: `mso_schema_site.py`'s "add site" op never carries this key, so a real
#: hardware MS-TN2 `create_tenant` pass-2 run (`-e automate_contract_graph=true`)
#: hit `KeyError: 'serviceGraphs'` on exactly this line — the same class of
#: bug as the template-level `serviceGraphs`/`intersiteL3outs` gap PR-11
#: fixed and the site-local `domainAssociations` gap PR-12 fixed, just one
#: level up (the site entry itself, not one of its child arrays).
SITE_TOP_LEVEL_DEFAULTS: dict[str, Any] = {
"bds": [],
"anps": [],
"serviceGraphs": [],
"contracts": [],
}
def _new_site_epg(epg_ref: str) -> dict:
"""Build a full-shaped site-local EPG dict from its canonical `epgRef`
string — every child-collection array real NDO backfills server-side,
so every sibling `mso_schema_site_anp_epg_*` module's bare subscript
(`["domainAssociations"]`/`["staticLeafs"]`/...) finds a real list.
Single source of truth: keys come from SITE_OBJECT_DEFAULTS["epgs"]."""
epg = {"epgRef": epg_ref}
for dkey, default in SITE_OBJECT_DEFAULTS["epgs"].items():
epg[dkey] = [] if isinstance(default, list) else default
return epg
def normalize_site(site: dict) -> dict:
"""Backfill missing collection-default keys on a schema `sites[]` entry
and its child bds/anps.epgs objects. Mutates and returns *site*."""
# PR-13: backfill the site's OWN top-level collection keys (bds/anps/
# serviceGraphs) before descending into their per-object defaults below
# — see SITE_TOP_LEVEL_DEFAULTS docstring for the serviceGraphs
# bare-subscript crash this closes.
for key, default in SITE_TOP_LEVEL_DEFAULTS.items():
if site.get(key) is None:
site[key] = [] if isinstance(default, list) else default
for key, defaults in SITE_OBJECT_DEFAULTS.items():
if key not in ("bds", "anps"):
continue
for obj in site.get(key, []):
if not isinstance(obj, dict):
continue
for dkey, default in defaults.items():
if obj.get(dkey) is None:
obj[dkey] = [] if isinstance(default, list) else default
if key == "anps":
for epg in obj.get("epgs", []):
if isinstance(epg, dict):
for dkey, default in SITE_OBJECT_DEFAULTS["epgs"].items():
if epg.get(dkey) is None:
epg[dkey] = [] if isinstance(default, list) else default
return site
def _is_list_index(seg: str) -> bool:
return seg.isdigit()
#: `*Ref`-keyed objects — site-local bds/anps/epgs carry no `name` field of
#: their own (their whole identity is the `*Ref` string pointing back at the
#: template-level object), yet sibling modules still address them by that
#: template-level object's bare name — e.g.
#: `/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-` (mso_schema_site_bd_subnet.py
#: /custom_mso_schema_site_bd_subnet.py: `bds_path = "/sites/{0}/bds".format(
#: site_template)`, then indexes by matching `bd_ref in bds`). Match by the
#: trailing path segment of the ref string.
_REF_LOOKUP_KEYS: tuple[str, ...] = ("bdRef", "vrfRef", "l3outRef", "anpRef", "epgRef", "contractRef")
#: The subset of `*Ref` keys that constitute a *nameless site-local shadow's*
#: whole IDENTITY — i.e. the one ref that IS the object (`bds[]` entry → its
#: `bdRef`, `anps[]` → `anpRef`, `epgs[]` → `epgRef`, `contracts[]` →
#: `contractRef`). A shadow object carries no `name`/`displayName` of its
#: own; its identity is exactly this single ref pointing back at the
#: template-level object it mirrors.
#:
#: PR-16 (corrected): the other `*Ref` keys in `_REF_LOOKUP_KEYS`
#: (`vrfRef`, `l3outRef`) are *properties* an object may carry, NOT its
#: identity — every template BD under an L3-attached VRF shares the same
#: `vrfRef` (e.g. `vrf-L3_LAB0`). Matching on those would collapse many
#: distinct BDs into one. So ref-based identity matching is restricted to
#: this map, keyed by the *list's* collection name so a `bds[]` list only
#: ever matches on `bdRef` (never a sibling's shared `vrfRef`).
_IDENTITY_REF_BY_COLLECTION: dict[str, str] = {
"bds": "bdRef",
"anps": "anpRef",
"epgs": "epgRef",
"contracts": "contractRef",
}
_IDENTITY_REF_KEYS: frozenset[str] = frozenset(_IDENTITY_REF_BY_COLLECTION.values())
def _bare_ref_name(ref_val: Any) -> str | None:
"""Extract the bare object name from a `*Ref` field value, regardless
of whether it's already NDO's canonical string form
(`/schemas/.../bds/bd-X`) or one of the dict forms cisco.mso's write
payloads send (`{schemaId, templateName, bdName}` — see
`_REF_NAME_FIELDS`/`_stringify_refs`). Returns None if no name can be
extracted (e.g. an empty dict).
PR-16: the single bare-name extractor for a shadow object's identity
ref. Handles both ref shapes so a dict-form `bdRef` (a site-module
payload's own shape, possibly a bare `{bdName: ...}`) and a string-form
`bdRef` (the mirror's canonical shape) resolve to the same bare name —
the property that lets mirror + site-module add/replace converge on one
entry. Deliberately used ONLY on identity refs (see
`_IDENTITY_REF_BY_COLLECTION`), never on property refs like `vrfRef`."""
if isinstance(ref_val, str) and ref_val:
return ref_val.rsplit("/", 1)[-1]
if isinstance(ref_val, dict):
for name_field, _category in _REF_NAME_FIELDS.values():
name = ref_val.get(name_field)
if name:
return name
return None
def _find_by_name(items: list, seg: str) -> int | None:
"""Return the index of the dict in *items* matching path segment *seg*.
Three addressing conventions appear across cisco.mso's schema-object
modules:
- most template-level objects (bds/anps/epgs/contracts/filters/
externalEpgs/vrfs) are addressed by their own `name` (or
`displayName`) field — e.g. `/templates/LAB1/bds/bd-App1_LAB1`. A
NAMED object is matched ONLY by name/displayName, never by any
`*Ref` field.
- the top-level schema `sites` array is instead addressed by a
synthetic composite key `"{siteId}-{templateName}"` that isn't a
field the object itself carries — every `mso_schema_site_*` module
builds this literally: `site_template = "{0}-{1}".format(site_id,
template)` then `"/sites/{0}/bds".format(site_template)` (see
mso_schema_site_bd.py / _anp.py / _anp_epg.py). Detect that shape by
checking for `siteId`+`templateName` keys on the candidate items and
matching the composite key instead of name/displayName.
- site-local child objects (sites[idx].bds / .anps / .contracts /
.anps[].epgs) carry no `name` of their own at all — only an IDENTITY
`*Ref` field (string OR dict form, PR-16) pointing at the template
object — yet are still addressed by that referenced object's bare
name (confirmed on a real hardware run: `/sites/1-LAB1/bds/
bd-App1_LAB1/subnets/-`). Only for such a NAMELESS item, match by its
identity ref's resolved bare name — and only via the identity refs
(`bdRef`/`anpRef`/`epgRef`/`contractRef`), NEVER a property ref like
`vrfRef` (which many distinct BDs share, PR-16 regression fix). This
is what lets a dict-form bdRef (a site-module payload's own shape)
and a string-form bdRef (the mirror's canonical shape) resolve to the
SAME entry without ever cross-matching two distinct objects.
"""
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
if item.get("name") == seg or item.get("displayName") == seg:
return i
if "siteId" in item and "templateName" in item:
composite = f"{item.get('siteId')}-{item.get('templateName')}"
if composite == seg:
return i
# Ref-matching is ONLY for nameless shadow entries, and ONLY via an
# identity ref — a named object above already returned; a property
# ref (vrfRef/l3outRef) must never be used to establish identity.
if item.get("name") is None and item.get("displayName") is None:
for ref_key in _IDENTITY_REF_KEYS:
if ref_key in item and _bare_ref_name(item.get(ref_key)) == seg:
return i
return None
def _find_shadow_by_identity(items: list, value: Any, collection: str) -> int | None:
"""Append-path dedup for a NAMELESS site-local shadow being added to the
*collection*-named list (`bds`/`anps`/`epgs`/`contracts`).
A JSON-Patch `add` whose path ends in the literal `"-"` (append)
bypasses `_find_by_name` entirely (there's no path segment to resolve),
so a site-module `add` for a BD/ANP/contract that the template-add
mirror (`_mirror_template_bd_to_sites` et al.) already created as a
site-local shadow would otherwise append a SECOND entry instead of
updating the existing one — confirmed against a reference NDO
schema (`MS-TN1-LAB0`) where `sites[0].bds` carried two entries for the
same BD after `create_tenant` (the empty full-path mirror) then
`create_bd`'s site-module subnet PATCH (a dict-bdRef entry carrying the
subnet).
Matches STRICTLY by the value's own identity ref for THIS collection
(`bds`→`bdRef`, etc.), resolved to a bare name, against existing entries'
same identity ref. Never matches:
- a NAMED value (a template-level object add — those append normally),
- via a non-identity/property ref (`vrfRef`/`l3outRef` — many distinct
BDs share one; the PR-16 regression that collapsed every template's
BDs to a single entry),
- across different bd_names.
"""
if not isinstance(value, dict):
return None
# A value carrying its own name is a template-level object, not a
# nameless shadow — it appends normally (distinct objects stay distinct).
if value.get("name") is not None or value.get("displayName") is not None:
return None
ref_key = _IDENTITY_REF_BY_COLLECTION.get(collection)
if ref_key is None or ref_key not in value:
return None
seg = _bare_ref_name(value.get(ref_key))
if seg is None:
return None
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
if item.get("name") is not None or item.get("displayName") is not None:
continue
if ref_key in item and _bare_ref_name(item.get(ref_key)) == seg:
return i
return None
def _resolve_container(doc: dict, tokens: list[str]) -> tuple[Any, str]:
"""Walk *tokens[:-1]* from *doc*, returning (container, last_token).
The container is either a dict (last_token is a key) or a list
(last_token is "-", a numeric index, or a name to resolve).
"""
node: Any = doc
middle = tokens[:-1]
for i, tok in enumerate(middle):
if isinstance(node, list):
if _is_list_index(tok):
idx = int(tok)
else:
idx = _find_by_name(node, tok)
if idx is None:
raise PatchError(f"path segment '{tok}' not found in list")
if idx >= len(node):
raise PatchError(f"index {idx} out of range")
node = node[idx]
elif isinstance(node, dict):
if tok not in node:
# Auto-vivify a missing intermediate container. Every
# cisco.mso schema-object module PATCHes into a collection
# the schema-create/normalize_* pass already seeded as a
# list (vrfs/bds/anps/.../subnets/epgs/...), so this should
# not normally trigger — but if it does, look at the NEXT
# token to pick the right shape instead of guessing a dict:
# a numeric index or "-" (append) means the caller expects
# a list; anything else means a nested dict key.
next_tok = tokens[i + 1] if i + 1 < len(tokens) else None
if next_tok is not None and (next_tok == "-" or _is_list_index(next_tok)):
node[tok] = []
else:
node[tok] = {}
node = node[tok]
else:
raise PatchError(f"cannot descend into non-container at '{tok}'")
return node, tokens[-1]
#: `*Ref` field name -> (name-field-in-payload, url-category-segment).
#: Real NDO always serves these back as canonical STRING refs
#: (`/schemas/{schemaId}/templates/{templateName}/{category}/{name}`) even
#: though several cisco.mso write payloads send a *dict* form instead — see
#: `mso_schema_site_bd.py`'s add payload (`bdRef=dict(schemaId=...,
#: templateName=..., bdName=...)`) versus its OWN query-side comparison
#: (`mso.bd_ref(...)` builds and compares against the string form, and
#: `mso.dict_from_ref()` exists specifically to convert the server's string
#: back to a dict client-side). A real hardware run proved the server must
#: store the string form: a sibling module
#: (`custom_mso_schema_site_bd_subnet.py`) reads `[v.get('bdRef') for v in
#: ...]` and does `bd_ref_string in bds` / `', '.join(bds)` — if the sim
#: stored the dict form verbatim, that `join()` crashes with "sequence item
#: 0: expected str instance, dict found" (confirmed on a real ACI-hardware "Add
#: site-local subnet to BD" run).
_REF_NAME_FIELDS: dict[str, tuple[str, str]] = {
"bdRef": ("bdName", "bds"),
"vrfRef": ("vrfName", "vrfs"),
"l3outRef": ("l3outName", "l3outs"),
"filterRef": ("filterName", "filters"),
"contractRef": ("contractName", "contracts"),
"anpRef": ("anpName", "anps"),
"serviceGraphRef": ("serviceGraphName", "serviceGraphs"),
}
#: `epgRef` is the one `*Ref` field whose canonical string form nests TWO
#: name segments under the template, not one — confirmed against
#: `ansible-mso`'s `plugins/module_utils/mso.py` `epg_ref()`:
#: `"/schemas/{schema_id}/templates/{template}/anps/{anp}/epgs/{epg}"`
#: (`anp_ref()` alone is the single-segment `.../anps/{anp}` form used by
#: `_REF_NAME_FIELDS["anpRef"]` above). `mso_schema_site_anp_epg_staticport.py`
#: sends the dict form `epgRef=dict(schemaId=..., templateName=..., anpName=...,
#: epgName=...)` when auto-creating a site-epg (PR-12) — handled separately
#: from the generic single-category table since it needs both name fields.
_EPG_REF_NAME_FIELDS: tuple[str, str] = ("anpName", "epgName")
def _stringify_refs(value: Any) -> Any:
"""Recursively convert any `*Ref` dict field to NDO's canonical string
ref form, leaving already-string refs and everything else untouched."""
if isinstance(value, dict):
for key, (name_field, category) in _REF_NAME_FIELDS.items():
ref_val = value.get(key)
if isinstance(ref_val, dict) and "schemaId" in ref_val and "templateName" in ref_val:
name = ref_val.get(name_field, ref_val.get("name", ""))
value[key] = "/schemas/{}/templates/{}/{}/{}".format(
ref_val["schemaId"], ref_val["templateName"], category, name
)
epg_ref_val = value.get("epgRef")
if isinstance(epg_ref_val, dict) and "schemaId" in epg_ref_val and "templateName" in epg_ref_val:
anp_name = epg_ref_val.get(_EPG_REF_NAME_FIELDS[0], "")
epg_name = epg_ref_val.get(_EPG_REF_NAME_FIELDS[1], epg_ref_val.get("name", ""))
value["epgRef"] = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(
epg_ref_val["schemaId"], epg_ref_val["templateName"], anp_name, epg_name
)
# `serviceNodeRef` is the other TWO-segment ref (serviceGraphs/{sg}/serviceNodes/{node})
# — `custom_mso_schema_service_graph.py` writes the dict form when it creates the graph
# node; the STOCK `mso_schema_template_contract_service_graph`'s idempotency re-read then
# does a string op on it and dies with "expected string ... got 'dict'" (F3). Real NDO
# returns the resolved string ref, so resolve it here like serviceGraphRef/epgRef.
sgn_ref_val = value.get("serviceNodeRef")
if isinstance(sgn_ref_val, dict) and "schemaId" in sgn_ref_val and "templateName" in sgn_ref_val:
value["serviceNodeRef"] = "/schemas/{}/templates/{}/serviceGraphs/{}/serviceNodes/{}".format(
sgn_ref_val["schemaId"], sgn_ref_val["templateName"],
sgn_ref_val.get("serviceGraphName", ""),
sgn_ref_val.get("serviceNodeName", sgn_ref_val.get("name", "")),
)
for v in value.values():
_stringify_refs(v)
elif isinstance(value, list):
for item in value:
_stringify_refs(item)
return value
#: Matches `add /templates/{template}/bds/-` — a brand-new template-level BD
#: (`mso_schema_template_bd.py`'s "BD does not exist" branch). PR-15: mirrored
#: into every associated site's `bds[]` the same way template ANPs/EPGs/
#: contracts are mirrored (PR-11/PR-12/PR-13), because
#: `mso_schema_site_bd.py` always PATCHes its site-local shadow with
#: `op: replace` (never `add`) — real NDO 4.x auto-creates the site BDDelta
#: entry as soon as the template BD is added to a template that already has
#: sites attached, so by the time the site-BD module runs the shadow already
#: exists and a fresh `add` would 409 with "Multiple BDDelta entries" (see
#: aci-py's `shims/mso_mso_bd_vrf.py::mso_schema_site_bd` docstring, which
#: documents this exact real-hardware behavior). Without this mirror, the
#: `replace` 400s with "'bd-X' not found at '/sites/{siteId}-{tpl}/bds/bd-X'"
#: — confirmed on a real-hardware aci-py `create_tenant`/`create_bd` run
#: (MS-TN2), the same class of gap PR-11/12/13 closed for anps/epgs/contracts.
_TEMPLATE_BD_ADD_RE = re.compile(r"^templates/([^/]+)/bds/-$")
#: Matches `add /templates/{template}/anps/-` — a brand-new template-level
#: ANP (`mso_schema_template_anp.py`'s "ANP does not exist" branch).
_TEMPLATE_ANP_ADD_RE = re.compile(r"^templates/([^/]+)/anps/-$")
#: Matches `add /templates/{template}/anps/{anp}/epgs/-` — a brand-new
#: template-level EPG under an existing ANP (`mso_schema_template_anp_epg.py`).
_TEMPLATE_EPG_ADD_RE = re.compile(r"^templates/([^/]+)/anps/([^/]+)/epgs/-$")
#: Matches `add /templates/{template}/contracts/-` — a brand-new
#: template-level contract (`mso_schema_template_contract_filter.py`'s
#: "contract does not exist" branch). PR-13: mirrored into every associated
#: site's `contracts[]` the same way template ANPs are mirrored, because
#: `mso_rest`-driven raw-PATCH tasks (e.g. the mso-model role's
#: "Atomic PATCH — bind service-graph redirect on ALL fabrics" task) address
#: a site-local contract by bare name at
#: `/sites/{siteId}-{template}/contracts/{contract}/serviceGraphRelationship`
#: — a real hardware MS-TN2 `create_tenant` pass-2 run (with
#: `automate_contract_graph=true automate_site_redirect=true`) hit `"path
#: segment 'con-Firewall_LAB0' not found in list"` on exactly this path
#: because the site never carried a mirrored `contracts[]` array at all.
_TEMPLATE_CONTRACT_ADD_RE = re.compile(r"^templates/([^/]+)/contracts/-$")
def _new_site_bd(bd_ref: str) -> dict:
"""Build a full-shaped site-local BD dict from its canonical `bdRef`
string — mirrors `_new_site_epg` for BDs. `hostBasedRouting` defaults to
`False` (the value `mso_schema_site_bd.py`'s own "BD does not exist yet"
`add` payload would send), so a subsequent `replace` (the module's normal
path once the shadow exists) reads a real bool, not a missing key."""
bd = {"bdRef": bd_ref, "hostBasedRouting": False}
for dkey, default in SITE_OBJECT_DEFAULTS["bds"].items():
bd[dkey] = [] if isinstance(default, list) else default
return bd
def _mirror_template_bd_to_sites(doc: dict, template_name: str, bd_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local BD shadow entry in every site associated
with *template_name*, mirroring what real NDO 4.x does automatically
when a template-level BD is added to a template that already has sites
attached — see `_TEMPLATE_BD_ADD_RE`'s comment for the full rationale
and the aci-py shim docstring it cites. Idempotent: skips sites that
already carry this bdRef."""
bd_ref = "/schemas/{}/templates/{}/bds/{}".format(schema_id or doc.get("id", ""), template_name, bd_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("bds", [])
if _find_by_name(site["bds"], bd_name) is not None:
continue
site["bds"].append(_new_site_bd(bd_ref))
def _mirror_template_anp_to_sites(doc: dict, template_name: str, anp_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local ANP entry in every site associated with
*template_name*, mirroring what real NDO 4.x does automatically when a
template-level ANP is added to a template that already has sites
attached (`mso_schema_site.py` having already run in create_tenant's
flow — PR-11). Idempotent: skips sites that already carry this anpRef.
Without this, `mso_schema_site_anp_epg_staticport.py`'s own fallback
"create site anp/epg if missing" branch is what fires instead — and that
fallback crashes with `'NoneType' object has no attribute 'details'` on
a subsequent step (see module docstring / CONTRACT.md §7a), matching a
real hardware "coverage misses this on 4.x and above" code comment in the
module itself: on real hardware this mirroring already happened, so the
fallback path is never exercised.
"""
anp_ref = "/schemas/{}/templates/{}/anps/{}".format(schema_id or doc.get("id", ""), template_name, anp_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("anps", [])
if _find_by_name(site["anps"], anp_name) is not None:
continue
site["anps"].append({"anpRef": anp_ref, "epgs": []})
def _mirror_template_epg_to_sites(
doc: dict, template_name: str, anp_name: str, epg_name: str, schema_id: str | None
) -> None:
"""Auto-create a site-local EPG entry (under its mirrored site-anp) in
every site associated with *template_name*, mirroring real NDO 4.x's
automatic site-epg creation on template EPG add. See
`_mirror_template_anp_to_sites` for the full rationale. Idempotent."""
sid = schema_id or doc.get("id", "")
epg_ref = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(sid, template_name, anp_name, epg_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("anps", [])
anp_idx = _find_by_name(site["anps"], anp_name)
if anp_idx is None:
# Template-level ANP add should have mirrored the site-anp
# already; auto-vivify defensively so EPG add never crashes on
# an out-of-order/partial patch batch.
anp_ref = "/schemas/{}/templates/{}/anps/{}".format(sid, template_name, anp_name)
site["anps"].append({"anpRef": anp_ref, "epgs": []})
anp_idx = len(site["anps"]) - 1
site_anp = site["anps"][anp_idx]
site_anp.setdefault("epgs", [])
if _find_by_name(site_anp["epgs"], epg_name) is not None:
continue
site_anp["epgs"].append(_new_site_epg(epg_ref))
def _mirror_template_contract_to_sites(doc: dict, template_name: str, contract_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local contract entry (`{contractRef}`) in every
site associated with *template_name*, mirroring real NDO 4.x's
automatic site-local mirroring for a template-level contract add — the
same family of behavior PR-12 implemented for template ANPs/EPGs (see
`_mirror_template_anp_to_sites`'s docstring for the general rationale).
Without this, a raw-PATCH task addressing a site-local contract by its
bare name (e.g. `/sites/{siteId}-{template}/contracts/{contract}/
serviceGraphRelationship`, the mso-model role's redirect-policy bind)
hits `_find_by_name`'s "not found in list" `PatchError` — confirmed on
a real hardware MS-TN2 `create_tenant` pass-2 run (PR-13).
"""
contract_ref = "/schemas/{}/templates/{}/contracts/{}".format(schema_id or doc.get("id", ""), template_name, contract_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("contracts", [])
if _find_by_name(site["contracts"], contract_name) is not None:
continue
site["contracts"].append({"contractRef": contract_ref})
def apply_json_patch(doc: dict, ops: list[dict]) -> dict:
"""Apply a list of RFC-6902-ish ops to *doc* in place and return it.
Supports op in {"add", "replace", "remove"}. Unknown ops are ignored
(forward-compatible with cisco.mso module versions we haven't seen).
"""
for entry in ops:
op = entry.get("op")
path = entry.get("path", "")
value = entry.get("value")
if op in ("add", "replace"):
value = _stringify_refs(value)
tokens = [t for t in path.split("/") if t != ""]
if not tokens:
continue
container, last = _resolve_container(doc, tokens)
if op == "add":
if isinstance(container, list):
if last == "-":
# A bare "-" (append) skips _find_by_name entirely (no
# path segment to resolve against). For a NAMELESS
# site-local shadow (`sites[].bds`/`anps`/`epgs`/
# `contracts` entries, whose whole identity is a single
# `*Ref`), this previously let a site-module `add` create
# a SECOND entry for an object the template-add mirror
# already shadowed — see _find_shadow_by_identity for the
# real-NDO duplicate-BD symptom this closes. Dedup ONLY a
# nameless shadow, ONLY via its identity ref for THIS
# collection (tokens[-2]); a named object (template-level
# BD add) always appends so distinct objects stay
# distinct (PR-16 regression fix).
collection = tokens[-2] if len(tokens) >= 2 else ""
existing_idx = _find_shadow_by_identity(container, value, collection)
if existing_idx is None:
container.append(value)
else:
container[existing_idx] = value
elif _is_list_index(last):
idx = int(last)
container.insert(idx, value)
else:
idx = _find_by_name(container, last)
if idx is None:
container.append(value)
else:
container[idx] = value
elif isinstance(container, dict):
container[last] = value
else:
raise PatchError(f"add: unsupported container type at '{path}'")
# PR-12: mirror a brand-new template-level ANP/EPG into every
# site already associated with this template — see
# _mirror_template_anp_to_sites for the real-NDO-4.x rationale.
normalized_path = "/".join(tokens)
bd_match = _TEMPLATE_BD_ADD_RE.match(normalized_path)
if bd_match and isinstance(value, dict):
bd_name = value.get("name")
if bd_name:
_mirror_template_bd_to_sites(doc, bd_match.group(1), bd_name, doc.get("id"))
anp_match = _TEMPLATE_ANP_ADD_RE.match(normalized_path)
if anp_match and isinstance(value, dict):
anp_name = value.get("name")
if anp_name:
_mirror_template_anp_to_sites(doc, anp_match.group(1), anp_name, doc.get("id"))
epg_match = _TEMPLATE_EPG_ADD_RE.match(normalized_path)
if epg_match and isinstance(value, dict):
epg_name = value.get("name")
if epg_name:
_mirror_template_epg_to_sites(doc, epg_match.group(1), epg_match.group(2), epg_name, doc.get("id"))
contract_match = _TEMPLATE_CONTRACT_ADD_RE.match(normalized_path)
if contract_match and isinstance(value, dict):
contract_name = value.get("name")
if contract_name:
_mirror_template_contract_to_sites(doc, contract_match.group(1), contract_name, doc.get("id"))
elif op == "replace":
if isinstance(container, list):
if _is_list_index(last):
idx = int(last)
else:
idx = _find_by_name(container, last)
if idx is None or idx >= len(container):
raise PatchError(f"replace: '{last}' not found at '{path}'")
container[idx] = value
elif isinstance(container, dict):
container[last] = value
else:
raise PatchError(f"replace: unsupported container type at '{path}'")
elif op == "remove":
if isinstance(container, list):
if _is_list_index(last):
idx = int(last)
else:
idx = _find_by_name(container, last)
if idx is not None and idx < len(container):
container.pop(idx)
elif isinstance(container, dict):
container.pop(last, None)
# else: unknown op — ignore rather than fail the whole batch.
return doc
View File
+251
View File
@@ -0,0 +1,251 @@
"""ACI query engine — maps APIC query patterns to MITStore lookups.
Implements three query shapes (CONTRACT §2):
1. ``run_class_query`` — GET /api/class/{cls}.json
2. ``run_mo_query`` — GET /api/mo/{dn}.json
3. ``run_node_scoped`` — GET /api/node/class/{node_dn}/{cls}.json
All three return ``(imdata: list[dict], total: int)`` where *total* is the
full pre-pagination matched count and *imdata* is the current page slice.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.filters import FilterParseError, Predicate, parse_filter
# ---------------------------------------------------------------------------
# QueryParams
# ---------------------------------------------------------------------------
@dataclass
class QueryParams:
"""Carries all APIC query-string parameters for a single request."""
filter_expr: str | None = None
rsp_subtree: str | None = None # "children" | "full"
rsp_subtree_class: list[str] | None = None
query_target: str | None = None # "subtree" (legacy)
target_subtree_class: list[str] | None = None
page: int = 0
page_size: int = 500
def params_from_dict(raw: dict[str, str]) -> QueryParams:
"""Build a :class:`QueryParams` from raw APIC query-string key names.
APIC key names:
``query-target-filter``, ``rsp-subtree``, ``rsp-subtree-class``,
``query-target``, ``target-subtree-class``, ``page``, ``page-size``.
Class-list params are comma-split.
``page-size`` is clamped to ``[1, 5000]``; ``page`` must be ``>= 0``.
Non-numeric or out-of-range ``page``/``page-size`` raise
:class:`~aci_sim.query.filters.FilterParseError` (mapped by the REST
layer to an APIC 400 error envelope — never a bare 500 or silent
truncation).
"""
def _classes(key: str) -> list[str] | None:
val = raw.get(key, "").strip()
if not val:
return None
return [c.strip() for c in val.split(",") if c.strip()]
def _int_param(key: str, default: int) -> int:
raw_val = raw.get(key)
if raw_val is None or raw_val == "":
return default
try:
return int(raw_val)
except ValueError:
raise FilterParseError(
f"invalid {key} value {raw_val!r}: must be an integer"
) from None
page = _int_param("page", 0)
if page < 0:
raise FilterParseError(f"invalid page value {page!r}: must be >= 0")
page_size = _int_param("page-size", 500)
if page_size < 1:
raise FilterParseError(f"invalid page-size value {page_size!r}: must be >= 1")
page_size = min(page_size, 5000)
return QueryParams(
filter_expr=raw.get("query-target-filter") or None,
rsp_subtree=raw.get("rsp-subtree") or None,
rsp_subtree_class=_classes("rsp-subtree-class"),
query_target=raw.get("query-target") or None,
target_subtree_class=_classes("target-subtree-class"),
page=page,
page_size=page_size,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _predicate(params: QueryParams) -> Predicate:
return parse_filter(params.filter_expr)
def _wants_subtree(params: QueryParams) -> bool:
return (
params.rsp_subtree in ("children", "full")
or params.query_target == "subtree"
)
def _subtree_classes(params: QueryParams) -> set[str] | None:
classes = params.rsp_subtree_class or params.target_subtree_class
return set(classes) if classes else None
def _nested_children(
store: MITStore, dn: str, cls_filter: set[str] | None
) -> list[dict[str, Any]]:
"""Build imdata child entries for *dn*, nested recursively by DN parentage.
Mirrors APIC ``rsp-subtree=full``: every descendant stays nested under its
real parent (grandchildren under children, not hoisted to the root). When
*cls_filter* is set, a node is emitted iff its own class is in the filter OR
it has an emitted descendant — so structural containers on the path to a
matching grandchild are preserved (matching ``rsp-subtree-class`` semantics).
MO-less structural intermediates are traversed through: their emitted
descendants hoist up to this level (there is no MO to emit for them).
"""
out: list[dict[str, Any]] = []
for cdn in store.child_dns(dn):
child_mo = store.get(cdn)
nested = _nested_children(store, cdn, cls_filter)
if child_mo is None:
out.extend(nested) # structural intermediate → hoist its matches up
continue
if cls_filter is None or child_mo.class_name in cls_filter or nested:
body: dict[str, Any] = {"attributes": dict(child_mo.attrs)}
if nested:
body["children"] = nested
out.append({child_mo.class_name: body})
return out
def _attach_subtree(store: MITStore, mo: MO, params: QueryParams) -> dict[str, Any]:
"""Produce the imdata entry for *mo* with its descendants nested as children."""
cls_filter = _subtree_classes(params)
if params.rsp_subtree == "children":
# rsp-subtree=children → one level only (direct children)
children = [d.to_imdata() for d in store.children(mo.dn, classes=cls_filter)]
else:
# rsp-subtree=full → full recursive tree, NESTED (hierarchy preserved).
# Real APIC keeps grandchildren under their parent; flattening them into
# direct children of the root silently empties multi-level consumers like
# autoACI contract_map (vzBrCP→vzSubj→vzRsSubjFiltAtt).
children = _nested_children(store, mo.dn, cls_filter)
body: dict[str, Any] = {"attributes": dict(mo.attrs)}
if children:
body["children"] = children
return {mo.class_name: body}
def _paginate(items: list[Any], page: int, page_size: int) -> list[Any]:
start = page * page_size
return items[start: start + page_size]
def _build_result(
store: MITStore,
top_level: list[MO],
params: QueryParams,
) -> tuple[list[dict], int]:
"""Filter, optionally attach subtrees, then paginate.
Returns ``(imdata_page, total_matched)``. *total* is always the full
pre-pagination count (the caller converts it to a string for the wire
``totalCount`` field).
"""
pred = _predicate(params)
# Legacy subtree (query-target=subtree, used by autoACI's query_dn(subtree=True)):
# real APIC returns the matched descendants as FLAT top-level imdata elements
# (filtered by target-subtree-class; root included only if it matches). autoACI
# consumers read the target class at the TOP LEVEL of imdata and never recurse into
# `children` (topology.py ISN/node-detail, fabric_bgp_evpn, l3out_detail, ~60 sites).
#
# ``query-target=subtree`` first sets the SCOPE to root+descendants (unfiltered),
# THEN both the class filter and ``query-target-filter`` predicate are evaluated
# per scoped MO — never pre-filtering the roots with `pred`. Otherwise a filter on
# a descendant-only attribute (e.g. faultInst.severity under a topSystem root)
# would exclude the root before subtree expansion ever runs, returning empty
# imdata even though matching descendants exist (attr-missing→False on the root
# is a legitimate non-match for the root itself, but must not gate expansion).
if params.query_target == "subtree" and params.rsp_subtree is None:
cls_filter = _subtree_classes(params)
flat: list[MO] = []
for root in top_level:
for mo in [root, *store.subtree(root.dn)]:
if cls_filter is None or mo.class_name in cls_filter:
flat.append(mo)
flat = [mo for mo in flat if pred(mo)]
total = len(flat)
page_items = _paginate(flat, params.page, params.page_size)
return [mo.to_imdata() for mo in page_items], total
# Modern rsp-subtree=children|full: NESTED children under each matched object.
filtered = [mo for mo in top_level if pred(mo)]
total = len(filtered)
page_items = _paginate(filtered, params.page, params.page_size)
if params.rsp_subtree in ("children", "full"):
imdata = [_attach_subtree(store, mo, params) for mo in page_items]
else:
imdata = [mo.to_imdata() for mo in page_items]
return imdata, total
# ---------------------------------------------------------------------------
# Public query functions
# ---------------------------------------------------------------------------
def run_class_query(
store: MITStore,
cls: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Query all MOs of class *cls* in the store."""
return _build_result(store, store.by_class(cls), params)
def run_mo_query(
store: MITStore,
dn: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Query the single MO at *dn* (plus subtree if requested)."""
mo = store.get(dn)
if mo is None:
return [], 0
return _build_result(store, [mo], params)
def run_node_scoped(
store: MITStore,
node_dn: str,
cls: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Return instances of *cls* whose DN is under *node_dn*.
Mirrors ``GET /api/node/class/{node_dn}/{cls}.json`` — used for
node-local MOs such as ``faultInst``.
"""
prefix = node_dn.rstrip("/") + "/"
top_level = [mo for mo in store.by_class(cls) if mo.dn.startswith(prefix)]
return _build_result(store, top_level, params)
+267
View File
@@ -0,0 +1,267 @@
"""Parse APIC filter expressions into a callable predicate ``fn(MO) -> bool``.
Supported grammar (CONTRACT §4):
eq(<class>.<attr>, "<val>") equal
ne(<class>.<attr>, "<val>") not equal
wcard(<class>.<attr>, "<substr>") substring containment
gt|lt|ge|le(<class>.<attr>, "<val>") numeric compare (string fallback)
bw(<class>.<attr>, "<lo>", "<hi>") inclusive range
and(<expr>, <expr>, ...)
or(<expr>, <expr>, ...)
Rules:
- Nesting is allowed: ``and(or(...), eq(...))``
- The ``<class>.`` prefix is advisory; we use the attr name after the last dot.
- ``wcard`` performs a substring containment check (not a glob).
- ``gt/lt/ge/le/bw`` compare numerically when both sides parse as numbers,
else fall back to string comparison.
- An unknown attr evaluates to False for eq/wcard/compares (attr not present).
- An empty / None expression is treated as "match everything".
- A malformed expression raises :class:`FilterParseError` (the REST layer maps
it to an APIC 400 error envelope — never a 500).
"""
from __future__ import annotations
from typing import Callable
from aci_sim.mit.mo import MO
Predicate = Callable[[MO], bool]
class FilterParseError(ValueError):
"""Raised when a ``query-target-filter`` expression cannot be parsed.
The REST layer catches this and returns an APIC error envelope with HTTP
400, matching real APIC (which rejects a malformed filter with a 400 +
imdata error) rather than surfacing a 500.
"""
# ---------------------------------------------------------------------------
# Tokenizer (quote- and bracket-aware)
# ---------------------------------------------------------------------------
def _tokenize(s: str) -> list[str]:
"""Produce a flat list of tokens from a filter expression string.
Token types:
- Identifiers / dotted names: ``eq``, ``wcard``, ``and``, ``or``,
``fabricNode.role``, …
- Quoted strings (double-quote delimited, returned WITH the quotes).
- Punctuation: ``(``, ``)`, ``,``.
Whitespace is skipped.
"""
tokens: list[str] = []
i = 0
n = len(s)
while i < n:
ch = s[i]
if ch in " \t\r\n":
i += 1
elif ch == '"':
j = i + 1
while j < n and s[j] != '"':
j += 1
tokens.append(s[i: j + 1])
i = j + 1
elif ch in "(,)":
tokens.append(ch)
i += 1
else:
# Identifier / dotted name — stop at delimiter chars
j = i
while j < n and s[j] not in '(,) \t\r\n"':
j += 1
tokens.append(s[i:j])
i = j
return tokens
# ---------------------------------------------------------------------------
# Recursive-descent parser
# ---------------------------------------------------------------------------
def _expect(tokens: list[str], pos: int, want: str) -> int:
"""Assert ``tokens[pos] == want`` and return ``pos + 1`` (else raise)."""
if pos >= len(tokens) or tokens[pos] != want:
got = tokens[pos] if pos < len(tokens) else "<end-of-expression>"
raise FilterParseError(f"expected {want!r} but got {got!r}")
return pos + 1
# Operators taking (class.attr, value): eq/ne substring-agnostic + comparisons.
_BINARY_OPS = ("eq", "ne", "wcard", "gt", "lt", "ge", "le")
def _parse_expr(tokens: list[str], pos: int) -> tuple[Predicate, int]:
"""Parse one expression starting at *tokens[pos]*.
Returns ``(predicate, new_pos)``. Raises :class:`FilterParseError` on any
structural problem.
"""
if pos >= len(tokens):
raise FilterParseError("unexpected end of filter expression")
tok = tokens[pos]
if tok in ("and", "or"):
pos = _expect(tokens, pos + 1, "(")
parts: list[Predicate] = []
while pos < len(tokens) and tokens[pos] != ")":
if tokens[pos] == ",":
pos += 1
continue
pred, pos = _parse_expr(tokens, pos)
parts.append(pred)
pos = _expect(tokens, pos, ")")
if not parts:
raise FilterParseError(f"{tok}() requires at least one argument")
combiner = _and_pred if tok == "and" else _or_pred
return combiner(parts), pos
if tok in _BINARY_OPS:
# <op>(<class>.<attr>, "<val>")
pos = _expect(tokens, pos + 1, "(")
dotted, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
val_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ")")
return _make_binary_pred(tok, dotted.split(".")[-1], val_token.strip('"')), pos
if tok == "bw":
# bw(<class>.<attr>, "<low>", "<high>")
pos = _expect(tokens, pos + 1, "(")
dotted, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
low_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
high_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ")")
return _bw_pred(dotted.split(".")[-1], low_token.strip('"'), high_token.strip('"')), pos
raise FilterParseError(f"unknown filter operator {tok!r} at position {pos}")
def _take(tokens: list[str], pos: int) -> tuple[str, int]:
"""Return ``(tokens[pos], pos + 1)`` or raise on end-of-input."""
if pos >= len(tokens):
raise FilterParseError("unexpected end of filter expression")
return tokens[pos], pos + 1
# ---------------------------------------------------------------------------
# Predicate factories
# ---------------------------------------------------------------------------
def _eq_pred(attr: str, val: str) -> Predicate:
def pred(mo: MO) -> bool:
return mo.attrs.get(attr) == val
return pred
def _wcard_pred(attr: str, substr: str) -> Predicate:
def pred(mo: MO) -> bool:
return substr in mo.attrs.get(attr, "")
return pred
def _and_pred(preds: list[Predicate]) -> Predicate:
def pred(mo: MO) -> bool:
return all(p(mo) for p in preds)
return pred
def _or_pred(preds: list[Predicate]) -> Predicate:
def pred(mo: MO) -> bool:
return any(p(mo) for p in preds)
return pred
def _ne_pred(attr: str, val: str) -> Predicate:
# ne = ¬eq: an object lacking the attr does not equal *val*, so it matches.
def pred(mo: MO) -> bool:
return mo.attrs.get(attr) != val
return pred
def _to_number(s: object) -> float | None:
try:
return float(s) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
_CMP_OPS: dict[str, Callable[[object, object], bool]] = {
"gt": lambda a, b: a > b,
"lt": lambda a, b: a < b,
"ge": lambda a, b: a >= b,
"le": lambda a, b: a <= b,
}
def _cmp_pred(op: str, attr: str, val: str) -> Predicate:
fn = _CMP_OPS[op]
def pred(mo: MO) -> bool:
raw = mo.attrs.get(attr)
if raw is None:
return False
rn, vn = _to_number(raw), _to_number(val)
if rn is not None and vn is not None:
return fn(rn, vn) # numeric compare when both parse
return fn(str(raw), val) # else lexical
return pred
def _bw_pred(attr: str, low: str, high: str) -> Predicate:
def pred(mo: MO) -> bool:
raw = mo.attrs.get(attr)
if raw is None:
return False
rn, ln, hn = _to_number(raw), _to_number(low), _to_number(high)
if None not in (rn, ln, hn):
return ln <= rn <= hn # type: ignore[operator]
return str(low) <= str(raw) <= str(high)
return pred
def _make_binary_pred(op: str, attr: str, val: str) -> Predicate:
if op == "eq":
return _eq_pred(attr, val)
if op == "ne":
return _ne_pred(attr, val)
if op == "wcard":
return _wcard_pred(attr, val)
return _cmp_pred(op, attr, val) # gt/lt/ge/le
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_filter(expr: str | None) -> Predicate:
"""Parse *expr* into a callable predicate.
Returns a predicate that always returns ``True`` for empty/``None`` input.
"""
if not expr:
return lambda mo: True
try:
tokens = _tokenize(expr)
pred, pos = _parse_expr(tokens, 0)
if pos != len(tokens):
trailing = tokens[pos]
raise FilterParseError(
f"unexpected trailing token {trailing!r} after complete expression "
f"in filter {expr!r}"
)
except FilterParseError:
raise
except (IndexError, ValueError) as exc: # defensive: any tokenizer/parse slip
raise FilterParseError(f"invalid filter expression {expr!r}: {exc}") from exc
return pred
View File
+385
View File
@@ -0,0 +1,385 @@
"""Main APIC FastAPI application.
URL routing matches exactly what aci_connector.py builds:
- GET /api/class/{cls}.json
- GET /api/mo/{dn:path} (dn may contain slashes + brackets)
- POST /api/mo/{dn:path}
- DELETE /api/mo/{dn:path} (PR-9 — cisco.aci state=absent)
- GET/POST/DELETE /api/node/mo/{dn:path} (PR-10 — real-APIC alias, see below)
- GET /api/node/class/{rest:path}
- Auth: /api/aaaLogin.json, /api/aaaRefresh.json, /api/aaaLogout.json
- Control: /_sim/reset, /_sim/snapshot/{name}, etc.
PR-10 — /api/node/mo/{dn} alias (live E2E blocker, 11 failures). Real APIC
serves /api/node/mo/{dn}.json as a full equivalent of /api/mo/{dn}.json — it
is the form the APIC GUI's API Inspector emits and what cisco.aci's
aci_rest module (and some aci_connector.py callers) actually issue. The sim
only registered /api/mo/*, so a node-alias request fell through to FastAPI's
default 404 {"detail":"Not Found"} — a shape cisco.aci cannot parse
("APIC Error None: None", status=-1). Each verb below is registered on BOTH
paths, sharing one handler body (stacked route decorators) so behavior can
never drift between the canonical and alias forms. A catch-all for any other
unmatched /api/* path returns an APIC-shaped 400 error envelope instead of
FastAPI's {"detail":...} — see `_unmatched_api_path` below.
PR-9 DELETE semantics: cisco.aci's module_utils/aci.py (`delete_config()`)
only issues an HTTP DELETE after its own `get_existing()` GET has confirmed
the MO is present — `if not self.existing: return` short-circuits before
ever calling DELETE (verified read-only against upstream
plugins/module_utils/aci.py). So the module itself never exercises a
DELETE-on-already-absent-DN round trip. This sim still makes the route
idempotent (200 + empty imdata on a missing DN, same as a present one)
rather than 404, for two reasons: (1) it matches the MIT's natural "delete
what's not there = no-op" semantics used elsewhere in this codebase
(MITStore.upsert's status=deleted branch is unconditional, no existence
check), and (2) it is the safer default for any OTHER client (raw curl,
future modules) that DOES call DELETE without a prior GET — a 404 there
would make state=absent playbooks non-idempotent on second-run against
such a client, which is the one thing real APIC's actual DELETE endpoint
is documented to guarantee. See docs/CONTRACT.md §Ansible-compatibility.
"""
from __future__ import annotations
import asyncio
import copy
from dataclasses import dataclass
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from aci_sim.mit.store import MITStore
from aci_sim.query.engine import (
params_from_dict,
run_class_query,
run_mo_query,
run_node_scoped,
)
from aci_sim.query.filters import FilterParseError
from aci_sim.rest_aci import subscriptions as subs
from aci_sim.rest_aci.auth import (
_auth_error_403,
_lookup_session,
make_auth_router,
require_session,
)
from aci_sim.rest_aci.writes import WriteValidationError
from aci_sim.rest_aci.writes import apply as write_apply
from aci_sim.topology.schema import Site, Topology
@dataclass
class ApicSiteState:
name: str
site: Site
topo: Topology
store: MITStore
baseline: MITStore # deepcopy at construction time
def _apic_ok(imdata: list[dict], total: int) -> dict:
"""Wrap imdata in the APIC envelope with string totalCount."""
return {"imdata": imdata, "totalCount": str(total)}
def _apic_error(text: str, code: str = "103", status_code: int = 400) -> JSONResponse:
"""Return an APIC error envelope as a JSONResponse."""
body = {
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
"totalCount": "1",
}
return JSONResponse(status_code=status_code, content=body)
def _maybe_subscribe(
body: dict, request: Request, token: str | None, scope_kind: str, scope_value: str
) -> dict:
"""Opt-in subscription hook shared by all three query routes.
If the request carries ``?subscription=yes``, registers a subscription
for *token* watching *scope_kind*/*scope_value* and adds a top-level
``subscriptionId`` key to *body* (mirroring real APIC — the field sits
alongside ``imdata``/``totalCount``, not inside them). Without that query
param, *body* is returned completely untouched — no key added, no
registry write — so plain queries stay byte-identical to pre-subscription
behavior (backward-compat requirement).
"""
if request.query_params.get("subscription") != "yes":
return body
if not token:
# Should not happen — require_session already gated the route — but
# never register a subscription with no token to notify later.
return body
sid = subs.subscribe(token, scope_kind, scope_value)
body = dict(body)
body["subscriptionId"] = sid
return body
def make_apic_app(state: ApicSiteState) -> FastAPI:
app = FastAPI(title=f"aci-sim APIC {state.name}")
# --- Auth routes ---
app.include_router(make_auth_router())
# --- Control-plane admin routes ---
from aci_sim.control.admin import make_admin_router
app.include_router(make_admin_router(state))
# --- Class query ---
@app.get("/api/class/{cls}.json")
async def get_class(cls: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_class_query(state.store, cls, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
token = request.cookies.get("APIC-cookie")
return _maybe_subscribe(body, request, token, "class", cls)
# --- MO query (GET) ---
# dn:path captures "uni/tn-Corp.json" including any slashes and brackets.
# We must strip the trailing ".json".
# PR-10: /api/node/mo/{dn} is a full alias of /api/mo/{dn} on real APIC
# (API Inspector / aci_rest form) — same handler, stacked route.
@app.get("/api/mo/{dn:path}")
@app.get("/api/node/mo/{dn:path}")
async def get_mo(dn: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
if dn.endswith(".json"):
dn = dn[:-5]
# PR-9 FEATURE 6 (live blocker, verified against upstream
# module_utils/aci.py's api_call(): status != 200 is UNCONDITIONALLY
# fatal via fail_json — there is no "404 is fine for GET" special
# case). cisco.aci's state=present flow always does a GET-before-POST
# existence check first; on a brand-new object that GET's DN does not
# exist yet. Real APIC returns 200 + empty imdata for a well-formed
# DN that simply doesn't exist (not 404) — 404/error codes are for
# malformed requests, not absent objects. A 404 here made every
# aci_tenant/aci_* state=present playbook fail on its very first run
# ("APIC Error 103: Unable to find the object specified"). Superseded
# the prior 404-on-missing-DN design (originally justified as
# matching an autoACI ACINotFoundError expectation — that consumer
# is a different client with different semantics; cisco.aci is this
# simulator's primary use case per DESIGN.md and takes precedence).
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_mo_query(state.store, dn, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
token = request.cookies.get("APIC-cookie")
return _maybe_subscribe(body, request, token, "dn", dn)
# --- MO write (POST) ---
# PR-10: /api/node/mo/{dn} alias — see get_mo above.
@app.post("/api/mo/{dn:path}")
@app.post("/api/node/mo/{dn:path}")
async def post_mo(dn: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
if dn.endswith(".json"):
dn = dn[:-5]
try:
body = await request.json()
except Exception:
return _apic_error("Invalid JSON body", code="103", status_code=400)
if not body or not isinstance(body, dict):
return _apic_error("Empty or non-object body", code="103", status_code=400)
cls = next(iter(body))
if not isinstance(body[cls], dict):
return _apic_error("Invalid MO body structure", code="103", status_code=400)
# Subscriptions need created-vs-modified, which writes.apply's own
# response shape doesn't distinguish (it always reports "created"
# for a non-delete write — see writes.py's `apply()`). Determine it
# here, pre-write, purely for the push event; the HTTP response body
# below is unchanged from before this feature.
effective_dn = (body[cls].get("attributes") or {}).get("dn") or dn
pre_existing = state.store.get(effective_dn) is not None
try:
imdata, total = write_apply(state.store, dn, body, topo=state.topo, site=state.site)
except (WriteValidationError, AttributeError, TypeError, KeyError) as exc:
return _apic_error(f"Malformed MO body: {exc}", code="103", status_code=400)
# Push-on-change (subscriptions): notify after the store commit above.
# imdata is the write result shape [{cls: {"attributes": {"dn":..., "status": "created"|"deleted"}}}]
# from writes.apply — one entry per top-level POSTed class (children
# are written but not individually reported here, matching the
# existing non-subscription response shape).
for entry in imdata:
for mo_cls, mo_body in entry.items():
mo_attrs = mo_body.get("attributes", {})
mo_dn = mo_attrs.get("dn", dn)
raw_status = mo_attrs.get("status", "created")
if raw_status == "deleted":
push_status = "deleted"
elif mo_dn == effective_dn and pre_existing:
push_status = "modified"
else:
push_status = "created"
subs.notify(mo_cls, mo_dn, push_status, mo_attrs)
return _apic_ok(imdata, total)
# --- MO delete (DELETE) ---
# cisco.aci state=absent path: DELETE /api/mo/{dn}.json. Removes the DN's
# entire subtree from the store. Idempotent: a missing DN also returns
# 200 + empty imdata rather than 404 (see module docstring above for the
# real-module-behavior justification).
# PR-10: /api/node/mo/{dn} alias — see get_mo above.
@app.delete("/api/mo/{dn:path}")
@app.delete("/api/node/mo/{dn:path}")
async def delete_mo(dn: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
if dn.endswith(".json"):
dn = dn[:-5]
# Capture the whole subtree BEFORE deleting so push-on-change can
# notify for every removed MO (a DN-scoped subscription may be
# watching a descendant, and a class-scoped one may be watching any
# class present in the subtree, not just the root DN's own class).
existing = state.store.get(dn)
removed = ([existing] if existing is not None else []) + state.store.subtree(dn)
state.store.delete(dn)
for mo in removed:
subs.notify(mo.class_name, mo.dn, "deleted", mo.attrs)
return _apic_ok([], 0)
# --- Node-scoped class query ---
# rest captures everything after /api/node/class/
# e.g. rest = "topology/pod-1/node-101/faultInst.json" (node-scoped)
# rest = "topSystem.json" (fabric-wide)
#
# Real APIC also supports the fabric-wide form of this endpoint — no
# topology/pod-X/node-Y prefix, just the bare "{cls}.json" — which is
# equivalent to /api/class/{cls}.json (finding #12). Distinguish the two
# by whether *rest* (once ".json" is stripped) contains a "/": a
# node-scoped DN always does (it's a full topology/... path), while the
# fabric-wide form is a single path segment.
@app.get("/api/node/class/{rest:path}")
async def get_node_class(rest: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
token = request.cookies.get("APIC-cookie")
stripped = rest[:-5] if rest.endswith(".json") else rest
if "/" not in stripped:
# Fabric-wide form: {cls}.json with no DN prefix at all.
cls = stripped
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_class_query(state.store, cls, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
return _maybe_subscribe(body, request, token, "class", cls)
parts = rest.rsplit("/", 1)
if len(parts) != 2:
return _apic_error("Malformed node-scoped URL", code="103", status_code=400)
node_dn = parts[0]
cls = parts[1]
if cls.endswith(".json"):
cls = cls[:-5]
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_node_scoped(state.store, node_dn, cls, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
return _maybe_subscribe(body, request, token, "dn", node_dn)
# --- Subscription refresh ---
# GET /api/subscriptionRefresh.json?id=<id> — keeps a subscription alive
# past its TTL (real APIC's default subscription lifetime is 90s,
# refreshed by polling this endpoint). Always 200s for a live session;
# an unknown/expired id still returns 200 with a small APIC-shaped
# imdata (real APIC does not error a stale refresh — the caller just
# re-subscribes on its next query if the id no longer resolves).
@app.get("/api/subscriptionRefresh.json")
async def subscription_refresh(request: Request):
if require_session(request) is None:
return _auth_error_403()
subscription_id = request.query_params.get("id", "")
subs.refresh(subscription_id)
return {"imdata": [], "totalCount": "0", "subscriptionId": subscription_id}
# --- Websocket push channel ---
# GET /socket<token> — real APIC's subscription websocket path, where
# <token> is the same APIC-cookie token minted by aaaLogin. FastAPI/
# Starlette route params can't match a bare suffix glued onto a fixed
# prefix with no separator (real APIC literally concatenates the token
# onto "/socket" — no slash), so this uses a `{token}` path param on the
# pattern "/socket{token}", which Starlette resolves correctly (the
# literal "/socket" prefix consumes up to where the param begins).
@app.websocket("/socket{token}")
async def subscription_socket(websocket: WebSocket, token: str):
session = _lookup_session(token)
if session is None:
# Unknown/expired token — reject before accept (real APIC closes
# the upgrade for an invalid session token).
await websocket.close(code=4403)
return
await websocket.accept()
queue = subs.register_connection(token)
async def _pump_events() -> None:
"""Drain the queue and push each event, forever."""
while True:
event = await queue.get()
await websocket.send_json(event)
async def _watch_for_disconnect() -> None:
"""Block on receive() so a client-initiated close is noticed
promptly even while _pump_events is idle waiting on an empty
queue (send_json alone only surfaces a disconnect on the NEXT
push, which could be arbitrarily far in the future — or never,
for a subscriber that stops getting matching events). receive()
does not raise on a disconnect message (only the receive_text/
receive_json/receive_bytes wrappers do) — it just returns it, so
this checks the message type itself and raises to match
_pump_events' failure mode.
"""
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
raise WebSocketDisconnect(code=message.get("code", 1000))
pump_task = asyncio.ensure_future(_pump_events())
watch_task = asyncio.ensure_future(_watch_for_disconnect())
try:
done, _pending = await asyncio.wait(
{pump_task, watch_task}, return_when=asyncio.FIRST_EXCEPTION
)
for task in done:
exc = task.exception()
if exc is not None and not isinstance(exc, WebSocketDisconnect):
raise exc
except WebSocketDisconnect:
pass
finally:
pump_task.cancel()
watch_task.cancel()
subs.drop_connection(token)
# --- Unmatched /api/* fallback ---
# PR-10: real APIC never emits FastAPI's default 404 {"detail":"Not
# Found"} shape — cisco.aci can't parse it (surfaces as "APIC Error
# None: None", status=-1). This handler only fires once routing has
# already failed to match every route above (including the /api/node/mo
# alias and /api/node/class/*), so it can never shadow a real route —
# it is Starlette's very last resort for an unmatched path, not a route
# itself. Scoped to /api/* only; anything outside that prefix (e.g. a
# typo'd /_sim/* control path) still gets the default FastAPI 404, since
# this sim's control plane is not part of the APIC REST contract.
@app.exception_handler(StarletteHTTPException)
async def _unmatched_api_path(request: Request, exc: StarletteHTTPException):
if exc.status_code == 404 and request.url.path.startswith("/api/"):
return _apic_error(
f"Invalid request path: {request.url.path}", code="400", status_code=400
)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
return app
+292
View File
@@ -0,0 +1,292 @@
"""Token store + auth routes for aaaLogin, aaaRefresh, aaaLogout.
Session tokens are minted on aaaLogin and validated (via `require_session`) by
every protected data route in app.py. Real APIC semantics emulated here:
- aaaLogin checks credentials (default admin/cisco, overridable via
SIM_USERNAME/SIM_PASSWORD env vars) → 401 APIC envelope on mismatch. The
login name may be plain ("admin") or domain-qualified
("apic:local\\admin", "local\\admin") — real APIC accepts both for its
local login domain; see `_bare_username()`.
- Tokens expire after SESSION_LIFETIME_SECONDS (default 600s); aaaRefresh
requires a live token and renews it to a full lifetime.
- Queries/writes without a valid, unexpired APIC-cookie → 403 APIC envelope
"Token was invalid (Error: Token timeout)".
- Malformed aaaLogin bodies never leak FastAPI's {"detail": ...} shape —
the route parses the request body manually and returns a 400 APIC
envelope instead of letting FastAPI raise RequestValidationError.
PR-9 addition — certificate signature auth (accept-mode). cisco.aci's
module_utils/aci.py supports password-less auth: when `private_key` is set,
every request's `cert_auth()` sets a single `Cookie` header carrying four
APIC-Certificate-* fields instead of (or alongside) the aaaLogin flow —
verified read-only against upstream `plugins/module_utils/aci.py`:
sig_dn = "uni/userext/user-{username}/usercert-{certificate_name}"
self.headers["Cookie"] = (
"APIC-Certificate-Algorithm=v1.0; "
"APIC-Certificate-DN={sig_dn}; "
"APIC-Certificate-Fingerprint=fingerprint; "
"APIC-Request-Signature={base64(RSA-SHA256(method+path+body))}"
)
This means a cert-auth playbook never calls aaaLogin at all — every single
request (GET/POST/DELETE) carries the Cookie header standalone. See
`require_session` below for the accept-mode implementation and
docs/DESIGN.md's "Certificate accept-mode trust model" note for what is
and is not verified.
"""
from __future__ import annotations
import os
import re
import secrets
import time
from dataclasses import dataclass
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
# Default lab credentials — overridable per CONTRACT.md §1 / README §Configuration.
SIM_USERNAME: str = os.environ.get("SIM_USERNAME", "admin")
SIM_PASSWORD: str = os.environ.get("SIM_PASSWORD", "cisco")
# Real APIC default session lifetime is 600s (refreshTimeoutSeconds).
SESSION_LIFETIME_SECONDS: float = 600.0
# Certificate-DN shape cisco.aci's cert_auth() builds:
# uni/userext/user-{username}/usercert-{certificate_name}
# Captured group 1 = the username, checked against SIM_USERNAME below.
_CERT_DN_RE = re.compile(r"^uni/userext/user-([^/]+)/usercert-[^/]+$")
# PR-15 addition — domain-qualified aaaLogin name. Real APIC accepts login
# names of the shape "apic:<loginDomain>\<username>" (e.g. "apic:local\admin")
# in addition to a bare username, per APIC's local/remote login-domain
# handling. aci-py's client posts the domain-qualified form by default.
# Captured group 1 = the domain (unused/ignored, any domain is accepted —
# this sim has no concept of configured login domains), group 2 = the bare
# username, which is what gets compared against SIM_USERNAME below.
_DOMAIN_QUALIFIED_NAME_RE = re.compile(r"^(?:apic:)?[^\\]+\\(.+)$")
def _bare_username(name: str) -> str:
"""Strip an optional `apic:<domain>\\` or `<domain>\\` prefix from a login name.
"apic:local\\admin" -> "admin"; "local\\admin" -> "admin"; "admin" -> "admin"
(no prefix present, returned unchanged).
"""
m = _DOMAIN_QUALIFIED_NAME_RE.match(name)
return m.group(1) if m else name
# SIM_CERT_STRICT — documented placeholder, NOT YET IMPLEMENTED (see
# docs/DESIGN.md). When unset/false (the only mode this sim supports today),
# a well-formed cert-cookie set authenticates its claimed user WITHOUT any
# RSA signature verification (accept-mode / trust-the-claimed-identity). A
# future strict mode would verify APIC-Request-Signature against a
# registered public key for that user — out of scope for PR-9.
SIM_CERT_STRICT: bool = os.environ.get("SIM_CERT_STRICT", "false").lower() in ("1", "true", "yes")
@dataclass
class _Session:
user: str
expires_at: float
# Module-level session store: token -> _Session. Shared across all APIC apps
# in this process (mirrors the pre-existing module-level behavior); each test
# builds a fresh FastAPI app/state per fixture but the token namespace is
# process-wide, which is fine since tokens are unguessable 128-hex-char values.
_sessions: dict[str, _Session] = {}
def _new_token() -> str:
return secrets.token_hex(64)
def _now() -> float:
return time.monotonic()
def _prune_expired() -> None:
"""Opportunistically drop expired tokens. Called on every store access."""
now = _now()
expired = [tok for tok, sess in _sessions.items() if sess.expires_at <= now]
for tok in expired:
del _sessions[tok]
def _create_session(user: str) -> str:
_prune_expired()
token = _new_token()
_sessions[token] = _Session(user=user, expires_at=_now() + SESSION_LIFETIME_SECONDS)
return token
def _lookup_session(token: str | None) -> _Session | None:
"""Return the live session for `token`, or None if missing/unknown/expired."""
_prune_expired()
if not token:
return None
return _sessions.get(token)
def _touch_session(token: str) -> bool:
"""Renew an existing session's expiry to a full lifetime. False if invalid."""
sess = _lookup_session(token)
if sess is None:
return False
sess.expires_at = _now() + SESSION_LIFETIME_SECONDS
return True
def _pop_session(token: str | None) -> None:
_prune_expired()
if token:
_sessions.pop(token, None)
def _cert_auth_user(request: Request) -> str | None:
"""Return the authenticated username for a well-formed cert-cookie request.
Accept-mode (documented trust model, see docs/DESIGN.md): if all four
APIC-Certificate-* cookies are present and APIC-Certificate-DN parses to
"uni/userext/user-{user}/usercert-{name}" with user == SIM_USERNAME, the
request is treated as authenticated WITHOUT verifying
APIC-Request-Signature's RSA-SHA256 bytes. Any of: a missing cookie, an
unparsable DN, or a DN whose user != SIM_USERNAME → returns None (caller
falls through to the normal cookie-session check, which will also fail
for a request that never called aaaLogin, yielding the standard 403).
This intentionally does NOT check SIM_CERT_STRICT — that flag is a
documented placeholder for a future signature-verifying mode and has no
effect yet (see auth.py module docstring + DESIGN.md).
"""
cookies = request.cookies
required = (
"APIC-Certificate-Algorithm",
"APIC-Certificate-Fingerprint",
"APIC-Certificate-DN",
"APIC-Request-Signature",
)
if not all(cookies.get(name) for name in required):
return None
cert_dn = cookies["APIC-Certificate-DN"]
m = _CERT_DN_RE.match(cert_dn)
if not m:
return None
user = m.group(1)
if user != SIM_USERNAME:
return None
return user
def require_session(request: Request) -> _Session | None:
"""Dependency-style helper: return the caller's live session or None.
Callers (app.py routes) turn a None into a 403 APIC envelope. Kept as a
plain function (not a FastAPI Depends) so routes can return the 403 body
in the exact APIC envelope shape rather than a generic exception page.
PR-9: also accepts certificate-signature auth (accept-mode) — a request
carrying a well-formed, matching-user cert-cookie set authenticates
without ever having called aaaLogin. A synthetic, non-expiring _Session
is returned for that request only (not stored — cheap to recompute per
request, and there is no real token to leak/reuse).
"""
token = request.cookies.get("APIC-cookie")
session = _lookup_session(token)
if session is not None:
return session
cert_user = _cert_auth_user(request)
if cert_user is not None:
return _Session(user=cert_user, expires_at=_now() + SESSION_LIFETIME_SECONDS)
return None
def _apic_error(text: str, code: str, status_code: int) -> JSONResponse:
body = {
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
"totalCount": "1",
}
return JSONResponse(status_code=status_code, content=body)
def _auth_error_403() -> JSONResponse:
return _apic_error("Token was invalid (Error: Token timeout)", code="403", status_code=403)
def _login_response(token: str) -> dict:
return {
"imdata": [
{
"aaaLogin": {
"attributes": {
"token": token,
"refreshTimeoutSeconds": str(int(SESSION_LIFETIME_SECONDS)),
}
}
}
],
"totalCount": "1",
}
def make_auth_router() -> APIRouter:
router = APIRouter()
@router.post("/api/aaaLogin.json")
async def aaa_login(request: Request, response: Response):
# Parse the body manually (not via a FastAPI `body: dict` parameter)
# so a malformed/non-dict body never triggers FastAPI's automatic
# RequestValidationError -> {"detail": [...]} leak. We want a 400
# APIC envelope for every malformed shape instead.
try:
raw = await request.json()
except Exception:
return _apic_error("Malformed request body", code="400", status_code=400)
if not isinstance(raw, dict):
return _apic_error("Malformed request body", code="400", status_code=400)
aaa_user = raw.get("aaaUser")
if not isinstance(aaa_user, dict):
return _apic_error("Malformed aaaUser body", code="400", status_code=400)
attrs = aaa_user.get("attributes")
if not isinstance(attrs, dict):
return _apic_error("Malformed aaaUser body", code="400", status_code=400)
username = attrs.get("name")
password = attrs.get("pwd")
bare_username = _bare_username(username) if isinstance(username, str) else username
if bare_username != SIM_USERNAME or password != SIM_PASSWORD:
return _apic_error(
"Authentication failed: invalid username or password",
code="401",
status_code=401,
)
token = _create_session(bare_username)
response.set_cookie("APIC-cookie", token)
return _login_response(token)
@router.get("/api/aaaRefresh.json")
async def aaa_refresh(request: Request, response: Response):
token = request.cookies.get("APIC-cookie")
if not token or not _touch_session(token):
return _auth_error_403()
response.set_cookie("APIC-cookie", token)
return _login_response(token)
@router.post("/api/aaaLogout.json")
async def aaa_logout(request: Request, response: Response):
token = request.cookies.get("APIC-cookie")
_pop_session(token)
response.delete_cookie("APIC-cookie")
return {"imdata": [], "totalCount": "0"}
return router
+212
View File
@@ -0,0 +1,212 @@
"""APIC query-subscription registry + websocket push-on-change (PR — subscriptions).
Real APIC's subscription mechanism (verified against the documented behavior
cisco.aci / ACI SDK clients and monitoring tools rely on):
1. **Subscribe**: any of the three query shapes (class/mo/node-class) accepts
`?subscription=yes`. The response is the NORMAL query result (imdata +
totalCount, unchanged) PLUS a top-level `"subscriptionId"` string. The
query itself also establishes the "current state" the subscription is
watching relative to.
2. **Websocket**: the client opens `GET /socket<token>` where `<token>` is
the same APIC-cookie token from aaaLogin. All of that session's live
subscriptions are pushed over this one connection.
3. **Push on change**: when a write (POST create/update or DELETE) commits,
APIC evaluates every live subscription's scope against the changed
MO(s). Any match gets a push:
`{"subscriptionId":[<id>,...],"imdata":[{<class>:{"attributes":{...,
"status":"created|modified|deleted","dn":...}}}]}`.
4. **Refresh**: `GET /api/subscriptionRefresh.json?id=<id>` keeps a
subscription alive past its TTL; real APIC's default subscription
lifetime is 90s, refreshed by polling this endpoint.
This module is the in-memory registry + notify/bridge glue. It has ZERO
FastAPI route decorators of its own — `app.py` wires the HTTP/websocket
routes and calls into this module's functions, matching how `auth.py` keeps
its session store separate from route registration.
Sync-write -> async-websocket bridge
-------------------------------------
`app.py`'s query/write handlers are `async def` but call straight into sync
store code — there's no actual concurrency boundary within a single request.
The awkward part is different: `notify()` must be callable from those
handlers (regular function-call context, not itself a coroutine) but the
actual send happens on a websocket that's being driven by its own `asyncio`
task (`websocket.receive()` loop). Two connections could be subscribed to
the same class, and a slow/stuck client must never block or break the write
request that triggered the notification.
The fix: give every websocket connection its own `asyncio.Queue`. `notify()`
is a plain sync function — it just does `queue.put_nowait(event)` (never
blocks, drops nothing since the queue is unbounded) for every connection
whose subscriptions match. The websocket route runs a small async task that
does `event = await queue.get(); await websocket.send_json(event)` in a loop.
This decouples "a write happened" (sync call stack) from "bytes went out on
a socket" (async task on that connection's own schedule) with no shared
event-loop reentrancy concerns and no risk of a write request awaiting
network I/O on some other client's socket.
`app.py`'s route actually runs two tasks per connection, raced via
``asyncio.wait(..., return_when=FIRST_EXCEPTION)``: one draining this
queue and pushing events, one blocked on ``websocket.receive()`` purely to
notice a client-initiated disconnect promptly (a lone queue-drain loop
would only discover a dead connection the next time it tried to push —
arbitrarily late, or never, for a subscriber sitting on a quiet class).
Either task finishing tears down both and calls `drop_connection`.
"""
from __future__ import annotations
import asyncio
import itertools
import time
from dataclasses import dataclass, field
# Real APIC's default subscription lifetime is 90s; refreshed via
# GET /api/subscriptionRefresh.json?id=<id>. Kept generous here since this
# sim has no eviction sweep beyond the refresh no-op / disconnect cleanup.
SUBSCRIPTION_LIFETIME_SECONDS: float = 90.0
_id_counter = itertools.count(1)
@dataclass
class _Connection:
"""One live `/socket<token>` websocket connection for a session token."""
token: str
queue: "asyncio.Queue[dict]" = field(default_factory=asyncio.Queue)
@dataclass
class Subscription:
"""A single registered subscription (one per `?subscription=yes` query).
scope is either:
- ("class", "<className>") — class query / node-class fabric-wide form
- ("dn", "<dn>") — mo query (dn-scoped; also matches the
DN's subtree so a child MO's change
still notifies a parent-DN subscriber)
"""
id: str
token: str
scope_kind: str # "class" | "dn"
scope_value: str
expires_at: float
# subscriptionId -> Subscription
_subscriptions: dict[str, Subscription] = {}
# token -> _Connection (at most one live websocket per session token)
_connections: dict[str, _Connection] = {}
def _now() -> float:
return time.monotonic()
def reset() -> None:
"""Test/isolation helper: drop all subscriptions + connections."""
_subscriptions.clear()
_connections.clear()
def _prune_expired() -> None:
now = _now()
expired = [sid for sid, sub in _subscriptions.items() if sub.expires_at <= now]
for sid in expired:
del _subscriptions[sid]
def subscribe(token: str, scope_kind: str, scope_value: str) -> str:
"""Register a new subscription for *token* watching *scope_kind*/*scope_value*.
Returns the new subscriptionId (a string, matching real APIC's shape).
"""
_prune_expired()
sid = str(next(_id_counter))
_subscriptions[sid] = Subscription(
id=sid,
token=token,
scope_kind=scope_kind,
scope_value=scope_value,
expires_at=_now() + SUBSCRIPTION_LIFETIME_SECONDS,
)
return sid
def refresh(subscription_id: str) -> bool:
"""Renew a subscription's TTL. Returns False if the id is unknown/expired."""
_prune_expired()
sub = _subscriptions.get(subscription_id)
if sub is None:
return False
sub.expires_at = _now() + SUBSCRIPTION_LIFETIME_SECONDS
return True
def register_connection(token: str) -> "asyncio.Queue[dict]":
"""Associate a websocket connection with *token*; returns its event queue.
Only one live connection per token is tracked (matches real APIC — a
session has one websocket). A reconnect replaces the prior queue.
"""
conn = _Connection(token=token)
_connections[token] = conn
return conn.queue
def drop_connection(token: str) -> None:
"""Remove the connection + any subscriptions owned by *token* (disconnect cleanup)."""
_connections.pop(token, None)
_prune_expired()
dead = [sid for sid, sub in _subscriptions.items() if sub.token == token]
for sid in dead:
del _subscriptions[sid]
def is_known_token(token: str) -> bool:
"""True if *token* has ever registered a connection (used only for tests/debug)."""
return token in _connections
def _dn_matches(scope_dn: str, changed_dn: str) -> bool:
"""True if *changed_dn* is *scope_dn* itself or lives in its subtree."""
return changed_dn == scope_dn or changed_dn.startswith(scope_dn + "/")
def notify(cls: str, dn: str, status: str, attributes: dict) -> None:
"""Push an event to every live subscription whose scope matches this change.
Called synchronously from the write path (POST/DELETE handlers in
app.py) right after the store commit. Never awaits, never raises for a
slow/absent client — `asyncio.Queue.put_nowait` on an unbounded queue
cannot block, and a token with no live connection (subscribed but the
websocket hasn't connected yet, or already disconnected) is skipped.
*attributes* should be the MO's attribute dict (will be shallow-copied)
with ``dn``/``status`` already set or overridable via the explicit args.
"""
_prune_expired()
matched: dict[str, list[str]] = {} # token -> [subscriptionId, ...]
for sid, sub in _subscriptions.items():
if sub.scope_kind == "class" and sub.scope_value == cls:
matched.setdefault(sub.token, []).append(sid)
elif sub.scope_kind == "dn" and _dn_matches(sub.scope_value, dn):
matched.setdefault(sub.token, []).append(sid)
if not matched:
return
attrs = dict(attributes)
attrs["dn"] = dn
attrs["status"] = status
event_body = {cls: {"attributes": attrs}}
for token, sids in matched.items():
conn = _connections.get(token)
if conn is None:
continue
event = {"subscriptionId": sids, "imdata": [event_body]}
conn.queue.put_nowait(event)
+469
View File
@@ -0,0 +1,469 @@
"""Write handler for POST /api/mo/{dn}.json."""
from __future__ import annotations
import re
from aci_sim.build.fabric import loopback_ip, oob_ip
from aci_sim.build.interfaces import _HOST_PORTS, _UPLINK_START, _add_port
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
# Class -> RN template for children POSTed without an explicit ``dn``.
#
# Real APIC derives a child's RN from the class's RN *prefix*, not from the
# class name itself (see aci_sim/mit/dn.py's dn_class_hint, which maps
# the same prefixes back to classes for the read path). Templates below are
# taken verbatim from the DNs the builders emit (aci_sim/build/*.py),
# so POSTed objects land on the same canonical DN as builder-created ones:
#
# fvBD -> BD-{name} (build/tenants.py:_build_bd, dn=f"uni/tn-{t}/BD-{bd.name}")
# fvAp -> ap-{name} (build/tenants.py:_build_tenant, dn=f"uni/tn-{t}/ap-{ap.name}")
# fvAEPg -> epg-{name} (build/tenants.py:_build_epg, dn=.../ap-{ap_name}/epg-{epg.name})
# fvCtx -> ctx-{name} (build/tenants.py:_build_tenant, dn=f"uni/tn-{t}/ctx-{vrf.name}")
# fvSubnet -> subnet-[{ip}] (build/tenants.py:_build_bd, dn=.../BD-{bd.name}/subnet-[{cidr}])
# fvRsCtx -> rsctx (build/tenants.py:_build_bd, dn=.../BD-{bd.name}/rsctx, fixed RN)
# fvRsBd -> rsbd (build/tenants.py:_build_epg, dn=.../epg-{epg.name}/rsbd, fixed RN)
# fvRsBDToOut -> rsBDToOut-{tnL3extOutName}
# (build/tenants.py:_build_bd, dn=.../rsBDToOut-{l3out.name})
# fvRsDomAtt -> rsdomAtt-[{tDn}] (build/tenants.py:_build_epg, dn=.../rsdomAtt-[uni/phys-{phys_dom}])
# fvRsProv -> rsprov-{tnVzBrCPName}
# (build/tenants.py:_build_epg, dn=.../rsprov-{c})
# fvRsCons -> rscons-{tnVzBrCPName}
# (build/tenants.py:_build_epg, dn=.../rscons-{c})
# vzBrCP -> brc-{name} (build/tenants.py:_build_contract, dn=f"uni/tn-{t}/brc-{contract.name}")
# vzSubj -> subj-{name} (build/tenants.py:_build_contract, dn=.../brc-{name}/subj-{name})
# vzRsSubjFiltAtt -> rssubjFiltAtt-{tnVzFilterName}
# (build/tenants.py:_build_contract, dn=.../rssubjFiltAtt-{flt.name})
# vzFilter -> flt-{name} (build/tenants.py:_build_filter, dn=f"uni/tn-{t}/flt-{flt.name}")
# vzEntry -> e-{name} (build/tenants.py:_build_filter, dn=.../flt-{name}/e-{ename})
#
# Batch-1 additions (RN schemes taken verbatim from the same builders that
# now emit these classes — see build/access.py + build/l3out.py + build/
# tenants.py docstrings for the full DN derivation/attribute-source notes):
# infraAccPortP -> accportprof-{name} (build/access.py:_build_leaf_profile)
# infraHPortS -> hports-{name}-typ-range (build/access.py:_build_hports)
# infraPortBlk -> portblk-block1 (build/access.py:_build_hports; fixed —
# one block per selector in this sim)
# infraRsAccBaseGrp -> rsaccBaseGrp (build/access.py:_build_hports, fixed RN)
# infraAccBndlGrp -> accbundle-{name} (build/access.py:_build_bndl_grp)
# rtctrlProfile -> prof-{name} (build/l3out.py:_build_route_control)
# rtctrlCtxP -> ctx-{name} (build/l3out.py:_build_route_control)
# rtctrlSubjP -> subj-{name} (build/l3out.py:_build_tenant_subj)
# rtctrlMatchRtDest -> dest-[{ip}] (build/l3out.py:_build_tenant_subj)
# ipRouteP -> rt-[{ip}] (build/l3out.py:_build_node_profile)
# ipNexthopP -> nh-[{nhAddr}] (build/l3out.py:_build_node_profile)
# l3extMember -> mem-A / mem-B (build/l3out.py:_build_node_profile; side
# picks the RN suffix directly, not a name/attr)
# ospfExtP -> ospfExtP (build/l3out.py:_build_l3out_tree, fixed RN)
# fvRsPathAtt -> rspathAtt-[{tDn}] (build/tenants.py:_build_epg)
# vzRsSubjGraphAtt -> rsSubjGraphAtt (build/tenants.py:_build_contract, fixed RN)
#
# Batch-2 addition — fabricNodeIdentP, the ONE batch-2 class an actual Fabric
# Build playbook POSTs (CONTRACT.md §3 "Node registration reaction":
# initial_fabric_discovery/add_leaf_switch_pair/add_spine_switch all POST
# fabricNodeIdentP; ansible's aci_fabric_node module keys the object on the
# node id, matching both the RN template's "nodeId" attribute AND the real
# ACI RN uni/controller/nodeidentpol/nodep-{id} — see build/fabric.py's
# boot-seeding docstring, which seeds the SAME nodep-{id} scheme so a
# boot-seeded registration and a later POSTed one always land on the same DN
# instead of silently diverging into nodep-{id} vs nodep-{serial}):
# fabricNodeIdentP -> nodep-{nodeId} (build/fabric.py:build, dn=f"uni/controller/
# nodeidentpol/nodep-{node.id}")
#
# Each entry is (attribute-to-read-the-value-from, rn-format). A rn-format of
# None means the RN is fixed (no suffix, no attribute lookup).
_RN_TEMPLATES: dict[str, tuple[str | None, str]] = {
"fvBD": ("name", "BD-{}"),
"fvAp": ("name", "ap-{}"),
"fvAEPg": ("name", "epg-{}"),
"fvCtx": ("name", "ctx-{}"),
"fvSubnet": ("ip", "subnet-[{}]"),
"fvRsCtx": (None, "rsctx"),
"fvRsBd": (None, "rsbd"),
"fvRsBDToOut": ("tnL3extOutName", "rsBDToOut-{}"),
"fvRsDomAtt": ("tDn", "rsdomAtt-[{}]"),
"fvRsProv": ("tnVzBrCPName", "rsprov-{}"),
"fvRsCons": ("tnVzBrCPName", "rscons-{}"),
"vzBrCP": ("name", "brc-{}"),
"vzSubj": ("name", "subj-{}"),
"vzRsSubjFiltAtt": ("tnVzFilterName", "rssubjFiltAtt-{}"),
"vzFilter": ("name", "flt-{}"),
"vzEntry": ("name", "e-{}"),
"infraAccPortP": ("name", "accportprof-{}"),
"infraHPortS": ("name", "hports-{}-typ-range"),
"infraPortBlk": (None, "portblk-block1"),
"infraRsAccBaseGrp": (None, "rsaccBaseGrp"),
"infraAccBndlGrp": ("name", "accbundle-{}"),
"rtctrlProfile": ("name", "prof-{}"),
"rtctrlCtxP": ("name", "ctx-{}"),
"rtctrlSubjP": ("name", "subj-{}"),
"rtctrlMatchRtDest": ("ip", "dest-[{}]"),
"ipRouteP": ("ip", "rt-[{}]"),
"ipNexthopP": ("nhAddr", "nh-[{}]"),
"l3extMember": ("side", "mem-{}"),
"ospfExtP": (None, "ospfExtP"),
"fvRsPathAtt": ("tDn", "rspathAtt-[{}]"),
"vzRsSubjGraphAtt": (None, "rsSubjGraphAtt"),
"fabricNodeIdentP": ("nodeId", "nodep-{}"),
}
#: Class-keyed default attribute sets, filled ONLY on CREATE (matching real
#: APIC, which commits object defaults at create time; posted attrs always win).
#: Scoped to fvBD — closes the "BD host_route / EP-move show empty" fidelity gap.
_CLASS_DEFAULTS: dict[str, dict[str, str]] = {
"fvBD": {
"mac": "00:22:BD:F8:19:FF",
"type": "regular",
"arpFlood": "no",
"unkMacUcastAct": "proxy",
"unkMcastAct": "flood",
"v6unkMcastAct": "nd",
"multiDstPktAct": "bd-flood",
"epMoveDetectMode": "", # real create-default: GARP detection OFF
"hostBasedRouting": "no",
"limitIpLearnToSubnets": "yes",
"ipLearning": "yes",
"unicastRoute": "yes",
"intersiteBumTrafficAllow": "no",
"mtu": "9000",
},
}
class WriteValidationError(ValueError):
"""Raised when a POST body fails shape validation before any store mutation."""
def _child_dn(parent_dn: str, cls: str, attrs: dict, index: int) -> str:
"""Derive a non-empty, correctly-parented DN for a child that omits ``dn``.
ACI POST bodies may nest children without an explicit dn. Storing them at
dn="" clobbers (last-writer-wins). Real APIC derives the RN from the
class's RN *prefix*, not from the class name, so known classes use the
canonical template harvested from the builders (see ``_RN_TEMPLATES``
above) — this keeps POSTed objects on the same DN as builder-created ones
(e.g. fvBD -> "BD-{name}", not "fvBD-{name}").
"""
dn = attrs.get("dn")
if dn:
return dn
template = _RN_TEMPLATES.get(cls)
if template is not None:
attr_name, rn_format = template
if attr_name is None:
# Fixed RN (e.g. fvRsCtx -> "rsctx"): no per-instance suffix at all.
rn = rn_format
else:
value = str(attrs.get(attr_name) or index)
rn = rn_format.format(value)
return f"{parent_dn}/{rn}"
# Fallback for classes not covered by _RN_TEMPLATES: best-effort
# '{cls}-{name}' heuristic. This does NOT match real APIC's RN-prefix
# scheme, so it may not round-trip with a canonical GET; kept only so
# unknown/unlisted classes still get a stable, non-clobbering DN.
name = (
attrs.get("name")
or attrs.get("ip")
or attrs.get("tDn")
or attrs.get("tnFvBDName")
or str(index)
)
rn = f"[{name}]" if ("/" in name or "[" in name) else name
return f"{parent_dn}/{cls}-{rn}"
def _validate_node(cls: str, body: dict, path: str) -> None:
"""Validate one {className: {"attributes": dict, "children"?: list}} node.
Raises WriteValidationError with a descriptive message on any shape
violation. Does not touch the store — pure validation.
"""
if not isinstance(body, dict):
raise WriteValidationError(f"{path}: expected an object body for class {cls!r}, got {type(body).__name__}")
attributes = body.get("attributes", {})
if not isinstance(attributes, dict):
raise WriteValidationError(f"{path}: 'attributes' must be an object")
children = body.get("children", [])
if children is None:
children = []
if not isinstance(children, list):
raise WriteValidationError(f"{path}: 'children' must be a list")
for index, child_entry in enumerate(children):
child_path = f"{path}/children[{index}]"
if not isinstance(child_entry, dict):
raise WriteValidationError(
f"{child_path}: expected a single-key object mapping class name to body, "
f"got {type(child_entry).__name__}"
)
if len(child_entry) != 1:
raise WriteValidationError(
f"{child_path}: expected exactly one class key, got {len(child_entry)}"
)
child_cls = next(iter(child_entry))
child_body = child_entry[child_cls]
_validate_node(child_cls, child_body, child_path)
def _plan_recursive(cls: str, attrs: dict, children: list, planned: list[tuple[str, dict]]) -> None:
"""Build the ordered list of (class, attrs) MOs to write, without touching the store.
Appends to *planned* in the same order the old eager code used to upsert,
so behavior (e.g. parent-before-child) is unchanged — only the timing of
the actual store mutation moves to after this whole plan succeeds.
"""
planned.append((cls, attrs))
# A deleted MO removes its whole subtree; do NOT plan children as orphans.
if attrs.get("status") == "deleted":
return
for index, child_entry in enumerate(children):
for child_cls, child_body in child_entry.items():
child_attrs = dict(child_body.get("attributes") or {})
child_attrs["dn"] = _child_dn(attrs["dn"], child_cls, child_attrs, index)
child_children = child_body.get("children") or []
_plan_recursive(child_cls, child_attrs, child_children, planned)
def _upsert_recursive(store: MITStore, cls: str, attrs: dict, children: list) -> list[tuple[str, dict]]:
"""Validate the entire body shape, then upsert the MO and its children.
Real APIC POST is all-or-nothing: a malformed descendant must not leave
earlier siblings/ancestors partially written. So this first validates the
complete subtree (raising WriteValidationError before touching the store
on any shape violation), builds the full ordered list of MOs to write,
and only then mutates the store.
Returns the full ordered ``(class, attrs)`` plan so callers (``apply``)
can inspect it for reactions (e.g. a nested ``fabricNodeIdentP`` child)
without re-walking the body themselves.
"""
# Re-wrap the already-parsed root attrs/children into the same node shape
# _validate_node expects, so root and descendants share one validation
# path (root attrs are pre-extracted by apply(), but must still satisfy
# the same shape rules children do).
_validate_node(cls, {"attributes": attrs, "children": children}, path=attrs.get("dn", cls))
planned: list[tuple[str, dict]] = []
_plan_recursive(cls, attrs, children, planned)
# Validation passed for the entire subtree — now, and only now, mutate
# the store (400 on validation failure => zero side effects).
for mo_cls, mo_attrs in planned:
# Real APIC commits a class's object defaults at CREATE time only —
# a later partial-update POST never resets an already-set attribute
# back to its default. So the overlay applies iff (a) this isn't a
# delete (no defaults that could resurrect a deleted object's attrs)
# and (b) the DN doesn't exist yet in the store. Posted attrs are
# layered on top of the defaults dict, so they always win.
dn = mo_attrs.get("dn")
if mo_attrs.get("status") != "deleted" and store.get(dn) is None:
mo_attrs = {**_CLASS_DEFAULTS.get(mo_cls, {}), **mo_attrs}
store.upsert(MO(mo_cls, **mo_attrs))
return planned
def materialize_node_registration(
store: MITStore,
*,
topo,
site,
node_id: int,
name: str,
role: str = "leaf",
) -> None:
"""Reaction: materialize a full node-registration MO set for *node_id*.
Mirrors what the real builders (build/fabric.py, build/cabling.py,
build/interfaces.py, build/health_faults.py) emit for a leaf that was
present at boot time, so a node registered dynamically via
fabricNodeIdentP or /_sim/add-leaf renders identically to one seeded from
topology.yaml:
- fabricNode (inventory row)
- topSystem (loopback/oob addresses, valid parseable IPv4 —
reuses build/fabric.py's loopback_ip/oob_ip so
the address scheme never drifts out of sync)
- healthInst (node health, matching health_faults.py's shape)
- fabricLink (+ lldpAdjEp/cdpAdjEp) to EVERY spine in the site, mirroring
build/cabling.py's leaf-uplink port assignment (eth1/{49+spine_idx})
- l1PhysIf/ethpmPhysIf inventory (fabric uplinks + host-access ports),
reusing build/interfaces.py's own `_add_port` helper so port shapes
never diverge from the boot-time build path
`pod` is derived from *site* (nodeidentpol DNs carry no pod of their
own) rather than hardcoded, so multi-pod/multi-site sims register nodes
under the correct pod.
"""
pod = site.pod
node_dn = f"topology/pod-{pod}/node-{node_id}"
store.upsert(MO(
"fabricNode",
dn=node_dn,
id=str(node_id),
name=name,
role=role,
adSt="on",
fabricSt="active",
model="N9K-C9332C",
serial="",
version="n9000-14.2(7f)",
))
store.upsert(MO(
"topSystem",
dn=f"{node_dn}/sys",
id=str(node_id),
name=name,
role=role,
version="n9000-14.2(7f)",
address=loopback_ip(pod, node_id),
oobMgmtAddr=oob_ip(pod, node_id),
fabricDomain=site.fabric_name or (topo.fabric.name if topo else ""),
state="in-service",
podId=str(pod),
))
store.upsert(MO(
"healthInst",
dn=f"{node_dn}/sys/health",
cur="95",
min="95",
max="100",
prev="95",
))
# Cable this node to every spine in the site (mirrors build/cabling.py's
# leaf-uplink port assignment: leaf uplink eth1/{49+spine_idx}, spine
# downlink port picked as the next free slot after its existing leaves).
spines = list(site.spine_nodes()) if site is not None else []
leaf_uplinks: list[tuple[int, int]] = []
for s_idx, spine in enumerate(spines):
leaf_slot, leaf_port = 1, _UPLINK_START + s_idx
# Spine-side port: next free downlink slot on that spine (after every
# existing leaf/border-leaf this site already cabled at boot time).
existing_downlinks = len(site.leaf_nodes()) + len(site.border_leaf_nodes())
spine_slot, spine_port = 1, existing_downlinks + 1
lnk_dn = (
f"topology/pod-{pod}"
f"/lnk-{spine.id}-{spine_slot}-{spine_port}-to-{node_id}-{leaf_slot}-{leaf_port}"
)
store.upsert(MO(
"fabricLink",
dn=lnk_dn,
n1=str(spine.id),
n2=str(node_id),
operSt="up",
operSpeed="100G",
linkType="leaf",
))
spine_port_str = f"eth{spine_slot}/{spine_port}"
leaf_port_str = f"eth{leaf_slot}/{leaf_port}"
spine_oob = oob_ip(pod, spine.id)
leaf_oob = oob_ip(pod, node_id)
# The dynamically-registered node has no Node schema instance of its
# own (only the id/name/role args this function received) — build a
# throwaway one so add_switch_adjacency can derive model/version/mac
# from it exactly like the boot-time builders do. model/version
# match the hardcoded fabricNode/topSystem values a few lines above.
new_node = Node(id=node_id, name=name, model="N9K-C9332C", version="n9000-14.2(7f)")
# Spine sees the new node as neighbor on spine_port_str
add_switch_adjacency(
store,
pod=pod,
local_node_id=spine.id,
local_port=spine_port_str,
remote_port=leaf_port_str,
neighbor=new_node,
neighbor_mgmt_ip=leaf_oob,
)
# The new node sees the spine as neighbor on leaf_port_str
add_switch_adjacency(
store,
pod=pod,
local_node_id=node_id,
local_port=leaf_port_str,
remote_port=spine_port_str,
neighbor=spine,
neighbor_mgmt_ip=spine_oob,
)
leaf_uplinks.append((leaf_slot, leaf_port))
# l1PhysIf inventory: fabric uplinks (one per spine) + host-access ports,
# reusing build/interfaces.py's own port-building helper so shapes never
# diverge from the boot-time build path.
for slot, port in leaf_uplinks:
_add_port(
store, pod, node_id, slot, port,
descr="Fabric uplink to spine",
mode="trunk",
with_fcot=True,
)
for hp in range(1, _HOST_PORTS + 1):
_add_port(
store, pod, node_id, 1, hp,
descr=f"Host port eth1/{hp}",
mode="access",
usage="access",
)
def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tuple[list[dict], int]:
"""Apply a write (upsert or delete) from a POST /api/mo/{dn}.json body.
Body shape: {"<cls>": {"attributes": {...}, "children": [...]}}
Returns (imdata, total).
*topo*/*site* are optional context needed by the fabricNodeIdentP
reaction (pod number, spine list for cabling) — passed through by the
caller the same way it already threads ``state.store`` here.
"""
if not body:
return [], 0
# Extract class name and body parts
cls = next(iter(body))
cls_body = body[cls]
attrs = dict(cls_body.get("attributes") or {})
children = cls_body.get("children") or []
# Honor attrs["dn"] if present, fall back to URL dn
effective_dn = attrs.get("dn") or dn
attrs["dn"] = effective_dn
# Perform the upsert/delete; get back the full ordered (class, attrs)
# plan so the fabricNodeIdentP reaction fires for a nested child too,
# not just when it's the top-level POSTed class (finding #19).
planned = _upsert_recursive(store, cls, attrs, children)
if site is not None:
for mo_cls, mo_attrs in planned:
if mo_cls != "fabricNodeIdentP":
continue
if mo_attrs.get("status") == "deleted":
continue
node_dn = mo_attrs.get("dn", "")
m = re.search(r"nodep-(\d+)$", node_dn)
if not m:
continue
node_id = int(m.group(1))
name = mo_attrs.get("name", f"leaf-{node_id}")
role = mo_attrs.get("role", mo_attrs.get("nodeType", "leaf"))
materialize_node_registration(
store, topo=topo, site=site, node_id=node_id, name=name, role=role,
)
status = "deleted" if attrs.get("status") == "deleted" else "created"
result = [{cls: {"attributes": {"dn": effective_dn, "status": status}}}]
return result, 1
View File
+19
View File
@@ -0,0 +1,19 @@
import os
APIC_A_PORT: int = int(os.environ.get("APIC_A_PORT", "8443"))
APIC_B_PORT: int = int(os.environ.get("APIC_B_PORT", "8444"))
NDO_PORT: int = int(os.environ.get("NDO_PORT", "8445"))
CERT_FILE: str = os.environ.get("CERT_FILE", "certs/sim.crt")
KEY_FILE: str = os.environ.get("KEY_FILE", "certs/sim.key")
TOPOLOGY_PATH: str = os.environ.get("TOPOLOGY_PATH", "topology.yaml")
# Default bind address for the non-sandbox servers (#23). Defaults to loopback
# so the unauthenticated /_sim control plane is never LAN-exposed by accident.
# Set SIM_BIND=0.0.0.0 explicitly for LAN deployments (e.g. the Raspberry Pi
# standing deployment, which is accessed from other hosts on the LAN).
SIM_BIND: str = os.environ.get("SIM_BIND", "127.0.0.1")
# Sandbox mode: bind each controller to its own mgmt_ip on :443 (via lo0 aliases),
# so autoACI connects by IP with no port — like real gear. Set by scripts/sandbox-up.sh.
SANDBOX: bool = os.environ.get("SIM_SANDBOX", "").strip().lower() in ("1", "true", "yes", "on")
SANDBOX_PORT: int = int(os.environ.get("SIM_SANDBOX_PORT", "443"))
+105
View File
@@ -0,0 +1,105 @@
"""Entry point: python -m aci_sim.runtime.supervisor
Loads topology → build_all + build_ndo_model → two ApicSiteStates + NdoState
→ make_apic_app×2 + make_ndo_app → three uvicorn servers with TLS, gathered
with asyncio.
"""
from __future__ import annotations
import asyncio
import copy
import uvicorn
from aci_sim.build.orchestrator import build_all
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.runtime.config import (
APIC_A_PORT,
APIC_B_PORT,
CERT_FILE,
KEY_FILE,
NDO_PORT,
SANDBOX,
SANDBOX_PORT,
SIM_BIND,
TOPOLOGY_PATH,
)
from aci_sim.topology.loader import load_topology
def apic_port_for_site(site_index: int) -> int:
"""Effective APIC port for a site, by index (#25).
- site 0 → APIC_A_PORT (the primary/first fabric's port env var)
- site 1 → APIC_B_PORT (so APIC_B_PORT, defined in config.py, is actually
honored instead of being silently shadowed by an APIC_A_PORT+i formula)
- site >1 → APIC_A_PORT + site_index, a documented fallback for topologies
with more than two sites (there is no APIC_C_PORT etc. env var; this
keeps ports contiguous and collision-free for the common 2-site case
while still supporting N sites).
"""
if site_index == 0:
return APIC_A_PORT
if site_index == 1:
return APIC_B_PORT
return APIC_A_PORT + site_index
async def run_servers() -> None:
topo = load_topology(TOPOLOGY_PATH)
stores = build_all(topo)
ndo_state = build_ndo_model(topo, sandbox=SANDBOX)
def _cfg(app, host, port):
return uvicorn.Config(
app, host=host, port=port,
ssl_certfile=CERT_FILE, ssl_keyfile=KEY_FILE, log_level="info",
)
# One APIC app per site (supports single-fabric or N sites).
# default → SIM_BIND (default 127.0.0.1, see #23) on distinct high ports (8443, 8444, …)
# sandbox → each site's own mgmt_ip on :443 (real-gear addressing, no ports)
configs = []
# site.id -> ApicSiteState, so the NDO app's deploy handler can mirror a
# deployed multi-site template's VRFs/BDs/ANPs/EPGs straight into the
# target sites' APIC MITStores (see ndo/deploy_mirror.py) — closing the
# confirmed SIM GAP where POST /mso/api/v1/task was a pure ack no-op and
# multi-site EPGs never appeared on the APIC side of the sim.
apic_states: dict = {}
for i, site in enumerate(topo.sites):
store = stores[site.name]
state = ApicSiteState(
name=site.name, site=site, topo=topo,
store=store, baseline=copy.deepcopy(store),
)
apic_states[site.id] = state
if SANDBOX:
host, port = (site.mgmt_ip or "127.0.0.1"), SANDBOX_PORT
else:
host, port = SIM_BIND, apic_port_for_site(i)
configs.append(_cfg(make_apic_app(state), host, port))
print(f"[sim] APIC {site.name} (asn {site.asn}) → https://{host if SANDBOX else SIM_BIND}:{port}")
if SANDBOX:
ndo_host, ndo_port = (topo.fabric.ndo_mgmt_ip or "127.0.0.1"), SANDBOX_PORT
else:
# NDO on NDO_PORT, shifted past the APIC ports if they would collide (>=3 sites).
ndo_host = SIM_BIND
last_apic = apic_port_for_site(len(topo.sites) - 1)
ndo_port = NDO_PORT if NDO_PORT > last_apic else last_apic + 1
configs.append(_cfg(make_ndo_app(ndo_state, apic_states), ndo_host, ndo_port))
print(f"[sim] NDO → https://{topo.fabric.ndo_mgmt_ip if SANDBOX else SIM_BIND}:{ndo_port}")
servers = [uvicorn.Server(cfg) for cfg in configs]
await asyncio.gather(*[s.serve() for s in servers])
def main() -> None:
asyncio.run(run_servers())
if __name__ == "__main__":
main()
View File
+47
View File
@@ -0,0 +1,47 @@
"""Topology YAML loader.
Usage::
from aci_sim.topology.loader import load_topology
topo = load_topology("topology.yaml")
"""
from __future__ import annotations
from pathlib import Path
from typing import Union
import yaml
from .schema import Topology
def load_topology(path: Union[str, Path]) -> Topology:
"""Read *path*, parse YAML, validate, and return a :class:`~schema.Topology`.
Raises
------
FileNotFoundError
If *path* does not exist.
ValueError
If the YAML is syntactically invalid.
pydantic.ValidationError
If the topology fails schema or cross-reference validation; the error
message lists every violation found.
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Topology file not found: {path}")
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise ValueError(f"Failed to parse YAML from {path}: {exc}") from exc
if not isinstance(raw, dict):
raise ValueError(
f"Topology YAML must be a mapping at the top level, got {type(raw).__name__}"
)
# Let ValidationError propagate naturally — callers inspect it directly.
return Topology.model_validate(raw)
File diff suppressed because it is too large Load Diff