Files
aci-sim/tests/test_pr11_ndo_write.py
taodengandClaude Fable 5 7e9a175ce6 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>
2026-07-05 20:06:36 +10:00

555 lines
22 KiB
Python

"""PR-11 — NDO write surface: schema POST/PATCH round-trip, ND user-class
fallback endpoints, bare /api/v1/sites.
These close the gap between PR-10 (site names, schema list-identity) and a
real multi-site aci-ansible E2E run: the tenant-region schema
(`<tenant>-<region>`, e.g. "MS-TN1-LAB0") that create_tenant's MSO play
creates via `mso_schema_template` must round-trip through
list-identity → GET-by-id → PATCH (add template/BD/ANP/EPG ops) → GET-by-id
reflecting the mutation, exactly the sequence cisco.mso's schema-object
modules perform (see docs/CONTRACT.md §7 and aci_sim/ndo/patch.py).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.ndo.patch import (
PatchError,
apply_json_patch,
normalize_site,
normalize_template,
)
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
@pytest.fixture()
def topo():
return load_topology(TOPOLOGY_YAML)
@pytest.fixture()
def client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# /api/config/class/remoteusers + localusers — ND fallback shape
# ---------------------------------------------------------------------------
def test_remoteusers_class_query_shape(client):
"""Must be a dict with an "items" list — get_user_from_list_of_users()
(ansible-mso module_utils/mso.py) only iterates users.get("items") when
given a dict; anything else (404, bare list) sends mso_tenant into the
"ND Error: Unknown error" abort observed on the real hardware run.
"""
resp = client.get("/api/config/class/remoteusers")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
assert "items" in body
assert isinstance(body["items"], list)
def test_localusers_class_query_shape_has_admin(client):
resp = client.get("/api/config/class/localusers")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
items = body["items"]
assert len(items) >= 1
admin = next(i for i in items if i["spec"]["loginID"] == "admin")
assert admin["spec"].get("id") or admin["spec"].get("userID")
# ---------------------------------------------------------------------------
# bare /api/v1/sites — ndo_template prereq lookup
# ---------------------------------------------------------------------------
def test_bare_sites_endpoint_matches_mso_prefixed(client):
"""cisco.mso.ndo_template's prereq TenantPol lookup hits the BARE
/api/v1/sites path (no /mso prefix) — confirmed 404 on a real hardware run
against the previously mso-only route.
"""
bare = client.get("/api/v1/sites")
prefixed = client.get("/mso/api/v1/sites")
assert bare.status_code == 200
assert bare.json() == prefixed.json()
# ---------------------------------------------------------------------------
# apply_json_patch — unit-level op semantics
# ---------------------------------------------------------------------------
def test_patch_add_append_to_list():
doc = {"templates": [{"name": "T1", "bds": []}]}
ops = [{"op": "add", "path": "/templates/T1/bds/-", "value": {"name": "bd1"}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["bds"] == [{"name": "bd1"}]
def test_patch_add_named_template_slot():
"""/templates/- appends a whole new template dict (mso_schema_template's
'template does not exist yet' branch)."""
doc = {"templates": []}
ops = [{"op": "add", "path": "/templates/-", "value": {"name": "T1", "bds": []}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["name"] == "T1"
def test_patch_replace_by_name_resolution():
doc = {"templates": [{"name": "T1", "displayName": "old"}]}
ops = [{"op": "replace", "path": "/templates/T1/displayName", "value": "new"}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["displayName"] == "new"
def test_patch_remove_by_name_resolution():
doc = {"templates": [{"name": "T1", "bds": [{"name": "bd1"}, {"name": "bd2"}]}]}
ops = [{"op": "remove", "path": "/templates/T1/bds/bd1"}]
apply_json_patch(doc, ops)
assert [b["name"] for b in doc["templates"][0]["bds"]] == ["bd2"]
def test_patch_stringifies_ref_dicts_on_add():
"""A `*Ref` field sent as a dict (schemaId/templateName/xName) must be
persisted as NDO's canonical string ref — real NDO's storage shape,
confirmed by a sibling module (custom_mso_schema_site_bd_subnet.py)
that string-compares/joins bdRef values and crashes on a dict."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1", "bds": []}]}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {"bdRef": {"schemaId": "schema-abc", "templateName": "LAB1", "bdName": "bd1"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"][0]["bdRef"] == "/schemas/schema-abc/templates/LAB1/bds/bd1"
def test_patch_leaves_already_string_refs_untouched():
doc = {"templates": [{"name": "T1", "bds": []}]}
ops = [{"op": "add", "path": "/templates/T1/bds/-", "value": {"name": "bd1", "vrfRef": "/vrfs/vrf1"}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["bds"][0]["vrfRef"] == "/vrfs/vrf1"
def test_patch_add_autovivifies_missing_collection_as_list():
"""Regression guard for a real bug this suite caught: if an
intermediate collection key is missing when a PATCH targets it with
"/-" (append), the resolver must create a LIST, not a dict — a dict
turns the "-" append marker into a literal key '-': {'-': value},
silently corrupting the document instead of appending."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1"}]} # no "bds" key at all
ops = [{"op": "add", "path": "/sites/1-LAB1/bds/-", "value": {"name": "bd1"}}]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"] == [{"name": "bd1"}]
def test_patch_unresolvable_path_raises():
doc = {"templates": []}
with pytest.raises(PatchError):
apply_json_patch(doc, [{"op": "replace", "path": "/templates/NoSuchTmpl/name", "value": "x"}])
# ---------------------------------------------------------------------------
# normalize_template — server-side collection-key defaulting
# ---------------------------------------------------------------------------
def test_normalize_template_backfills_bare_create_payload():
"""mso_schema_template.py's create payload is only {name, displayName,
tenantId} — every other collection key (vrfs/bds/anps/.../
intersiteL3outs/serviceGraphs) must come back as [] or MSOSchema's
`set_template_vrf()` etc. crash with 'NoneType' object is not iterable
(observed on a real hardware run's Create VRF task)."""
tmpl = {"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "abc"}
normalize_template(tmpl)
for key in ("vrfs", "bds", "anps", "contracts", "filters", "externalEpgs", "intersiteL3outs", "serviceGraphs"):
assert tmpl[key] == [], f"{key} not defaulted to []"
assert tmpl["templateID"], "templateID must be assigned"
def test_normalize_template_backfills_external_epg_subnets():
"""mso_schema_template_external_epg.py's add payload never sets
'subnets' — mso_schema_template_external_epg_subnet.py subscripts it
bare (`externalEpgs[idx]["subnets"]`), a real hardware run hit
KeyError: 'subnets' on exactly this path."""
tmpl = {
"name": "LAB1-LAB2",
"externalEpgs": [{"name": "xepg-All", "vrfRef": "/vrfs/v1", "l3outRef": "/l3outs/l1"}],
}
normalize_template(tmpl)
assert tmpl["externalEpgs"][0]["subnets"] == []
assert tmpl["externalEpgs"][0]["contractRelationships"] == []
def test_normalize_template_backfills_nested_epg_under_anp():
tmpl = {"name": "T1", "anps": [{"name": "AP1", "epgs": [{"name": "web"}]}]}
normalize_template(tmpl)
epg = tmpl["anps"][0]["epgs"][0]
assert epg["subnets"] == []
assert epg["contractRelationships"] == []
assert epg["staticPorts"] == []
def test_patch_add_site_bd_by_composite_key():
"""mso_schema_site_bd.py addresses the top-level `sites[]` array by a
synthetic "{siteId}-{templateName}" composite key, not by name/
displayName — confirmed on a real hardware create_bd run that hit
"path segment '1-LAB1' not found in list" before this resolution mode
was added (site_template = "{siteId}-{templateName}").
"""
doc = {
"sites": [
{"siteId": "1", "templateName": "LAB1", "bds": []},
{"siteId": "2", "templateName": "LAB1", "bds": []},
]
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {"bdRef": {"bdName": "bd-App1_LAB1"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"] == [{"bdRef": {"bdName": "bd-App1_LAB1"}, "hostBasedRouting": False}]
assert doc["sites"][1]["bds"] == []
def test_patch_add_site_bd_subnet_by_ref_name_resolution():
"""Site-local bds[] entries carry no `name` of their own — only a
bdRef string — yet mso_schema_site_bd_subnet.py addresses them by the
referenced BD's bare name: `/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-`.
A real hardware run 400'd with "path segment 'bd-App1_LAB1' not found in
list" before ref-name resolution was added.
"""
doc = {
"sites": [
{
"siteId": "1",
"templateName": "LAB1",
"bds": [
{
"bdRef": "/schemas/schema-abc/templates/LAB1/bds/bd-App1_LAB1",
"hostBasedRouting": False,
"subnets": [],
}
],
}
]
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"][0]["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
def test_normalize_site_backfills_bd_subnets():
"""mso_schema_site_bd.py's add payload is {bdRef, hostBasedRouting} — no
subnets — but mso_schema_site_bd_subnet.py reads
site_bd.details.get("subnets") and iterates it."""
site = {"siteId": "1", "templateName": "LAB1", "bds": [{"bdRef": {}, "hostBasedRouting": False}]}
normalize_site(site)
assert site["bds"][0]["subnets"] == []
def test_normalize_template_idempotent():
tmpl = {"name": "T1"}
normalize_template(tmpl)
first_id = tmpl["templateID"]
normalize_template(tmpl)
assert tmpl["templateID"] == first_id
assert tmpl["bds"] == []
# ---------------------------------------------------------------------------
# End-to-end schema write round trip via the FastAPI routes
# ---------------------------------------------------------------------------
def test_schema_create_list_identity_get_roundtrip(client):
"""POST creates a schema whose displayName is resolvable via
list-identity and whose full doc is servable by GET /schemas/{id}
mso_schema_template.py's flow when a tenant-region schema doesn't exist
yet (schema_path = "schemas"; POST {displayName, templates:[...], sites:[]}).
"""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [
{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "abc123", "bds": [], "anps": []}
],
"sites": [],
}
created = client.post("/mso/api/v1/schemas", json=payload)
assert created.status_code == 200
schema_id = created.json()["id"]
identity = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
match = [s for s in identity if s["displayName"] == "MS-TN1-LAB0"]
assert len(match) == 1, f"Expected exactly one list-identity match, got {match}"
assert match[0]["id"] == schema_id
detail = client.get(f"/mso/api/v1/schemas/{schema_id}")
assert detail.status_code == 200
body = detail.json()
assert body["templates"][0]["name"] == "LAB1-LAB2"
def test_schema_patch_add_bd_then_get_reflects_it(client):
"""PATCH adds a BD to an existing template; a subsequent GET must show
it — the mso_schema_template_bd.py 'BD does not exist, add it' flow:
ops = [{"op":"add","path":"/templates/{tmpl}/bds/-","value":{...}}].
"""
payload = {
"displayName": "MS-TN2-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "xyz", "bds": [], "anps": []}],
"sites": [],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
bd_ops = [
{
"op": "add",
"path": "/templates/LAB1/bds/-",
"value": {"name": "bd-App1_LAB1", "vrfRef": "/vrfs/vrf-L3_LAB0", "subnets": []},
}
]
patched = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=bd_ops)
assert patched.status_code == 200
assert patched.json()["templates"][0]["bds"][0]["name"] == "bd-App1_LAB1"
# Independent GET after the PATCH must reflect the same mutation.
detail = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
bd_names = {b["name"] for b in detail["templates"][0]["bds"]}
assert "bd-App1_LAB1" in bd_names
def test_schema_patch_add_template_to_existing_schema(client):
"""mso_schema_template.py's 'schema exists, template doesn't' branch:
PATCH ops = [{"op":"add","path":"/templates/-","value":{...}}]."""
payload = {"displayName": "MS-Multi-Schema", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
ops = [{"op": "add", "path": "/templates/-", "value": {"name": "LAB2", "displayName": "LAB2", "tenantId": "t1"}}]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=ops)
assert resp.status_code == 200
assert any(t["name"] == "LAB2" for t in resp.json()["templates"])
# list-identity / GET /schemas summary must reflect the new template too.
schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
summary = next(s for s in schemas if s["id"] == schema_id)
assert {t["name"] for t in summary["templates"]} == {"LAB2"}
def test_schema_patch_external_epg_then_subnet_add(client):
"""End-to-end replica of the real playbook sequence: add an externalEpg
(mso_schema_template_external_epg.py, no subnets key in its payload),
then add a subnet to it (mso_schema_template_external_epg_subnet.py,
which bare-subscripts externalEpgs[idx]["subnets"])."""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "t1"}],
"sites": [],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
add_epg_ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/externalEpgs/-",
"value": {"name": "xepg-All_LAB0", "vrfRef": "/vrfs/vrf-L3_LAB0", "l3outRef": "/l3outs/l3o-bgp-Core_LAB0"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_epg_ops)
assert resp.status_code == 200
assert resp.json()["templates"][0]["externalEpgs"][0]["subnets"] == []
add_subnet_ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/externalEpgs/xepg-All_LAB0/subnets/-",
"value": {"ip": "0.0.0.0/0"},
}
]
resp2 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_subnet_ops)
assert resp2.status_code == 200
subnets = resp2.json()["templates"][0]["externalEpgs"][0]["subnets"]
assert subnets == [{"ip": "0.0.0.0/0"}]
def test_schema_validate_route(client):
"""mso_schema_template_deploy.py calls GET schemas/{id}/validate before
every deploy on ND-platform NDO — a real hardware run 404'd here because
the route was entirely missing."""
payload = {"displayName": "MS-Validate-Check", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
resp = client.get(f"/mso/api/v1/schemas/{schema_id}/validate")
assert resp.status_code == 200
def test_schema_validate_unknown_id_404(client):
resp = client.get("/mso/api/v1/schemas/does-not-exist/validate")
assert resp.status_code == 404
def test_execute_and_status_schema_template(client):
"""mso_schema_template_deploy.py's actual deploy/status request:
GET execute|status/schema/{id}/template/{name} — response is splatted
into mso.exit_json(**status), so it must be a JSON object."""
payload = {"displayName": "MS-Deploy-Check", "templates": [{"name": "LAB1"}], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
execute_resp = client.get(f"/mso/api/v1/execute/schema/{schema_id}/template/LAB1")
assert execute_resp.status_code == 200
assert isinstance(execute_resp.json(), dict)
status_resp = client.get(f"/mso/api/v1/status/schema/{schema_id}/template/LAB1")
assert status_resp.status_code == 200
assert isinstance(status_resp.json(), dict)
def test_schema_patch_site_local_bd_end_to_end(client):
"""Full replica of the real create_bd MS-TN1 sequence: schema already
has a `sites[]` entry (from mso_schema_site.py's earlier "Add site to
template" run), then mso_schema_site_bd.py PATCHes a site-local BD by
composite key. A real hardware run 400'd with "path segment '1-LAB1' not
found in list" before the composite-key resolver was added.
The write payload's `bdRef` is a DICT (schemaId/templateName/bdName —
what mso_schema_site_bd.py actually sends), but the server must store
and serve it back as the canonical STRING ref
`/schemas/{id}/templates/{tmpl}/bds/{name}` — a sibling module
(custom_mso_schema_site_bd_subnet.py) does `bd_ref_string in [v.get(
'bdRef') for v in ...]` and `', '.join(bds)` on a failed match; a dict
stored verbatim crashes that join with "sequence item 0: expected str
instance, dict found" (confirmed on a real hardware "Add site-local subnet
to BD" run).
"""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "t1", "bds": [{"name": "bd-App1_LAB1"}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {
"bdRef": {"schemaId": schema_id, "templateName": "LAB1", "bdName": "bd-App1_LAB1"},
"hostBasedRouting": False,
},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=ops)
assert resp.status_code == 200
site_entry = resp.json()["sites"][0]
assert site_entry["bds"][0]["bdRef"] == f"/schemas/{schema_id}/templates/LAB1/bds/bd-App1_LAB1"
assert site_entry["bds"][0]["subnets"] == []
def test_schema_patch_site_local_bd_subnet_end_to_end(client):
"""create_bd's full MS-TN1 sequence tail: after the site-local BD exists
(bdRef-only, no name), add a subnet to it addressed by the BD's bare
name — real NDO/ACI-hardware request shape."""
schema_id = client.post(
"/mso/api/v1/schemas",
json={
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "bds": [{"name": "bd-App1_LAB1"}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
},
).json()["id"]
add_bd_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {
"bdRef": {"schemaId": schema_id, "templateName": "LAB1", "bdName": "bd-App1_LAB1"},
"hostBasedRouting": False,
},
}
]
client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_bd_ops)
add_subnet_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_subnet_ops)
assert resp.status_code == 200
site_bd = resp.json()["sites"][0]["bds"][0]
assert site_bd["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
def test_post_task_deploy(client):
"""cisco.mso.ndo_schema_template_deploy — the module aci-ansible's
mso-model role actually invokes — sends deploy/redeploy as
POST /mso/api/v1/task {schemaId, templateName, isRedeploy}. A real
real hardware run 404'd here; confirmed against the collection installed there
(differs from the legacy mso_schema_template_deploy.py's execute/status
GET routes, which are exercised separately above)."""
payload = {"displayName": "MS-Task-Deploy-Check", "templates": [{"name": "LAB1"}], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": schema_id, "templateName": "LAB1", "isRedeploy": False},
)
assert resp.status_code == 200
body = resp.json()
assert body["schemaId"] == schema_id
assert body["templateName"] == "LAB1"
def test_post_task_unknown_schema_404(client):
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": "does-not-exist", "templateName": "LAB1", "isRedeploy": False},
)
assert resp.status_code == 404
def test_schema_patch_unknown_id_404(client):
resp = client.patch("/mso/api/v1/schemas/does-not-exist", json=[{"op": "add", "path": "/x", "value": 1}])
assert resp.status_code == 404
def test_schema_patch_no_double_listing_after_mutation(client):
"""Regression guard: patching a schema must not create a duplicate
summary entry in GET /schemas (seeded-schema id collision check)."""
payload = {"displayName": "MS-Dup-Check", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
client.patch(
f"/mso/api/v1/schemas/{schema_id}",
json=[{"op": "add", "path": "/templates/-", "value": {"name": "T1"}}],
)
schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
matching = [s for s in schemas if s["id"] == schema_id]
assert len(matching) == 1, f"Expected exactly one entry, found {len(matching)}"