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>
This commit is contained in:
dtzp555-max
2026-07-07 18:11:59 +10:00
co-authored by Claude Fable 5
parent 7fdf31aca9
commit 867c05c9c5
7 changed files with 323 additions and 1 deletions
+35
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import ipaddress
import re
from aci_sim.build.fabric import loopback_ip, oob_ip
@@ -213,6 +214,39 @@ def _validate_node(cls: str, body: dict, path: str) -> None:
_validate_node(child_cls, child_body, child_path)
def _validate_planned(planned: list[tuple[str, dict]]) -> None:
"""Reject malformed property values a real APIC would 400 on (error-801
style) — e.g. ``fvSubnet ip=".1/24"`` or ``vnsRedirectDest ip="."``, the
exact garbage an unguarded J2 template renders when its input vars are
missing. The sim's contract is to FAIL the way real gear fails, not to
absorb it into the MIT. Only values actually PRESENT on a non-delete
write are validated (a delete needs nothing but the DN — that stays the
cleanup path for anything malformed that predates this check)."""
for mo_cls, mo_attrs in planned:
if mo_attrs.get("status") == "deleted":
continue
if mo_cls in ("fvSubnet", "l3extSubnet"):
ip = mo_attrs.get("ip")
if ip is not None:
try:
ipaddress.ip_interface(ip)
except ValueError:
raise WriteValidationError(
f"Invalid value {ip!r} for property 'ip' of {mo_cls} "
f"{mo_attrs.get('dn', '')!r}: not a valid address[/prefix]"
) from None
elif mo_cls == "vnsRedirectDest":
ip = mo_attrs.get("ip")
if ip is not None:
try:
ipaddress.ip_address(ip)
except ValueError:
raise WriteValidationError(
f"Invalid value {ip!r} for property 'ip' of vnsRedirectDest "
f"{mo_attrs.get('dn', '')!r}: not a valid IP address"
) from None
def _plan_recursive(cls: str, attrs: dict, children: list, planned: list[tuple[str, dict]]) -> None:
"""Build the ordered list of (class, attrs) MOs to write, without touching the store.
@@ -253,6 +287,7 @@ def _upsert_recursive(store: MITStore, cls: str, attrs: dict, children: list) ->
planned: list[tuple[str, dict]] = []
_plan_recursive(cls, attrs, children, planned)
_validate_planned(planned) # reject malformed values BEFORE any store mutation
# Validation passed for the entire subtree — now, and only now, mutate
# the store (400 on validation failure => zero side effects).