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>
64 KiB
aci-sim — DESIGN
A software simulator that faithfully impersonates the REST APIs of a 2-site Cisco ACI
fabric (two per-site APICs + one ND/NDO), driven by an editable topology.yaml, so that
pointing local autoACI at it makes Topology, NDO views, all query plugins, and Fabric
Build render internally-consistent, believable control- + data-plane data.
The contract we emulate is in docs/CONTRACT.md (authoritative — trust it, don't re-explore autoACI).
Locked decisions (owner-approved)
- Generic MIT engine — model each APIC as a real Managed Information Tree (DN-keyed
objects, parent/child) with a query resolver implementing APIC's class/mo/node-scoped +
rsp-subtree/target-subtree-class+eq/wcard/and/or+ pagination semantics. One YAML → full object graph; every current AND future autoACI query works with no per-class code. - Accept-reflect writes + reset/snapshot —
POST /api/moand NDO schema/deploy upsert into the live MIT so read-back + Topology show the change (enables push→verify playbook testing). In-memory, withreset(to baseline) andsnapshot/restore. - Static deterministic snapshot — stable, internally-consistent, reproducible data (no liveness engine in v1). Deterministic so views are easy to verify.
- Configurable topology — YAML parametrizes spine/leaf counts; leaves can be added/removed
(statically via YAML+reload, dynamically via the
add_leaf_switch_pairplaybook'sfabricNodeIdentPwrite reaction). Border-leaf vPC → CSW, 2 sites + ISN, full L3Out subtree. - Scope — ACI + NDO REST surface (~22 REST playbooks fully testable + the ACI half of migration). NX-OS SSH is OUT of v1 (extension seam left, no auth stub).
- Stack — Python 3.11+, FastAPI + uvicorn + Pydantic v2, PyYAML, httpx (tests). Self-signed
TLS on each host (autoACI uses
verify=False). Matches autoACI's stack.
Process / host layout
One process, three HTTPS apps on three localhost ports (self-signed certs in certs/):
APIC Site-A :8443, APIC Site-B :8444, NDO :8445. Each site APIC serves its own MIT;
both sites are built from one global topology so stretched tenants + ISN peering are consistent.
The NDO app derives its schema model from the same topology (same tenant/VRF/BD names).
Point autoACI: Site-A→127.0.0.1:8443, Site-B→127.0.0.1:8444, NDO→127.0.0.1:8445.
Module map + interfaces (the contracts subagents implement against)
mit/ — object store (foundation; no deps)
mo.py:MO={class_name:str, attrs:dict[str,str], children:list[MO]};attrs["dn"]is the key. Helpers:MO(cls, **attrs),.dn,.add_child(mo),.flat()(self+descendants).dn.py: bracket-aware DN utils —dn_split(dn)->list[str](never split inside[]),parent_dn(dn),rn_of(dn),dn_class_hint,pod_from_dn,name_from_dn.store.py:MITStore—add(mo),upsert(mo)(merge attrs + recurse children; honorstatus=deleted),get(dn)->MO|None,children(dn, classes=None),subtree(dn, classes=None),by_class(cls)->list[MO],all(); maintains adn→MOmap and aparent→[child dn]index. Deterministic iteration order (insertion order).
query/ — ACI query engine (deps: mit)
filters.py: parseeq/wcard/and/or(...)strings → predicatefn(MO)->bool. Bracket/quote aware.engine.py:run_class_query(store, cls, params),run_mo_query(store, dn, params),run_node_scoped(store, node_dn, cls, params). Params object carries filter, subtree mode (rsp-subtreechildren|full or legacyquery-target=subtree), subtree classes, page, page_size. Returns(imdata:list[dict], total:int)in the exact{cls:{attributes,children}}shape. Two subtree shapes (must match real APIC + autoACI): (a) legacyquery-target=subtree(autoACI'squery_dn(subtree=True)) → descendants FLAT as top-level imdata, filtered bytarget-subtree-class(root included only if it matches); consumers read the target class at the top level, never recurse intochildren. (b) modernrsp-subtree=children|full(autoACI'squery_class(subtree=…)) → matched objects with NESTEDchildren(children=one level, full=recursive). Pagination slices the result set;total= pre-pagination count.
topology/ — the YAML contract (deps: none)
schema.py: Pydantic v2 models for the whole topology (seetopology.yaml+ §Topology schema below). Validates counts, references (bd→vrf, epg→bd, l3out→vrf), site/ISN config. Fail loudly on bad refs. Also validates (PR-8, findings #27/#28):fabric.loopback_pool/fabric.oob_poolfall within the fixed10.0.0.0/8/192.168.0.0/16ranges thatbuild/fabric.py'sloopback_ip()/oob_ip()actually generate (those functions are not parameterized by an arbitrary CIDR — 6+ call sites acrossbuild/*.pycall them with just(pod, node_id)— so the pool fields describe, and are checked against, the builder's real output rather than being purely cosmetic).- CSW peering params duplicated between
site.border_leaves[].csw(authoritative — carrieslocal_ip/vlantoo, used byl3out.pyfor the physical sub-interface/l3extMember) andtenant.l3outs[].csw_peer(used for the L3Out'sbgpPeerP/static-route nexthop) must agree onname/asn/peer_ipfor any border-leaf the l3out references, or loading fails with a clear error.
loader.py:load_topology(path)->Topology.
build/ — generators (deps: topology, mit)
Each sub-builder is build(topo, site, store) and only ADDS MOs. orchestrator.build_site(topo, site) -> MITStore runs them in dependency order and returns a populated store; build_all(topo)->{site:store}.
Sub-builders (one concern each, <~200 lines):
fabric.py— fabricNode (roles/ids/model/serial/version from YAML), topSystem, fabricHealthTotal, vpcDom/vpcIf (border-leaf pair), infraWiNode.cabling.py— fabricLink (with/lnk-…/DN), lldpAdjEp + cdpAdjEp derived STRICTLY from the cabling graph, isisAdjEp.underlay.py— bgpInst (local ASN), ospfAdjEp/ospfIf✅ (spine↔leaf), IS-IS dom.overlay.py— intra-site BGP-EVPN (spines as RR, leaves as clients): bgpPeer/bgpPeerEntry(established)/bgpPeerAfEntry; ISN: on each spine'ssys/bgp/inst, add inter-site bgpPeer with peer addr /32 + remote (other-site) ASN.endpoints.py— fvCEp/fvIp distributed across leaves+EPGs (encap/fabricPathDn consistent), epmMacEp/epmIpEp, fvIfConn. Runs BEFOREtenants.py(PR-3b-batch1 reorder) sotenants.pycan read back real endpoint paths forfvRsPathAtt.tenants.py— fvTenant/fvCtx/fvBD/fvSubnet/fvAp/fvAEPg + fvRs* rels + vzBrCP/vzSubj/vzFilter/vzEntry from YAML, plus fvRsPathAtt✅ (static binding, reusing the EPG's own endpoint path) and vzRsSubjGraphAtt✅.access.py— infra AAEP/IPG/port-profiles/selectors/blocks + physDomP/l3extDomP + fvnsVlanInstP/fvnsEncapBlk + rels, plus the leaf-interface-policy cluster✅ (infraAccPortP/infraHPortS/infraPortBlk/infraRsAccBaseGrp/infraAccBndlGrp) and aninfraInfraroot MO atuni/infra.l3out.py— full l3extOut subtree (LNodeP/LIfP/InstP/subnets/RsEctx/RsL3DomAtt/RsPathAtt) + bgpPeerP/bgpAsP to CSW, plus the operational bgpPeer/bgpPeerEntry(established) representing the CSW eBGP session; actrlRule for contracts; plus the route-control cluster✅ (rtctrlProfile/rtctrlCtxP/rtctrlSubjP/rtctrlMatchRtDest/rtctrlSetComm/rtctrlSetPref), static routes✅ (ipRouteP/ipNexthopP), l3extMember✅, and ospfExtP✅.routing.py(new, PR-3b-batch1) — per-(tenant,vrf) uribv4Route/uribv4Nexthop✅ RIB entries on every leaf/border-leaf (BD subnets as connected routes + a 0.0.0.0/0 default via the L3Out CSW peer on border leaves), and arpAdjEp✅ — one per locally-learned endpoint, reusing that endpoint's real MAC/IP. Runs afterendpoints.interfaces.py— l1PhysIf + ethpmPhysIf (per node, ports incl. fabric uplinks + host ports), ethpmFcot, eqptcapacityPolUsage5min.health_faults.py— healthInst per node/tenant (…/sys/health,/tn-X/health), a seeded believable faultInst set (node-local DNs so node-scoped queries work), configExportP/configJob, coopPol/coopInst.userprofile.py— staticuni/userprofile-admin(aaaUserEp) +aaaUserDomainchild (name="all"), so the CONTRACT §1 login probe (query-target=subtree&target-subtree-class=aaaUserDomain) resolves instead of 404ing.
rest_aci/ — APIC FastAPI app (deps: query, mit, build)
auth.py— aaaLogin/aaaRefresh/aaaLogout, token+cookie, the topSystem/userprofile login probes.app.py—make_apic_app(site_state): routes/api/aaaLogin.json,/api/aaaRefresh.json,/api/aaaLogout.json,/api/class/{cls}.json,/api/mo/{dn:path}.json(GET+POST),/api/node/class/{dn:path}/{cls}.json(node-scoped) and/api/node/class/{cls}.json(fabric-wide — equivalent to/api/class/{cls}.json; PR-6 FIX 3). Parses query params → query engine → envelope. Error mapping (§5 of CONTRACT).writes.py— POST body → upsert into MIT (recurse children, honor status);fabricNodeIdentPreaction materializes a full node-registration set (fabricNode + topSystem + healthInst + fabricLink/lldpAdjEp/cdpAdjEp cabling to every spine + l1PhysIf inventory), fires for afabricNodeIdentPanywhere in the planned write (not just top-level), and derives pod from the target site instead of hardcoding it (PR-6 FIX 2; shared with/_sim/add-leaf). Returns created/modified imdata.
ndo/ — NDO (deps: topology, mit optional)
model.py—build_ndo_model(topo): sites, tenants, schemas/templates (vrfs/bds/anps/epgs/contracts/filters/externalEpgs with/vrfs/…/bds/…refs), template summaries + tenantPolicy DHCP, fabric-connectivity, policy-states, audit records. Names MUST match the APIC MIT (cross-plane consistency).app.py—make_ndo_app(ndo_state): login/version + all §7 GETs + schema/deploy POST (store + return id/status).
runtime/ — supervisor (deps: all)
config.py— ports, cert paths, topology path, host→site map (env-overridable).supervisor.py— load topology →build_all+build_ndo_model→ start 3 uvicorn servers (asyncio) with TLS. Keeps abaselinedeep-copy of each store forreset.
control/ — admin API (mounted on each app under /_sim/…, deps: runtime state)
reset(restore baseline),snapshot/restore(named),reload(re-read YAML + rebuild against the FRESH matchingSiteobject from the just-loaded topology — matched bySite.id, falling back toSite.name— not the stale pre-reload one, so edits to a site's nodes/leaves actually take effect; returns a 400 APIC error envelope if the site no longer exists in the reloaded topology instead of crashing or silently keeping stale state; PR-6 FIX 1),add-leaf/remove-leaf(runtime mutate + rebuild affected derived data —add-leafshareswrites.py's enriched node-registration reaction, not a barefabricNode). Not part of the APIC/NDO contract — a side control plane for testing.
Internal-consistency rules (the "尽可能真实" core — enforce in build/)
- Nothing free-floating. Every operational MO derives from a topology fact.
- Cabling graph is the single source for
fabricLink,lldpAdjEp,cdpAdjEp(neighbors ↔ actual links). - IS-IS/OSPF underlay adjacencies only between physically-cabled spine↔leaf pairs.
- BGP-EVPN overlay: spines = route-reflectors, leaves = clients (sessions to spine loopbacks), operSt=established.
- ISN: for each spine, an inter-site eBGP-EVPN peer to the remote site's spines — addr /32, remote ASN = the OTHER site's fabric ASN (this is what makes autoACI draw the ISN cloud). EVPN AF up.
- L3Out: border leaves peer eBGP to CSW (asn from YAML); the l3extOut config subtree AND the operational bgpPeer(Entry) to the CSW must both exist and agree (addr/asn).
- Endpoints: each fvCEp lives on a real leaf port, in a real EPG/BD, with a plausible IP in the BD subnet; epmMacEp/epmIpEp mirror them on the owning node. Endpoint host ports never overlap the APIC-controller port range on the same leaf (see PR-5 FIX 3 below). A stretched-tenant endpoint is local (front-panel port) on exactly ONE site and tunnel/vxlan-learned on the other — never front-panel-local on both (PR-5 FIX 4).
- Health/faults: healthInst per node/tenant/controller; a small seeded faultInst set with node-local DNs so counts render and node-scoped fault queries return.
- Two sites share stretched tenants (per YAML
stretch:true) → same tenant/VRF/BD names present in both MITs- NDO schema references them; site-local tenants present only in their site.
PR-5 topology-consistency fixes (findings #13-#18)
Six internal-consistency findings, all fixed in build/. Every port an operational MO references
(l3extRsPathL3OutAtt.tDn, ospfAdjEp interface, fvCEp.fabricPathDn) must resolve against a real
l1PhysIf built by interfaces.py for that exact node — this is now a checked invariant
(tests/test_pr5_topology_consistency.py).
Full port inventory per node role (see interfaces.py module docstring for the authoritative version):
- Spines:
eth1/1..4fabric uplinks (one per downlink leaf/BL) +eth1/49[,50,...]dedicated ISN/IPN uplink ports, multi-site topologies only, one per spine (eth1/{49+spine_index}— matches the portunderlay.py'sospfAdjEpalready referenced; previously these ports didn't exist at all → FIX 2). - Leaves + border-leaves:
eth1/1..8host-facing access ports,eth1/49[,50,...]fabric uplinks (one per spine). Border-leaves ONLY also get a dedicatedeth1/48routed L3Out sub-interface port (previouslyl3out.pyreferenced this port but nothing ever built it → FIX 1).
FIX 1 (#13) — L3Out port. Chose to extend the border-leaf port inventory with a dedicated eth1/48
routed port (mode="routed", usage="l3out") rather than reassign the L3Out onto an existing host port
(e.g. eth1/8), because real ACI border-leaf L3Out sub-interfaces conventionally live on a dedicated
front-panel port, not one shared with regular EPG/host traffic. l3out.py was unchanged — it already
referenced eth1/48; the fix makes that port real.
FIX 2 (#14) — spine OSPF/ISN port. Chose to extend the spine port inventory with a dedicated
eth1/{49+i} ISN uplink (one per spine, multi-site only) rather than reuse an existing fabric-uplink port,
because every spine port in eth1/1-4 is already consumed by the intra-fabric spine↔leaf mesh (full mesh
= 2 leaves + 2 border-leaves = 4 downlinks per spine in the default topology) — there is no free fabric port
to reuse. This also matches real ACI: multi-site spines use a dedicated ISN-facing port, distinct from the
leaf-facing fabric ports. underlay.py was unchanged — its ospfAdjEp already used eth1/{49+si}; the fix
makes that port real via the same index arithmetic.
FIX 3 (#15) — endpoint/APIC port separation. fabric.py wires each APIC-{cid} (cid 1..
site.controllers, default 3) to eth1/{cid} on the first two leaves (site.leaf_nodes()[:2]) — the same
leaves endpoints.py places host endpoints on. endpoints.py now reserves eth1/1..{controllers} on those
leaves and starts endpoint port allocation at eth1/{controllers+1}, wrapping within the remaining
_HOST_PORTS - controllers budget. Non-APIC-attached leaves are unaffected (still start at eth1/1).
FIX 4 (#16) — multi-site endpoint semantics. See below.
FIX 5 (#17) — BD unicastRoute. unicastRoute now reflects whether the BD actually has fvSubnet
children (routed) rather than l2stretch — a stretched BD can still carry routed subnets, and this
topology's stretched BDs (bd-BD3_LAB0, bd-BD4_LAB0) do. This agrees with ndo/model.py, which
unconditionally reports unicastRouting: True for every BD (all BDs in topology.yaml have subnets).
FIX 6 (#18) — controller health. health_faults.py now emits a healthInst at
topology/pod-{pod}/node-{cid}/sys/health for every controller node id (1..site.controllers), matching
the topSystem fabric.py already builds there. Real APIC exposes controller health the same way as
switch health.
FIX 4 — multi-site endpoint representation (design)
Real ACI never shows the identical endpoint as locally front-panel-attached at both sites of a stretched
tenant simultaneously. One site has it physically attached; the other only knows about it via the
multi-site overlay (ISN) tunnel. endpoints.py now models this:
- HOME site (physically attached):
fvCEp.lcC="learned",fabricPathDn= the real front-panel leaf path (topology/pod-{pod}/paths-{leaf_id}/pathep-[eth1/{port}]), epm mirrors useflags="local", and afvIfConnis emitted (real front-panel port). - REMOTE/peer site (learned via the ISN): same MAC/IP/DN, but
fvCEp.lcC="learned,vxlan"andfabricPathDnpoints at atunnelIfon one of the local spines (topology/pod-{pod}/paths-{spine_id}/pathep-[tunnel{remote_spine_id}]), epm mirrors useflags="learned,vxlan", and nofvIfConnis emitted (there is no front-panel port to attach one to). - A minimal
tunnelIfMO is created per (local spine, remote spine) pair used (topology/pod-{pod}/node-{spine_id}/sys/tunnel-[tunnel{remote_spine_id}]),dest= the remote spine's loopback (representing its spine-proxy anycast TEP),src= the local spine's own loopback.overlay.pydoes not yet build tunnel/TEP objects, so this is a new minimal object, not a reuse. - HOME-site assignment is deterministic and stateless across the two independent per-site
build()calls: for the i-th endpoint in a stretched tenant's sequence, home site =tenant.sites[i % len(tenant.sites)]. Since both sites iterate the exact same tenant/AP/EPG/per-EPG sequence in the same order, they agree on which site is home for a given MAC without sharing any state — each site's independent build() computes the sameifor the same endpoint and reaches the same conclusion. - Non-stretched (site-local) tenants are unaffected — every endpoint is simply local, exactly as before.
Topology schema (topology.yaml) — shape
fabric: { name, evpn_rr: spine }
sites:
- name, id, apic_host, asn, pod
spines: N # count OR explicit list [{id,name,model,serial}]
leaves: M # count OR explicit list
border_leaves: [{id, name, vpc_domain, csw:{name,asn,peer_ip,local_ip,vlan}}]
cabling: auto|explicit # auto = full spine×leaf mesh + border-leaf uplinks
isn: { enabled: true, transit_asn?, peer_mesh: full } # drives inter-site EVPN
tenants:
- name, stretch: bool, sites: [..]
vrfs: [{name}]
bds: [{name, vrf, subnets:[cidr], l2stretch?}]
aps: [{name, epgs:[{name, bd, contracts:{provide:[],consume:[]}}]}]
contracts: [{name, scope, filters:[{name, entries:[{proto,from,to,ether}]}]}]
l3outs: [{name, vrf, border_leaves:[..], csw_peer:{...}, ext_epgs:[{name, subnets:[cidr]}]}]
endpoints: { per_epg: K, ip_pool_from_subnet: true } # generation knobs
faults: { seed: [{severity,code,node,descr}] , health_defaults:{node:95,tenant:98} }
access: { vlan_pools:[{name,from,to}], phys_domains:[..], aaeps:[..] } # sensible defaults if omitted
Default topology.yaml ships the spec baseline: 2 sites × (2 spine + 2 leaf + 1 border-leaf vPC pair to CSW),
ISN between them, a couple stretched + site-local tenants, one L3Out per site to its CSW, seeded endpoints/faults.
Phase plan (each phase: implementer subagent(s) → independent reviewer per 第十律)
- P1 foundation —
mit/(mo, dn, store) +query/(filters, engine). Unit-tested in isolation. - P2 topology —
topology/schema.py+loader.py+ defaulttopology.yaml. - P3 fabric builders — fabric, cabling, underlay, overlay(+ISN), health_faults, interfaces → Topology view renders.
- P4 tenant builders — tenants, access, l3out, endpoints → config + data-plane plugins render.
- P5 ACI REST app — auth + GET routes + POST writes + reactions + runtime supervisor + TLS + control API.
- P6 NDO — model + app.
- P7 verify — verification harness that mimics autoACI's queries for every view/plugin + write→readback + add_leaf; README + run scripts.
Verification (第二律 evidence-first)
tests/verify_autoaci.py replays the exact query patterns from docs/CONTRACT.md §8–9 against the running
sim and asserts non-empty, shape-correct, internally-consistent results for: Topology (2 sites + ISN cloud +
vpc pairs + faults/health), every plugin's class set, L3Out BGP-to-CSW, NDO sites/tenants/schemas, and a
write→read-back cycle (POST a tenant → GET returns it) + an add_leaf reaction. Real command output is the
acceptance evidence.
PR-3b-batch1 — 21 previously-catalogued-but-unbuilt classes seeded
CONTRACT.md §6 catalogued 21 classes across 6 concern groups that had no builder ever emitting them, so
their class queries returned totalCount: 0 and the depending autoACI plugins (access_policy.py,
epg_detail.py, contract_detail/contract_map/contract_debug.py, l3out_detail.py, l3out_status.py,
route_control.py, routing_state.py, route_table.py, fabric_ospf.py) rendered nothing. All 21 are now seeded,
derived from the existing topology/tenant objects rather than invented data.
Access-policy cluster (build/access.py) — one leaf-interface-policy tree per border-leaf vPC domain
(infraAccBndlGrp lagT="node", consistent with the vpcDom/vpcIf pair fabric.py already builds for the same
border leaves) plus one for the regular-leaf pair (lagT="link", a PC bundle). Each infraAccPortP gets one
infraHPortS (type="range") selector spanning the real host-port inventory (eth1/1..eth1/_HOST_PORTS) via
one infraPortBlk + one infraRsAccBaseGrp pointing at the bundle group. Also seeds an infraInfra MO at
uni/infra itself — previously a structural-only DN with no MO, which 404'd GET /api/mo/uni/infra.json
even under rsp-subtree=full (fixed as part of this batch since the TESTS requirement needed it; the generic
subtree engine already worked correctly once a root MO existed).
Route-control cluster (build/l3out.py) — one rtctrlProfile (export) per L3Out, with a nested
rtctrlCtxP (+ rtctrlSetComm/rtctrlSetPref children using real ACI's fixed short-form RNs scomm/spref).
One tenant-level rtctrlSubjP match rule per L3Out (not nested under the L3Out — autoACI's route_control.py
queries rtctrlSubjP with wcard(dn,"tn-{tenant}"), tenant-scoped, verified read-only against the plugin
source) with an rtctrlMatchRtDest child reusing a real tenant BD subnet. A static default route (ipRouteP
ip="0.0.0.0/0") nested under each L3Out's l3extRsNodeL3OutAtt, with an ipNexthopP child pointing at the
same CSW peer IP the eBGP session builder (_upsert_ebgp_sessions) already peers with. l3extMember
(side="A") on the existing l3extRsPathL3OutAtt — this topology's L3Out path is a single port per
border-leaf (not vPC-bundled), so only one member is emitted, per the batch-1 placement note. ospfExtP
added to the SAME L3Out that already carries bgpExtP (real ACI supports OSPF+BGP coexistence on one
L3Out) rather than fabricating a second OSPF-only L3Out in topology.yaml — the simpler faithful choice since
this topology has no existing OSPF-only L3Out to attach to.
EPG/contract classes (build/tenants.py) — fvRsPathAtt (static binding) per EPG, pointing at the SAME
front-panel path that EPG's own endpoints already use. This required reordering the orchestrator's builder
list (endpoints now runs BEFORE tenants, moved up from after l3out) so tenants.py can read back the
real fvCEp.fabricPathDn/encap endpoints.py already wrote for that EPG, instead of independently
re-deriving the port-allocation algorithm (which risked silently drifting out of sync). Every other builder
remains pure add-only; endpoints.py itself never reads the store, so the reorder is safe. A stretched EPG
whose only endpoints at this site are REMOTE (tunnel-learned) gets no fvRsPathAtt — a static binding must
reference a real local port. vzRsSubjGraphAtt attached to exactly one contract's subject per tenant (the
tenant's first contract), with a plausible tnVnsAbsGraphName; vnsAbsGraph itself stays out of scope.
Operational/routing classes (new build/routing.py + build/underlay.py) — uribv4Route/uribv4Nexthop
seeded per (tenant, vrf) on every leaf/border-leaf: BD subnets as directly-connected routes (nexthop = the
node's own loopback) plus a 0.0.0.0/0 default via the L3Out's CSW peer on border leaves, matching the
ipRouteP static route already seeded on the same node. VRF "dom" name reuses the exact {tenant}:{vrf}
convention l3out.py's BGP dom already established. uribv4Nexthop's RN uses the 4-bracket
nh-[proto]-[addr]-[ifname]-[vrf] shape autoACI's _RE_NH_PARENT regex (route_table.py/routing_state.py,
identical in both files) requires — verified read-only against the plugin source, not re-derived from
guesswork. arpAdjEp — one per locally-learned (front-panel) endpoint, reusing that endpoint's real MAC/IP
from the store endpoints.py already populated (routing.py runs after endpoints in the orchestrator
order); REMOTE/tunnel-learned endpoints get no ARP entry (real ACI resolves those via the overlay, not local
ARP). ospfIf nested between the existing ospfDom and ospfAdjEp in underlay.py, on the same
eth1/{49+i} ISN uplink port interfaces.py already builds a dedicated l1PhysIf for — so the pre-existing
ospfAdjEp now sits on a real, matching ospfIf.
Writes — rest_aci/writes.py _RN_TEMPLATES gained entries for all POST-able batch-1 classes so a
child posted without an explicit dn lands on the same canonical DN a GET or builder-created MO would use.
PR-3b-batch2/3 — remaining previously-catalogued-but-unbuilt classes seeded
Batches 2+3 of the seeding effort closed the rest of CONTRACT.md §6's gap: 9 more classes across
build/fabric.py, build/interfaces.py, build/tenants.py, a new build/zoning.py, and build/overlay.py,
plus a deliberate decision NOT to build infraNodeIdentP. DN/attribute shapes were verified read-only
against the specific autoACI consumer plugins (a targeted grep of autoACI's backend/plugins/*.py)
before writing any builder code, per the same discipline batch-1 established.
Port-channel/vPC (build/fabric.py) — pcAggrIf + pcRsMbrIfs, one pair per vPC leg (border-leaf),
consistent with the vpcDom/vpcIf this module already builds for the same pairs. vpc_status.py matches
pcAggrIf to vpcIf by (node, name) — so pcAggrIf.name intentionally reuses the exact same "po{bl.id}"
string vpcIf.name already carries (not access.py's differently-named infraAccBndlGrp, a different IPG
identity autoACI's plugin does not join on). Naming correction: CONTRACT.md catalogued this relation
class as vpcRsMbrIfs; a read-only grep of vpc_status.py showed it actually queries pcRsMbrIfs (the real
ACI class, child of pcAggrIf, RN rsmbrIfs-[{ifDn}]) — built as pcRsMbrIfs, with a CONTRACT.md §6
changelog note recording the correction. Member ports are two real host-facing l1PhysIf per leg
(eth1/1, eth1/2 — always present on every leaf/border-leaf) so pcRsMbrIfs.tDn always resolves.
Optics (build/interfaces.py) — ethpmFcot, sibling of ethpmPhysIf under the same port DN (own
fcot RN). Emitted only for fabric-uplink, ISN-uplink, and L3Out routed ports — real optics-bearing
front-panel ports — not plain host-access ports, mirroring real labs where copper/DAC host ports often
report no transceiver inventory while fabric/WAN-facing optical ports do (a deliberate realism choice,
documented in the module docstring, not an omission).
Cluster health (build/fabric.py) — infraWiNode, the APIC appliance-vector: every controller carries
its own view of every cluster member (an N×N matrix for N=site.controllers), all seeded health="fully-fit"
to match this sim's other all-healthy defaults (fabricHealthTotal.cur=100).
Zoning-rule / pcTag plumbing (build/tenants.py + new build/zoning.py) — real ACI zoning rules key off
per-EPG/VRF pcTag ("sclass") + scopeId, neither of which existed anywhere in this sim before batch-2
(verified: zero prior pcTag references). tenants.py gained pctag_for/scope_id_for — small
CRC32-derived helpers (same technique endpoints.py's _vnid already uses) offset clear of autoACI's 5
hardcoded system pcTags (1/13/14/15/16384) — assigning a stable pcTag to every fvAEPg and fvCtx
(+ scope on fvCtx), and seeding one fvEpP (uni/epp/fv-[{epg_dn}]) per EPG carrying the matching
pcTag, the EPG's VRF scopeId, and epgPKey=the EPG's own dn (zoning_rules.py extracts the friendly
EPG name back out of epgPKey's trailing epg- segment). New build/zoning.py derives actrlRule — one
permit rule per (contract subject filter, provider EPG, consumer EPG) triple, emitted on every leaf where
either side has a locally-learned endpoint (read back from endpoints.py's fvCEp, same technique
tenants.py's fvRsPathAtt already established), with sPcTag/dPcTag/scopeId reusing the exact
pctag_for/scope_id_for values the owning EPGs/VRF already carry. Also seeds real MOs at the
.../sys/actrl and .../sys/actrl/scope-[...] structural containers — the same class of gap batch-1's
infraInfra fix addressed for uni/infra: without a real MO at the exact DN a subtree query targets,
GET /api/mo/{dn}.json 404s even though the generic subtree engine itself works fine once a root MO exists.
zoning runs right after tenants in the orchestrator (both already after endpoints).
EVPN routes (build/overlay.py) — bgpVpnRoute + bgpPath, the per-spine EVPN VPN route family
fabric_bgp_evpn.py's "vpn_routes" view two-pass parses (pass 1 collects routes by dn; pass 2 recovers
the parent route dn from a path dn via path_dn.rsplit("/path-", 1)[0] — so bgpPath's RN must start with
path- directly under the route's own dn, verified read-only against the plugin). Seeded per spine
(the EVPN route-reflector role) for each BD subnet deployed at the site, with one bgpPath per leaf that
actually hosts a locally-learned endpoint for that BD (read back from fvRsBd+fvCEp). This needs the
same post-tenants/endpoints data zoning.py needs, but overlay.build() itself must stay in its
original EARLY orchestrator slot (BGP peer setup only needs fabric/topology data) — so the new logic lives
in a second function, overlay.build_vpn_routes(), called by the orchestrator as an explicit extra step
after the main _BUILDERS loop completes, rather than moving/splitting overlay.build()'s existing peer
logic.
Node registration (build/fabric.py + rest_aci/writes.py) — fabricNodeIdentP, boot-seeded one per
real switch fabricNode (spines/leaves/border-leaves — NOT controllers; real ACI's Fabric Membership
registers switches joining the fabric, not the APICs themselves) at uni/controller/nodeidentpol/nodep-{id},
so a GET returns already-registered nodes without requiring a prior POST. Keys on the node id, not
serial, to stay consistent with writes.py's pre-existing _materialize_fabric_node reaction (which parses
the id back out of the same nodep-(\d+)$ pattern) — a boot-seeded and a later-POSTed registration must
agree on the identifier scheme, or they'd silently diverge into two different DNs for the same node.
_RN_TEMPLATES gained a fabricNodeIdentP -> nodep-{nodeId} entry (the one batch-2/3 class an actual
Fabric Build playbook POSTs, per CONTRACT.md §3). Regression-tested: tests/test_pr3b_batch2.py re-verifies
test_rest_aci.py::test_add_leaf_reaction's exact scenario (POSTing a NEW nodep-950 must still
materialize a matching fabricNode) to confirm boot-seeding existing nodes doesn't interfere with the
write-path reaction for a genuinely new one.
infraNodeIdentP — deliberately NOT built. No autoACI plugin references it (verified via a fabric-wide
grep across autoACI's backend/plugins/*.py). Real ACI's infraNodeIdentP is a node provisioning-policy
object, a different concept from fabricNodeIdentP's Fabric Membership registration despite the similar
name — building it with fabricNodeIdentP-like semantics would be a wrong object satisfying no real
consumer. Removed from CONTRACT.md's catalog entirely rather than built incorrectly; see that file's
batch-2/3 changelog note for the full reasoning.
PR-9 — Ansible cisco.aci fidelity
The owner's primary public use case for this simulator is running Ansible cisco.aci playbooks against
it directly. PR-9 hardens the write/delete/auth surface to match what cisco.aci's
plugins/module_utils/aci.py actually sends, verified read-only against the upstream collection source
(https://github.com/CiscoDevNet/ansible-aci). Full behavioral contract: docs/CONTRACT.md §11. Highlights:
- DELETE
/api/mo/{dn}.jsonadded (state=absent), server-side idempotent (200-empty on both existing-then-removed and already-missing DNs) — see CONTRACT.md §11 for why idempotent-on-missing was chosen over 404 despite the module never actually exercising that exact case itself. - GET-on-missing-DN is 200-empty, not 404 (FEATURE 6, a live blocker found running an actual playbook
against the sim:
fatal: APIC Error 103on the very first task of astate=presentcreate). This superseded the pre-PR-9 design (404-on-missing), which had been justified as matching a DIFFERENT consumer's (autoACI'sACINotFoundError) expectation — cisco.aci is now this simulator's primary target per the owner's stated use case, and itsapi_call()treats any non-200 GET as fatal with no "not found is fine" exception./api/class/*and node-scoped queries already had no such 404 case (they return200+ empty on a no-match query); this brings/api/mo/*in line for "the DN itself doesn't exist" too. status="deleted"cascade — already correctly removed the full subtree pre-PR-9 (MITStore.upsert'sstatus="deleted"branch predates this PR); PR-9 added regression coverage proving it (not just "skips planning children", which is a separate, narrower behavior the write-planning layer also has).status="created,modified"(and any status other than"deleted") is a plain upsert — no special-casing needed since the store only ever special-cases the literal"deleted"string.- annotation/nameAlias/descr passthrough — no fix needed;
MO.attrsis a generic dict and the write path never allowlists attribute names. PR-9 added the regression test proving the round-trip survives POST → mo GET → class query, since this was previously undocumented/unverified rather than actually broken. - firmwareCtrlrRunning — new, small: one per controller in
build/fabric.py,versionreusing the samesite.apic_versionthe controller'stopSystemalready carries.
Certificate accept-mode trust model (cert-auth, PR-9)
cisco.aci supports password-less certificate-signature auth: instead of calling aaaLogin, every request
carries a single Cookie header with four fields (APIC-Certificate-Algorithm, APIC-Certificate-DN,
APIC-Certificate-Fingerprint, APIC-Request-Signature — an RSA-SHA256 signature over
METHOD + path + body, base64-encoded). Real APIC verifies that signature against a public key
previously uploaded for the named user (aaaUserCert).
This simulator does NOT verify the signature. require_session() (rest_aci/auth.py) implements
accept-mode: if all four 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 for that user — full stop. The APIC-Request-Signature bytes are read (cookie must be
non-empty) but never decoded or checked against any key. This is a deliberate, documented trust
shortcut, not an oversight:
- The simulator has no real user-certificate registry (
aaaUserCertupload flow is out of scope, same tier as NX-OS SSH being out of v1 per the "Locked decisions" above) — implementing real verification would require building that entire subsystem for a security property the simulator doesn't need (there is no real secret being protected; it's a local test fixture). Trusting the claimed identity once the DN's user matchesSIM_USERNAMEis sufficient to unblock cert-auth playbooks running unmodified against the sim, but is insufficient as a real security boundary. - Do not point this accept-mode design at anything internet-reachable or multi-tenant. Anyone who
can reach the sim's port can authenticate as
SIM_USERNAMEby sending four cookies with no cryptographic proof of possession. SIM_CERT_STRICTenv var is reserved (parsed but currently a no-op) for a future mode that would register a real public key per user and verifyAPIC-Request-Signatureagainst it. Not implemented in PR-9 — out of scope; noted here so a future PR doesn't have to rediscover the gap.
PR-18 — Tier-1 fabric configuration parameters
Exposes real-ACI-lab-variable Tier-1 fabric knobs that Ansible/cisco.aci/aci-py fidelity depends on,
all optional with defaults matching the pre-PR-18 hardcodes so the existing topology.yaml (and any
hand-authored one) validates and builds unchanged.
site.controllers: int(default 3) — already existed pre-PR-18 (introduced with the APIC-cluster work) and was already fully wired:build/fabric.py's controllerfabricNode/topSystem/firmwareCtrlrRunningloop, theinfraWiNodeappliance-vector double loop (both viewer and member ranges),build/health_faults.py, andbuild/endpoints.py's APIC-reserved-port accounting all already readsite.controllers— no hardcoded3remained to replace. PR-18 only added--controllerstoaci-sim newand surfaced the value inaci-sim show.fabric.tep_pool: str(default"10.0.0.0/16") — the infra TEP address space. Stored + CIDR- validated + surfaced (aci-sim show/new). No builder derives a per-node address from this field —build/fabric.py'sloopback_ip()/oob_ip()are the actual address source (see the pre-existingloopback_pool/oob_poolnote intopology/schema.py'sFabricdocstring, which documents the same "hardcoded family, pool is descriptive metadata" pattern this field follows). Treattep_poolas store-only/informational until a future PR threads it into an actual infra-address builder.fabric.infra_vlan: int(default 3967, range 1-4094) — the fabric infrastructure VLAN. Stored + range-validated + surfaced. No MIT class in this sim currently carries a distinct infra-VLAN attribute to wire it into (real ACI'sinfraSetPol.fabricInfraVlanclasses are not part of the current CONTRACT.md catalog) — store-only, same caveat astep_pool.fabric.gipo_pool: str(default"225.0.0.0/15") — the multicast GIPo pool. Stored + CIDR-validated- surfaced. Store-only, same caveat.
- Deterministic node serials —
build/fabric.py'sdefault_serial(site_id, node_id)replaces the previous inlinef"SIM{node.id:08d}"fallback used whenever an auto-generatedNode.serialis empty (fabricNode.serialandfabricNodeIdentP.serial). New format:f"SAL{site_id}{node_id:04d}"(e.g. site "1" node 101 →"SAL10101") — encodes the site id so serials stay visibly distinct per site. Explicitserial:values in a YAMLNodeentry are unaffected (still override — this is only theserial or default_serial(...)fallback path). No test asserted the old"SIM..."format, so this is a safe behavior change;aci-sim shownow prints the resolved serial per node. site.pod: int(already existed, default 1) — verified honored end-to-end: every builder that emits atopology/pod-{N}/...DN readssite.pod(grepped all ofbuild/*.py,rest_aci/writes.py,control/admin.py— zero remainingpod-1/pod=1literals). A site withpod: 2builds all itsfabricNode/topSystem/etc. DNs underpod-2(verified:topology/pod-2/node-201, ...).
N-site limit (verified, not fixed by PR-18)
aci-sim new --sites N (N>2) and the builders that only depend on site.pod/site.controllers/node-ID
offsets work for arbitrary N with zero node-ID collisions (verified with a 3-site scaffold: sites at
offsets 0/200/400, infraWiNode count = controllers² per site, all DNs correct).
However, Topology.other_site(name) (topology/schema.py) is hard-coded to 2-site semantics: it
returns "the first site in self.sites that is not name", not "every other site". Two builders call it
to resolve the ISN/multi-site remote peer:
build/overlay.py's ISN inter-site eBGP-EVPN peering (build(), "ISN: each local spine → each remote-site spine loopback").build/endpoints.py's stretched-tenant HOME/REMOTE endpoint placement (is_stretchedbranch).
Verified with a live 3-site topology (generate_topology(sites=3, ...), ISN peer_mesh: full): site C
(3rd site, asn 65003) peers ISN eBGP ONLY with site A (asn 65001) — other_site("LABC") picks the first
non-C site in list order (LABA) and never reaches LABB. This means with 3+ sites:
- ISN is NOT a true full mesh across all sites, despite
isn.peer_mesh: full— each site peers with exactly one other (the first site in the YAML'ssites:list order that isn't itself), not N-1 others. - A tenant with
stretch: trueand 3+sites:entries passes schema validation (the validator only requireslen(tenant.sites) >= 2, not== 2) butendpoints.py's remote-peer logic will treat only one of the non-home sites as the ISN-reachable remote for tunnel/REMOTE-endpoint purposes.
This is a pre-existing limitation, not introduced by PR-18 (confirmed via git diff main -- aci_sim/topology/schema.py: other_site() is byte-identical to what PR-18 branched from).
Fixing it to a real N-site full mesh is a bigger architectural change (both call sites would need to
iterate [s for s in topo.sites if s.name != site.name] instead of a single other_site() call, and
endpoints.py's single remote_site/tunnel_spine/home_spine variables would need to become
per-remote-site collections) — out of scope for a Tier-1-parameters PR. Practical limit: ISN/stretch
correctness is verified for exactly 2 sites; 3+ site topologies build without collision but do not get
a correct N-site ISN mesh or N-site stretched-endpoint placement.
PR-19 — Tier-2 topology-elasticity parameters
Exposes ISN underlay knobs (ospf_area, mtu) and a VMM domain vCenter-connection model, plus fixes a
real leaf/spine-count-elasticity bug in aci-sim new's node-ID generator. All new fields optional with
defaults matching pre-PR-19 hardcoded behavior, so the existing topology.yaml validates and builds
byte-identical output.
isn.ospf_area: str (default "0.0.0.0") and isn.mtu: int (default 9150)
Both were previously hardcoded literals in the builders:
build/underlay.py'sospfIf/ospfAdjEpareaattribute was hardcoded to"0.0.0.0"— now readstopo.isn.ospf_area. Accepts either dotted-decimal ("0.0.0.0") or the decimal-integer shorthand ACI also renders ("0") — validated inTopology.normalize_and_validateviaIPv4Addressparse orstr.isdigit().build/interfaces.py's dedicated ISN/IPN spine uplink port (eth1/{49+i}, multi-site only) was built via the same_add_port()helper as every other port, which hardcodedmtu="9216"(the intra-fabric default) for every port including this one._add_port()now takes anmtukeyword (default"9216", unchanged for all ordinary ports); the ISN uplink call site alone passesstr(topo.isn.mtu). Verified: a leaf'seth1/49fabric-uplink port and a spine'seth1/49ISN uplink port are different physical interfaces on different node roles (interfaces.py's port-numbering docstring), so this change touches only the true ISN port — ordinary fabric ports (including any leaf port that happens to share the same port number) are unaffected.isn.mturange-validated 576-9216 (real ACI inter-pod/inter-site MTU range, same ceiling as the fabric's own 9216).isn.peer_mesh: partialremains a documented validate-and-reject (unchanged from PR-17/18's decision inbuild/overlay.py) — re-examined for PR-19 and kept: a partial mesh has no sensible default partition without additional per-site config this schema doesn't carry (which spine pairs with which isn't inferable from spine count alone). Silently building full-mesh (ignoring the setting) or an arbitrary subset (guessing a partition no author asked for) would both be worse than failing fast with a clear message, so PR-19 keeps the existing rejection rather than inventing a partition scheme.
VMM domain vCenter connection model — pure addition, not a rewire
access.vmm_domains: list[VMMDomain] (default: empty list, unlike phys_domains/l3_domains/
aaeps, which each default to one seeded entry). Each VMMDomain carries name (required) plus
optional vcenter_ip, vcenter_dvs, vlan_pool, datacenter.
Why this is additive, not a wire-up of an existing gap (verified read-only against a real aci-py + cisco.mso production chain on a lab test host):
~/aci-py/aci_py/playbooks.py'sbind_epg_to_vmm_domainplaybook (MSO section) drivesmso_schema_site_anp_epg_domain(shimmed in~/aci-py/aci_py/shims/mso_mso_epg.py), which appends a JSON-Patch opadd /sites/{site}-{tpl}/anps/{anp}/epgs/{epg}/domainAssociations/-withvalue.dn = "uni/vmmp-VMware/dom-{domain_profile}"— a DN string, not a live object reference. The sim's NDO layer (aci_sim/ndo/patch.py) stores this JSON-Patch document verbatim; nothing cross-validates the referenceddnagainst a realvmmDomPin any APIC-side MIT store.~/aci-py/environments/lab-multisite-sim/vars/bind_epg_to_vmm_domain_MS-TN1.ymlconfirms the domain names actually exercised in the live MS-TN1 chain (vmm-vmw-lab1,vmm-vmw-lab2) are just opaque strings from this var file's perspective.- The single-fabric branch of the same playbook template (
bind_epg_to_vmm_domain.j2.yml'sAPICsection) DOES drivecisco.aci.aci_domain(shimmed in~/aci-py/aci_py/shims/access.py's_domain_dn_class, which mapsvmmDomain→vmmDomPatuni/vmmp-{provider}/dom-{name}) — but this branch is gated onsingle_fabric|default(false)and is not what the MS-TN1 multi-site chain (scripts/live_chain.py environments/lab-multisite-sim MS-TN1) exercises. - Conclusion: no consumer in either verified production chain (autoACI sandbox e2e, aci-py MS-TN1)
reads a
vmmDomP/vmmCtrlrPobject from this sim today. Adding one is genuinely new surface, not a fix for an existing silently-broken path — hence the empty-list default (true backward-compat baseline: omittingaccess.vmm_domainsproduces zerovmmDomP/vmmCtrlrP/vmmUsrAccPMOs, byte-identical to pre-PR-19) rather than seeding a default domain the wayphys_domains/l3_domains/aaepsdo.
MO shape (build/access.py::_build_vmm_domain), verified against the real DN scheme in
~/aci-py/aci_py/shims/access.py's _domain_dn_class ("vmmDomP", "uni/vmmp-{provider}/dom-{name}") and
~/aci-py/aci_py/shims/mso_mso_epg.py's dn_map["vmmDomain"] = "uni/vmmp-VMware/dom-{0}":
vmmDomPatuni/vmmp-VMware/dom-{name}— always built (only "VMware" is modeled; the sole provider either verified call site actually exercises).vmmCtrlrPat{vmmDomP dn}/ctrlr-{name}— built only whenvcenter_ipis set:hostOrIp=vcenter_ip,rootContName=datacenter(falls back to the domain's ownnameifdatacenteris unset).dvsNameis additionally set whenvcenter_dvsis provided.vmmUsrAccPat{vmmCtrlrP dn}/usracc-{name}— built alongsidevmmCtrlrP(placeholder credential-profile reference; no real credentials in a sim, matching the no-secret pattern phys/l3 domains already use for their own child objects).infraRsVlanNsat{vmmDomP dn}/rsvlanNs— built only whenvlan_poolis set, pointing at the firstaccess.vlan_pools[]entry's DN (same convention_build_phys_dom/_build_l3_domalready use).vlan_poolis cross-referenced againstaccess.vlan_pools[].nameinTopology.normalize_and_validate— an unknown pool name is a validation error, same discipline astenant.bds[].vrf/epg.bd.
_build_epg() in build/tenants.py (the actual EPG-domain binding builder) is untouched — EPGs still
bind only to the first phys_domains[] entry via fvRsDomAtt, exactly as before PR-19. This is
intentional: the real production chain's bind_epg_to_vmm_domain never touches this MIT tree (see
above), so there was nothing to rewire.
Leaf/spine count elasticity — a real generator bug found and fixed
aci-sim new's node-ID scheme (topology/schema.py's docstring: leaves base 101, spines base 201,
site_offset=200) has a documented ceiling — "max ~40 leaves + ~40 spines per site before the ranges
touch" — but prior to PR-19, generate_topology() (cli.py) had no guard enforcing that ceiling.
Stress-testing the exact boundary (required by this PR's acceptance bar) found three distinct ways a
large --leaves-per-site/--spines-per-site/--border-pairs value silently produced an invalid
topology.yaml dict that only failed deep inside Topology.normalize_and_validate, with a generic "node
ids collide" message that didn't name the offending flag:
--leaves-per-site > 100— the leaf block itself (101..100+N) runs into the spine block (201+). Reproduced:--leaves-per-site 150 --spines-per-site 4→ leaf ids run 101-250, directly overlapping spine ids 201-204.--spines-per-site > 100— the spine block itself (201..200+N) runs into the NEXT site's leaf block (301+). Reproduced:--leaves-per-site 2 --spines-per-site 101→ site 0's spine ids run 201-301, colliding with site 1's leaf ids starting at 301.- Large
--border-pairs— the border-leaf block (already pushed past whichever of leaves/spines extends further, per the pre-existing PR-17 border-ID fix) can itself run past the next site's leaf block ifborder_pairsis large enough relative to a small leaf/spine count. Reproduced:--leaves-per-site 2 --spines-per-site 2 --border-pairs 50→ border ids run up to 300+, colliding with the next site's leaf block at 301.
Fix: generate_topology() now computes the same leaf/spine/border-top arithmetic the ID-assignment
loop uses and raises a ValueError naming the specific offending flag before building any node dicts,
for all three cases above. Verified boundaries: --leaves-per-site 100/--spines-per-site 100 (exact
max) still validate and build cleanly; 101 of either is rejected with a clear message;
--border-pairs 49 at the default leaf/spine count is the max safe value, 50 is rejected. The
acceptance-bar case from this PR's scope (--leaves-per-site 8 --spines-per-site 4, well within the
safe range) validates clean and builds a full MITStore with the expected node count, verified via
orchestrator.build_site() (not just schema validation) in tests/test_pr19_tier2.py's
TestLeafSpineElasticity.test_full_build_succeeds_for_8_leaves_4_spines.
This is a genuine bug fix (not a new feature) — the ID scheme's stated ceiling was never actually enforced anywhere before this PR, meaning any topology author who requested more than ~100 leaves/spines-per-site via the CLI would previously get a confusing Pydantic stack trace instead of an actionable error.
PR-20 — Tier-3 fine-tuning parameters (final param tier)
The last planned parameter tier. Exposes commonly-tuned mgmt-subnet/BD-MAC/routing-timer/APIC-version
knobs as optional topology.yaml fields, wired into a real MO attribute wherever this sim already builds
one — and honestly marked store-only where it doesn't (BFD timers, inb_subnet). All new fields optional
with defaults matching pre-existing hardcoded behavior (or the real ACI factory default where there was
no prior hardcoded value at all), so the existing topology.yaml validates and builds byte-identical
output — verified via the full tests/test_pr20_tier3.py::TestBackwardCompat class plus the unchanged
672-test suite passing alongside 41 new tests (713 total).
bd.mac / fabric.default_bd_mac — the critical backward-compat case
build/tenants.py's _build_bd hardcoded fvBD.mac = "00:22:BD:F8:19:FF" (the real ACI default gateway
MAC) for every BD before this PR. fabric.default_bd_mac (fabric-wide default) reproduces that exact
literal as its own schema default, and bd.mac (per-BD, optional) overrides it when set — resolution
order in _build_bd is bd.mac or topo.fabric.default_bd_mac. Because the fabric-wide default equals
the pre-existing hardcoded literal exactly, a topology with neither field set produces byte-identical
fvBD.mac output to pre-PR-20 — this is the single highest-risk field in this PR (any drift from the
existing literal would visibly change every BD's gateway MAC that a consumer like autoACI might read),
so it got its own dedicated backward-compat test (test_repo_fvBD_mac_is_real_aci_default) asserting the
literal value directly against the real repo topology.yaml, not just "no schema-level default drift."
BGP keepalive/hold — l3out.csw_peer.keepalive/.hold → bgpPeerP
Real ACI defaults 60s/180s (the standard 1:3 keepalive:hold ratio). build/l3out.py's
_build_node_profile already builds a bgpPeerP MO (control-plane BGP peer policy, distinct from the
operational bgpPeer/bgpPeerEntry session MOs _upsert_ebgp_sessions builds) with only addr/
peerCtrl attrs pre-PR-20; this PR adds keepAliveIntvl/holdIntvl to the same object. Validated:
hold must exceed keepalive (a hold at or below the keepalive interval can never survive a single
missed keepalive) — same sanity-check pattern as PR-19's isn.mtu range check. The operational
bgpPeer/bgpPeerEntry MOs are not touched — real ACI's operational BGP session table doesn't carry
configured-timer attrs (those live on the policy object), so there's nothing to wire there.
OSPF hello/dead — isn.ospf_hello/.ospf_dead → ospfIf
Real ACI defaults 10s/40s. build/underlay.py's ospfIf MO (already built for isn.ospf_area, PR-19)
gains helloIntvl/deadIntvl attrs. Validated: ospf_dead must exceed ospf_hello, matching real ACI's
own OSPF sanity check (a dead timer at or below the hello interval can never detect a live neighbor
before declaring it down).
BFD timers — isn.bfd_min_rx/.bfd_min_tx/.bfd_multiplier — explicitly STORE-ONLY
Real ACI defaults 50ms/50ms/3. Grepped build/*.py for "bfd"/"Bfd"/"BFD" — zero matches. This sim
builds no bfdIfP/bfdRsIfPol-style MO anywhere, and adding a faithful one would additionally require a
believable bfdIfStats/oper-state child tree with no verified consumer (autoACI plugin or aci-py
playbook) to anchor its shape against — inventing an MO purely to hold a timer, with no real read path
exercising it, would be worse than being explicit that these three fields are schema-level only. Store +
range-validate (min_rx/min_tx positive; multiplier 1-50, real ACI's UI range) + surface in
aci-sim show/new.
OOB/INB mgmt subnets — fabric.oob_subnet (validate-only) / fabric.inb_subnet (store-only)
fabric.oob_subnet sits alongside the existing fabric.oob_pool (PR-early/#27) — build/fabric.py's
oob_ip() derives every node's OOB address from (pod, node_id) only and does not consume an arbitrary
subnet/pool CIDR (documented limitation, unchanged by this PR). oob_subnet is stored + validated (must
fall within 192.168.0.0/16, same family as oob_pool) + surfaced, letting a topology author record the
intended OOB subnet distinctly from the pool the builder actually draws from.
fabric.inb_subnet is genuinely new surface, not a wire-up of an existing gap: grepped build/*.py for
"mgmtInB"/"inbMgmt" — zero matches. Real ACI's in-band management is a distinct mgmtInB bridge-domain
living in the special mgmt tenant, requiring its own tenant/BD/EPG-style builder — a materially bigger
addition than "wire an existing attr," and out of scope for a fine-tuning-parameters PR. Stored +
validated (must fall within a private RFC1918 range: 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16)
- surfaced, explicitly marked store-only in
aci-sim show's own output ((store-only)label) so a user never mistakes it for something the sim actually builds.
fabric.default_apic_version — fabric-wide override, on top of already-independent versioning
Important finding while implementing this PR: the task ask ("make apic_version drive the APIC
controller topSystem/firmwareCtrlrRunning version consistently, independent of switch-node version") was
already fully satisfied before this PR — site.apic_version (added in the PR-9/PR-18 lineage) has
driven build/fabric.py's controller fabricNode.version/topSystem.version/firmwareCtrlrRunning.version
since PR-9, completely independently of the per-node switch Node.version field (PR-18). Verified: grepped
build/*.py for the literal "4.2(7f)" and found it only as Site.apic_version's own schema default in
topology/schema.py — no builder hardcodes an APIC version literal that bypasses site.apic_version.
What was missing: (1) no fabric-wide override existed for topologies with many sites that all want the
same APIC version, and (2) aci-sim show/new never surfaced apic_version at all (verified: zero
matches for "apic_version" in pre-PR-20 cli.py). This PR adds fabric.default_apic_version (optional,
default None = unchanged behavior — each site keeps using its own site.apic_version) with resolution
topo.fabric.default_apic_version or site.apic_version in build/fabric.py, and surfaces the effective
resolved value per-site in aci-sim show's new apic_version= column — closing the surfacing gap without
touching the wiring that already worked.
Backward-compat verification performed vs. NOT performed (be precise)
Performed in this sandboxed development environment: aci-sim validate on the repo topology.yaml
(OK), the full unit suite (713 passed: 672 pre-existing + 41 new, zero regressions), and aci-sim show/
new manual smoke checks confirming every new field surfaces correctly.
NOT performed — no access from this environment: the real production-chain regression gate this
project's acceptance bar calls for (autoACI sandbox e2e 11/11, aci-py MS-TN1 live_chain 0-fail) requires
an SSH-reachable hardware test host, which does not exist in
this sandboxed clone. The fvBD.mac default was double-checked against the pre-PR-20 hardcoded literal
in build/tenants.py specifically because it is the one Tier-3 change with plausible autoACI-regression
risk (autoACI reads fvBD attrs) — but this is static-code verification, not a live e2e run. The main
thread's independent re-run of the real Ansible/aci-py chains is the authoritative backward-compat gate
for this PR, per this project's established process (several prior PRs passed unit tests but broke the
real production chain).
PR-21 — single-APIC default + per-controller OOB IPs
Design intent (owner directive): APIC cluster size is irrelevant to Ansible playbook deployment
testing — a playbook connects to and pushes config through exactly one APIC endpoint, regardless of
how many controllers back it in real ACI. Cluster size only shows up in cluster-health/appliance-vector
tooling, e.g. autoACI's health_score.py, which reads infraWiNode across every controller. This PR
therefore flips Site.controllers's default from 3 (pre-PR-21) to 1 (single-APIC-per-site), while
keeping the field fully configurable (validated range 1-5) for anyone who does need to exercise
multi-controller/cluster-health semantics.
Site.controllers default 3 → 1, validated range 1-5
This is a breaking change to generated node counts, not merely a config default: every site's
fabricNode/topSystem count drops by (old controllers − new controllers) = 2 by default (e.g. a
2-spine/2-leaf/2-border-leaf/3-controller site was 9 fabricNodes; the same shape at the new
1-controller default is 7). fabricLink/lldpAdjEp/cdpAdjEp counts for the APIC↔leaf attachment drop
proportionally (2 leaves × N controllers, so N=3→6 links becomes N=1→2 links), and infraWiNode count
(N²) drops from 9 to 1. Every hardcoded count in the test suite and e2e scripts that assumed the old
default was audited and updated to the new default — see the PR-21 commit message / CHANGELOG for the
full list of touched assertions (the pattern used throughout: prefer a test that derives the expected
count from site.controllers dynamically, as test_pr3b_batch2.py's infraWiNode test already did
pre-PR-21, over one that hardcodes a literal that will need updating again if the default ever changes).
Range validation (1-5) lives in Topology.normalize_and_validate alongside the other per-site checks —
6 (or 0, or negative) is rejected with a clear error naming the site.
Site.controller_ips — per-controller OOB mgmt IP (optional)
Pre-PR-21, every controller's topSystem.oobMgmtAddr was derived from (pod, controller_id) via the
same oob_ip() helper switch nodes use — never from the site's own mgmt_ip, which is the address
the sim's REST server actually binds to (controller-1 / the cluster VIP in sandbox mode). That's fine
for a single controller (there's only one address to assign, and CI/tests never asserted a specific
controller OOB IP), but becomes a believability gap for controllers > 1: real multi-controller clusters
assign each APIC its own OOB IP typically adjacent to the cluster's primary address, not derived from an
arbitrary per-node formula.
controller_ips: list[str] (optional, default unset) fixes this: when set, controller_ips[i] is
controller i+1's OOB IP verbatim (length must equal controllers, each entry a valid IP — validated in
Topology.normalize_and_validate, same place as the controllers range check). When unset, Site. controller_oob_ips() (new helper, topology/schema.py) auto-derives sequentially from mgmt_ip:
controller 1 = mgmt_ip, controller 2 = mgmt_ip + 1, controller 3 = mgmt_ip + 2, etc. (plain
ipaddress integer arithmetic, so it correctly rolls over octets). If mgmt_ip is also unset (empty
string — some hand-authored topologies never set it), controller_oob_ips() returns [] and
build/fabric.py falls back to the legacy oob_ip(pod, cid) scheme, so a topology with neither field
set keeps its pre-PR-21 controller OOB addresses unchanged.
Wired into build/fabric.py's controller loop for both topSystem.oobMgmtAddr and the leaf-side
lldpAdjEp/cdpAdjEp.mgmtIp (the neighbor-reported address must match the controller's own OOB IP, or
autoACI's LLDP-based topology rendering would show a controller "announcing" an IP that doesn't match its
own topSystem) — both read the same per-call controller_oob_ips list so they can never drift apart.
Shipped topology.yaml — dropped to the new default, not left at 3
Both LAB1/LAB2 already omitted an explicit controllers: field pre-PR-21 (it was commented out at
its then-default of 3), so they automatically pick up the new default of 1 with no YAML edit required.
The commented example line was updated to describe the current default (1) rather than the old one
(3), and a new commented block was added showing the controllers: 3 + controller_ips: [...] opt-in
shape for anyone who copies this file as a starting point and wants multi-controller semantics.
Backward-compat verification performed vs. NOT performed (be precise)
Performed in this sandboxed development environment: aci-sim validate on the repo topology.yaml
(OK, now reporting 1 controller/site), the full unit suite (counts below), and manual aci-sim show
checks confirming controller_ips surfaces correctly for both the auto-derived and explicit cases.
NOT performed from this sandboxed environment (no SSH/network access here): the real production-chain
regression gate this project's acceptance bar calls for (autoACI sandbox e2e, aci-py MS-TN1
live_chain.py) — this PR explicitly changes generated node counts, which is exactly the kind of change
that gate exists to catch (a hardcoded node-count assertion inside autoACI itself, if one exists, would
only surface there). The main thread's independent re-run of the real Ansible/aci-py/autoACI chains on
ACI hardware is the authoritative backward-compat gate for this PR.