From ffaf9964c2e55eff77d04807bbbe9bb3bcccdd8a Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Wed, 8 Jul 2026 10:57:31 +1000 Subject: [PATCH] feat(ndo): DELETE endpoints for schemas and tenants (v0.22.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1 --- CHANGELOG.md | 18 ++++ aci_sim/ndo/app.py | 65 ++++++++++++ pyproject.toml | 2 +- tests/test_ndo_delete.py | 220 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/test_ndo_delete.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ce1d4e9..0ac968d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/aci_sim/ndo/app.py b/aci_sim/ndo/app.py index 5f3d8dd..521f52d 100644 --- a/aci_sim/ndo/app.py +++ b/aci_sim/ndo/app.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 83db37e..45a2271 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_ndo_delete.py b/tests/test_ndo_delete.py new file mode 100644 index 0000000..1895f44 --- /dev/null +++ b/tests/test_ndo_delete.py @@ -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