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
View File
+115
View File
@@ -0,0 +1,115 @@
"""Bracket-aware DN utilities.
ACI Distinguished Names are slash-separated, but path components inside
square brackets must never be split on the slash. Examples:
uni/tn-T/BD-b/subnet-[10.0.0.1/24]
uni/tn-T/pathep-[eth1/9]
uni/phys-X/rsdomAtt-[uni/phys-X]
uni/infra/.../rslldpIfAtt-[pathep-[eth1/9]] (nested brackets)
"""
from __future__ import annotations
import re
def dn_split(dn: str) -> list[str]:
"""Split a DN into RN components, never splitting inside [...].
Returns a list of RN strings; returns [] for an empty DN.
"""
parts: list[str] = []
depth = 0
current: list[str] = []
for ch in dn:
if ch == "[":
depth += 1
current.append(ch)
elif ch == "]":
depth -= 1
current.append(ch)
elif ch == "/" and depth == 0:
parts.append("".join(current))
current = []
else:
current.append(ch)
if current:
parts.append("".join(current))
return parts
def parent_dn(dn: str) -> str:
"""Return the parent DN (everything except the last RN).
Returns "" for top-level DNs (no parent).
"""
parts = dn_split(dn)
if len(parts) <= 1:
return ""
return "/".join(parts[:-1])
def rn_of(dn: str) -> str:
"""Return the last RN of a DN."""
parts = dn_split(dn)
return parts[-1] if parts else ""
def name_from_dn(dn: str) -> str:
"""Extract the name from the last RN (part after the first '-').
For bracket RNs like `subnet-[10.0.0.1/24]`, returns `10.0.0.1/24`.
For plain RNs like `tn-T`, returns `T`.
For RNs with no dash (e.g. `uni`), returns the whole RN.
"""
rn = rn_of(dn)
# Bracket form: foo-[...] → extract content of innermost [...] span
m = re.match(r"[^-]+-\[(.+)\]$", rn)
if m:
return m.group(1)
# Plain dash: foo-bar → bar
idx = rn.find("-")
if idx >= 0:
return rn[idx + 1:]
return rn
def pod_from_dn(dn: str) -> str:
"""Extract the pod number from a DN.
Searches for `/pod-N` in the DN; returns "1" as the default when absent.
"""
m = re.search(r"/pod-(\d+)(?:/|$)", dn)
if m:
return m.group(1)
# Also handle DN that starts with "pod-N"
m2 = re.match(r"pod-(\d+)(?:/|$)", dn)
if m2:
return m2.group(1)
return "1"
def dn_class_hint(rn: str) -> str | None:
"""Guess the ACI class from an RN prefix (best-effort).
Used by the REST layer to build default class names from path components.
Returns None if the prefix is not recognised.
"""
_PREFIX_MAP: dict[str, str] = {
"uni": "polUni",
"tn-": "fvTenant",
"ctx-": "fvCtx",
"BD-": "fvBD",
"subnet-": "fvSubnet",
"ap-": "fvAp",
"epg-": "fvAEPg",
"cep-": "fvCEp",
"node-": "fabricNode",
"sys": "topSystem",
"health": "healthInst",
"fault-": "faultInst",
"lnk-": "fabricLink",
}
for prefix, cls in _PREFIX_MAP.items():
if rn == prefix or (prefix.endswith("-") and rn.startswith(prefix)):
return cls
return None
+50
View File
@@ -0,0 +1,50 @@
"""MO (Managed Object) — building block of the ACI Managed Information Tree."""
from __future__ import annotations
from typing import Any
class MO:
"""A single node in the ACI MIT.
Attributes
----------
class_name : str e.g. ``"fvTenant"``
attrs : dict[str, str] must include ``"dn"``
children : list[MO] direct children in this MO's subtree
"""
def __init__(self, class_name: str, **attrs: str) -> None:
self.class_name = class_name
self.attrs: dict[str, str] = dict(attrs)
self.children: list[MO] = []
@property
def dn(self) -> str:
return self.attrs.get("dn", "")
def add_child(self, mo: MO) -> None:
"""Append *mo* to this MO's children list."""
self.children.append(mo)
def flat(self) -> list[MO]:
"""Return self + all descendants in depth-first order."""
result: list[MO] = [self]
for child in self.children:
result.extend(child.flat())
return result
def to_imdata(self) -> dict[str, Any]:
"""Produce the APIC wire shape for this MO.
``{"class_name": {"attributes": {...}, "children": [...]}}``
The ``children`` key is omitted when the MO has no children.
"""
body: dict[str, Any] = {"attributes": dict(self.attrs)}
if self.children:
body["children"] = [c.to_imdata() for c in self.children]
return {self.class_name: body}
def __repr__(self) -> str:
return f"MO({self.class_name!r}, dn={self.dn!r})"
+174
View File
@@ -0,0 +1,174 @@
"""MITStore — DN-keyed in-memory object store for ACI MOs."""
from __future__ import annotations
import copy
from aci_sim.mit.mo import MO
from aci_sim.mit.dn import parent_dn
class MITStore:
"""Maintains a ``dn -> MO`` map and a ``parent_dn -> [child dn]`` index.
Iteration order is deterministic (insertion order of DNs).
The store is deep-copyable for baseline/snapshot workflows.
"""
def __init__(self) -> None:
self._mos: dict[str, MO] = {} # dn → MO
self._children: dict[str, list[str]] = {} # parent_dn → [child dn]
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _register(self, dn: str) -> None:
"""Register *dn* AND ensure its full ancestor chain is linked (idempotent).
Walking the whole chain (not just the immediate parent) lets :meth:`subtree`
traverse through structural intermediates that were never added as MOs — e.g.
a deep ``epmMacEp`` at ``…/sys/ctx-[…]/bd-[…]/db-ep/mac-…`` whose ctx/bd/db-ep
containers have no MO of their own. Without this, ``subtree(node/sys)`` could
not reach it.
"""
self._children.setdefault(dn, [])
cur = dn
while True:
pdn = parent_dn(cur)
if not pdn or pdn == cur:
break
siblings = self._children.setdefault(pdn, [])
if cur not in siblings:
siblings.append(cur)
cur = pdn
def _remove_subtree(self, dn: str) -> None:
"""Remove *dn* and all its descendants from both maps."""
for child_dn in list(self._children.get(dn, [])):
self._remove_subtree(child_dn)
self._mos.pop(dn, None)
self._children.pop(dn, None)
pdn = parent_dn(dn)
siblings = self._children.get(pdn)
if siblings is not None:
try:
siblings.remove(dn)
except ValueError:
pass
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def add(self, mo: MO) -> None:
"""Insert *mo* (and recursively its ``.children``) without merging.
Stores a *childless* copy — hierarchy lives only in the parent index
(same invariant as :meth:`upsert`). Otherwise a stored tree MO would
carry a second copy of its subtree via ``MO.to_imdata()``, leaking
``children`` onto non-subtree queries and duplicating descendants on
subtree queries.
"""
dn = mo.dn
self._mos[dn] = MO(mo.class_name, **mo.attrs)
self._register(dn)
for child in mo.children:
self.add(child)
def upsert(self, mo: MO) -> None:
"""Merge *mo*'s attrs into an existing entry or insert it.
Recurses into ``mo.children``.
If ``mo.attrs["status"] == "deleted"``, removes the DN and its entire
subtree instead of merging.
"""
dn = mo.dn
if mo.attrs.get("status") == "deleted":
self._remove_subtree(dn)
return
existing = self._mos.get(dn)
if existing is None:
new_mo = MO(mo.class_name, **mo.attrs)
self._mos[dn] = new_mo
self._register(dn)
else:
existing.attrs.update(mo.attrs)
for child in mo.children:
self.upsert(child)
def get(self, dn: str) -> MO | None:
"""Return the MO at *dn*, or ``None`` if not present."""
return self._mos.get(dn)
def delete(self, dn: str) -> None:
"""Remove *dn* and its entire subtree from the store.
Idempotent: deleting a *dn* that is not present (or was already
removed) is a silent no-op, matching ``upsert``'s ``status="deleted"``
branch (also unconditional — no existence check) and real APIC's
DELETE /api/mo/{dn}.json used by cisco.aci's ``state=absent`` path
(PR-9). Public wrapper around the same ``_remove_subtree`` helper
``upsert`` already uses, so callers outside this module (the DELETE
HTTP route) don't need to reach into a private method.
"""
self._remove_subtree(dn)
def children(self, dn: str, classes: set[str] | None = None) -> list[MO]:
"""Return direct children of *dn*, optionally filtered by class names."""
result: list[MO] = []
for cdn in self._children.get(dn, []):
mo = self._mos.get(cdn)
if mo is None:
continue
if classes is None or mo.class_name in classes:
result.append(mo)
return result
def child_dns(self, dn: str) -> list[str]:
"""Return the raw child-DN list of *dn* in insertion order.
Includes structural intermediates that have no MO of their own (see
:meth:`_register`). Used by the query engine to reconstruct the nested
subtree for ``rsp-subtree=full`` without flattening the hierarchy.
"""
return list(self._children.get(dn, []))
def subtree(self, dn: str, classes: set[str] | None = None) -> list[MO]:
"""Return all descendants of *dn* (not *dn* itself), depth-first.
If *classes* is given, only include MOs whose class_name is in the set;
but always recurse into ALL children regardless (mirrors APIC behaviour
where a class filter on rsp-subtree-class traverses the full tree).
"""
acc: list[MO] = []
self._collect_subtree(dn, classes, acc)
return acc
def _collect_subtree(self, dn: str, classes: set[str] | None, acc: list[MO]) -> None:
for cdn in self._children.get(dn, []):
mo = self._mos.get(cdn)
if mo is not None and (classes is None or mo.class_name in classes):
acc.append(mo)
# Recurse even through MO-less structural intermediates so deeply
# nested descendants (e.g. epm* under uninstantiated containers) are reached.
self._collect_subtree(cdn, classes, acc)
def by_class(self, cls: str) -> list[MO]:
"""Return all MOs whose ``class_name == cls``, in insertion order."""
return [mo for mo in self._mos.values() if mo.class_name == cls]
def all(self) -> list[MO]:
"""Return all MOs in insertion order."""
return list(self._mos.values())
# ------------------------------------------------------------------
# Deep-copy support (for baseline / snapshot)
# ------------------------------------------------------------------
def __deepcopy__(self, memo: dict) -> MITStore:
new = MITStore.__new__(MITStore)
new._mos = copy.deepcopy(self._mos, memo)
new._children = copy.deepcopy(self._children, memo)
return new