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
+251
View File
@@ -0,0 +1,251 @@
"""ACI query engine — maps APIC query patterns to MITStore lookups.
Implements three query shapes (CONTRACT §2):
1. ``run_class_query`` — GET /api/class/{cls}.json
2. ``run_mo_query`` — GET /api/mo/{dn}.json
3. ``run_node_scoped`` — GET /api/node/class/{node_dn}/{cls}.json
All three return ``(imdata: list[dict], total: int)`` where *total* is the
full pre-pagination matched count and *imdata* is the current page slice.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.filters import FilterParseError, Predicate, parse_filter
# ---------------------------------------------------------------------------
# QueryParams
# ---------------------------------------------------------------------------
@dataclass
class QueryParams:
"""Carries all APIC query-string parameters for a single request."""
filter_expr: str | None = None
rsp_subtree: str | None = None # "children" | "full"
rsp_subtree_class: list[str] | None = None
query_target: str | None = None # "subtree" (legacy)
target_subtree_class: list[str] | None = None
page: int = 0
page_size: int = 500
def params_from_dict(raw: dict[str, str]) -> QueryParams:
"""Build a :class:`QueryParams` from raw APIC query-string key names.
APIC key names:
``query-target-filter``, ``rsp-subtree``, ``rsp-subtree-class``,
``query-target``, ``target-subtree-class``, ``page``, ``page-size``.
Class-list params are comma-split.
``page-size`` is clamped to ``[1, 5000]``; ``page`` must be ``>= 0``.
Non-numeric or out-of-range ``page``/``page-size`` raise
:class:`~aci_sim.query.filters.FilterParseError` (mapped by the REST
layer to an APIC 400 error envelope — never a bare 500 or silent
truncation).
"""
def _classes(key: str) -> list[str] | None:
val = raw.get(key, "").strip()
if not val:
return None
return [c.strip() for c in val.split(",") if c.strip()]
def _int_param(key: str, default: int) -> int:
raw_val = raw.get(key)
if raw_val is None or raw_val == "":
return default
try:
return int(raw_val)
except ValueError:
raise FilterParseError(
f"invalid {key} value {raw_val!r}: must be an integer"
) from None
page = _int_param("page", 0)
if page < 0:
raise FilterParseError(f"invalid page value {page!r}: must be >= 0")
page_size = _int_param("page-size", 500)
if page_size < 1:
raise FilterParseError(f"invalid page-size value {page_size!r}: must be >= 1")
page_size = min(page_size, 5000)
return QueryParams(
filter_expr=raw.get("query-target-filter") or None,
rsp_subtree=raw.get("rsp-subtree") or None,
rsp_subtree_class=_classes("rsp-subtree-class"),
query_target=raw.get("query-target") or None,
target_subtree_class=_classes("target-subtree-class"),
page=page,
page_size=page_size,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _predicate(params: QueryParams) -> Predicate:
return parse_filter(params.filter_expr)
def _wants_subtree(params: QueryParams) -> bool:
return (
params.rsp_subtree in ("children", "full")
or params.query_target == "subtree"
)
def _subtree_classes(params: QueryParams) -> set[str] | None:
classes = params.rsp_subtree_class or params.target_subtree_class
return set(classes) if classes else None
def _nested_children(
store: MITStore, dn: str, cls_filter: set[str] | None
) -> list[dict[str, Any]]:
"""Build imdata child entries for *dn*, nested recursively by DN parentage.
Mirrors APIC ``rsp-subtree=full``: every descendant stays nested under its
real parent (grandchildren under children, not hoisted to the root). When
*cls_filter* is set, a node is emitted iff its own class is in the filter OR
it has an emitted descendant — so structural containers on the path to a
matching grandchild are preserved (matching ``rsp-subtree-class`` semantics).
MO-less structural intermediates are traversed through: their emitted
descendants hoist up to this level (there is no MO to emit for them).
"""
out: list[dict[str, Any]] = []
for cdn in store.child_dns(dn):
child_mo = store.get(cdn)
nested = _nested_children(store, cdn, cls_filter)
if child_mo is None:
out.extend(nested) # structural intermediate → hoist its matches up
continue
if cls_filter is None or child_mo.class_name in cls_filter or nested:
body: dict[str, Any] = {"attributes": dict(child_mo.attrs)}
if nested:
body["children"] = nested
out.append({child_mo.class_name: body})
return out
def _attach_subtree(store: MITStore, mo: MO, params: QueryParams) -> dict[str, Any]:
"""Produce the imdata entry for *mo* with its descendants nested as children."""
cls_filter = _subtree_classes(params)
if params.rsp_subtree == "children":
# rsp-subtree=children → one level only (direct children)
children = [d.to_imdata() for d in store.children(mo.dn, classes=cls_filter)]
else:
# rsp-subtree=full → full recursive tree, NESTED (hierarchy preserved).
# Real APIC keeps grandchildren under their parent; flattening them into
# direct children of the root silently empties multi-level consumers like
# autoACI contract_map (vzBrCP→vzSubj→vzRsSubjFiltAtt).
children = _nested_children(store, mo.dn, cls_filter)
body: dict[str, Any] = {"attributes": dict(mo.attrs)}
if children:
body["children"] = children
return {mo.class_name: body}
def _paginate(items: list[Any], page: int, page_size: int) -> list[Any]:
start = page * page_size
return items[start: start + page_size]
def _build_result(
store: MITStore,
top_level: list[MO],
params: QueryParams,
) -> tuple[list[dict], int]:
"""Filter, optionally attach subtrees, then paginate.
Returns ``(imdata_page, total_matched)``. *total* is always the full
pre-pagination count (the caller converts it to a string for the wire
``totalCount`` field).
"""
pred = _predicate(params)
# Legacy subtree (query-target=subtree, used by autoACI's query_dn(subtree=True)):
# real APIC returns the matched descendants as FLAT top-level imdata elements
# (filtered by target-subtree-class; root included only if it matches). autoACI
# consumers read the target class at the TOP LEVEL of imdata and never recurse into
# `children` (topology.py ISN/node-detail, fabric_bgp_evpn, l3out_detail, ~60 sites).
#
# ``query-target=subtree`` first sets the SCOPE to root+descendants (unfiltered),
# THEN both the class filter and ``query-target-filter`` predicate are evaluated
# per scoped MO — never pre-filtering the roots with `pred`. Otherwise a filter on
# a descendant-only attribute (e.g. faultInst.severity under a topSystem root)
# would exclude the root before subtree expansion ever runs, returning empty
# imdata even though matching descendants exist (attr-missing→False on the root
# is a legitimate non-match for the root itself, but must not gate expansion).
if params.query_target == "subtree" and params.rsp_subtree is None:
cls_filter = _subtree_classes(params)
flat: list[MO] = []
for root in top_level:
for mo in [root, *store.subtree(root.dn)]:
if cls_filter is None or mo.class_name in cls_filter:
flat.append(mo)
flat = [mo for mo in flat if pred(mo)]
total = len(flat)
page_items = _paginate(flat, params.page, params.page_size)
return [mo.to_imdata() for mo in page_items], total
# Modern rsp-subtree=children|full: NESTED children under each matched object.
filtered = [mo for mo in top_level if pred(mo)]
total = len(filtered)
page_items = _paginate(filtered, params.page, params.page_size)
if params.rsp_subtree in ("children", "full"):
imdata = [_attach_subtree(store, mo, params) for mo in page_items]
else:
imdata = [mo.to_imdata() for mo in page_items]
return imdata, total
# ---------------------------------------------------------------------------
# Public query functions
# ---------------------------------------------------------------------------
def run_class_query(
store: MITStore,
cls: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Query all MOs of class *cls* in the store."""
return _build_result(store, store.by_class(cls), params)
def run_mo_query(
store: MITStore,
dn: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Query the single MO at *dn* (plus subtree if requested)."""
mo = store.get(dn)
if mo is None:
return [], 0
return _build_result(store, [mo], params)
def run_node_scoped(
store: MITStore,
node_dn: str,
cls: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Return instances of *cls* whose DN is under *node_dn*.
Mirrors ``GET /api/node/class/{node_dn}/{cls}.json`` — used for
node-local MOs such as ``faultInst``.
"""
prefix = node_dn.rstrip("/") + "/"
top_level = [mo for mo in store.by_class(cls) if mo.dn.startswith(prefix)]
return _build_result(store, top_level, params)
+267
View File
@@ -0,0 +1,267 @@
"""Parse APIC filter expressions into a callable predicate ``fn(MO) -> bool``.
Supported grammar (CONTRACT §4):
eq(<class>.<attr>, "<val>") equal
ne(<class>.<attr>, "<val>") not equal
wcard(<class>.<attr>, "<substr>") substring containment
gt|lt|ge|le(<class>.<attr>, "<val>") numeric compare (string fallback)
bw(<class>.<attr>, "<lo>", "<hi>") inclusive range
and(<expr>, <expr>, ...)
or(<expr>, <expr>, ...)
Rules:
- Nesting is allowed: ``and(or(...), eq(...))``
- The ``<class>.`` prefix is advisory; we use the attr name after the last dot.
- ``wcard`` performs a substring containment check (not a glob).
- ``gt/lt/ge/le/bw`` compare numerically when both sides parse as numbers,
else fall back to string comparison.
- An unknown attr evaluates to False for eq/wcard/compares (attr not present).
- An empty / None expression is treated as "match everything".
- A malformed expression raises :class:`FilterParseError` (the REST layer maps
it to an APIC 400 error envelope — never a 500).
"""
from __future__ import annotations
from typing import Callable
from aci_sim.mit.mo import MO
Predicate = Callable[[MO], bool]
class FilterParseError(ValueError):
"""Raised when a ``query-target-filter`` expression cannot be parsed.
The REST layer catches this and returns an APIC error envelope with HTTP
400, matching real APIC (which rejects a malformed filter with a 400 +
imdata error) rather than surfacing a 500.
"""
# ---------------------------------------------------------------------------
# Tokenizer (quote- and bracket-aware)
# ---------------------------------------------------------------------------
def _tokenize(s: str) -> list[str]:
"""Produce a flat list of tokens from a filter expression string.
Token types:
- Identifiers / dotted names: ``eq``, ``wcard``, ``and``, ``or``,
``fabricNode.role``, …
- Quoted strings (double-quote delimited, returned WITH the quotes).
- Punctuation: ``(``, ``)`, ``,``.
Whitespace is skipped.
"""
tokens: list[str] = []
i = 0
n = len(s)
while i < n:
ch = s[i]
if ch in " \t\r\n":
i += 1
elif ch == '"':
j = i + 1
while j < n and s[j] != '"':
j += 1
tokens.append(s[i: j + 1])
i = j + 1
elif ch in "(,)":
tokens.append(ch)
i += 1
else:
# Identifier / dotted name — stop at delimiter chars
j = i
while j < n and s[j] not in '(,) \t\r\n"':
j += 1
tokens.append(s[i:j])
i = j
return tokens
# ---------------------------------------------------------------------------
# Recursive-descent parser
# ---------------------------------------------------------------------------
def _expect(tokens: list[str], pos: int, want: str) -> int:
"""Assert ``tokens[pos] == want`` and return ``pos + 1`` (else raise)."""
if pos >= len(tokens) or tokens[pos] != want:
got = tokens[pos] if pos < len(tokens) else "<end-of-expression>"
raise FilterParseError(f"expected {want!r} but got {got!r}")
return pos + 1
# Operators taking (class.attr, value): eq/ne substring-agnostic + comparisons.
_BINARY_OPS = ("eq", "ne", "wcard", "gt", "lt", "ge", "le")
def _parse_expr(tokens: list[str], pos: int) -> tuple[Predicate, int]:
"""Parse one expression starting at *tokens[pos]*.
Returns ``(predicate, new_pos)``. Raises :class:`FilterParseError` on any
structural problem.
"""
if pos >= len(tokens):
raise FilterParseError("unexpected end of filter expression")
tok = tokens[pos]
if tok in ("and", "or"):
pos = _expect(tokens, pos + 1, "(")
parts: list[Predicate] = []
while pos < len(tokens) and tokens[pos] != ")":
if tokens[pos] == ",":
pos += 1
continue
pred, pos = _parse_expr(tokens, pos)
parts.append(pred)
pos = _expect(tokens, pos, ")")
if not parts:
raise FilterParseError(f"{tok}() requires at least one argument")
combiner = _and_pred if tok == "and" else _or_pred
return combiner(parts), pos
if tok in _BINARY_OPS:
# <op>(<class>.<attr>, "<val>")
pos = _expect(tokens, pos + 1, "(")
dotted, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
val_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ")")
return _make_binary_pred(tok, dotted.split(".")[-1], val_token.strip('"')), pos
if tok == "bw":
# bw(<class>.<attr>, "<low>", "<high>")
pos = _expect(tokens, pos + 1, "(")
dotted, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
low_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
high_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ")")
return _bw_pred(dotted.split(".")[-1], low_token.strip('"'), high_token.strip('"')), pos
raise FilterParseError(f"unknown filter operator {tok!r} at position {pos}")
def _take(tokens: list[str], pos: int) -> tuple[str, int]:
"""Return ``(tokens[pos], pos + 1)`` or raise on end-of-input."""
if pos >= len(tokens):
raise FilterParseError("unexpected end of filter expression")
return tokens[pos], pos + 1
# ---------------------------------------------------------------------------
# Predicate factories
# ---------------------------------------------------------------------------
def _eq_pred(attr: str, val: str) -> Predicate:
def pred(mo: MO) -> bool:
return mo.attrs.get(attr) == val
return pred
def _wcard_pred(attr: str, substr: str) -> Predicate:
def pred(mo: MO) -> bool:
return substr in mo.attrs.get(attr, "")
return pred
def _and_pred(preds: list[Predicate]) -> Predicate:
def pred(mo: MO) -> bool:
return all(p(mo) for p in preds)
return pred
def _or_pred(preds: list[Predicate]) -> Predicate:
def pred(mo: MO) -> bool:
return any(p(mo) for p in preds)
return pred
def _ne_pred(attr: str, val: str) -> Predicate:
# ne = ¬eq: an object lacking the attr does not equal *val*, so it matches.
def pred(mo: MO) -> bool:
return mo.attrs.get(attr) != val
return pred
def _to_number(s: object) -> float | None:
try:
return float(s) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
_CMP_OPS: dict[str, Callable[[object, object], bool]] = {
"gt": lambda a, b: a > b,
"lt": lambda a, b: a < b,
"ge": lambda a, b: a >= b,
"le": lambda a, b: a <= b,
}
def _cmp_pred(op: str, attr: str, val: str) -> Predicate:
fn = _CMP_OPS[op]
def pred(mo: MO) -> bool:
raw = mo.attrs.get(attr)
if raw is None:
return False
rn, vn = _to_number(raw), _to_number(val)
if rn is not None and vn is not None:
return fn(rn, vn) # numeric compare when both parse
return fn(str(raw), val) # else lexical
return pred
def _bw_pred(attr: str, low: str, high: str) -> Predicate:
def pred(mo: MO) -> bool:
raw = mo.attrs.get(attr)
if raw is None:
return False
rn, ln, hn = _to_number(raw), _to_number(low), _to_number(high)
if None not in (rn, ln, hn):
return ln <= rn <= hn # type: ignore[operator]
return str(low) <= str(raw) <= str(high)
return pred
def _make_binary_pred(op: str, attr: str, val: str) -> Predicate:
if op == "eq":
return _eq_pred(attr, val)
if op == "ne":
return _ne_pred(attr, val)
if op == "wcard":
return _wcard_pred(attr, val)
return _cmp_pred(op, attr, val) # gt/lt/ge/le
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_filter(expr: str | None) -> Predicate:
"""Parse *expr* into a callable predicate.
Returns a predicate that always returns ``True`` for empty/``None`` input.
"""
if not expr:
return lambda mo: True
try:
tokens = _tokenize(expr)
pred, pos = _parse_expr(tokens, 0)
if pos != len(tokens):
trailing = tokens[pos]
raise FilterParseError(
f"unexpected trailing token {trailing!r} after complete expression "
f"in filter {expr!r}"
)
except FilterParseError:
raise
except (IndexError, ValueError) as exc: # defensive: any tokenizer/parse slip
raise FilterParseError(f"invalid filter expression {expr!r}: {exc}") from exc
return pred