mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
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>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""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))
|