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
+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