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