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