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>
555 lines
22 KiB
Python
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)}"
|