mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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>
786 lines
40 KiB
Python
786 lines
40 KiB
Python
"""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
|