Files
aci-sim/aci_sim/query/engine.py
T
dtzp555-maxandClaude Fable 5 867c05c9c5 feat: APIC-style write validation, query-target=children, contract shadow mirroring (v0.21.0)
Three fidelity gaps surfaced by one broken TN2 var file (missing firewall
block), all fixed:
- writes: reject malformed fvSubnet/l3extSubnet/vnsRedirectDest ip values
  with an APIC-style 400 before any store mutation (deletes exempt)
- query engine: support query-target=children (direct children, flat
  imdata, root excluded) — was silently returning empty
- deploy mirror: materialize vzFilter/vzEntry, vzBrCP/vzSubj (+ filter and
  service-graph subject bindings) and vnsAbsGraph shadows per target site;
  undeploy removes them

E2E-verified through the real pipeline (aci-py hidden push): a TN2 var
missing the fw block now fails with 'Invalid value "." for property ip of
vnsRedirectDest ... -> 400' and nothing lands; a complete var set builds
both MS tenants with contract shadows + sgt-FW graph binding on both sites.
Suite: 914 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:11:59 +10:00

269 lines
10 KiB
Python

"""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
# query-target=children (real APIC): scope = the DIRECT children of each
# matched root — root itself excluded — returned as FLAT top-level imdata
# entries; target-subtree-class and query-target-filter apply per child.
# (Was previously unsupported and silently returned empty imdata, which
# reads as "object has no children" — a verification false-negative.)
if params.query_target == "children" and params.rsp_subtree is None:
cls_filter = _subtree_classes(params)
flat_children: list[MO] = []
for root in top_level:
for mo in store.children(root.dn):
if cls_filter is None or mo.class_name in cls_filter:
flat_children.append(mo)
flat_children = [mo for mo in flat_children if pred(mo)]
total = len(flat_children)
page_items = _paginate(flat_children, 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)