mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
Initial public release — aci-sim v0.16.0
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO management planes) — for testing ACI automation (Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates without real hardware. Highlights: APIC + NDO REST surface served from one in-memory MIT built from a declarative topology.yaml; port mode + sandbox (real per-device IPs on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror (multi-site templates materialize onto the target sites' APIC stores); real-APIC fvBD default attributes; file-backed state save/restore; an `aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite. History squashed for the public release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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))
|
||||
Reference in New Issue
Block a user