mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user