Files
aci-sim/aci_sim/build/neighbors.py
T
taodengandClaude Fable 5 7e9a175ce6 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>
2026-07-05 20:06:36 +10:00

231 lines
8.4 KiB
Python

"""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),
)