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,230 @@
|
||||
"""build/neighbors.py — shared LLDP/CDP adjacency + container-MO helper.
|
||||
|
||||
There are THREE call sites that construct lldpAdjEp/cdpAdjEp adjacencies:
|
||||
- build/cabling.py (spine<->leaf fabric links, both directions)
|
||||
- build/fabric.py (APIC<->leaf attachment, leaf-side adjacency)
|
||||
- rest_aci/writes.py (dynamic leaf<->spine adjacencies for a POST-registered
|
||||
node, via store.upsert on an already-built store)
|
||||
|
||||
All three must emit an IDENTICAL, consistent field set (real-APIC-shaped
|
||||
lldpAdjEp/cdpAdjEp) AND materialize the lldpInst/lldpIf/cdpInst/cdpIf
|
||||
container MOs the adjacency hangs off of — without this, the container's own
|
||||
DN (".../sys/lldp/inst") has no MO of its own, and a deep-root subtree query
|
||||
(`GET .../sys/lldp/inst.json?query-target=subtree&target-subtree-class=
|
||||
lldpAdjEp`) returns totalCount=0 even though the adjacency itself is fully
|
||||
reachable via a plain class query. See `aci_sim.query.engine.
|
||||
run_mo_query`: `store.get(dn) is None` short-circuits to `([], 0)` before the
|
||||
subtree walk even starts.
|
||||
|
||||
This module centralizes both concerns behind one function
|
||||
(`add_adjacency`) so the three call sites can never drift apart (e.g. one
|
||||
site adding `portIdV` and another forgetting it).
|
||||
|
||||
Store-method choice: every write below goes through ``store.upsert(...)``,
|
||||
never ``store.add``. Verified against ``aci_sim/mit/store.py``:
|
||||
``upsert`` on a DN that doesn't exist yet takes the exact same path ``add``
|
||||
does (construct a fresh ``MO``, store it, call ``_register``) — the two are
|
||||
behaviorally identical on an empty/fresh store. ``upsert`` additionally
|
||||
merges into an existing entry instead of clobbering it, which is required
|
||||
here because lldpInst/lldpIf/cdpInst/cdpIf are shared containers written
|
||||
once per node/port but visited twice (once per directed adjacency at a
|
||||
spine<->leaf link, per-APIC at the controller-attach site). Callers that
|
||||
already exclusively use ``store.add`` (cabling.py, fabric.py boot-time build)
|
||||
lose nothing by switching: the store is empty at those DNs on first visit,
|
||||
so ``upsert`` behaves exactly like ``add`` there too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from aci_sim.mit.mo import MO
|
||||
from aci_sim.mit.store import MITStore
|
||||
from aci_sim.topology.schema import Node
|
||||
|
||||
# APIC controllers report this fixed platform id on real hardware.
|
||||
APIC_PLATFORM_ID = "APIC-SERVER-M3"
|
||||
|
||||
|
||||
def node_mac(pod: int, node_id: int) -> str:
|
||||
"""Deterministic, unique, valid MAC for *node_id* in *pod*.
|
||||
|
||||
Used as lldpAdjEp.chassisIdV (the neighbor's chassis MAC) and as
|
||||
lldpIf.mac (the local interface's reported MAC) — real LLDP always
|
||||
carries a chassis MAC, so a believable, stable value beats an empty
|
||||
string. Scheme: 00:1B:0D:<pod>:<node_id high byte>:<node_id low byte>.
|
||||
00:1B:0D is an arbitrary-but-fixed OUI-shaped prefix (not a real
|
||||
allocation) chosen purely so the value looks like a plausible MAC;
|
||||
pod/node_id are masked into single bytes so the address is always
|
||||
well-formed regardless of how large either input is.
|
||||
"""
|
||||
return f"00:1B:0D:{pod & 0xFF:02X}:{(node_id >> 8) & 0xFF:02X}:{node_id & 0xFF:02X}"
|
||||
|
||||
|
||||
def _lldp_inst_dn(pod: int, node_id: int) -> str:
|
||||
return f"topology/pod-{pod}/node-{node_id}/sys/lldp/inst"
|
||||
|
||||
|
||||
def _cdp_inst_dn(pod: int, node_id: int) -> str:
|
||||
return f"topology/pod-{pod}/node-{node_id}/sys/cdp/inst"
|
||||
|
||||
|
||||
def ensure_lldp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
|
||||
"""Idempotently materialize lldpInst (per-node) + lldpIf (per-port).
|
||||
|
||||
Safe to call once per directed adjacency that lands on this node/port —
|
||||
``store.upsert`` merges into the same DN instead of duplicating it.
|
||||
"""
|
||||
inst_dn = _lldp_inst_dn(pod, node_id)
|
||||
store.upsert(MO(
|
||||
"lldpInst",
|
||||
dn=inst_dn,
|
||||
adminSt="enabled",
|
||||
name="default",
|
||||
holdTime="120",
|
||||
initDelayTime="2",
|
||||
txFreq="30",
|
||||
ctrl="rx-en,tx-en",
|
||||
optTlvSel="mgmt-addr,port-desc,port-vlan,sys-cap,sys-desc,sys-name",
|
||||
))
|
||||
store.upsert(MO(
|
||||
"lldpIf",
|
||||
dn=f"{inst_dn}/if-[{local_port}]",
|
||||
id=local_port,
|
||||
adminRxSt="enabled",
|
||||
adminTxSt="enabled",
|
||||
operRxSt="up",
|
||||
operTxSt="up",
|
||||
mac=node_mac(pod, node_id),
|
||||
portDesc="",
|
||||
portVlan="0",
|
||||
sysDesc="",
|
||||
wiring="valid",
|
||||
))
|
||||
|
||||
|
||||
def ensure_cdp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
|
||||
"""Idempotently materialize cdpInst (per-node) + cdpIf (per-port)."""
|
||||
inst_dn = _cdp_inst_dn(pod, node_id)
|
||||
store.upsert(MO(
|
||||
"cdpInst",
|
||||
dn=inst_dn,
|
||||
adminSt="enabled",
|
||||
name="default",
|
||||
holdIntvl="180",
|
||||
txFreq="60",
|
||||
ver="v2",
|
||||
ctrl="",
|
||||
))
|
||||
store.upsert(MO(
|
||||
"cdpIf",
|
||||
dn=f"{inst_dn}/if-[{local_port}]",
|
||||
id=local_port,
|
||||
adminSt="enabled",
|
||||
operSt="up",
|
||||
))
|
||||
|
||||
|
||||
def add_adjacency(
|
||||
store: MITStore,
|
||||
*,
|
||||
pod: int,
|
||||
local_node_id: int,
|
||||
local_port: str,
|
||||
remote_port: str,
|
||||
neighbor_name: str,
|
||||
neighbor_mgmt_ip: str,
|
||||
neighbor_kind: str,
|
||||
neighbor_model: str = "",
|
||||
neighbor_version: str = "",
|
||||
neighbor_mac: str = "",
|
||||
) -> None:
|
||||
"""Emit one directed lldpAdjEp+cdpAdjEp (local node sees *neighbor* on
|
||||
*local_port*) AND ensure the lldpInst/lldpIf/cdpInst/cdpIf containers for
|
||||
(local_node_id, local_port) exist.
|
||||
|
||||
*neighbor_kind* is ``"switch"`` (spine/leaf/border-leaf) or
|
||||
``"controller"`` (APIC) — drives the sysDesc/capability/platId/cap field
|
||||
shapes below (real APIC neighbors don't report bridge/router capability).
|
||||
*neighbor_mac* is the neighbor's chassis MAC (chassisIdV). Every call site
|
||||
passes one (derived via ``node_mac`` from the neighbor's node id), so the
|
||||
``""`` default is never hit in practice; it exists only so an omitted MAC
|
||||
yields a syntactically valid (if uninformative) empty chassisIdV rather
|
||||
than a KeyError.
|
||||
"""
|
||||
base = f"topology/pod-{pod}/node-{local_node_id}/sys"
|
||||
|
||||
if neighbor_kind == "controller":
|
||||
sys_desc = "Cisco APIC"
|
||||
capability = ""
|
||||
plat_id = APIC_PLATFORM_ID
|
||||
cap = ""
|
||||
else:
|
||||
sys_desc = f"{neighbor_model} running {neighbor_version}"
|
||||
capability = "bridge,router"
|
||||
plat_id = neighbor_model
|
||||
cap = "bridge,router"
|
||||
|
||||
store.upsert(MO(
|
||||
"lldpAdjEp",
|
||||
dn=f"{base}/lldp/inst/if-[{local_port}]/adj-1",
|
||||
sysName=neighbor_name,
|
||||
mgmtIp=neighbor_mgmt_ip,
|
||||
portIdV=remote_port,
|
||||
# 802.1AB Port-ID subtype 5: portIdV carries a named interface
|
||||
# (e.g. "eth1/1"), which real ACI reports as interfaceName — NOT
|
||||
# locallyAssigned (subtype 7), which is for opaque port-id strings.
|
||||
portIdT="interfaceName",
|
||||
chassisIdT="mac",
|
||||
chassisIdV=neighbor_mac,
|
||||
sysDesc=sys_desc,
|
||||
capability=capability,
|
||||
id="1",
|
||||
ttl="120",
|
||||
))
|
||||
store.upsert(MO(
|
||||
"cdpAdjEp",
|
||||
dn=f"{base}/cdp/inst/if-[{local_port}]/adj-1",
|
||||
sysName=neighbor_name,
|
||||
mgmtIp=neighbor_mgmt_ip,
|
||||
devId=neighbor_name,
|
||||
portId=remote_port,
|
||||
platId=plat_id,
|
||||
ver=neighbor_version,
|
||||
cap=cap,
|
||||
))
|
||||
|
||||
ensure_lldp_containers(store, pod, local_node_id, local_port)
|
||||
ensure_cdp_containers(store, pod, local_node_id, local_port)
|
||||
|
||||
|
||||
def add_switch_adjacency(
|
||||
store: MITStore,
|
||||
*,
|
||||
pod: int,
|
||||
local_node_id: int,
|
||||
local_port: str,
|
||||
remote_port: str,
|
||||
neighbor: Node,
|
||||
neighbor_mgmt_ip: str,
|
||||
) -> None:
|
||||
"""Convenience wrapper for the common switch<->switch case (cabling.py,
|
||||
fabric.py's dynamic-registration mirror in writes.py): derives
|
||||
model/version/mac from the neighbor ``Node`` directly instead of making
|
||||
every call site repeat ``neighbor_model=neighbor.model,
|
||||
neighbor_version=neighbor.version, neighbor_mac=node_mac(pod, neighbor.id)``.
|
||||
``neighbor_mgmt_ip`` is still passed explicitly because callers derive it
|
||||
via ``oob_ip()`` (or, for a dynamically-registered node, a value already
|
||||
computed by the caller) rather than something derivable from ``Node``
|
||||
alone.
|
||||
"""
|
||||
add_adjacency(
|
||||
store,
|
||||
pod=pod,
|
||||
local_node_id=local_node_id,
|
||||
local_port=local_port,
|
||||
remote_port=remote_port,
|
||||
neighbor_name=neighbor.name,
|
||||
neighbor_mgmt_ip=neighbor_mgmt_ip,
|
||||
neighbor_kind="switch",
|
||||
neighbor_model=neighbor.model,
|
||||
neighbor_version=neighbor.version,
|
||||
neighbor_mac=node_mac(pod, neighbor.id),
|
||||
)
|
||||
Reference in New Issue
Block a user