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