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,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
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user