Initial public release — aci-sim v0.16.0

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

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

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
"""build/interfaces.py — l1PhysIf, ethpmPhysIf, eqptcapacityPolUsage5min per node.
Port layout:
Fabric uplink ports: same assignment as cabling.py (derived independently)
- Spines: eth1/1, eth1/2, ... (one per downlink node)
- Leaves/BLs: eth1/49, eth1/50, ... (one per spine)
Host-facing ports on leaves/border-leaves: eth1/1 eth1/8 (simulated access)
L3Out routed sub-interface port on border-leaves ONLY: eth1/48 (dedicated,
outside the host-access range — matches real ACI convention of a dedicated
front-panel port for the L3Out CSW uplink rather than sharing a host port).
ISN/IPN uplink ports on spines ONLY, multi-site fabrics only: eth1/49, 50, ...
(one per spine, dedicated — outside the fabric-uplink range eth1/1-4, which
is fully consumed by intra-fabric spine↔leaf links). Real ACI spines facing
a multi-site ISN use dedicated front-panel ports for the IPN uplink, distinct
from the leaf-facing fabric ports; underlay.py's ospfAdjEp references these.
ethpmPhysIf DN must contain "phys-[eth{s}/{p}]" — read by topology.py line 730:
port = dn.split("phys-[")[1].split("]")[0] → "eth1/1"
l1PhysIf DN is under the same sys/ subtree; id attribute carries the port name.
Batch-2 addition (CONTRACT.md §6 "Interfaces"): ethpmFcot — SFP/transceiver
info, sibling of ethpmPhysIf under the same phys-[eth{s}/{p}] port DN (own RN
"fcot", per real ACI's ethpmFcot placement one level under ethpmPhysIf's own
"phys" RN's parent). Attrs verified read-only against autoACI's
interface_comprehensive.py: typeName, guiName, vendorName, vendorSn,
actualType (decoded via that plugin's own `_decode_sfp_field`/`_port_from_dn`
helpers, which just strip a "phys-[" bracket segment off the dn — same
placement l1PhysIf/ethpmPhysIf already use). Only emitted for fabric-uplink
and L3Out routed ports (real optics-bearing ports); plain host-access ports
are left without an ethpmFcot, matching real ACI labs where copper/DAC host
ports often report no transceiver inventory while fabric/WAN-facing optical
ports do — a deliberate realism choice, not an omission.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.cabling import cabling_links
_UPLINK_START = 49 # first leaf-side uplink port index
_HOST_PORTS = 8 # number of host-facing ports on leaves
_L3OUT_PORT = 48 # dedicated border-leaf L3Out routed sub-interface port
def _add_port(
store: MITStore,
pod: int,
node_id: int,
slot: int,
port: int,
descr: str = "",
mode: str = "trunk",
usage: str = "fabric",
with_fcot: bool = False,
mtu: str = "9216",
) -> None:
"""Add l1PhysIf + ethpmPhysIf (+ optional ethpmFcot) for one port.
`mtu` defaults to the fabric intra-fabric MTU (9216); the ISN/IPN uplink
port (multi-site only) passes `topo.isn.mtu` instead (Tier-2, PR-19) —
real ACI's inter-pod/inter-site default is 9150, distinct from the
intra-fabric 9216 ceiling.
"""
port_id = f"eth{slot}/{port}"
sys_dn = f"topology/pod-{pod}/node-{node_id}/sys"
phys_dn = f"{sys_dn}/phys-[{port_id}]"
# l1PhysIf — administrative config
store.add(MO(
"l1PhysIf",
dn=phys_dn,
id=port_id,
adminSt="up",
descr=descr,
mtu=mtu,
mode=mode,
))
# ethpmPhysIf — operational state; DN must contain "phys-[eth…]"
store.add(MO(
"ethpmPhysIf",
dn=f"{phys_dn}/phys",
operSt="up",
operSpeed="100G",
usage=usage,
lastLinkStChg="00:00:00:00.000",
resetCtr="0",
))
# Batch-2: ethpmFcot — SFP/transceiver inventory, sibling of ethpmPhysIf
# (own "fcot" RN under the same phys-[eth…] port DN — interface_comprehensive.py's
# _port_from_dn strips the same "phys-[" bracket segment this DN shares with
# ethpmPhysIf, so both resolve to the same port string). Only real
# optics-bearing ports (fabric uplinks, ISN uplinks, L3Out routed ports)
# get one — see module docstring for the realism rationale.
if with_fcot:
store.add(MO(
"ethpmFcot",
dn=f"{phys_dn}/fcot",
typeName="QSFP-100G-SR4",
guiName="QSFP-100G-SR4",
vendorName="CISCO-FINISAR",
vendorSn=f"FNS{pod:02d}{node_id:04d}{port:02d}",
actualType="qsfp-100g-sr4",
operSt="up",
operSpeed="100G",
))
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit l1PhysIf + ethpmPhysIf for all nodes, plus eqptcapacityPolUsage5min."""
pod = site.pod
spine_ids = {n.id for n in site.spine_nodes()}
# Collect port assignments from the cabling graph
# spine_ports[node_id] = set of (slot, port) used as fabric uplinks
# leaf_ports[node_id] = set of (slot, port) used as fabric uplinks
spine_uplinks: dict[int, list[tuple[int, int]]] = {n.id: [] for n in site.spine_nodes()}
leaf_uplinks: dict[int, list[tuple[int, int]]] = {
n.id: [] for n in site.leaf_nodes() + site.border_leaf_nodes()
}
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
if n1 in spine_ids:
spine_uplinks[n1].append((s1, p1))
leaf_uplinks[n2].append((s2, p2))
else:
spine_uplinks[n2].append((s2, p2))
leaf_uplinks[n1].append((s1, p1))
# Spines — fabric uplinks, plus (multi-site only) dedicated ISN uplinks
for si, spine in enumerate(site.spine_nodes()):
for slot, port in spine_uplinks[spine.id]:
_add_port(
store, pod, spine.id, slot, port,
descr=f"Fabric uplink eth{slot}/{port}",
mode="trunk",
with_fcot=True,
)
if len(topo.sites) > 1:
isn_port = _UPLINK_START + si
_add_port(
store, pod, spine.id, 1, isn_port,
descr=f"ISN/IPN uplink eth1/{isn_port}",
mode="routed",
usage="isn",
with_fcot=True,
mtu=str(topo.isn.mtu),
)
border_leaf_ids = {bl.id for bl in site.border_leaves}
# Leaves + border-leaves — fabric uplinks + host-facing ports
for leaf in site.leaf_nodes() + site.border_leaf_nodes():
# Fabric uplink ports
for slot, port in leaf_uplinks[leaf.id]:
_add_port(
store, pod, leaf.id, slot, port,
descr="Fabric uplink to spine",
mode="trunk",
with_fcot=True,
)
# Host-facing access ports: eth1/1 … eth1/_HOST_PORTS
for hp in range(1, _HOST_PORTS + 1):
_add_port(
store, pod, leaf.id, 1, hp,
descr=f"Host port eth1/{hp}",
mode="access",
usage="access",
)
# Border-leaves only: dedicated L3Out routed sub-interface port,
# outside the host-access range — l3out.py's l3extRsPathL3OutAtt
# tDn references this exact port so it resolves against a real
# l1PhysIf (real ACI convention: dedicated front-panel port for the
# L3Out CSW uplink, not shared with regular host/EPG traffic).
if leaf.id in border_leaf_ids:
_add_port(
store, pod, leaf.id, 1, _L3OUT_PORT,
descr=f"L3Out routed uplink eth1/{_L3OUT_PORT}",
mode="routed",
usage="l3out",
with_fcot=True,
)
# eqptcapacityPolUsage5min — capacity stats (contract-rendering metrics)
store.add(MO(
"eqptcapacityPolUsage5min",
dn=f"topology/pod-{pod}/node-{leaf.id}/sys/eqptcapacity/polUsage5min",
polUsage="42",
polUsageCap="100",
polUsageCum="38",
polUsageCapCum="100",
))