feat(ndo): DELETE endpoints for schemas and tenants (v0.22.0)

F5 fidelity gap: cisco.mso mso_schema/mso_tenant state=absent send
DELETE /mso/api/v1/schemas/{id} and DELETE /mso/api/v1/tenants/{id};
both were unrouted (405), blocking E2E baseline cleanup.

- DELETE schemas/{id}: removes detail + summary (seeded or extra) +
  policy-states; 404 on unknown id. No cascade into the APIC deploy
  mirror — real NDO orphans deployed objects on schema delete.
- DELETE tenants/{id} (+ bare /api/v1 alias, matching the GET pair):
  400 while any schema template tenantId references the tenant,
  200 once dereferenced, 404 on unknown id.
- tests/test_ndo_delete.py: 8 tests incl. no-cascade mirror check.

Full suite: 922 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1
This commit is contained in:
dtzp555-max
2026-07-08 10:57:31 +10:00
co-authored by Claude Fable 5
parent 867c05c9c5
commit ffaf9964c2
4 changed files with 304 additions and 1 deletions
+18
View File
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(pre-1.0: minor bumps may include breaking changes to the sim's behavior).
## [0.22.0] - 2026-07-08
### Added
- **NDO DELETE endpoints — schemas and tenants (F5)**:
`DELETE /mso/api/v1/schemas/{id}` removes a schema (detail, summary —
seeded or runtime-POSTed — and policy-states record; unknown id → 404)
and `DELETE /mso/api/v1/tenants/{id}` (+ bare `/api/v1/tenants/{id}`
alias, same dual-shape pair as GET /tenants) removes a tenant.
`cisco.mso.mso_schema` / `mso_tenant` with `state=absent` send exactly
these requests and previously got 405, blocking E2E baseline cleanup.
Real-NDO guard: a tenant still referenced by any schema template's
`tenantId` is refused with a 400 ("referenced by schema(s) …") until
the referencing schemas are deleted. Schema deletion deliberately does
NOT cascade into the APIC deploy mirror — real NDO does not undeploy on
schema delete, so already-deployed objects stay orphaned on the sites'
APICs unless the caller undeploys first (`POST /mso/api/v1/task` with
`undeploy`).
## [0.21.0] - 2026-07-07
### Added
+65
View File
@@ -163,6 +163,43 @@ def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) ->
state.tenants.append(tenant)
return tenant
# F5: `cisco.mso.mso_tenant` (state=absent) tears a tenant down with
# `DELETE tenants/{id}` — previously unrouted here (observed 405),
# which blocked E2E baseline cleanup. Real NDO refuses to delete a
# tenant while any schema template still references it (tenants must
# be dissociated/schema-free first), so mirror that guard: any
# schema_details template whose `tenantId` matches → 400. Registered
# on both the /mso-prefixed and bare paths, following the
# GET /mso/api/v1/tenants ↔ GET /api/v1/tenants dual-shape pair above
# (`delegate_to: localhost` cisco.mso tasks skip the /mso prefix —
# see the PR-13 templates-store comment below).
@app.delete("/mso/api/v1/tenants/{tenant_id}")
@app.delete("/api/v1/tenants/{tenant_id}")
async def delete_tenant(tenant_id: str):
tenant = _find_tenant(tenant_id)
if tenant is None:
raise HTTPException(
status_code=404, detail=f"Tenant '{tenant_id}' not found"
)
referencing = sorted(
{
detail.get("displayName") or detail.get("name") or detail["id"]
for detail in state.schema_details.values()
for tmpl in detail.get("templates", []) or []
if isinstance(tmpl, dict) and tmpl.get("tenantId") == tenant_id
}
)
if referencing:
raise HTTPException(
status_code=400,
detail=(
f"Tenant '{tenant_id}' is referenced by schema(s): "
f"{', '.join(referencing)} — delete those schemas first"
),
)
state.tenants.remove(tenant)
return {}
# 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
@@ -754,6 +791,34 @@ def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) ->
return detail
# F5: `cisco.mso.mso_schema` (state=absent) deletes a whole schema via
# `DELETE schemas/{id}` — previously unrouted (observed 405). Removes
# the full detail, its summary entry (whichever list holds it — seeded
# `state.schemas` or runtime `state.extra_schemas`) and its
# policy-states record, so GET /schemas, /schemas/list-identity and
# GET /schemas/{id} all stop reflecting it immediately. /mso prefix
# only — the schema CRUD surface (GET/POST/PATCH above) has no bare
# /api/v1 alias convention, unlike tenants/sites/templates.
#
# Deliberately NO cascade into the APIC deploy mirror: real NDO does
# not undeploy when a schema is deleted — objects already deployed to
# the sites' APICs are left orphaned there. Callers that want a clean
# teardown must undeploy first (`POST /mso/api/v1/task` with
# `undeploy: [siteId, ...]`), exactly as on real gear.
@app.delete("/mso/api/v1/schemas/{schema_id}")
async def delete_schema(schema_id: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
del state.schema_details[schema_id]
state.schemas[:] = [s for s in state.schemas if s["id"] != schema_id]
state.extra_schemas[:] = [
s for s in state.extra_schemas if s["id"] != schema_id
]
state.policy_states.pop(schema_id, None)
return {}
# ------------------------------------------------------------------
# State persistence (/_sim) — mirrors the APIC plane's admin router
# (aci_sim/control/admin.py), which the NDO app doesn't otherwise
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aci-sim"
version = "0.21.0"
version = "0.22.0"
description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
readme = "README.md"
license = "PolyForm-Noncommercial-1.0.0"
+220
View File
@@ -0,0 +1,220 @@
"""F5 — NDO DELETE endpoints: schemas and tenants.
`cisco.mso.mso_schema` / `cisco.mso.mso_tenant` with `state=absent` send
`DELETE /mso/api/v1/schemas/{id}` / `DELETE /mso/api/v1/tenants/{id}`;
before this fix neither route existed (observed 405), blocking E2E
baseline cleanup.
Semantics under test (aligned with real NDO 4.x):
- DELETE schema: 200 + gone from GET /schemas/{id} (404), the full list
and list-identity; unknown id -> 404.
- DELETE tenant: refused with 400 while any schema template's `tenantId`
still references it; 200 once the referencing schemas are deleted;
unknown id -> 404. Bare `/api/v1/tenants/{id}` alias works too
(same dual-shape pair as GET /tenants).
- Deleting a schema does NOT cascade into the APIC deploy mirror — real
NDO leaves already-deployed objects orphaned on the sites' APICs.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import pytest
from fastapi.testclient import TestClient
from aci_sim.mit.store import MITStore
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import NdoState
TENANT_ID = "tenant-ms-tn1-id"
TENANT_NAME = "ANS-MS_TN1"
SCHEMA_ID = "schema-del-1"
SCHEMA_NAME = "MS-TN1-LAB1"
TEMPLATE_NAME = "MS-TN1-template"
@dataclass
class _FakeApicState:
"""Minimal stand-in for rest_aci.app.ApicSiteState — the deploy mirror
only ever touches `.store` (same shim as tests/test_deploy_mirror.py)."""
store: MITStore = field(default_factory=MITStore)
def _build_state() -> NdoState:
"""One tenant, one schema whose single template references the tenant,
associated with site '1' so the deploy mirror has a target."""
template = {
"name": TEMPLATE_NAME,
"tenantId": TENANT_ID,
"vrfs": [{"name": "VRF1"}],
"bds": [],
"anps": [],
"contracts": [],
}
detail = {
"id": SCHEMA_ID,
"displayName": SCHEMA_NAME,
"name": SCHEMA_NAME,
"templates": [template],
"sites": [
{"siteId": "1", "templateName": TEMPLATE_NAME, "bds": [], "anps": []}
],
}
summary = {
"id": SCHEMA_ID,
"displayName": SCHEMA_NAME,
"name": SCHEMA_NAME,
"templates": [{"name": TEMPLATE_NAME}],
}
return NdoState(
sites=[],
tenants=[{"id": TENANT_ID, "name": TENANT_NAME, "displayName": TENANT_NAME}],
schemas=[summary],
schema_details={SCHEMA_ID: detail},
template_summaries=[],
tenant_policy_templates={},
fabric_connectivity={},
policy_states={SCHEMA_ID: [{"status": "synced", "drift": False}]},
audit_records=[],
)
@pytest.fixture()
def state():
return _build_state()
@pytest.fixture()
def client(state):
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# DELETE /mso/api/v1/schemas/{id}
# ---------------------------------------------------------------------------
def test_delete_schema_removes_from_all_read_paths(client):
assert client.get(f"/mso/api/v1/schemas/{SCHEMA_ID}").status_code == 200
resp = client.delete(f"/mso/api/v1/schemas/{SCHEMA_ID}")
assert resp.status_code == 200
# detail GET now 404s with the standard not-found body
resp = client.get(f"/mso/api/v1/schemas/{SCHEMA_ID}")
assert resp.status_code == 404
assert resp.json()["detail"] == f"Schema '{SCHEMA_ID}' not found"
# gone from the full list and from list-identity
full = client.get("/mso/api/v1/schemas").json()["schemas"]
assert all(s["id"] != SCHEMA_ID for s in full)
identity = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
assert all(s["id"] != SCHEMA_ID for s in identity)
def test_delete_schema_unknown_404(client):
resp = client.delete("/mso/api/v1/schemas/no-such-schema")
assert resp.status_code == 404
assert resp.json()["detail"] == "Schema 'no-such-schema' not found"
def test_delete_runtime_posted_schema(client):
"""A schema POSTed at runtime lands in `state.extra_schemas` — DELETE
must remove it from that list too (not just the seeded one)."""
resp = client.post(
"/mso/api/v1/schemas",
json={"displayName": "RUNTIME-SCHEMA", "templates": [], "sites": []},
)
assert resp.status_code == 200
new_id = resp.json()["id"]
assert client.delete(f"/mso/api/v1/schemas/{new_id}").status_code == 200
assert client.get(f"/mso/api/v1/schemas/{new_id}").status_code == 404
identity = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
assert all(s["id"] != new_id for s in identity)
def test_delete_schema_does_not_undeploy_mirrored_objects(state):
"""Real-NDO fidelity: deleting a schema does NOT undeploy — objects the
deploy mirror already materialized on a site's APIC stay orphaned."""
apic_states = {"1": _FakeApicState()}
app = make_ndo_app(state, apic_states)
with TestClient(app) as client:
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": SCHEMA_ID, "templateName": TEMPLATE_NAME},
)
assert resp.status_code == 200
store = apic_states["1"].store
ctx_dn = f"uni/tn-{TENANT_NAME}/ctx-VRF1"
assert store.get(ctx_dn) is not None
assert store.get(f"uni/tn-{TENANT_NAME}") is not None
assert client.delete(f"/mso/api/v1/schemas/{SCHEMA_ID}").status_code == 200
# schema is gone from NDO ...
assert client.get(f"/mso/api/v1/schemas/{SCHEMA_ID}").status_code == 404
# ... but the mirrored APIC objects survive (orphaned, like real gear)
assert store.get(ctx_dn) is not None
assert store.get(f"uni/tn-{TENANT_NAME}") is not None
# ---------------------------------------------------------------------------
# DELETE /mso/api/v1/tenants/{id} (+ bare /api/v1 alias)
# ---------------------------------------------------------------------------
def test_delete_tenant_referenced_by_schema_400(client):
resp = client.delete(f"/mso/api/v1/tenants/{TENANT_ID}")
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "referenced by schema(s)" in detail
assert SCHEMA_NAME in detail
# tenant must still be there
tenants = client.get("/mso/api/v1/tenants").json()["tenants"]
assert any(t["id"] == TENANT_ID for t in tenants)
def test_delete_tenant_after_schema_removed_200(client):
"""解除引用后删 — delete the referencing schema first, then the tenant."""
assert client.delete(f"/mso/api/v1/schemas/{SCHEMA_ID}").status_code == 200
resp = client.delete(f"/mso/api/v1/tenants/{TENANT_ID}")
assert resp.status_code == 200
tenants = client.get("/mso/api/v1/tenants").json()["tenants"]
assert all(t["id"] != TENANT_ID for t in tenants)
# bare-path enumeration reflects the same store
tenants_bare = client.get("/api/v1/tenants").json()["tenants"]
assert all(t["id"] != TENANT_ID for t in tenants_bare)
def test_delete_tenant_unknown_404(client):
resp = client.delete("/mso/api/v1/tenants/no-such-tenant")
assert resp.status_code == 404
assert resp.json()["detail"] == "Tenant 'no-such-tenant' not found"
def test_delete_tenant_bare_path_alias(client):
"""`delegate_to: localhost` cisco.mso tasks hit the un-prefixed
/api/v1 shape — same handler, same guard + delete semantics."""
# unreferenced tenant created at runtime
resp = client.post(
"/mso/api/v1/tenants",
json={"name": "SCRATCH-TN", "displayName": "SCRATCH-TN"},
)
assert resp.status_code == 200
new_id = resp.json()["id"]
# referenced tenant is refused on the bare path too
assert client.delete(f"/api/v1/tenants/{TENANT_ID}").status_code == 400
assert client.delete(f"/api/v1/tenants/{new_id}").status_code == 200
tenants = client.get("/mso/api/v1/tenants").json()["tenants"]
assert all(t["id"] != new_id for t in tenants)
# second delete -> 404 (it's really gone)
assert client.delete(f"/api/v1/tenants/{new_id}").status_code == 404