Initial public release — aci-sim v0.16.0

aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
View File
+785
View File
@@ -0,0 +1,785 @@
"""
NDO FastAPI application — Phase 6.
`make_ndo_app(state)` returns a FastAPI app that implements every §7 endpoint
from CONTRACT.md. Bearer auth is lenient: tokens are issued on login but
never validated on subsequent requests (accept present-or-absent).
"""
from __future__ import annotations
import hashlib
import uuid
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from aci_sim.control.persist import apply_ndo, load_json, save_json, serialize_ndo, state_dir
from .deploy_mirror import mirror_template_to_sites
from .model import NdoState
from .patch import PatchError, apply_json_patch, normalize_site, normalize_template
def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) -> FastAPI:
"""Build and return the NDO FastAPI application backed by *state*.
*apic_states* (optional — defaults to ``None``, keeping every existing
single-arg call site valid) maps site_id -> ``ApicSiteState``. When
provided, ``POST /mso/api/v1/task`` (deploy/undeploy) mirrors the
deployed template's VRFs/BDs/ANPs/EPGs into the target sites' APIC
MITStores via ``deploy_mirror.mirror_template_to_sites`` — see that
module's docstring for the SIM GAP this closes. Typed as a plain
``dict[str, Any]`` (not ``dict[str, ApicSiteState]``) to avoid importing
``aci_sim.rest_aci.app`` here, which would risk a circular import
(that module doesn't currently import ``ndo.app``, but the two live in
sibling packages wired together only by ``runtime/supervisor.py`` — this
keeps ``ndo/app.py`` decoupled from the APIC app module's import graph).
"""
app = FastAPI(title="aci-sim NDO", version="4.2.2")
# ------------------------------------------------------------------
# Authentication
# ------------------------------------------------------------------
@app.post("/api/v1/auth/login")
async def login(request: Request):
"""Accept any credentials and return a bearer token."""
body = await request.json()
token = f"sim-ndo-{uuid.uuid4().hex[:16]}"
return {
"token": token,
"userName": body.get("userName", "admin"),
"domain": body.get("domain", "local"),
}
@app.post("/login")
async def nd_bare_login(request: Request):
"""Classic Nexus Dashboard bare-login endpoint.
Real ND serves ``POST /login`` (body ``{userName, userPasswd, domain}``)
alongside the newer ``/api/v1/auth/login``. Clients that use the ND
platform login directly (aci-py's NDO connector, cisco.nd httpapi's
session bookkeeping) POST here and read the JWT from ``token`` /
``jwttoken``.
"""
body = await request.json()
token = f"sim-ndo-{uuid.uuid4().hex[:16]}"
return {
"token": token,
"jwttoken": token,
"userName": body.get("userName", "admin"),
"domain": body.get("domain", "local"),
}
@app.post("/logout")
async def nd_bare_logout():
"""Classic ND bare-logout — mirrors POST /login."""
return {"status": "ok"}
# ------------------------------------------------------------------
# Platform version
# ------------------------------------------------------------------
@app.get("/api/v1/platform/version")
async def platform_version():
# Dotted form per CONTRACT.md §7 — matches real Nexus Dashboard;
# autoACI's ndo_connector.py treats this as an opaque passthrough string.
return {"version": "4.2.2"}
# ------------------------------------------------------------------
# Sites
# ------------------------------------------------------------------
@app.get("/mso/api/v1/sites/fabric-connectivity")
async def get_fabric_connectivity():
"""Per-site connectivity status.
Must be declared BEFORE the generic /{site_id} route (if any)
so FastAPI's literal-first match picks it up correctly.
"""
return state.fabric_connectivity
@app.get("/mso/api/v1/sites")
async def get_sites():
return {"sites": state.sites}
# ------------------------------------------------------------------
# Tenants
# ------------------------------------------------------------------
@app.get("/mso/api/v1/tenants")
async def get_tenants():
return {"tenants": state.tenants}
# PR-10: cisco.mso.ndo_template's `lookup_tenant()` prereq lookup (used
# by TenantPol tenant-policy templates) resolves through this bare
# /api/v1/tenants path in addition to the /mso-prefixed form above —
# backs both with the same tenant list so a caller using either shape
# sees identical data (see docs/CONTRACT.md §7).
@app.get("/api/v1/tenants")
async def get_tenants_bare():
return {"tenants": state.tenants}
def _find_tenant(tenant_id: str) -> dict | None:
for t in state.tenants:
if t["id"] == tenant_id:
return t
return None
@app.put("/mso/api/v1/tenants/{tenant_id}")
async def update_tenant(tenant_id: str, request: Request):
"""Update an existing tenant in place — cisco.mso.mso_tenant's
"tenant already exists" branch (PR-11).
Every tenant this sim seeds comes straight from the topology (see
build_ndo_model), so a real E2E run's `mso_tenant` task always finds
the tenant already present via `GET /mso/api/v1/tenants` and takes
this PUT-to-update path rather than POST-to-create — confirmed on a
real hardware run: `PUT https://.../mso/api/v1/tenants/{id}` → sim 404
(route didn't exist) → "MSO Error:". Accept the module's full
payload (description/displayName/siteAssociations/userAssociations)
and merge it into the stored tenant dict, then echo it back per
MSOModule.request()'s 200/201/202 "parse and return the body" path.
"""
tenant = _find_tenant(tenant_id)
if tenant is None:
raise HTTPException(
status_code=404, detail=f"Tenant '{tenant_id}' not found"
)
body = await request.json()
tenant.update(body)
tenant["id"] = tenant_id
return tenant
@app.post("/mso/api/v1/tenants")
async def create_tenant(request: Request):
"""Create a brand-new tenant not already in the seeded topology."""
body = await request.json()
new_id = body.get("id") or f"tenant-{uuid.uuid4().hex[:16]}"
tenant = dict(body)
tenant["id"] = new_id
tenant.setdefault("siteAssociations", [])
state.tenants.append(tenant)
return tenant
# PR-11: `cisco.mso.ndo_template`'s prereq lookup for TenantPol templates
# (`prereq_tenantpol.yml`'s `ndo_template` task) resolves sites through
# this BARE path too — confirmed against a real hardware run that hit
# `GET https://.../api/v1/sites` (no `/mso` prefix) → 404. Same data as
# the canonical `/mso/api/v1/sites` route above.
@app.get("/api/v1/sites")
async def get_sites_bare():
return {"sites": state.sites}
# ------------------------------------------------------------------
# ND-platform user class queries — PR-11
# ------------------------------------------------------------------
# cisco.mso's MSOModule.lookup_users()/lookup_remote_users() query the
# modern `/nexus/infra/api/aaa/v4/{local,remote}users` endpoints first
# (ignore_not_found_error=True) and, only when BOTH come back as an empty
# dict, fall back to these legacy `/api/config/class/*` routes — see
# ansible-mso plugins/module_utils/mso.py. The sim doesn't implement the
# v4 aaa routes, so httpapi's connection plugin treats their absence as
# "not found" → empty dict → the fallback below is exactly what's hit.
# A real ACI-hardware E2E run confirmed the failure signature: GET
# /api/config/class/remoteusers → sim 404 {"detail":"Not Found"} → nd_request()
# has no "code"/"messages" in that body → "ND Error: Unknown error no
# error code in decoded payload", aborting mso_tenant's Create a tenant task.
@app.get("/api/config/class/remoteusers")
async def get_remote_users_class():
return state.remote_users
@app.get("/api/config/class/localusers")
async def get_local_users_class():
return state.local_users
# ------------------------------------------------------------------
# Schemas — list and detail
# ------------------------------------------------------------------
@app.get("/mso/api/v1/schemas")
async def get_schemas():
"""Return the FULL schema detail list (not the `list-identity`
lightweight summary below).
PR-13: real NDO's plain `GET /schemas` returns every schema's
complete nested doc (`templates[].{bds,anps,serviceGraphs,...}`,
top-level `sites[]`) — this is precisely why `schemas/list-identity`
exists as a separate, lighter-weight enumeration endpoint (per its
own PR-10 docstring below: callers that only need id/displayName
use that one instead of paying for the full payload here). Several
OLDER `cisco.mso` community modules that predate the `MSOTemplate`/
`MSOSchema` module_utils classes — e.g. `mso-model`'s
`custom_mso_schema_service_graph.py` (`mso.get_obj('schemas',
displayName=schema)`) — bare-subscript straight into this response
(`schema_obj.get('templates')[idx]['serviceGraphs']`,
`schema_obj.get('sites')[idx]['serviceGraphs']`), so a trimmed
`{name}`-only projection here would KeyError/IndexError on those
exact modules even with every other collection-default fix in
place; a real hardware MS-TN2 `create_tenant` pass-2 run confirmed
this class of failure (`KeyError: 'serviceGraphs'`).
"""
all_ids = [s["id"] for s in state.schemas] + [s["id"] for s in state.extra_schemas]
full = [state.schema_details[sid] for sid in all_ids if sid in state.schema_details]
return {"schemas": full}
# PR-10: cisco.mso's MSOModule.lookup_schema() calls this lightweight
# enumeration endpoint FIRST to resolve a schema displayName -> id
# (verified against ansible-mso plugins/module_utils/mso.py:
# `self.query_objs("schemas/list-identity", key="schemas", displayName=schema)`,
# which reads json["schemas"][] entries with at least id + displayName).
# CRITICAL ORDERING: this MUST be declared before the parameterized
# GET /mso/api/v1/schemas/{schema_id} route below, or FastAPI/Starlette
# matches "list-identity" as a schema_id path param first (that bug was
# observed as a 404 body {"detail":"Schema 'list-identity' not found"}).
# Same store as GET /mso/api/v1/schemas — every schema this sim knows
# about (seeded + runtime-POSTed) is enumerable here too.
@app.get("/mso/api/v1/schemas/list-identity")
async def get_schemas_list_identity():
all_schemas = list(state.schemas) + list(state.extra_schemas)
return {
"schemas": [
{"id": s["id"], "displayName": s.get("displayName", s.get("name", ""))}
for s in all_schemas
]
}
# PR-13: `custom_mso_schema_service_graph.py` (mso-model role's "Create
# service graph" task) resolves a service node's display type
# (Firewall/Load Balancer/Other) to a stable id via
# `mso.get_obj('schemas/service-node-types', key='serviceNodeTypes',
# displayName=node_type)` before adding the service-graph node. Same
# routing-order requirement as `list-identity` above: this literal path
# segment MUST be declared before the parameterized `/schemas/{schema_id}`
# route below or FastAPI/Starlette matches "service-node-types" as a
# schema id instead (observed as a 404 body {"detail":"Schema
# 'service-node-types' not found"}).
@app.get("/mso/api/v1/schemas/service-node-types")
@app.get("/api/v1/schemas/service-node-types")
async def get_service_node_types():
return {
"serviceNodeTypes": [
{"id": "svc-node-type-firewall", "displayName": "Firewall"},
{"id": "svc-node-type-adc", "displayName": "Load Balancer"},
{"id": "svc-node-type-other", "displayName": "Other"},
]
}
@app.get("/mso/api/v1/schemas/{schema_id}/policy-states")
async def get_policy_states(schema_id: str):
"""Per-schema deployment / drift state.
Declared before the bare `/{schema_id}` route so FastAPI routes
`/schemas/X/policy-states` here rather than treating `policy-states`
as part of the schema id.
"""
ps = state.policy_states.get(schema_id)
if ps is None:
# Caller may have POSTed a schema whose id we stored in policy_states;
# fall back to a healthy default rather than 404.
ps = [{"status": "synced", "drift": False}]
return {"policyStates": ps}
# PR-11: both `cisco.mso.mso_schema_template_deploy` (legacy) and
# `cisco.mso.ndo_schema_template_deploy` (the one aci-ansible's mso-model
# role actually calls, confirmed against the collection installed on
# real ACI hardware) call `mso.validate_schema(schema_id)` — `GET
# schemas/{id}/validate` — before every deploy/redeploy. The return
# value is discarded by the caller, only the status code matters; a real
# hardware run 404'd here because the route didn't exist at all. Declared
# before the bare `/{schema_id}` route for the same routing-order reason
# as policy-states above.
@app.get("/mso/api/v1/schemas/{schema_id}/validate")
async def validate_schema(schema_id: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"errors": [], "warnings": []}
@app.get("/mso/api/v1/schemas/{schema_id}")
async def get_schema(schema_id: str):
"""Full schema detail — templates with VRFs/BDs/EPGs/contracts/filters."""
detail = state.schema_details.get(schema_id)
if detail is None:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return detail
# PR-11: legacy `mso_schema_template_deploy`'s deploy/undeploy request —
# `GET execute/schema/{id}/template/{name}` (deploy/undeploy) or
# `GET status/schema/{id}/template/{name}` (state=status). The response
# is splatted straight into `mso.exit_json(**status)`, so it must be a
# JSON object (dict), not a list/scalar. Kept alongside the POST /task
# route below since both modules ship in the same collection and either
# could be used by a given playbook.
@app.get("/mso/api/v1/execute/schema/{schema_id}/template/{template_name}")
async def execute_schema_template(schema_id: str, template_name: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"status": "success", "schemaId": schema_id, "templateName": template_name}
@app.get("/mso/api/v1/status/schema/{schema_id}/template/{template_name}")
async def status_schema_template(schema_id: str, template_name: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"status": "success", "schemaId": schema_id, "templateName": template_name}
# PR-11: `cisco.mso.ndo_schema_template_deploy` — the module aci-ansible's
# mso-model/tasks/template_deploy.yml role actually invokes (confirmed
# against the collection installed on real ACI hardware, which differs slightly from
# the legacy mso_schema_template_deploy.py) — sends deploy/redeploy/
# undeploy as `POST /mso/api/v1/task` with body
# `{"schemaId":...,"templateName":...,"isRedeploy":bool}` (or `undeploy:
# [siteId,...]`), and `state=query` as `GET status/schema/{id}/template/
# {name}` (same route as the legacy module's status query above). A real
# hardware run 404'd on `POST .../mso/api/v1/task` — the route didn't exist.
@app.post("/mso/api/v1/task")
async def post_task(request: Request):
body = await request.json()
schema_id = body.get("schemaId")
if schema_id is not None and schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
task_id = f"task-{uuid.uuid4().hex[:12]}"
template_name = body.get("templateName")
# Deploy mirror (confirmed SIM GAP): `ndo_schema_template_deploy`
# sends `undeploy: [siteId, ...]` (a non-empty list) to tear a
# template down from those sites; every other shape (deploy /
# `isRedeploy: true` redeploy) is a create/refresh. `apic_states`
# is None in every pre-existing test/call site (default arg), so
# this stays a strict no-op there — unchanged behavior.
if apic_states is not None and template_name:
undeploy = bool(body.get("undeploy"))
mirror_template_to_sites(
state, template_name, apic_states, schema_id=schema_id, undeploy=undeploy,
)
return {
"id": task_id,
"schemaId": schema_id,
"templateName": template_name,
"status": "success",
}
# ------------------------------------------------------------------
# Template summaries and detail — PR-13 generalized template store
# ------------------------------------------------------------------
# `cisco.mso.ndo_template`'s CREATE flow (`prereq_tenantpol.yml`'s
# `Create TenantPol-<tenant> tenant policy template` task) needs a real
# POST + PATCH round trip against a persistent template store, not just
# the single fixed tenantPolicy template PR-10/PR-11 seeded. And because
# that task carries `delegate_to: localhost` while every OTHER
# `cisco.mso`/`ndo_*` task in the same playbook run does not, the two
# code paths hit DIFFERENT URL shapes for the identical logical
# endpoint:
# - `delegate_to: localhost` tasks build `MSOModule` with no
# persistent httpapi connection (`module._socket_path is None`), so
# `MSOModule.request()` takes its direct-HTTP branch and never adds
# the `/mso` prefix at all: `GET/POST https://host/api/v1/templates*`
# (confirmed on a real hardware run: `prereq_tenantpol.yml`'s
# `ndo_template` task 404'd on exactly this bare path).
# - every other task on this same playbook host runs over the
# persistent `ansible.netcommon.httpapi` connection
# (`ansible_network_os: cisco.nd.nd`), whose platform tag routes
# through `NDO_API_VERSION_PATH_FORMAT = "/mso/api/{v}/{path}"` —
# e.g. `create_dhcp_relay`'s `ndo_dhcp_relay_policy` task.
# Both shapes must therefore back the SAME mutable store so a template
# created via the bare path is visible to a later mso-prefixed lookup
# (and vice versa) — registered via a shared handler, matching the
# existing `/api/v1/tenants` ↔ `/mso/api/v1/tenants` / `/api/v1/sites`
# ↔ `/mso/api/v1/sites` pattern PR-10/PR-11 already established.
def _template_summary_view(doc: dict) -> dict:
return {
"templateId": doc.get("templateId", ""),
"templateName": doc.get("displayName", ""),
"templateType": doc.get("templateType", ""),
}
def _sync_template_summary(doc: dict) -> None:
"""Keep `state.template_summaries` in sync with a stored template
doc — mutate the existing summary entry in place if present, else
append. Mirrors `_schema_summary`/`_find_summary`'s schema-side
pattern below."""
view = _template_summary_view(doc)
for existing in state.template_summaries:
if existing.get("templateId") == view["templateId"]:
existing.update(view)
return
state.template_summaries.append(view)
async def _get_template_summaries():
"""Return a list of {templateId, templateName, templateType}.
ndo_connector.get_dhcp_policy_map() handles both a plain list and
{"templates": [...]} — we return the list directly. Query params
(templateName/templateType/schemaName/schemaId) are accepted but
filtering happens client-side in `MSOTemplate`/`query_objs()`, so
this route always returns the full list — same as every other
`query_objs()`-backed enumeration endpoint in this file.
"""
return state.template_summaries
async def _get_template(template_id: str):
"""Return a specific template detail (e.g. the tenantPolicy DHCP template)."""
doc = state.tenant_policy_templates.get(template_id)
if doc is None:
raise HTTPException(
status_code=404, detail=f"Template '{template_id}' not found"
)
return doc
def _backfill_policy_uuids(doc: dict, template_id: str) -> None:
"""Assign a stable `uuid` to every dhcpRelayPolicies/
dhcpOptionPolicies entry that lacks one.
`ndo_dhcp_relay_policy`'s add payload is only `{name, providers,
description?}` (no `uuid` — real NDO assigns that server-side).
`ndo_schema_template_bd_dhcp_policy`'s `get_dhcp_relay_policy_uuid()`/
`get_dhcp_option_policy_uuid()` (the `bind_dhcp_relay_to_bd`
playbook step) then requires a non-empty `uuid` on the matched
entry — `mso.fail_json()`s otherwise — so a query-back immediately
after the add must already see one.
"""
container = doc.get("tenantPolicyTemplate", {}).get("template", {})
tenant_id = container.get("tenantId", "")
for list_key in ("dhcpRelayPolicies", "dhcpOptionPolicies"):
for item in container.get(list_key, []) or []:
if isinstance(item, dict) and not item.get("uuid"):
seed = "{}-uuid-{}-{}-{}".format(list_key, template_id, tenant_id, item.get("name", ""))
item["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
async def _create_template(request: Request):
"""Create a new template (`ndo_template`'s "does not exist yet"
branch — POST payload shape: `{displayName, templateType,
<typeContainer>: {template:{...}, sites:[{siteId}]}}`, e.g.
`tenantPolicyTemplate: {template:{tenantId}, sites:[{siteId}]}}`).
Assigns a stable-looking id, stores the full doc (so a later
`ndo_dhcp_relay_policy` PATCH against `templates/{id}` finds it),
and mirrors a summary entry into `template_summaries` so the next
`templates/summaries?templateName=...` lookup (by any caller,
bare-path or mso-prefixed) resolves it.
"""
body = await request.json()
new_id = f"template-{uuid.uuid4().hex[:20]}"
doc = dict(body)
doc["templateId"] = new_id
doc.setdefault("displayName", body.get("displayName", ""))
doc.setdefault("templateType", body.get("templateType", ""))
_backfill_policy_uuids(doc, new_id)
state.tenant_policy_templates[new_id] = doc
_sync_template_summary(doc)
return doc
async def _patch_template(template_id: str, request: Request):
"""Apply a JSON-Patch op list to a stored template (e.g.
`ndo_dhcp_relay_policy`'s `add /tenantPolicyTemplate/template/
dhcpRelayPolicies/-`, or `ndo_template`'s site add/remove ops)."""
doc = state.tenant_policy_templates.get(template_id)
if doc is None:
raise HTTPException(
status_code=404, detail=f"Template '{template_id}' not found"
)
body = await request.json()
ops = body if isinstance(body, list) else body.get("ops", [])
if not ops:
return doc
try:
apply_json_patch(doc, ops)
except PatchError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
doc["templateId"] = template_id
_backfill_policy_uuids(doc, template_id)
_sync_template_summary(doc)
return doc
async def _delete_template(template_id: str):
state.tenant_policy_templates.pop(template_id, None)
state.template_summaries[:] = [
s for s in state.template_summaries if s.get("templateId") != template_id
]
return {}
#: `type`→(store-path-segment, name-key) for the generic cross-template
#: object lookup below.
_TEMPLATE_OBJECT_TYPES: dict[str, tuple[str, str]] = {
"dhcpRelay": ("dhcpRelayPolicies", "name"),
"dhcpOption": ("dhcpOptionPolicies", "name"),
}
async def _get_template_objects(request: Request):
"""Cross-template object lookup — `GET templates/objects?type=
{dhcpRelay|dhcpOption|epg|externalEpg}&{uuid=...|name=...}`.
Real NDO exposes this as a flat cross-cutting search over every
template's policy objects; `ansible-mso`'s `MSOTemplate.
get_template_object_by_uuid()` (`plugins/module_utils/template.py`)
and `ndo_schema_template_bd_dhcp_policy.py`'s
`get_dhcp_relay_policy_uuid()`/`get_dhcp_relay_label_name()` both
call this exact route — the former to resolve a DHCP relay/option
policy's UUID by name (`create_dhcp_relay`'s NDO 4.x path), the
latter to resolve a stored `dhcpLabels[].ref` UUID back to a name
(`bind_dhcp_relay_to_bd`'s query-back-after-PATCH step). Confirmed
against a real hardware run: both hit `GET /mso/api/v1/templates/
objects?type=dhcpRelay&name=...` mid-playbook.
`type=epg`/`type=externalEpg` additionally search every SCHEMA
template's `anps[].epgs[]` / `externalEpgs[]` (not just the
tenantPolicy templates) — `ndo_dhcp_relay_policy`'s
`insert_dhcp_relay_policy_relation_name()` resolves a stored
`epgRef`/`externalEpgRef` UUID back to a display name this way on
every query/present round trip.
"""
obj_type = request.query_params.get("type", "")
uuid_q = request.query_params.get("uuid")
name_q = request.query_params.get("name")
results: list[dict] = []
if obj_type in _TEMPLATE_OBJECT_TYPES:
list_key, name_key = _TEMPLATE_OBJECT_TYPES[obj_type]
for tmpl_doc in state.tenant_policy_templates.values():
tenant_id = (
tmpl_doc.get("tenantPolicyTemplate", {}).get("template", {}).get("tenantId")
)
container = tmpl_doc.get("tenantPolicyTemplate", {}).get("template", {})
for item in container.get(list_key, []) or []:
if not isinstance(item, dict):
continue
entry = dict(item)
entry.setdefault("tenantId", tenant_id)
results.append(entry)
elif obj_type in ("epg", "externalEpg"):
array_key = "externalEpgs" if obj_type == "externalEpg" else None
for detail in list(state.schema_details.values()):
for tmpl in detail.get("templates", []):
if array_key:
for item in tmpl.get("externalEpgs", []) or []:
if isinstance(item, dict):
results.append(item)
else:
for anp in tmpl.get("anps", []) or []:
for epg in anp.get("epgs", []) or []:
if isinstance(epg, dict):
results.append(epg)
if uuid_q is not None:
match = next((r for r in results if r.get("uuid") == uuid_q), None)
return match or {}
if name_q is not None:
matches = [r for r in results if r.get(_TEMPLATE_OBJECT_TYPES.get(obj_type, ("", "name"))[1]) == name_q]
return matches
return results
for prefix in ("/mso/api/v1", "/api/v1"):
app.add_api_route(f"{prefix}/templates/summaries", _get_template_summaries, methods=["GET"])
app.add_api_route(f"{prefix}/templates/objects", _get_template_objects, methods=["GET"])
app.add_api_route(f"{prefix}/templates", _create_template, methods=["POST"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _get_template, methods=["GET"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _patch_template, methods=["PATCH"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _delete_template, methods=["DELETE"])
# ------------------------------------------------------------------
# Audit records (both canonical and fallback paths)
# ------------------------------------------------------------------
async def _audit_records(count: int = 50):
return {"auditRecords": state.audit_records[:count]}
app.add_api_route(
"/api/v1/audit-records",
_audit_records,
methods=["GET"],
)
app.add_api_route(
"/mso/api/v1/audit-records",
_audit_records,
methods=["GET"],
)
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def _schema_summary(detail: dict) -> dict:
"""Project a full schema detail dict down to the list/GET-many shape.
Kept in sync on every write (POST create, PATCH mutate) so GET
/mso/api/v1/schemas and GET /mso/api/v1/schemas/list-identity always
reflect the current templates[].name set — mso_schema_template.py's
`get_obj("schemas", displayName=schema)` inspects exactly this.
"""
return {
"id": detail["id"],
"displayName": detail.get("displayName", detail.get("name", "")),
"name": detail.get("name", detail.get("displayName", "")),
"templates": [
{"name": t.get("name", "")} for t in detail.get("templates", [])
],
}
def _find_summary(schema_id: str) -> dict | None:
"""Locate a schema's summary dict in whichever list holds it."""
for s in state.schemas:
if s["id"] == schema_id:
return s
for s in state.extra_schemas:
if s["id"] == schema_id:
return s
return None
@app.post("/mso/api/v1/schemas")
async def create_schema(request: Request):
"""Store a new schema and return its assigned id + status.
Two shapes reach this route:
- `mso_schema.py` (state=present, no templates): POST
`{"displayName":..., "description":...}` — an empty schema, no
`id`/`templates` in the body.
- `mso_schema_template.py`'s "schema does not exist yet" branch:
POST `{"displayName": schema, "templates": [{name, displayName,
tenantId}], "sites": []}` — the schema is born already carrying
its first template. This is the path a real hardware run takes
for the per-tenant-region `<tenant>-<region>` schemas (e.g.
"MS-TN1-LAB0") — the schema must round-trip through GET
/schemas/list-identity (by displayName) and GET /schemas/{id}
for the subsequent mso_schema_template_bd/_anp/... PATCH calls
to find it (PR-11).
"""
body = await request.json()
new_id = f"schema-{uuid.uuid4().hex[:16]}"
display_name = body.get("displayName", body.get("name", "unnamed"))
schema_name = body.get("name", display_name)
# Full detail (returned by GET /schemas/{id}) — store the POSTed
# body verbatim (templates/sites and all) plus the assigned id, so
# every field a later PATCH op path might reference already exists.
detail = dict(body)
detail["id"] = new_id
detail["displayName"] = display_name
detail["name"] = schema_name
detail.setdefault("templates", [])
detail.setdefault("sites", [])
for tmpl in detail["templates"]:
normalize_template(tmpl, new_id)
for site in detail["sites"]:
if isinstance(site, dict):
site.setdefault("bds", [])
site.setdefault("anps", [])
normalize_site(site)
summary = _schema_summary(detail)
state.extra_schemas.append(summary)
state.schema_details[new_id] = detail
state.policy_states[new_id] = [{"status": "synced", "drift": False}]
return {"id": new_id, "displayName": display_name, "status": "active"}
@app.patch("/mso/api/v1/schemas/{schema_id}")
async def patch_schema(schema_id: str, request: Request):
"""Apply a JSON-Patch op list to the stored schema and return the doc.
Every cisco.mso schema-object module (mso_schema_template,
mso_schema_template_bd/_anp/_anp_epg, mso_schema_site, ...) mutates a
schema this way: look it up (list-identity → id, then GET by id),
then PATCH with a small list of `{op, path, value}` entries such as
`{"op":"add","path":"/templates/LAB1/bds/-","value":{...}}`. See
aci_sim/ndo/patch.py for the applier and docs/CONTRACT.md §7
for the full request-sequence writeup (PR-11).
"""
detail = state.schema_details.get(schema_id)
if detail is None:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
body = await request.json()
ops = body if isinstance(body, list) else body.get("ops", [])
if not ops:
# cisco.mso's own MSOModule.request() short-circuits an empty-ops
# PATCH client-side and never sends it, but stay tolerant.
return detail
try:
apply_json_patch(detail, ops)
except PatchError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
detail["id"] = schema_id # ops must never be able to clobber the id
# An "add" op can introduce a brand-new template dict (mso_schema_
# template.py's "template does not exist" branch PATCHes
# {"op":"add","path":"/templates/-","value":{name,displayName,
# tenantId}} — no vrfs/bds/... keys). Re-normalize every template
# after each PATCH so downstream MSOSchema.set_template_vrf() etc.
# never meets a missing collection key (same rule as create_schema).
for tmpl in detail.get("templates", []):
normalize_template(tmpl, schema_id)
# Same rule for the top-level `sites[]` array — mso_schema_site_bd.py
# et al PATCH child objects into `/sites/{siteId-templateName}/bds/-`
# etc. without every collection default (PR-11).
for site in detail.get("sites", []):
if isinstance(site, dict):
site.setdefault("bds", [])
site.setdefault("anps", [])
normalize_site(site)
# Keep the summary (GET /schemas, /schemas/list-identity) in sync —
# template adds/removes and displayName/name replace ops all need to
# be visible to the next lookup_schema() call in the same playbook.
# Mutate whichever summary list already holds this id in place, so
# GET /schemas never double-lists or drops the entry.
updated_summary = _schema_summary(detail)
existing_summary = _find_summary(schema_id)
if existing_summary is not None:
existing_summary.update(updated_summary)
else:
# Shouldn't normally happen (every schema_details entry is
# created alongside a summary), but degrade gracefully.
state.extra_schemas.append(updated_summary)
state.policy_states.setdefault(schema_id, [{"status": "synced", "drift": False}])
return detail
# ------------------------------------------------------------------
# State persistence (/_sim) — mirrors the APIC plane's admin router
# (aci_sim/control/admin.py), which the NDO app doesn't otherwise
# mount, so it's registered directly here.
# ------------------------------------------------------------------
@app.post("/_sim/save/{name}")
async def sim_save(name: str):
"""Persist the NDO state to disk under *name*."""
path = state_dir() / f"{name}.ndo.json"
save_json(path, serialize_ndo(state))
return {"status": "ok", "file": str(path)}
@app.post("/_sim/load/{name}")
async def sim_load(name: str):
"""Restore the NDO state from a prior :func:`sim_save`."""
path = state_dir() / f"{name}.ndo.json"
if not path.exists():
raise HTTPException(status_code=404, detail=f"state '{name}' not found")
apply_ndo(state, load_json(path))
return {"status": "ok"}
@app.post("/mso/api/v1/deploy")
async def deploy(request: Request):
"""Acknowledge a deploy request and return an in-progress record."""
deploy_id = f"deploy-{uuid.uuid4().hex[:12]}"
return {"id": deploy_id, "status": "in-progress"}
return app
+435
View File
@@ -0,0 +1,435 @@
"""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"))
return dns
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
return written
+530
View File
@@ -0,0 +1,530 @@
"""
NDO state model builder — Phase 6.
`build_ndo_model(topo)` derives a complete NdoState from the same Topology
object used for the APIC MIT builders, so every NDO tenant/VRF/BD/EPG/contract
name *exactly* matches the APIC MIT (cross-plane consistency, DESIGN.md rule).
The state is a plain dataclass — no FastAPI dependency here.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass, field
from aci_sim.topology.schema import Topology
from .patch import _new_site_bd, _new_site_epg, normalize_site, normalize_template
# ---------------------------------------------------------------------------
# Fixed deterministic artefacts (stable across test runs — no datetime.now())
# ---------------------------------------------------------------------------
#: UUID used as the dhcpRelayPolicy reference in every BD's dhcpLabels.
#: The tenantPolicy template exposes this UUID so ndo_bd_view can resolve it.
DHCP_RELAY_UUID: str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
#: UUID for the DHCP option policy exposed in the tenantPolicy template.
DHCP_OPTION_UUID: str = "f9e8d7c6-b5a4-3210-fedc-ba9876543210"
#: Stable template-id for the single tenantPolicy template.
TENANT_POLICY_TEMPLATE_ID: str = "tenantpolicy000000000001" # 24 chars
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hex_id(seed: str) -> str:
"""Return a 24-char lower-hex deterministic id (Mongo ObjectId style)."""
return hashlib.sha256(seed.encode()).hexdigest()[:24]
# ---------------------------------------------------------------------------
# State container
# ---------------------------------------------------------------------------
@dataclass
class NdoState:
"""All NDO-plane state derived from the topology, plus a mutable POST bucket."""
#: [{id, name, platform, urls}] — from topo.sites
sites: list
#: [{id, name, displayName, siteAssociations}] — one per topo.tenants
tenants: list
#: Summary shape [{id, displayName, name, templates:[{name}]}]
schemas: list
#: schema_id → full schema detail dict (returned by GET /schemas/{id})
schema_details: dict
#: [{templateId, templateType}] — includes one "tenantPolicy" entry
template_summaries: list
#: templateId → full template doc (returned by GET /templates/{id})
tenant_policy_templates: dict
#: {"sites": [{id, siteId, status, connectivityStatus}]}
fabric_connectivity: dict
#: schema_id → [{status, drift}]
policy_states: dict
#: [{id, timestamp, user, action, description, details}] — fixed set
audit_records: list
#: ND-platform local users, ND-shaped `{"items":[{"spec":{...}}]}` — PR-11.
#: `cisco.mso.mso_tenant`'s `lookup_users()`/`lookup_remote_users()` query
#: this (and the sibling `remote_users`) as part of every tenant create.
local_users: dict = field(default_factory=dict)
#: ND-platform remote (AAA/TACACS/LDAP) users, same ND aggregate shape.
#: Seeded with the sandbox's own `admin` login so `mso_tenant`'s implicit
#: `users: [ansible_user]` (defaulted to admin if unset) resolves.
remote_users: dict = field(default_factory=dict)
#: Accumulates schemas POSTed at runtime (mutable — shared with app closures)
extra_schemas: list = field(default_factory=list)
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
def build_ndo_model(topo: Topology, sandbox: bool = False) -> NdoState: # noqa: C901
"""Derive an NdoState from *topo* with names matching the APIC MIT.
In sandbox mode each APIC is served on its own ``mgmt_ip`` at :443, so NDO
advertises the portless ``https://<mgmt_ip>`` URL — exactly how real NDO
reports controller URLs, and what autoACI's discovery expects. Otherwise it
advertises ``https://<apic_host>`` (the 127.0.0.1:<port> loopback form).
"""
# ------------------------------------------------------------------
# Sites
# ------------------------------------------------------------------
# PR-10: real NDO registers sites under their fabric name (e.g.
# "LAB1-IT-ACI"), not the short topology-internal site name ("LAB1") —
# verified against a real-gear cisco.mso.mso_tenant run (see
# docs/CONTRACT.md §7 / CHANGELOG 0.2.1): the tenant's `sites` list only
# accepted the fabric-name form, and `mso_tenant` failed with
# "Site 'LAB1-IT-ACI' is not a valid site name" when only the short name
# was exposed here. `Site.fabric_name` (topSystem.fabricDomain) already
# carries this value; fall back to the short `site.name` only if a
# topology omits fabric_name (keeps this builder tolerant of minimal
# topologies used by unit tests that don't set it).
sites: list = []
site_id_map: dict[str, str] = {} # site.name (short) → site.id — internal join key
for site in topo.sites:
apic_url = f"https://{site.mgmt_ip}" if (sandbox and site.mgmt_ip) else f"https://{site.apic_host}"
ndo_site_name = site.fabric_name or site.name
sites.append(
{
"id": site.id,
"name": ndo_site_name,
"platform": "APIC",
"urls": [apic_url],
}
)
site_id_map[site.name] = site.id
# ------------------------------------------------------------------
# Tenants
# ------------------------------------------------------------------
tenants: list = []
for tenant in topo.tenants:
t_id = _hex_id(f"tenant-{tenant.name}")
site_assocs = [
{"siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
tenants.append(
{
"id": t_id,
"name": tenant.name,
"displayName": tenant.name,
"siteAssociations": site_assocs,
}
)
# ------------------------------------------------------------------
# Schemas — one per tenant
# ------------------------------------------------------------------
schemas: list = []
schema_details: dict = {}
policy_states: dict = {}
for tenant in topo.tenants:
schema_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
tmpl_name = f"{tenant.name}-template"
# VRFs — name/displayName/vrfRef match APIC fvCtx names
vrfs = [
{
"name": vrf.name,
"displayName": vrf.name,
"vrfRef": f"/vrfs/{vrf.name}",
"vzAnyEnabled": False,
"ipDataPlaneLearning": "enabled",
}
for vrf in tenant.vrfs
]
# BDs — name/vrfRef match APIC fvBD names; every BD carries a DHCP
# relay label so ndo_bd_view can exercise uuid→name resolution.
bds = []
for bd in tenant.bds:
bds.append(
{
"name": bd.name,
"vrfRef": f"/vrfs/{bd.vrf}",
"subnets": [{"ip": s} for s in bd.subnets],
"l2Stretch": bd.l2stretch,
"intersiteBumTraffic": bd.l2stretch,
"l2UnknownUnicast": "flood",
"arpFlood": True,
"unicastRouting": True,
"epMoveDetectMode": "garp",
"dhcpLabels": [{"ref": DHCP_RELAY_UUID}],
}
)
# ANPs / EPGs — names match APIC fvAp / fvAEPg names
anps = []
for ap in tenant.aps:
epgs = []
for epg in ap.epgs:
contract_rels = [
{
"relationshipType": "provider",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.provide
] + [
{
"relationshipType": "consumer",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.consume
]
epgs.append(
{
"name": epg.name,
"bdRef": f"/bds/{epg.bd}",
"preferredGroup": False,
"proxyArp": False,
"uSegEpg": False,
"intraEpg": "unenforced",
"contractRelationships": contract_rels,
}
)
anps.append({"name": ap.name, "epgs": epgs})
# Contracts — names match APIC vzBrCP names
contracts = []
for contract in tenant.contracts:
filter_rels = [
{"filterRef": f"/filters/{flt.name}"}
for flt in contract.filters
]
contracts.append(
{
"name": contract.name,
"scope": contract.scope,
"filterRelationships": filter_rels,
}
)
# Filters — deduplicated; entries use CONTRACT §7 field names
# (ipProtocol / dFromPort / dToPort / etherType)
seen_filters: set[str] = set()
filters: list = []
for contract in tenant.contracts:
for flt in contract.filters:
if flt.name in seen_filters:
continue
seen_filters.add(flt.name)
entries = [
{
"name": f"{e.proto}-{e.from_port}",
"ipProtocol": e.proto,
"dFromPort": e.from_port,
"dToPort": e.to_port,
"etherType": e.ether_type,
}
for e in flt.entries
]
filters.append({"name": flt.name, "entries": entries})
# External EPGs from L3Out ext_epgs — vrfRef / l3outRef match APIC names
external_epgs: list = []
for l3out in tenant.l3outs:
for ext_epg in l3out.ext_epgs:
external_epgs.append(
{
"name": ext_epg.name,
"vrfRef": f"/vrfs/{l3out.vrf}",
"l3outRef": f"/l3outs/{l3out.name}",
"type": "on-premise",
"subnets": [{"ip": s} for s in ext_epg.subnets],
}
)
# Schema-level site associations (template ↔ site mappings)
schema_sites = [
{"templateName": tmpl_name, "siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
template = {
"name": tmpl_name,
"templateType": tmpl_type,
# PR-13: `custom_mso_schema_service_graph.py`'s "service graph
# already exists, add a node to it" branch (invoked the SECOND
# time the module PATCHes the same service-graph name — e.g.
# once per site in a multi-site service-graph bind) reads
# `schema_obj.get('templates')[template_idx]['tenantId']` with
# a bare subscript to resolve the L4-L7 device's owning tenant.
# Same deterministic id `build_ndo_model`'s tenants[] list
# already assigns this tenant (`_hex_id(f"tenant-{name}")`) —
# recomputed here rather than threaded through because it's
# derived purely from the tenant name.
"tenantId": _hex_id(f"tenant-{tenant.name}"),
"vrfs": vrfs,
"bds": bds,
"anps": anps,
"contracts": contracts,
"filters": filters,
"externalEpgs": external_epgs,
}
# PR-12: backfill self-referencing anpRef/epgRef on every template
# anp/epg (real NDO carries these — see aci_sim/ndo/patch.py
# normalize_template's docstring), then mirror each template ANP/EPG
# into every site already associated with this template, matching
# real NDO 4.x's auto-population of sites[].anps[].epgs[] the moment
# a template with sites attached gains an ANP/EPG — otherwise a
# topology.yaml tenant that ships with ANPs/EPGs already configured
# would boot into the same `set_site_anp()`-returns-None crash this
# PR fixes for the PATCH-built case.
normalize_template(template, schema_id)
for site in schema_sites:
if site.get("templateName") != tmpl_name:
continue
# PR-15: mirror each template BD into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring below (and `_mirror_template_bd_to_sites` in
# patch.py, which does the equivalent for a runtime PATCH `add`):
# real NDO 4.x auto-creates the site BDDelta shadow the moment a
# template BD is added to a template with sites attached, and
# `mso_schema_site_bd.py` always PATCHes that shadow with `op:
# replace` (never `add`) — a topology.yaml tenant that boots with
# BDs already configured must start with the shadow already
# present, or the FIRST site-BD PATCH 400s with "'bd-X' not found
# at '/sites/{siteId}-{t}/bds/bd-X'" (confirmed on a real hardware
# aci-py create_tenant/create_bd run against a freshly-booted sim).
site.setdefault("bds", [])
for bd in template["bds"]:
bd_ref = "/schemas/{}/templates/{}/bds/{}".format(schema_id, tmpl_name, bd["name"])
site["bds"].append(_new_site_bd(bd_ref))
site.setdefault("anps", [])
for anp in template["anps"]:
site["anps"].append(
{"anpRef": anp["anpRef"], "epgs": [_new_site_epg(epg["epgRef"]) for epg in anp["epgs"]]}
)
# PR-13: mirror each template contract into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring above, for the site-local `contracts[]`
# array a raw mso_rest-driven PATCH (e.g. the mso-model role's
# per-fabric service-graph redirect bind) addresses by bare
# contract name (`/sites/{siteId}-{t}/contracts/{c}/
# serviceGraphRelationship`).
site.setdefault("contracts", [])
for contract in template["contracts"]:
contract_ref = "/schemas/{}/templates/{}/contracts/{}".format(schema_id, tmpl_name, contract["name"])
site["contracts"].append({"contractRef": contract_ref})
normalize_site(site)
schema_name = f"{tenant.name}-schema"
schemas.append(
{
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
# Summary shape: only template names (no inner lists)
"templates": [{"name": tmpl_name}],
}
)
schema_details[schema_id] = {
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
"templates": [template],
"sites": schema_sites,
}
policy_states[schema_id] = [{"status": "synced", "drift": False}]
# ------------------------------------------------------------------
# Template summaries
# ------------------------------------------------------------------
# One tenantPolicy template (carries DHCP policies), plus one summary
# entry per schema template so ndo_deployment_status / stretch-analysis
# can enumerate them.
#
# PR-13: `MSOTemplate.__init__`'s name-based lookup
# (`ansible-mso`'s `plugins/module_utils/template.py`) filters
# `templates/summaries` entries by `templateName`+`templateType` (and
# tolerates absent `schemaName`/`schemaId` kwargs — `query_objs()` skips
# `None`-valued filter kwargs). Every summary entry must therefore carry
# a `templateName` field, not just `templateId`/`templateType`, or a
# by-name lookup (`ndo_template`/`ndo_dhcp_relay_policy`'s `template:
# "TenantPol-MS-TN1"` param) never matches anything.
template_summaries: list = [
{
"templateId": TENANT_POLICY_TEMPLATE_ID,
"templateName": "tenantpolicy-default",
"templateType": "tenantPolicy",
}
]
for tenant in topo.tenants:
s_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
template_summaries.append(
{
"templateId": f"{s_id}-{tenant.name}",
"templateName": f"{tenant.name}-template",
"templateType": tmpl_type,
}
)
# ------------------------------------------------------------------
# Tenant policy template detail (DHCP relay + option policies)
# ------------------------------------------------------------------
# PR-13: keyed by templateId, one entry per `GET /templates/{id}` a
# caller might ask for. Every entry mirrors real NDO's generic template
# envelope shape: `{templateId, displayName, templateType,
# tenantPolicyTemplate:{template:{...}, sites:[{siteId}]}}` — the
# `sites` array (not just the inner `template` dict) is required because
# `ndo_template`'s "template already exists" PATCH branch
# (`append_site_config_to_ops`) reads
# `config.get(template_type_container, {}).get("sites", [])` directly.
tenant_policy_templates: dict = {
TENANT_POLICY_TEMPLATE_ID: {
"templateId": TENANT_POLICY_TEMPLATE_ID,
"displayName": "tenantpolicy-default",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {
"dhcpRelayPolicies": [
{"uuid": DHCP_RELAY_UUID, "name": "dhcp-relay-1"},
],
"dhcpOptionPolicies": [
{"uuid": DHCP_OPTION_UUID, "name": "dhcp-option-1"},
],
},
"sites": [],
},
}
}
# ------------------------------------------------------------------
# Fabric connectivity
# ------------------------------------------------------------------
fabric_connectivity: dict = {
"sites": [
{
"id": site.id,
"siteId": site.id,
"status": "up",
"connectivityStatus": "up",
}
for site in topo.sites
]
}
# ------------------------------------------------------------------
# Audit records (fixed timestamps — no Date.now() / datetime.now())
# ------------------------------------------------------------------
audit_records: list = [
{
"id": "audit-001",
"timestamp": "2026-06-01T10:00:00Z",
"user": "admin",
"action": "create",
"description": "Schema Corp-schema created",
"details": "Initial schema creation via NDO",
},
{
"id": "audit-002",
"timestamp": "2026-06-01T10:05:00Z",
"user": "admin",
"action": "deploy",
"description": "Template Corp-template deployed to SiteA, SiteB",
"details": "Full deployment: 1 VRF, 2 BDs, 2 EPGs, 1 contract",
},
{
"id": "audit-003",
"timestamp": "2026-06-02T09:00:00Z",
"user": "operator",
"action": "update",
"description": "BD app-bd updated",
"details": "Changed ARP flood setting to enabled",
},
]
# ------------------------------------------------------------------
# ND local/remote users — PR-11
# ------------------------------------------------------------------
# `cisco.mso.mso_tenant`'s state=present flow always resolves the tenant's
# `users` list (defaulted to ["admin"] when unset) via `lookup_users()`,
# which on ND platforms queries `/nexus/infra/api/aaa/v4/localusers` +
# `/remoteusers` first, falling back to the legacy `/api/config/class/*`
# form when both come back empty (see ansible-mso module_utils/mso.py).
# The sim only serves the legacy `/api/config/class/remoteusers` route
# (see ndo/app.py), so seed a single "admin" local user in ND's
# `{"items":[{"spec":{...}}]}` aggregate shape — `get_user_from_list_of_
# users()` reads `item["spec"]["loginID"]` and needs `id`/`userID` on
# that same spec dict to build the `userAssociations` id list.
local_users: dict = {
"items": [
{
"spec": {
"loginID": "admin",
"loginDomain": None,
"id": "admin-local-0000000000000001",
"userID": "admin-local-0000000000000001",
}
}
]
}
# Remote (AAA/TACACS/LDAP) users start empty: `nd_request()` returns the
# raw JSON body untouched (see module_utils/mso.py `nd_request()`), and
# `get_user_from_list_of_users()` only iterates `body["items"]` when body
# is a dict — so any 200 response with an `"items"` key (even empty)
# short-circuits the "ND Error: Unknown error" 404 failure the real
# playbook run hit. No remote users are configured in the tenants under
# test, only the local "admin" ships above.
remote_users: dict = {"items": []}
return NdoState(
sites=sites,
tenants=tenants,
schemas=schemas,
schema_details=schema_details,
template_summaries=template_summaries,
tenant_policy_templates=tenant_policy_templates,
fabric_connectivity=fabric_connectivity,
policy_states=policy_states,
audit_records=audit_records,
local_users=local_users,
remote_users=remote_users,
)
+785
View File
@@ -0,0 +1,785 @@
"""Minimal JSON-Patch (RFC 6902) applier for the NDO schema PATCH surface — PR-11.
`cisco.mso`'s schema-object modules (mso_schema_template, mso_schema_template_bd,
mso_schema_template_anp, mso_schema_template_anp_epg, mso_schema_site, ...) all
mutate a schema the same way: look the schema up by displayName/id, then send
`PATCH /mso/api/v1/schemas/{id}` with a JSON body that is a *list* of ops:
[{"op": "add", "path": "/templates/LAB1/bds/-", "value": {...}}]
[{"op": "replace", "path": "/templates/LAB1/anps/0/epgs/2", "value": {...}}]
[{"op": "remove", "path": "/templates/LAB1/bds/bd-App1_LAB1"}]
Two path shapes appear in ansible-mso source:
- numeric array index / "-" (append) — standard RFC 6902.
- a *named* segment used as a dict-list lookup key ("bds/bd-App1_LAB1",
"anps/AP1"): the module resolves those to a numeric index in Python and
sends the resolved path in the common case (see mso_schema_template_bd.py
`bd_path = "/templates/{0}/bds/{1}".format(template, bd)` used directly in
ops when `mso.existing` was truthy), but every module also computes
`template` as a *name*, not an index — "/templates/LAB1/..." — so the verb
is really "the templates list, resolved by matching name field", not
"index 0". We replicate that: any path segment that isn't a valid array
index and isn't "-" is resolved by scanning the current list for a dict
whose `name` (or `displayName`) attribute equals that segment.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
#: Every collection key a schema template can carry. Real NDO always serves
#: these back as (at minimum) empty lists even when a caller's create/add
#: payload omitted them — e.g. `mso_schema_template.py`'s "schema does not
#: exist yet" POST payload is only `{name, displayName, tenantId}`, no
#: `vrfs`/`bds`/... . `MSOSchema.set_template_vrf()` (ansible-mso
#: module_utils/schema.py) then does
#: `enumerate(self.schema_objects["template"].details.get("vrfs"))`
#: unconditionally — a missing key there is `None`, not `[]`, and blows up
#: with "'NoneType' object is not iterable" (PR-11, observed on a real
#: ACI-hardware create_tenant → create VRF run). Normalize on every
#: template create so every reader sees a real list.
TEMPLATE_COLLECTION_KEYS: tuple[str, ...] = (
"vrfs",
"bds",
"anps",
"contracts",
"filters",
"externalEpgs",
# PR-11: mso_schema_template_l3out.py / _service_graph.py subscript
# these directly (`schema_obj.get("templates")[idx]["intersiteL3outs"]`,
# no `.get()` fallback) — a missing key is a hard KeyError, confirmed on
# a real hardware create_tenant run's "Create L3Out" task.
"intersiteL3outs",
"serviceGraphs",
)
#: Child-object collection defaults, one level down from a template's own
#: arrays. Every cisco.mso "add a child object" module writes a payload with
#: only the fields *it* cares about (e.g. mso_schema_template_external_epg.py
#: never sets `subnets`), then a *sibling* module
#: (mso_schema_template_external_epg_subnet.py) reads
#: `externalEpgs[idx]["subnets"]` with a bare subscript — a real-hardware run
#: hit `KeyError: 'subnets'` on exactly this path. Real NDO must default
#: these server-side; replicate that here so every object born via PATCH
#: carries the collection keys its sibling modules expect.
#: Maps: template-array-key -> {default-key: default-value}.
CHILD_OBJECT_DEFAULTS: dict[str, dict[str, Any]] = {
"bds": {"subnets": [], "dhcpLabels": []},
"anps": {"epgs": []},
"epgs": {"subnets": [], "contractRelationships": [], "staticPorts": [], "staticLeafs": [], "domains": []},
"externalEpgs": {"subnets": [], "contractRelationships": [], "selectors": []},
"contracts": {"filterRelationships": []},
"filters": {"entries": []},
"intersiteL3outs": {},
"serviceGraphs": {},
"vrfs": {"rpConfigs": []},
}
class PatchError(Exception):
"""Raised when a JSON-Patch op cannot be applied to the stored document."""
def _normalize_object(obj: dict, array_key: str, schema_id: str, template_name: str, anp_name: str = "") -> None:
"""Backfill *obj*'s missing collection-default keys for its container
array (e.g. array_key="bds" -> obj gets subnets/dhcpLabels defaults),
then recurse into any nested arrays this object type carries (anps.epgs).
PR-12: template-level `anps[]`/`epgs[]` entries also get a
self-referencing `anpRef`/`epgRef` string field backfilled — real NDO
stores one on every template ANP/EPG object itself (confirmed by
`ansible-mso`'s `mso_schema_template_anp.py`/`_anp_epg.py` explicitly
stripping it client-side before an idempotency comparison: `if "anpRef"
in mso.previous: del mso.previous["anpRef"]` — dead code unless the
server actually sends one back). `MSOSchema.set_site_anp()`/
`set_site_anp_epg()` (`module_utils/schema.py`) key their site-local
lookup off exactly this field (`template_anp.details.get("anpRef")`) —
without it those lookups compare against `None` and never match,
crashing `mso_schema_site_anp_epg_staticport.py` with `'NoneType'
object has no attribute 'details'` (confirmed on a real MS-TN1
bind_epg_to_static_port run on ACI hardware before this fix).
PR-13: `epgs`/`externalEpgs` entries also get a stable `uuid` field.
Real NDO assigns one to every schema-template EPG/external-EPG; the
NDO-plane `cisco.mso.ndo_dhcp_relay_policy` module reads it as
`schema_objects["template_anp_epg"].details.get("uuid")` (via
`MSOSchema.set_template_anp_epg()`) to build a DHCP relay policy
provider's `epgRef` — without it the sim always hands back `None`,
and the relay-policy-add PATCH round-trips a provider that can never
resolve back to a real EPG (confirmed against `ansible-mso`'s
`plugins/modules/ndo_dhcp_relay_policy.py::get_providers_payload()`).
"""
for key, default in CHILD_OBJECT_DEFAULTS.get(array_key, {}).items():
if obj.get(key) is None:
obj[key] = [] if isinstance(default, list) else default
if array_key == "anps":
anp_name = obj.get("name", anp_name)
if not obj.get("anpRef"):
obj["anpRef"] = "/schemas/{}/templates/{}/anps/{}".format(schema_id, template_name, anp_name)
for epg in obj.get("epgs", []):
if isinstance(epg, dict):
_normalize_object(epg, "epgs", schema_id, template_name, anp_name)
elif array_key == "epgs":
epg_name = obj.get("name", "")
if not obj.get("epgRef"):
obj["epgRef"] = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(
schema_id, template_name, anp_name, epg_name
)
if not obj.get("uuid"):
seed = "epg-uuid-{}-{}-{}-{}".format(schema_id, template_name, anp_name, epg_name)
obj["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
elif array_key == "externalEpgs":
if not obj.get("uuid"):
ext_epg_name = obj.get("name", "")
seed = "extepg-uuid-{}-{}-{}".format(schema_id, template_name, ext_epg_name)
obj["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
def normalize_template(template: dict, schema_id: str = "") -> dict:
"""Backfill missing collection keys + templateID on a template dict, and
recursively normalize every child object already present in its arrays.
Mutates and returns *template* in place. Safe to call repeatedly
(idempotent — only fills keys that are absent or None). *schema_id* is
used to build the self-referencing anpRef/epgRef strings PR-12 backfills
on every anps[]/epgs[] entry — see _normalize_object's docstring.
"""
template_name = template.get("name", "")
for key in TEMPLATE_COLLECTION_KEYS:
if template.get(key) is None:
template[key] = []
for obj in template[key]:
if isinstance(obj, dict):
_normalize_object(obj, key, schema_id, template_name)
# `MSOSchema.set_template()` reads `match.details.get("templateID")` —
# a capital-ID sibling of the lowercase `tenantId`/`id` keys the create
# payload sends. Real NDO assigns this server-side; derive a stable one
# from (schema-scoped) template name so repeated GETs are consistent.
if not template.get("templateID"):
seed = f"template-{template.get('name', '')}"
template["templateID"] = hashlib.sha256(seed.encode()).hexdigest()[:24]
return template
#: Site-array (top-level schema `sites[]`) collection defaults — a distinct
#: namespace from TEMPLATE_COLLECTION_KEYS/CHILD_OBJECT_DEFAULTS because
#: `mso_schema_site_bd.py`'s add payload is only `{bdRef, hostBasedRouting}`
#: (no `subnets`), yet `mso_schema_site_bd_subnet.py` reads
#: `site_bd.details.get("subnets")` and iterates it — a missing key there is
#: `None`, not `[]` (PR-11, same "iterate a None" family of bug as the
#: template-level fix above).
#:
#: The `epgs` entry is the canonical *full* site-local EPG collection shape.
#: A site-local EPG is created by `mso_schema_site_anp_epg.py` (or the
#: staticport/domain modules' own inline fallback) with a payload of only
#: `{epgRef}` — real NDO backfills every child collection array server-side,
#: and every sibling `mso_schema_site_anp_epg_*` module then reads its own
#: array with a **bare subscript** (not `.get()`), so a missing key is a hard
#: `KeyError`, not a 4xx:
#: - `staticPorts` — `mso_schema_site_anp_epg_staticport.py`
#: (`set_existing_static_ports`: `...["epgs"][idx].get("staticPorts")` /
#: bulk module iterates it).
#: - `staticLeafs` — `mso_schema_site_anp_epg_staticleaf.py`
#: (`...["epgs"][epg_idx]["staticLeafs"]`).
#: - `domainAssociations` — `mso_schema_site_anp_epg_domain.py`
#: (`domains = [dom.get("dn") for dom in ...["epgs"][epg_idx]
#: ["domainAssociations"]]`) — a real MS-TN1 hardware
#: `bind_epg_to_physical_domain`/`_vmm_domain` run hit
#: `KeyError: 'domainAssociations'` on exactly this line once PR-12
#: started auto-creating the site-EPG (pre-PR-12 no site-EPG existed at
#: all, so the module took a non-crashing branch).
#: - `subnets` — `mso_schema_site_anp_epg_subnet.py`.
SITE_OBJECT_DEFAULTS: dict[str, dict[str, Any]] = {
"bds": {"subnets": []},
"anps": {"epgs": []},
"epgs": {"subnets": [], "staticPorts": [], "staticLeafs": [], "domainAssociations": []},
}
#: Top-level collection keys on a schema `sites[]` ENTRY ITSELF (a sibling
#: of `bds`/`anps`, not a nested child-object default like
#: SITE_OBJECT_DEFAULTS above) — PR-13. `custom_mso_schema_service_graph.py`
#: (the "Create service graph" task's module, `mso-model/library/`) reads
#: `schema_obj.get('sites')[site_idx]['serviceGraphs']` with a **bare
#: subscript** to find/append the site-local service-graph-to-device
#: binding; a schema born via `POST /schemas` or PATCHed via
#: `mso_schema_site.py`'s "add site" op never carries this key, so a real
#: hardware MS-TN2 `create_tenant` pass-2 run (`-e automate_contract_graph=true`)
#: hit `KeyError: 'serviceGraphs'` on exactly this line — the same class of
#: bug as the template-level `serviceGraphs`/`intersiteL3outs` gap PR-11
#: fixed and the site-local `domainAssociations` gap PR-12 fixed, just one
#: level up (the site entry itself, not one of its child arrays).
SITE_TOP_LEVEL_DEFAULTS: dict[str, Any] = {
"bds": [],
"anps": [],
"serviceGraphs": [],
"contracts": [],
}
def _new_site_epg(epg_ref: str) -> dict:
"""Build a full-shaped site-local EPG dict from its canonical `epgRef`
string — every child-collection array real NDO backfills server-side,
so every sibling `mso_schema_site_anp_epg_*` module's bare subscript
(`["domainAssociations"]`/`["staticLeafs"]`/...) finds a real list.
Single source of truth: keys come from SITE_OBJECT_DEFAULTS["epgs"]."""
epg = {"epgRef": epg_ref}
for dkey, default in SITE_OBJECT_DEFAULTS["epgs"].items():
epg[dkey] = [] if isinstance(default, list) else default
return epg
def normalize_site(site: dict) -> dict:
"""Backfill missing collection-default keys on a schema `sites[]` entry
and its child bds/anps.epgs objects. Mutates and returns *site*."""
# PR-13: backfill the site's OWN top-level collection keys (bds/anps/
# serviceGraphs) before descending into their per-object defaults below
# — see SITE_TOP_LEVEL_DEFAULTS docstring for the serviceGraphs
# bare-subscript crash this closes.
for key, default in SITE_TOP_LEVEL_DEFAULTS.items():
if site.get(key) is None:
site[key] = [] if isinstance(default, list) else default
for key, defaults in SITE_OBJECT_DEFAULTS.items():
if key not in ("bds", "anps"):
continue
for obj in site.get(key, []):
if not isinstance(obj, dict):
continue
for dkey, default in defaults.items():
if obj.get(dkey) is None:
obj[dkey] = [] if isinstance(default, list) else default
if key == "anps":
for epg in obj.get("epgs", []):
if isinstance(epg, dict):
for dkey, default in SITE_OBJECT_DEFAULTS["epgs"].items():
if epg.get(dkey) is None:
epg[dkey] = [] if isinstance(default, list) else default
return site
def _is_list_index(seg: str) -> bool:
return seg.isdigit()
#: `*Ref`-keyed objects — site-local bds/anps/epgs carry no `name` field of
#: their own (their whole identity is the `*Ref` string pointing back at the
#: template-level object), yet sibling modules still address them by that
#: template-level object's bare name — e.g.
#: `/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-` (mso_schema_site_bd_subnet.py
#: /custom_mso_schema_site_bd_subnet.py: `bds_path = "/sites/{0}/bds".format(
#: site_template)`, then indexes by matching `bd_ref in bds`). Match by the
#: trailing path segment of the ref string.
_REF_LOOKUP_KEYS: tuple[str, ...] = ("bdRef", "vrfRef", "l3outRef", "anpRef", "epgRef", "contractRef")
#: The subset of `*Ref` keys that constitute a *nameless site-local shadow's*
#: whole IDENTITY — i.e. the one ref that IS the object (`bds[]` entry → its
#: `bdRef`, `anps[]` → `anpRef`, `epgs[]` → `epgRef`, `contracts[]` →
#: `contractRef`). A shadow object carries no `name`/`displayName` of its
#: own; its identity is exactly this single ref pointing back at the
#: template-level object it mirrors.
#:
#: PR-16 (corrected): the other `*Ref` keys in `_REF_LOOKUP_KEYS`
#: (`vrfRef`, `l3outRef`) are *properties* an object may carry, NOT its
#: identity — every template BD under an L3-attached VRF shares the same
#: `vrfRef` (e.g. `vrf-L3_LAB0`). Matching on those would collapse many
#: distinct BDs into one. So ref-based identity matching is restricted to
#: this map, keyed by the *list's* collection name so a `bds[]` list only
#: ever matches on `bdRef` (never a sibling's shared `vrfRef`).
_IDENTITY_REF_BY_COLLECTION: dict[str, str] = {
"bds": "bdRef",
"anps": "anpRef",
"epgs": "epgRef",
"contracts": "contractRef",
}
_IDENTITY_REF_KEYS: frozenset[str] = frozenset(_IDENTITY_REF_BY_COLLECTION.values())
def _bare_ref_name(ref_val: Any) -> str | None:
"""Extract the bare object name from a `*Ref` field value, regardless
of whether it's already NDO's canonical string form
(`/schemas/.../bds/bd-X`) or one of the dict forms cisco.mso's write
payloads send (`{schemaId, templateName, bdName}` — see
`_REF_NAME_FIELDS`/`_stringify_refs`). Returns None if no name can be
extracted (e.g. an empty dict).
PR-16: the single bare-name extractor for a shadow object's identity
ref. Handles both ref shapes so a dict-form `bdRef` (a site-module
payload's own shape, possibly a bare `{bdName: ...}`) and a string-form
`bdRef` (the mirror's canonical shape) resolve to the same bare name —
the property that lets mirror + site-module add/replace converge on one
entry. Deliberately used ONLY on identity refs (see
`_IDENTITY_REF_BY_COLLECTION`), never on property refs like `vrfRef`."""
if isinstance(ref_val, str) and ref_val:
return ref_val.rsplit("/", 1)[-1]
if isinstance(ref_val, dict):
for name_field, _category in _REF_NAME_FIELDS.values():
name = ref_val.get(name_field)
if name:
return name
return None
def _find_by_name(items: list, seg: str) -> int | None:
"""Return the index of the dict in *items* matching path segment *seg*.
Three addressing conventions appear across cisco.mso's schema-object
modules:
- most template-level objects (bds/anps/epgs/contracts/filters/
externalEpgs/vrfs) are addressed by their own `name` (or
`displayName`) field — e.g. `/templates/LAB1/bds/bd-App1_LAB1`. A
NAMED object is matched ONLY by name/displayName, never by any
`*Ref` field.
- the top-level schema `sites` array is instead addressed by a
synthetic composite key `"{siteId}-{templateName}"` that isn't a
field the object itself carries — every `mso_schema_site_*` module
builds this literally: `site_template = "{0}-{1}".format(site_id,
template)` then `"/sites/{0}/bds".format(site_template)` (see
mso_schema_site_bd.py / _anp.py / _anp_epg.py). Detect that shape by
checking for `siteId`+`templateName` keys on the candidate items and
matching the composite key instead of name/displayName.
- site-local child objects (sites[idx].bds / .anps / .contracts /
.anps[].epgs) carry no `name` of their own at all — only an IDENTITY
`*Ref` field (string OR dict form, PR-16) pointing at the template
object — yet are still addressed by that referenced object's bare
name (confirmed on a real hardware run: `/sites/1-LAB1/bds/
bd-App1_LAB1/subnets/-`). Only for such a NAMELESS item, match by its
identity ref's resolved bare name — and only via the identity refs
(`bdRef`/`anpRef`/`epgRef`/`contractRef`), NEVER a property ref like
`vrfRef` (which many distinct BDs share, PR-16 regression fix). This
is what lets a dict-form bdRef (a site-module payload's own shape)
and a string-form bdRef (the mirror's canonical shape) resolve to the
SAME entry without ever cross-matching two distinct objects.
"""
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
if item.get("name") == seg or item.get("displayName") == seg:
return i
if "siteId" in item and "templateName" in item:
composite = f"{item.get('siteId')}-{item.get('templateName')}"
if composite == seg:
return i
# Ref-matching is ONLY for nameless shadow entries, and ONLY via an
# identity ref — a named object above already returned; a property
# ref (vrfRef/l3outRef) must never be used to establish identity.
if item.get("name") is None and item.get("displayName") is None:
for ref_key in _IDENTITY_REF_KEYS:
if ref_key in item and _bare_ref_name(item.get(ref_key)) == seg:
return i
return None
def _find_shadow_by_identity(items: list, value: Any, collection: str) -> int | None:
"""Append-path dedup for a NAMELESS site-local shadow being added to the
*collection*-named list (`bds`/`anps`/`epgs`/`contracts`).
A JSON-Patch `add` whose path ends in the literal `"-"` (append)
bypasses `_find_by_name` entirely (there's no path segment to resolve),
so a site-module `add` for a BD/ANP/contract that the template-add
mirror (`_mirror_template_bd_to_sites` et al.) already created as a
site-local shadow would otherwise append a SECOND entry instead of
updating the existing one — confirmed against a reference NDO
schema (`MS-TN1-LAB0`) where `sites[0].bds` carried two entries for the
same BD after `create_tenant` (the empty full-path mirror) then
`create_bd`'s site-module subnet PATCH (a dict-bdRef entry carrying the
subnet).
Matches STRICTLY by the value's own identity ref for THIS collection
(`bds`→`bdRef`, etc.), resolved to a bare name, against existing entries'
same identity ref. Never matches:
- a NAMED value (a template-level object add — those append normally),
- via a non-identity/property ref (`vrfRef`/`l3outRef` — many distinct
BDs share one; the PR-16 regression that collapsed every template's
BDs to a single entry),
- across different bd_names.
"""
if not isinstance(value, dict):
return None
# A value carrying its own name is a template-level object, not a
# nameless shadow — it appends normally (distinct objects stay distinct).
if value.get("name") is not None or value.get("displayName") is not None:
return None
ref_key = _IDENTITY_REF_BY_COLLECTION.get(collection)
if ref_key is None or ref_key not in value:
return None
seg = _bare_ref_name(value.get(ref_key))
if seg is None:
return None
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
if item.get("name") is not None or item.get("displayName") is not None:
continue
if ref_key in item and _bare_ref_name(item.get(ref_key)) == seg:
return i
return None
def _resolve_container(doc: dict, tokens: list[str]) -> tuple[Any, str]:
"""Walk *tokens[:-1]* from *doc*, returning (container, last_token).
The container is either a dict (last_token is a key) or a list
(last_token is "-", a numeric index, or a name to resolve).
"""
node: Any = doc
middle = tokens[:-1]
for i, tok in enumerate(middle):
if isinstance(node, list):
if _is_list_index(tok):
idx = int(tok)
else:
idx = _find_by_name(node, tok)
if idx is None:
raise PatchError(f"path segment '{tok}' not found in list")
if idx >= len(node):
raise PatchError(f"index {idx} out of range")
node = node[idx]
elif isinstance(node, dict):
if tok not in node:
# Auto-vivify a missing intermediate container. Every
# cisco.mso schema-object module PATCHes into a collection
# the schema-create/normalize_* pass already seeded as a
# list (vrfs/bds/anps/.../subnets/epgs/...), so this should
# not normally trigger — but if it does, look at the NEXT
# token to pick the right shape instead of guessing a dict:
# a numeric index or "-" (append) means the caller expects
# a list; anything else means a nested dict key.
next_tok = tokens[i + 1] if i + 1 < len(tokens) else None
if next_tok is not None and (next_tok == "-" or _is_list_index(next_tok)):
node[tok] = []
else:
node[tok] = {}
node = node[tok]
else:
raise PatchError(f"cannot descend into non-container at '{tok}'")
return node, tokens[-1]
#: `*Ref` field name -> (name-field-in-payload, url-category-segment).
#: Real NDO always serves these back as canonical STRING refs
#: (`/schemas/{schemaId}/templates/{templateName}/{category}/{name}`) even
#: though several cisco.mso write payloads send a *dict* form instead — see
#: `mso_schema_site_bd.py`'s add payload (`bdRef=dict(schemaId=...,
#: templateName=..., bdName=...)`) versus its OWN query-side comparison
#: (`mso.bd_ref(...)` builds and compares against the string form, and
#: `mso.dict_from_ref()` exists specifically to convert the server's string
#: back to a dict client-side). A real hardware run proved the server must
#: store the string form: a sibling module
#: (`custom_mso_schema_site_bd_subnet.py`) reads `[v.get('bdRef') for v in
#: ...]` and does `bd_ref_string in bds` / `', '.join(bds)` — if the sim
#: stored the dict form verbatim, that `join()` crashes with "sequence item
#: 0: expected str instance, dict found" (confirmed on a real ACI-hardware "Add
#: site-local subnet to BD" run).
_REF_NAME_FIELDS: dict[str, tuple[str, str]] = {
"bdRef": ("bdName", "bds"),
"vrfRef": ("vrfName", "vrfs"),
"l3outRef": ("l3outName", "l3outs"),
"filterRef": ("filterName", "filters"),
"contractRef": ("contractName", "contracts"),
"anpRef": ("anpName", "anps"),
"serviceGraphRef": ("serviceGraphName", "serviceGraphs"),
}
#: `epgRef` is the one `*Ref` field whose canonical string form nests TWO
#: name segments under the template, not one — confirmed against
#: `ansible-mso`'s `plugins/module_utils/mso.py` `epg_ref()`:
#: `"/schemas/{schema_id}/templates/{template}/anps/{anp}/epgs/{epg}"`
#: (`anp_ref()` alone is the single-segment `.../anps/{anp}` form used by
#: `_REF_NAME_FIELDS["anpRef"]` above). `mso_schema_site_anp_epg_staticport.py`
#: sends the dict form `epgRef=dict(schemaId=..., templateName=..., anpName=...,
#: epgName=...)` when auto-creating a site-epg (PR-12) — handled separately
#: from the generic single-category table since it needs both name fields.
_EPG_REF_NAME_FIELDS: tuple[str, str] = ("anpName", "epgName")
def _stringify_refs(value: Any) -> Any:
"""Recursively convert any `*Ref` dict field to NDO's canonical string
ref form, leaving already-string refs and everything else untouched."""
if isinstance(value, dict):
for key, (name_field, category) in _REF_NAME_FIELDS.items():
ref_val = value.get(key)
if isinstance(ref_val, dict) and "schemaId" in ref_val and "templateName" in ref_val:
name = ref_val.get(name_field, ref_val.get("name", ""))
value[key] = "/schemas/{}/templates/{}/{}/{}".format(
ref_val["schemaId"], ref_val["templateName"], category, name
)
epg_ref_val = value.get("epgRef")
if isinstance(epg_ref_val, dict) and "schemaId" in epg_ref_val and "templateName" in epg_ref_val:
anp_name = epg_ref_val.get(_EPG_REF_NAME_FIELDS[0], "")
epg_name = epg_ref_val.get(_EPG_REF_NAME_FIELDS[1], epg_ref_val.get("name", ""))
value["epgRef"] = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(
epg_ref_val["schemaId"], epg_ref_val["templateName"], anp_name, epg_name
)
# `serviceNodeRef` is the other TWO-segment ref (serviceGraphs/{sg}/serviceNodes/{node})
# — `custom_mso_schema_service_graph.py` writes the dict form when it creates the graph
# node; the STOCK `mso_schema_template_contract_service_graph`'s idempotency re-read then
# does a string op on it and dies with "expected string ... got 'dict'" (F3). Real NDO
# returns the resolved string ref, so resolve it here like serviceGraphRef/epgRef.
sgn_ref_val = value.get("serviceNodeRef")
if isinstance(sgn_ref_val, dict) and "schemaId" in sgn_ref_val and "templateName" in sgn_ref_val:
value["serviceNodeRef"] = "/schemas/{}/templates/{}/serviceGraphs/{}/serviceNodes/{}".format(
sgn_ref_val["schemaId"], sgn_ref_val["templateName"],
sgn_ref_val.get("serviceGraphName", ""),
sgn_ref_val.get("serviceNodeName", sgn_ref_val.get("name", "")),
)
for v in value.values():
_stringify_refs(v)
elif isinstance(value, list):
for item in value:
_stringify_refs(item)
return value
#: Matches `add /templates/{template}/bds/-` — a brand-new template-level BD
#: (`mso_schema_template_bd.py`'s "BD does not exist" branch). PR-15: mirrored
#: into every associated site's `bds[]` the same way template ANPs/EPGs/
#: contracts are mirrored (PR-11/PR-12/PR-13), because
#: `mso_schema_site_bd.py` always PATCHes its site-local shadow with
#: `op: replace` (never `add`) — real NDO 4.x auto-creates the site BDDelta
#: entry as soon as the template BD is added to a template that already has
#: sites attached, so by the time the site-BD module runs the shadow already
#: exists and a fresh `add` would 409 with "Multiple BDDelta entries" (see
#: aci-py's `shims/mso_mso_bd_vrf.py::mso_schema_site_bd` docstring, which
#: documents this exact real-hardware behavior). Without this mirror, the
#: `replace` 400s with "'bd-X' not found at '/sites/{siteId}-{tpl}/bds/bd-X'"
#: — confirmed on a real-hardware aci-py `create_tenant`/`create_bd` run
#: (MS-TN2), the same class of gap PR-11/12/13 closed for anps/epgs/contracts.
_TEMPLATE_BD_ADD_RE = re.compile(r"^templates/([^/]+)/bds/-$")
#: Matches `add /templates/{template}/anps/-` — a brand-new template-level
#: ANP (`mso_schema_template_anp.py`'s "ANP does not exist" branch).
_TEMPLATE_ANP_ADD_RE = re.compile(r"^templates/([^/]+)/anps/-$")
#: Matches `add /templates/{template}/anps/{anp}/epgs/-` — a brand-new
#: template-level EPG under an existing ANP (`mso_schema_template_anp_epg.py`).
_TEMPLATE_EPG_ADD_RE = re.compile(r"^templates/([^/]+)/anps/([^/]+)/epgs/-$")
#: Matches `add /templates/{template}/contracts/-` — a brand-new
#: template-level contract (`mso_schema_template_contract_filter.py`'s
#: "contract does not exist" branch). PR-13: mirrored into every associated
#: site's `contracts[]` the same way template ANPs are mirrored, because
#: `mso_rest`-driven raw-PATCH tasks (e.g. the mso-model role's
#: "Atomic PATCH — bind service-graph redirect on ALL fabrics" task) address
#: a site-local contract by bare name at
#: `/sites/{siteId}-{template}/contracts/{contract}/serviceGraphRelationship`
#: — a real hardware MS-TN2 `create_tenant` pass-2 run (with
#: `automate_contract_graph=true automate_site_redirect=true`) hit `"path
#: segment 'con-Firewall_LAB0' not found in list"` on exactly this path
#: because the site never carried a mirrored `contracts[]` array at all.
_TEMPLATE_CONTRACT_ADD_RE = re.compile(r"^templates/([^/]+)/contracts/-$")
def _new_site_bd(bd_ref: str) -> dict:
"""Build a full-shaped site-local BD dict from its canonical `bdRef`
string — mirrors `_new_site_epg` for BDs. `hostBasedRouting` defaults to
`False` (the value `mso_schema_site_bd.py`'s own "BD does not exist yet"
`add` payload would send), so a subsequent `replace` (the module's normal
path once the shadow exists) reads a real bool, not a missing key."""
bd = {"bdRef": bd_ref, "hostBasedRouting": False}
for dkey, default in SITE_OBJECT_DEFAULTS["bds"].items():
bd[dkey] = [] if isinstance(default, list) else default
return bd
def _mirror_template_bd_to_sites(doc: dict, template_name: str, bd_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local BD shadow entry in every site associated
with *template_name*, mirroring what real NDO 4.x does automatically
when a template-level BD is added to a template that already has sites
attached — see `_TEMPLATE_BD_ADD_RE`'s comment for the full rationale
and the aci-py shim docstring it cites. Idempotent: skips sites that
already carry this bdRef."""
bd_ref = "/schemas/{}/templates/{}/bds/{}".format(schema_id or doc.get("id", ""), template_name, bd_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("bds", [])
if _find_by_name(site["bds"], bd_name) is not None:
continue
site["bds"].append(_new_site_bd(bd_ref))
def _mirror_template_anp_to_sites(doc: dict, template_name: str, anp_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local ANP entry in every site associated with
*template_name*, mirroring what real NDO 4.x does automatically when a
template-level ANP is added to a template that already has sites
attached (`mso_schema_site.py` having already run in create_tenant's
flow — PR-11). Idempotent: skips sites that already carry this anpRef.
Without this, `mso_schema_site_anp_epg_staticport.py`'s own fallback
"create site anp/epg if missing" branch is what fires instead — and that
fallback crashes with `'NoneType' object has no attribute 'details'` on
a subsequent step (see module docstring / CONTRACT.md §7a), matching a
real hardware "coverage misses this on 4.x and above" code comment in the
module itself: on real hardware this mirroring already happened, so the
fallback path is never exercised.
"""
anp_ref = "/schemas/{}/templates/{}/anps/{}".format(schema_id or doc.get("id", ""), template_name, anp_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("anps", [])
if _find_by_name(site["anps"], anp_name) is not None:
continue
site["anps"].append({"anpRef": anp_ref, "epgs": []})
def _mirror_template_epg_to_sites(
doc: dict, template_name: str, anp_name: str, epg_name: str, schema_id: str | None
) -> None:
"""Auto-create a site-local EPG entry (under its mirrored site-anp) in
every site associated with *template_name*, mirroring real NDO 4.x's
automatic site-epg creation on template EPG add. See
`_mirror_template_anp_to_sites` for the full rationale. Idempotent."""
sid = schema_id or doc.get("id", "")
epg_ref = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(sid, template_name, anp_name, epg_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("anps", [])
anp_idx = _find_by_name(site["anps"], anp_name)
if anp_idx is None:
# Template-level ANP add should have mirrored the site-anp
# already; auto-vivify defensively so EPG add never crashes on
# an out-of-order/partial patch batch.
anp_ref = "/schemas/{}/templates/{}/anps/{}".format(sid, template_name, anp_name)
site["anps"].append({"anpRef": anp_ref, "epgs": []})
anp_idx = len(site["anps"]) - 1
site_anp = site["anps"][anp_idx]
site_anp.setdefault("epgs", [])
if _find_by_name(site_anp["epgs"], epg_name) is not None:
continue
site_anp["epgs"].append(_new_site_epg(epg_ref))
def _mirror_template_contract_to_sites(doc: dict, template_name: str, contract_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local contract entry (`{contractRef}`) in every
site associated with *template_name*, mirroring real NDO 4.x's
automatic site-local mirroring for a template-level contract add — the
same family of behavior PR-12 implemented for template ANPs/EPGs (see
`_mirror_template_anp_to_sites`'s docstring for the general rationale).
Without this, a raw-PATCH task addressing a site-local contract by its
bare name (e.g. `/sites/{siteId}-{template}/contracts/{contract}/
serviceGraphRelationship`, the mso-model role's redirect-policy bind)
hits `_find_by_name`'s "not found in list" `PatchError` — confirmed on
a real hardware MS-TN2 `create_tenant` pass-2 run (PR-13).
"""
contract_ref = "/schemas/{}/templates/{}/contracts/{}".format(schema_id or doc.get("id", ""), template_name, contract_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("contracts", [])
if _find_by_name(site["contracts"], contract_name) is not None:
continue
site["contracts"].append({"contractRef": contract_ref})
def apply_json_patch(doc: dict, ops: list[dict]) -> dict:
"""Apply a list of RFC-6902-ish ops to *doc* in place and return it.
Supports op in {"add", "replace", "remove"}. Unknown ops are ignored
(forward-compatible with cisco.mso module versions we haven't seen).
"""
for entry in ops:
op = entry.get("op")
path = entry.get("path", "")
value = entry.get("value")
if op in ("add", "replace"):
value = _stringify_refs(value)
tokens = [t for t in path.split("/") if t != ""]
if not tokens:
continue
container, last = _resolve_container(doc, tokens)
if op == "add":
if isinstance(container, list):
if last == "-":
# A bare "-" (append) skips _find_by_name entirely (no
# path segment to resolve against). For a NAMELESS
# site-local shadow (`sites[].bds`/`anps`/`epgs`/
# `contracts` entries, whose whole identity is a single
# `*Ref`), this previously let a site-module `add` create
# a SECOND entry for an object the template-add mirror
# already shadowed — see _find_shadow_by_identity for the
# real-NDO duplicate-BD symptom this closes. Dedup ONLY a
# nameless shadow, ONLY via its identity ref for THIS
# collection (tokens[-2]); a named object (template-level
# BD add) always appends so distinct objects stay
# distinct (PR-16 regression fix).
collection = tokens[-2] if len(tokens) >= 2 else ""
existing_idx = _find_shadow_by_identity(container, value, collection)
if existing_idx is None:
container.append(value)
else:
container[existing_idx] = value
elif _is_list_index(last):
idx = int(last)
container.insert(idx, value)
else:
idx = _find_by_name(container, last)
if idx is None:
container.append(value)
else:
container[idx] = value
elif isinstance(container, dict):
container[last] = value
else:
raise PatchError(f"add: unsupported container type at '{path}'")
# PR-12: mirror a brand-new template-level ANP/EPG into every
# site already associated with this template — see
# _mirror_template_anp_to_sites for the real-NDO-4.x rationale.
normalized_path = "/".join(tokens)
bd_match = _TEMPLATE_BD_ADD_RE.match(normalized_path)
if bd_match and isinstance(value, dict):
bd_name = value.get("name")
if bd_name:
_mirror_template_bd_to_sites(doc, bd_match.group(1), bd_name, doc.get("id"))
anp_match = _TEMPLATE_ANP_ADD_RE.match(normalized_path)
if anp_match and isinstance(value, dict):
anp_name = value.get("name")
if anp_name:
_mirror_template_anp_to_sites(doc, anp_match.group(1), anp_name, doc.get("id"))
epg_match = _TEMPLATE_EPG_ADD_RE.match(normalized_path)
if epg_match and isinstance(value, dict):
epg_name = value.get("name")
if epg_name:
_mirror_template_epg_to_sites(doc, epg_match.group(1), epg_match.group(2), epg_name, doc.get("id"))
contract_match = _TEMPLATE_CONTRACT_ADD_RE.match(normalized_path)
if contract_match and isinstance(value, dict):
contract_name = value.get("name")
if contract_name:
_mirror_template_contract_to_sites(doc, contract_match.group(1), contract_name, doc.get("id"))
elif op == "replace":
if isinstance(container, list):
if _is_list_index(last):
idx = int(last)
else:
idx = _find_by_name(container, last)
if idx is None or idx >= len(container):
raise PatchError(f"replace: '{last}' not found at '{path}'")
container[idx] = value
elif isinstance(container, dict):
container[last] = value
else:
raise PatchError(f"replace: unsupported container type at '{path}'")
elif op == "remove":
if isinstance(container, list):
if _is_list_index(last):
idx = int(last)
else:
idx = _find_by_name(container, last)
if idx is not None and idx < len(container):
container.pop(idx)
elif isinstance(container, dict):
container.pop(last, None)
# else: unknown op — ignore rather than fail the whole batch.
return doc