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
+92
View File
@@ -314,9 +314,97 @@ def _template_object_dns(tenant: str, template: dict) -> list[tuple[str, str]]:
epg_name = epg.get("name") if isinstance(epg, dict) else None
if epg_name:
dns.append((f"uni/tn-{tenant}/ap-{anp_name}/epg-{epg_name}", "fvAEPg"))
for flt in template.get("filters", []) or []:
name = flt.get("name") if isinstance(flt, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/flt-{name}", "vzFilter"))
for con in template.get("contracts", []) or []:
name = con.get("name") if isinstance(con, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/brc-{name}", "vzBrCP"))
for graph in template.get("serviceGraphs", []) or []:
name = graph.get("name") if isinstance(graph, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/AbsGraph-{name}", "vnsAbsGraph"))
return dns
def _mirror_contracts(store: MITStore, tenant: str, template: dict) -> int:
"""Mirror the template's filters/contracts/service-graphs into a site
APIC store as their shadow MOs (vzFilter/vzEntry, vzBrCP/vzSubj + the
filter and service-graph subject bindings, vnsAbsGraph).
A real NDO deploy creates these shadow objects on every target site's
APIC; without them the mirrored EPGs' fvRsProv/fvRsCons are DANGLING
references — same family as F1 (children/relations exist, the referenced
object MO doesn't), surfaced 2026-07-07 when `GET /api/class/vzBrCP`
returned 0 fabric-wide despite deployed MS tenants providing/consuming
contracts. Shadow-subject naming: one vzSubj per contract, named after
the contract ("con-X" -> "sub-X"), carrying the filter attachments and
the service-graph binding (tnVnsAbsGraphName) when the NDO contract has
a serviceGraphRelationship."""
written = 0
for flt in template.get("filters", []) or []:
if not isinstance(flt, dict) or not flt.get("name"):
continue
flt_name = flt["name"]
flt_dn = f"uni/tn-{tenant}/flt-{flt_name}"
store.upsert(MO("vzFilter", dn=flt_dn, name=flt_name))
written += 1
for entry in flt.get("entries", []) or []:
if not isinstance(entry, dict) or not entry.get("name"):
continue
store.upsert(MO(
"vzEntry",
dn=f"{flt_dn}/e-{entry['name']}",
name=entry["name"],
etherT=entry.get("etherType", "unspecified"),
prot=entry.get("ipProtocol", "unspecified"),
))
written += 1
for con in template.get("contracts", []) or []:
if not isinstance(con, dict) or not con.get("name"):
continue
con_name = con["name"]
con_dn = f"uni/tn-{tenant}/brc-{con_name}"
store.upsert(MO("vzBrCP", dn=con_dn, name=con_name, scope=con.get("scope", "context")))
written += 1
subj_name = f"sub-{con_name[4:]}" if con_name.startswith("con-") else f"sub-{con_name}"
subj_dn = f"{con_dn}/subj-{subj_name}"
store.upsert(MO("vzSubj", dn=subj_dn, name=subj_name))
written += 1
for rel in con.get("filterRelationships", []) or []:
ref = rel.get("filterRef", "") if isinstance(rel, dict) else ""
rel_flt = _basename(ref)
if not rel_flt:
continue
store.upsert(MO(
"vzRsSubjFiltAtt",
dn=f"{subj_dn}/rssubjFiltAtt-{rel_flt}",
tnVzFilterName=rel_flt,
))
written += 1
graph_rel = con.get("serviceGraphRelationship") or {}
graph_name = _basename(graph_rel.get("serviceGraphRef", "")) if isinstance(graph_rel, dict) else ""
if graph_name:
store.upsert(MO(
"vzRsSubjGraphAtt",
dn=f"{subj_dn}/rsSubjGraphAtt",
tnVnsAbsGraphName=graph_name,
))
written += 1
for graph in template.get("serviceGraphs", []) or []:
if not isinstance(graph, dict) or not graph.get("name"):
continue
store.upsert(MO(
"vnsAbsGraph",
dn=f"uni/tn-{tenant}/AbsGraph-{graph['name']}",
name=graph["name"],
))
written += 1
return written
def mirror_template_to_sites(
state: NdoState,
template_name: str,
@@ -432,4 +520,8 @@ def mirror_template_to_sites(
_mirror_epg(store, tenant, anp_name, epg, site_epg)
written += 1
# Contract/filter/service-graph shadows for this site — see
# _mirror_contracts (dangling fvRsProv/fvRsCons fix).
written += _mirror_contracts(store, tenant, template)
return written
+17
View File
@@ -197,6 +197,23 @@ def _build_result(
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)
+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).