Files
aci-sim/aci_sim/ndo/deploy_mirror.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

528 lines
21 KiB
Python

"""NDO -> APIC deploy mirror.
Confirmed SIM GAP: `POST /mso/api/v1/task` (the deploy/undeploy request
`cisco.mso.ndo_schema_template_deploy` sends — see ndo/app.py's docstring on
that route) has always been a pure ack no-op: it never materializes the
deployed schema template's VRFs/BDs/ANPs/EPGs/binds into the TARGET SITES'
APIC MITStores. A real E2E Ansible run never direct-POSTs those MOs to the
APIC for a multi-site tenant either — it relies entirely on NDO's deploy to
push them down. So today, multi-site EPGs exist in the NDO schema doc but
never appear on the APIC side of this sim, which breaks any playbook/test
that reads EPGs back from the APIC after an NDO-driven multi-site deploy.
`mirror_template_to_sites()` closes that gap: given the NDO state, a
template name, and a site_id -> ApicSiteState map, it walks the owning
schema's template (vrfs/bds/anps/epgs) plus each associated SITE entry
(hostBasedRouting overlay, domainAssociations, staticPorts) and upserts the
equivalent fvCtx/fvBD/fvAp/fvAEPg (+ children) MOs into every target site's
MITStore — reusing the exact DN/attribute conventions build/tenants.py's
boot-time builders and rest_aci/writes.py's POST-write path already
establish, so a mirrored MO is indistinguishable from a boot-built or
directly-POSTed one.
Pure and HTTP-free: no FastAPI/Request dependency, so it's unit-testable
without spinning up either app.
"""
from __future__ import annotations
from typing import Any
from aci_sim.build.tenants import pctag_for
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.writes import _CLASS_DEFAULTS
from .model import NdoState
#: Leaf object-name keys inside a DICT-form NDO ref, in priority order. An
#: epgRef carries BOTH anpName and epgName, so epgName is checked first (the
#: EPG's own name wins); every other ref dict carries only its own *Name key.
#: schemaId/templateName are deliberately excluded.
_REF_NAME_KEYS = ("epgName", "bdName", "vrfName", "contractName", "anpName", "name")
def _basename(ref: Any) -> str:
"""Return the object name for an NDO `*Ref`, handling BOTH forms it uses.
- STRING ref `/schemas/<id>/templates/<tmpl>/bds/<bdName>` -> last segment.
- DICT ref `{"vrfName": ..., "schemaId": ..., "templateName": ...}` -> the
leaf object-name key (see `_REF_NAME_KEYS`). aci-ansible's cisco.mso
modules store the dict shape; aci-py stringifies its refs — so the mirror
must accept either or it AttributeErrors (`'dict'.rsplit`) on a redeploy
of an aci-ansible multi-site tenant.
The last segment / leaf name already matches the APIC MO name (see
ndo/model.py's docstring: NDO names are derived verbatim from the APIC
fvBD/fvAp/fvAEPg names at build time).
"""
if isinstance(ref, dict):
for key in _REF_NAME_KEYS:
value = ref.get(key)
if isinstance(value, str) and value:
return value
return ""
if not ref:
return ""
return ref.rsplit("/", 1)[-1]
def _tenant_name(state: NdoState, tenant_id: str) -> str:
"""Resolve a template's `tenantId` to the tenant's APIC-facing name."""
for tenant in state.tenants:
if tenant.get("id") == tenant_id:
return tenant.get("name", "")
return ""
def _find_template(
state: NdoState, template_name: str, schema_id: str | None = None
) -> tuple[dict | None, dict | None]:
"""Locate (schema_detail, template_doc) for a template named *template_name*.
If *schema_id* is given, resolve ONLY within that schema — template names
are NOT unique across schemas (every multi-site tenant's schema commonly
has templates literally named e.g. 'LAB1-LAB2'/'LAB1'/'LAB2'), so a
cross-schema scan risks mirroring the WRONG tenant. When *schema_id* is
None, fall back to the legacy scan-every-schema behavior (kept for
existing callers/tests that don't have a schema id handy).
"""
if schema_id is not None:
sd = state.schema_details.get(schema_id)
if sd is None:
return None, None
for tmpl in sd.get("templates", []):
if tmpl.get("name") == template_name:
return sd, tmpl
return sd, None
for schema_detail in state.schema_details.values():
for tmpl in schema_detail.get("templates", []):
if tmpl.get("name") == template_name:
return schema_detail, tmpl
return None, None
def _site_entries_for_template(schema_detail: dict, template_name: str) -> list[dict]:
"""Return every top-level `sites[]` entry belonging to *template_name*."""
return [
site
for site in schema_detail.get("sites", [])
if isinstance(site, dict) and site.get("templateName") == template_name
]
def _find_site_bd(site_entry: dict, bd_name: str) -> dict | None:
for bd in site_entry.get("bds", []) or []:
if isinstance(bd, dict) and _basename(bd.get("bdRef")) == bd_name:
return bd
return None
def _find_site_epg(site_entry: dict, anp_name: str, epg_name: str) -> dict | None:
for anp in site_entry.get("anps", []) or []:
if not isinstance(anp, dict) or _basename(anp.get("anpRef")) != anp_name:
continue
for epg in anp.get("epgs", []) or []:
if isinstance(epg, dict) and _basename(epg.get("epgRef")) == epg_name:
return epg
return None
def _mirror_bd(store: MITStore, tenant: str, bd: dict, site_bd: dict | None) -> None:
"""Upsert fvCtx-referencing fvBD (+ fvRsCtx/fvSubnet children) for one
template BD, overlaid with the SITE bd's hostBasedRouting."""
bd_name = bd.get("name", "")
vrf_name = _basename(bd.get("vrfRef"))
bd_dn = f"uni/tn-{tenant}/BD-{bd_name}"
l2_unknown_ucast = bd.get("l2UnknownUnicast", "flood")
unicast_routing = bool(bd.get("unicastRouting", True))
host_based_routing = bool(site_bd.get("hostBasedRouting")) if site_bd else False
attrs: dict[str, Any] = dict(_CLASS_DEFAULTS.get("fvBD", {}))
attrs.update(
{
"dn": bd_dn,
"name": bd_name,
"epMoveDetectMode": bd.get("epMoveDetectMode", ""),
"arpFlood": "yes" if bd.get("arpFlood") else "no",
"unkMacUcastAct": l2_unknown_ucast,
"unicastRoute": "yes" if unicast_routing else "no",
"hostBasedRouting": "yes" if host_based_routing else "no",
"intersiteBumTrafficAllow": "yes" if bd.get("intersiteBumTrafficAllow") else "no",
}
)
bd_mo = MO("fvBD", **attrs)
if vrf_name:
bd_mo.add_child(MO(
"fvRsCtx",
dn=f"{bd_dn}/rsctx",
tnFvCtxName=vrf_name,
))
for subnet in bd.get("subnets", []) or []:
if not isinstance(subnet, dict):
continue
ip = subnet.get("ip")
if not ip:
continue
scope = subnet.get("scope") or "public,shared"
bd_mo.add_child(MO(
"fvSubnet",
dn=f"{bd_dn}/subnet-[{ip}]",
ip=ip,
scope=scope,
preferred="yes" if subnet.get("primary") else "no",
))
# Merge-upsert (NOT delete-then-recreate) on purpose: the BD may carry an
# externally-POSTed `epClear` attr (the clear_remote_mac stub a playbook
# posts directly to the APIC) that a delete-then-recreate would drop.
# store.upsert() merges attrs/children onto any existing MO at this dn,
# so that attr survives a redeploy. Contrast with _mirror_epg below,
# which deletes-then-recreates because EPGs have no such externally-set
# state to preserve.
store.upsert(bd_mo)
def _mirror_epg(store: MITStore, tenant: str, anp_name: str, epg: dict, site_epg: dict | None) -> None:
"""Upsert fvAEPg (+ fvRsBd/fvRsProv/fvRsCons/fvSubnet/fvRsDomAtt/
fvRsPathAtt children) for one template EPG, overlaid with the SITE epg's
domainAssociations/staticPorts (bind data — usually empty until a bind
playbook runs)."""
epg_name = epg.get("name", "")
epg_dn = f"uni/tn-{tenant}/ap-{anp_name}/epg-{epg_name}"
epg_mo = MO(
"fvAEPg",
dn=epg_dn,
name=epg_name,
prefGrMemb="include" if epg.get("preferredGroup") else "exclude",
pcTag=pctag_for(epg_dn),
pcEnfPref="unenforced",
isAttrBasedEPg="no",
floodOnEncap="disabled",
)
bd_name = _basename(epg.get("bdRef"))
if bd_name:
epg_mo.add_child(MO(
"fvRsBd",
dn=f"{epg_dn}/rsbd",
tnFvBDName=bd_name,
))
for rel in epg.get("contractRelationships", []) or []:
if not isinstance(rel, dict):
continue
contract = _basename(rel.get("contractRef"))
if not contract:
continue
if rel.get("relationshipType") == "provider":
epg_mo.add_child(MO(
"fvRsProv",
dn=f"{epg_dn}/rsprov-{contract}",
tnVzBrCPName=contract,
))
elif rel.get("relationshipType") == "consumer":
epg_mo.add_child(MO(
"fvRsCons",
dn=f"{epg_dn}/rscons-{contract}",
tnVzBrCPName=contract,
))
for subnet in epg.get("subnets", []) or []:
if not isinstance(subnet, dict):
continue
ip = subnet.get("ip")
if not ip:
continue
epg_mo.add_child(MO(
"fvSubnet",
dn=f"{epg_dn}/subnet-[{ip}]",
ip=ip,
scope=subnet.get("scope") or "public,shared",
preferred="yes" if subnet.get("primary") else "no",
))
if site_epg is not None:
for dom in site_epg.get("domainAssociations", []) or []:
if not isinstance(dom, dict):
continue
t_dn = dom.get("dn") or dom.get("domainRef") or dom.get("tDn")
if not t_dn:
continue
epg_mo.add_child(MO(
"fvRsDomAtt",
dn=f"{epg_dn}/rsdomAtt-[{t_dn}]",
tDn=t_dn,
instrImedcy="lazy",
))
for path in site_epg.get("staticPorts", []) or []:
if not isinstance(path, dict):
continue
path_dn = path.get("path") or path.get("dn") or path.get("tDn")
if not path_dn:
continue
epg_mo.add_child(MO(
"fvRsPathAtt",
dn=f"{epg_dn}/rspathAtt-[{path_dn}]",
tDn=path_dn,
encap=path.get("encap", ""),
mode=path.get("mode", "regular"),
instrImedcy=path.get("deploymentImmediacy", "lazy"),
))
# Redeploy idempotency: delete-then-recreate (NOT a merge-upsert like
# _mirror_bd). store.upsert() merges attrs/children onto an existing MO,
# so a contract/bind removed in NDO since the last deploy would otherwise
# leave a stale fvRsProv/fvRsDomAtt/etc. child behind forever. This is
# safe because multi-site EPGs have NO externally-posted children of
# their own — every child under this dn comes from this mirror, so
# wiping the subtree first and rebuilding it clean can't drop anything
# an outside POST relied on (contrast with the BD's epClear, above).
store.upsert(MO("fvAEPg", dn=epg_dn, status="deleted"))
store.upsert(epg_mo)
def _template_object_dns(tenant: str, template: dict) -> list[tuple[str, str]]:
"""Every tenant-scoped (dn, class_name) pair this template owns (its
VRFs/BDs/ANPs/EPGs) — used to scope an undeploy's delete to just this
template's objects, never the whole `uni/tn-<T>` tenant (a schema may
hold more than one template against the same tenant).
Deletion in the store is DN-based (status="deleted" removes by dn
regardless of the class passed), so the class here only matters for
fidelity — the emitted delete MO now carries the object's real class
instead of a hardcoded 'fvBD' for everything.
"""
dns: list[tuple[str, str]] = []
for vrf in template.get("vrfs", []) or []:
name = vrf.get("name") if isinstance(vrf, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/ctx-{name}", "fvCtx"))
for bd in template.get("bds", []) or []:
name = bd.get("name") if isinstance(bd, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/BD-{name}", "fvBD"))
for anp in template.get("anps", []) or []:
if not isinstance(anp, dict):
continue
anp_name = anp.get("name")
if not anp_name:
continue
dns.append((f"uni/tn-{tenant}/ap-{anp_name}", "fvAp"))
for epg in anp.get("epgs", []) or []:
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,
apic_states: dict,
*,
schema_id: str | None = None,
undeploy: bool = False,
) -> int:
"""Materialize (or, if *undeploy*, tear down) *template_name*'s
VRFs/BDs/ANPs/EPGs into every associated site's APIC MITStore.
*schema_id*, when known (the real `POST /mso/api/v1/task` body always
carries one), scopes template resolution to that ONE schema — template
names collide across schemas (e.g. every multi-site tenant's schema has
templates literally named 'LAB1-LAB2'/'LAB1'/'LAB2'), so resolving by
name alone across ALL schemas risks mirroring the WRONG tenant. Pass
None only for legacy callers/tests that don't have a schema id handy.
*apic_states* maps site_id -> an object exposing a `.store` (MITStore) —
typically `aci_sim.rest_aci.app.ApicSiteState`. Only sites that
are BOTH associated with this template (schema `sites[].templateName ==
template_name`) AND present in *apic_states* are touched; every other
site's store is left completely untouched.
Returns the number of MOs upserted/deleted (0 if the template or none of
its sites could be resolved — a safe no-op).
"""
schema_detail, template = _find_template(state, template_name, schema_id)
if template is None or schema_detail is None:
return 0
tenant = _tenant_name(state, template.get("tenantId", ""))
if not tenant:
return 0
site_entries = _site_entries_for_template(schema_detail, template_name)
if not site_entries:
return 0
written = 0
if undeploy:
object_dns = _template_object_dns(tenant, template)
for site_entry in site_entries:
site_id = site_entry.get("siteId")
apic_state = apic_states.get(str(site_id)) if apic_states else None
if apic_state is None:
continue
store = apic_state.store
for dn, cls in object_dns:
store.upsert(MO(cls, dn=dn, status="deleted"))
written += 1
return written
for site_entry in site_entries:
site_id = site_entry.get("siteId")
apic_state = apic_states.get(str(site_id)) if apic_states else None
if apic_state is None:
continue
store = apic_state.store
# Materialize the tenant shadow on this site FIRST. An NDO deploy
# creates the tenant on each target site's APIC; without the fvTenant
# root MO, a subtree/mo query on `uni/tn-<T>` returns empty even though
# the mirrored children exist (real F1 root cause: the direct-POST path
# only creates fvTenant on the --apic site, so the OTHER site had the
# mirrored VRF/BD/EPG children but no tenant root to query them under).
store.upsert(MO("fvTenant", dn=f"uni/tn-{tenant}", name=tenant))
written += 1
for vrf in template.get("vrfs", []) or []:
if not isinstance(vrf, dict):
continue
vrf_name = vrf.get("name", "")
if not vrf_name:
continue
store.upsert(MO(
"fvCtx",
dn=f"uni/tn-{tenant}/ctx-{vrf_name}",
name=vrf_name,
))
written += 1
for bd in template.get("bds", []) or []:
if not isinstance(bd, dict):
continue
bd_name = bd.get("name", "")
if not bd_name:
continue
site_bd = _find_site_bd(site_entry, bd_name)
_mirror_bd(store, tenant, bd, site_bd)
written += 1
for anp in template.get("anps", []) or []:
if not isinstance(anp, dict):
continue
anp_name = anp.get("name", "")
if not anp_name:
continue
store.upsert(MO(
"fvAp",
dn=f"uni/tn-{tenant}/ap-{anp_name}",
name=anp_name,
))
written += 1
for epg in anp.get("epgs", []) or []:
if not isinstance(epg, dict):
continue
epg_name = epg.get("name", "")
if not epg_name:
continue
site_epg = _find_site_epg(site_entry, anp_name, epg_name)
_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