mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
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:
co-authored by
Claude Fable 5
parent
7fdf31aca9
commit
867c05c9c5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user