From c2959ab2cb71ee8fe7b991bbee2d5958285872d6 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Fri, 10 Jul 2026 22:52:43 +1000 Subject: [PATCH] feat(rest-aci): faithful created/modified/unchanged write-status (F8a) + idempotent delete-of-missing (F1) (v0.25.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The APIC POST handler unconditionally stamped status:"created" on every write with no comparison to stored state. cisco.aci.aci_rest derives its "changed" result from a recursive deep-scan of the response for any status in {created,modified,deleted}, so every raw aci_rest-driven write reported changed=true forever — breaking pass2 idempotency across the L3Out topology (l3extLNodeP/l3extLIfP/l3extRsNodeL3OutAtt/l3extRsPathL3OutAtt/bgpPeerP/ bfdIfP), static-path, and PBR-redirect blocks. F8a computes a real per-MO status at the write choke point (writes._upsert_recursive) by diffing ONLY the caller's raw posted attrs (excluding dn/status, before the create-time _CLASS_DEFAULTS overlay) against the pre-existing stored MO. apply() builds imdata from only the changed MOs — empty imdata (+ totalCount "0") when nothing changed, which is the shape that makes aci_rest's changed()-scan return false. post_mo reads ?rsp-subtree and threads it through (modified/absent/full -> changed list; no -> empty). Higher-level cisco.aci modules were already idempotent (client-side get-diff) and are unaffected. The flat changed-MO response is changed()-scan faithful; real APIC's nested rsp-subtree wire shape is a documented deferred enhancement (no real-gear capture to reproduce it). F1 fold-in: deleting an already-absent DN now reports unchanged (store pop is a silent no-op) instead of deleted, matching real-APIC idempotent delete. Verification: full pytest 1143 passed (1131 baseline + 12 new F8a tests, zero regressions); independent opus review traced the posted-keys-only / pre-defaults diff invariant + dict-aliasing safety to ground truth (APPROVE); live E2E gate on the restarted sim — create_tenant SF-TN1 pass2 changed 6->0 (idempotent), pass1 still builds (changed=17), F10 bad-mtu 400 + good-mtu 200 intact. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1 --- CHANGELOG.md | 29 +++ aci_sim/rest_aci/app.py | 11 +- aci_sim/rest_aci/writes.py | 112 ++++++++-- pyproject.toml | 2 +- tests/test_f8a_changed_status.py | 339 +++++++++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 23 deletions(-) create mode 100644 tests/test_f8a_changed_status.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a73c510..3ed1678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ 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.25.0] - 2026-07-10 + +### Changed +- **APIC write-face changed-status fidelity (F8a)** — a POST now reports the + real per-MO status (`created` / `modified` / *unchanged*) instead of + unconditionally stamping `created`. `aci_sim/rest_aci/writes.py` diffs each + planned MO's **raw posted attributes** (excluding `dn`/`status`, before the + create-time `_CLASS_DEFAULTS` overlay) against the pre-existing stored MO at + the write choke point, and `apply()` builds `imdata` from **only the changed + MOs** — returning empty `imdata` (+ `totalCount "0"`) when nothing changed. + `post_mo` reads `?rsp-subtree` and threads it through (`modified`/absent/`full` + → changed-MO list; `no` → empty). This restores real-APIC idempotency for + every raw `aci_rest`-driven write: an unchanged re-push now yields + `changed=false` (previously `changed=true` forever), fixing pass2 idempotency + across the L3Out topology (`l3extLNodeP`/`l3extLIfP`/`l3extRsNodeL3OutAtt`/ + `l3extRsPathL3OutAtt`/`bgpPeerP`/`bfdIfP`), static-path, and PBR-redirect + blocks. Higher-level cisco.aci modules were already idempotent (client-side + get-diff) and are unaffected; the flat changed-MO response is `changed()`-scan + faithful (real APIC's nested `rsp-subtree` wire shape is a documented deferred + enhancement — no real-gear capture available to reproduce it accurately). +- **Idempotent delete-of-missing (F1)** — POSTing `status:"deleted"` to an + already-absent DN now reports *unchanged* (empty `imdata`) instead of + `deleted`, matching real APIC: `store.upsert` pops a missing DN as a silent + no-op, so nothing changed. A delete that actually removes an existing MO still + reports `deleted`. + +12 tests (re-push idempotency, defaults-overlay guard, partial re-push, + parent-unchanged/child-changed, status-pollution re-push, delete-of-missing, + `rsp-subtree=no`). + ## [0.24.0] - 2026-07-10 ### Added diff --git a/aci_sim/rest_aci/app.py b/aci_sim/rest_aci/app.py index e4fa71a..41cbac7 100644 --- a/aci_sim/rest_aci/app.py +++ b/aci_sim/rest_aci/app.py @@ -201,8 +201,17 @@ def make_apic_app(state: ApicSiteState) -> FastAPI: # below is unchanged from before this feature. effective_dn = (body[cls].get("attributes") or {}).get("dn") or dn pre_existing = state.store.get(effective_dn) is not None + # F8a: cisco.aci.aci_rest appends ?rsp-subtree=modified to every + # non-GET by default (rsp_subtree_preserve=false); thread the raw + # value through so write_apply can decide what to include in the + # response ({modified,absent,full} -> changed-MO list, "no" -> []). + # The changed-status computation itself is unconditional — this + # param only gates response inclusion, never the diff. + rsp_subtree = request.query_params.get("rsp-subtree") try: - imdata, total = write_apply(state.store, dn, body, topo=state.topo, site=state.site) + imdata, total = write_apply( + state.store, dn, body, topo=state.topo, site=state.site, rsp_subtree=rsp_subtree + ) except (WriteValidationError, AttributeError, TypeError, KeyError) as exc: return _apic_error(f"Malformed MO body: {exc}", code="103", status_code=400) # Push-on-change (subscriptions): notify after the store commit above. diff --git a/aci_sim/rest_aci/writes.py b/aci_sim/rest_aci/writes.py index 8c2aff9..08d39cb 100644 --- a/aci_sim/rest_aci/writes.py +++ b/aci_sim/rest_aci/writes.py @@ -263,7 +263,7 @@ def _plan_recursive(cls: str, attrs: dict, children: list, planned: list[tuple[s _plan_recursive(child_cls, child_attrs, child_children, planned) -def _upsert_recursive(store: MITStore, cls: str, attrs: dict, children: list) -> list[tuple[str, dict]]: +def _upsert_recursive(store: MITStore, cls: str, attrs: dict, children: list) -> list[tuple[str, dict, str]]: """Validate the entire body shape, then upsert the MO and its children. Real APIC POST is all-or-nothing: a malformed descendant must not leave @@ -272,9 +272,31 @@ def _upsert_recursive(store: MITStore, cls: str, attrs: dict, children: list) -> on any shape violation), builds the full ordered list of MOs to write, and only then mutates the store. - Returns the full ordered ``(class, attrs)`` plan so callers (``apply``) - can inspect it for reactions (e.g. a nested ``fabricNodeIdentP`` child) - without re-walking the body themselves. + Returns the full ordered ``(class, attrs, status)`` plan so callers + (``apply``) can inspect it for reactions (e.g. a nested + ``fabricNodeIdentP`` child) and for changed-MO response assembly + (F8a), without re-walking the body themselves. ``attrs`` is always the + RAW posted attrs (never the defaults-merged copy — see below), matching + the pre-F8a contract that reaction code already relies on. + + ``status`` (F8a) is one of "created" / "modified" / "deleted" / + "unchanged", computed per-MO against the pre-existing stored MO: + + - a posted status="deleted" -> "deleted"; + - dn not yet in the store -> "created"; + - else diff ONLY the user-posted keys (excluding "dn" — identity, + always equal by construction — and "status" — a control attr, not + real config) against the stored MO's attrs; any difference -> + "modified", else "unchanged". + + The diff MUST run on the raw posted ``mo_attrs`` from *before* any + ``_CLASS_DEFAULTS`` overlay: the store accumulates create-time defaults + (e.g. fvBD's mac/mtu/...) that were never part of what the caller + posted, so diffing against the full stored attr set would spuriously + report "modified" on a byte-identical re-push of a class with defaults. + Because the sim stores values verbatim (no normalization), a + byte-identical re-push always yields stored == posted for every posted + key -> "unchanged". """ # Re-wrap the already-parsed root attrs/children into the same node shape # _validate_node expects, so root and descendants share one validation @@ -288,19 +310,42 @@ def _upsert_recursive(store: MITStore, cls: str, attrs: dict, children: list) -> # Validation passed for the entire subtree — now, and only now, mutate # the store (400 on validation failure => zero side effects). + results: list[tuple[str, dict, str]] = [] for mo_cls, mo_attrs in planned: + dn = mo_attrs.get("dn") + # F8a: look up the pre-existing MO once, BEFORE any mutation, and + # use it both for the defaults-overlay decision (pre-existing + # behavior, unchanged) and for the changed-status diff (new). The + # diff uses ONLY the raw posted mo_attrs, computed here before the + # defaults overlay is applied to the local write-time copy below. + existing = store.get(dn) + if mo_attrs.get("status") == "deleted": + # F8a: deleting an already-absent DN is a store no-op + # (store.upsert pops a missing DN silently), so nothing + # changed -> report "unchanged", matching real APIC's + # idempotent delete. Only a delete that actually removes an + # existing MO reports "deleted". + status = "deleted" if existing is not None else "unchanged" + elif existing is None: + status = "created" + else: + posted = {k: v for k, v in mo_attrs.items() if k not in ("dn", "status")} + status = "modified" if any(existing.attrs.get(k) != v for k, v in posted.items()) else "unchanged" + # Real APIC commits a class's object defaults at CREATE time only — # a later partial-update POST never resets an already-set attribute # back to its default. So the overlay applies iff (a) this isn't a # delete (no defaults that could resurrect a deleted object's attrs) # and (b) the DN doesn't exist yet in the store. Posted attrs are # layered on top of the defaults dict, so they always win. - dn = mo_attrs.get("dn") - if mo_attrs.get("status") != "deleted" and store.get(dn) is None: - mo_attrs = {**_CLASS_DEFAULTS.get(mo_cls, {}), **mo_attrs} - store.upsert(MO(mo_cls, **mo_attrs)) + write_attrs = mo_attrs + if mo_attrs.get("status") != "deleted" and existing is None: + write_attrs = {**_CLASS_DEFAULTS.get(mo_cls, {}), **mo_attrs} + store.upsert(MO(mo_cls, **write_attrs)) - return planned + results.append((mo_cls, mo_attrs, status)) + + return results def materialize_node_registration( @@ -548,7 +593,9 @@ def _materialize_l3out_bgp_session( store.upsert(inst_mo) -def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tuple[list[dict], int]: +def apply( + store: MITStore, dn: str, body: dict, *, topo=None, site=None, rsp_subtree: str | None = None +) -> tuple[list[dict], int]: """Apply a write (upsert or delete) from a POST /api/mo/{dn}.json body. Body shape: {"": {"attributes": {...}, "children": [...]}} @@ -557,6 +604,18 @@ def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tupl *topo*/*site* are optional context needed by the fabricNodeIdentP reaction (pod number, spine list for cabling) — passed through by the caller the same way it already threads ``state.store`` here. + + *rsp_subtree* (F8a) is the raw ``?rsp-subtree=`` query value (or + ``None`` if absent). ``cisco.aci.aci_rest`` sends + ``?rsp-subtree=modified`` on every non-GET by default; its ``changed()`` + check is a recursive scan for any ``status`` in + {created,modified,deleted} anywhere in ``imdata`` — so real idempotency + requires the response to be genuinely empty when nothing changed. + {"modified", None, "full"} all map to "return the changed-MO list, [] + if none changed"; "no" maps to "return []" unconditionally (matching + real APIC's contract of echoing nothing under rsp-subtree=no). The + status computation itself always runs, independent of this param — the + param only gates what's included in the response. """ if not body: return [], 0 @@ -571,13 +630,14 @@ def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tupl effective_dn = attrs.get("dn") or dn attrs["dn"] = effective_dn - # Perform the upsert/delete; get back the full ordered (class, attrs) - # plan so the fabricNodeIdentP reaction fires for a nested child too, - # not just when it's the top-level POSTed class (finding #19). - planned = _upsert_recursive(store, cls, attrs, children) + # Perform the upsert/delete; get back the full ordered (class, attrs, + # status) plan so the fabricNodeIdentP reaction fires for a nested + # child too, not just when it's the top-level POSTed class (finding + # #19), and so the changed-MO response (F8a) can be assembled below. + results = _upsert_recursive(store, cls, attrs, children) if site is not None: - for mo_cls, mo_attrs in planned: + for mo_cls, mo_attrs, _status in results: if mo_cls != "fabricNodeIdentP": continue if mo_attrs.get("status") == "deleted": @@ -598,9 +658,9 @@ def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tupl # table (routers/topology.py) finds it — see _materialize_l3out_bgp_session # docstring. bgpAsP (the peer ASN) is POSTed as bgpPeerP's own nested # child in the SAME body (cisco.aci's tenant.yml task), so it is already - # in this same `planned` list — read it from there instead of a second + # in this same `results` list — read it from there instead of a second # store lookup. - for mo_cls, mo_attrs in planned: + for mo_cls, mo_attrs, _status in results: if mo_cls != "bgpPeerP": continue if mo_attrs.get("status") == "deleted": @@ -610,12 +670,22 @@ def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tupl if not peer_dn or not addr: continue remote_asn = "" - for as_cls, as_attrs in planned: + for as_cls, as_attrs, _as_status in results: if as_cls == "bgpAsP" and as_attrs.get("dn", "").startswith(f"{peer_dn}/"): remote_asn = as_attrs.get("asn", "") break _materialize_l3out_bgp_session(store, peer_dn, addr, remote_asn) - status = "deleted" if attrs.get("status") == "deleted" else "created" - result = [{cls: {"attributes": {"dn": effective_dn, "status": status}}}] - return result, 1 + # F8a: build imdata from only the MOs that actually changed. Reactions + # (fabricNodeIdentP node materialization, bgpPeerP session + # materialization) upsert sim-internal MOs that were never part of + # `results` — correctly excluded here, same as before F8a. + if rsp_subtree == "no": + return [], 0 + + imdata = [ + {mo_cls: {"attributes": {"dn": mo_attrs["dn"], "status": st}}} + for mo_cls, mo_attrs, st in results + if st in ("created", "modified", "deleted") + ] + return imdata, len(imdata) diff --git a/pyproject.toml b/pyproject.toml index 35fa625..5089c65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aci-sim" -version = "0.24.0" +version = "0.25.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_f8a_changed_status.py b/tests/test_f8a_changed_status.py new file mode 100644 index 0000000..237a673 --- /dev/null +++ b/tests/test_f8a_changed_status.py @@ -0,0 +1,339 @@ +"""Regression tests for F8a: real created/modified/unchanged write-status. + +Before F8a, ``writes.py::apply()`` hard-coded ``status="created"`` on every +non-delete write with no comparison against stored state. +``cisco.aci.aci_rest`` derives its ``changed`` result from a recursive scan +of the response ``imdata`` for any ``status`` in +{created,modified,deleted} at any depth — so every aci_rest-driven write +reported ``changed=true`` forever, breaking pass2 idempotency. + +F8a computes a real per-MO status at the write choke point +(``writes._upsert_recursive``) by diffing the caller's RAW posted attrs +(excluding "dn"/"status") against the pre-existing stored MO, and +``apply()`` now builds ``imdata`` from ONLY the changed MOs — empty +imdata (+ totalCount "0") when nothing changed. + +These tests assert on the HTTP response shape the same way +``tests/test_pr9_ansible.py`` and ``tests/test_subscriptions.py`` already +do (no cisco.aci module is available in this environment to call its +``changed()`` directly) — an empty ``imdata`` is the shape that makes +``aci_rest``'s recursive status-scan return False (see +``_F8A_CHANGED_STATUS_DESIGN.md`` §2.2), and a non-empty ``imdata`` with a +``status`` in {created,modified,deleted} anywhere is the shape that makes +it return True. +""" + +from __future__ import annotations + +import copy + +from fastapi.testclient import TestClient + +from aci_sim.build.orchestrator import build_site +from aci_sim.rest_aci.app import ApicSiteState, make_apic_app +from aci_sim.topology.loader import load_topology + +TOPO_PATH = "topology.yaml" + + +def _new_client() -> TestClient: + topo = load_topology(TOPO_PATH) + site = topo.sites[0] + store = build_site(topo, site) + state = ApicSiteState( + name=site.name, + site=site, + topo=topo, + store=store, + baseline=copy.deepcopy(store), + ) + return TestClient(make_apic_app(state)) + + +def _logged_in_client() -> TestClient: + c = _new_client() + resp = c.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + assert resp.status_code == 200 + return c + + +# --------------------------------------------------------------------------- +# 1. Byte-identical re-push -> empty imdata (the core idempotency fix) +# --------------------------------------------------------------------------- + + +def test_repush_identical_mo_is_unchanged(): + c = _logged_in_client() + body = { + "fvTenant": { + "attributes": {"name": "F8AProbe1", "dn": "uni/tn-F8AProbe1", "descr": "probe"}, + } + } + first = c.post("/api/mo/uni/tn-F8AProbe1.json?rsp-subtree=modified", json=body) + assert first.status_code == 200 + assert first.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created" + + second = c.post("/api/mo/uni/tn-F8AProbe1.json?rsp-subtree=modified", json=body) + assert second.status_code == 200 + data = second.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0" + + +# --------------------------------------------------------------------------- +# 2. Re-push with one attr changed -> status=="modified" +# --------------------------------------------------------------------------- + + +def test_repush_with_changed_attr_reports_modified(): + c = _logged_in_client() + c.post( + "/api/mo/uni/tn-F8AProbe2.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AProbe2", "dn": "uni/tn-F8AProbe2", "descr": "before"}}}, + ) + resp = c.post( + "/api/mo/uni/tn-F8AProbe2.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AProbe2", "dn": "uni/tn-F8AProbe2", "descr": "after"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "1" + entry = data["imdata"][0]["fvTenant"]["attributes"] + assert entry["status"] == "modified" + assert entry["dn"] == "uni/tn-F8AProbe2" + + +# --------------------------------------------------------------------------- +# 3. First create -> status=="created" (guard against over-eager unchanged) +# --------------------------------------------------------------------------- + + +def test_first_create_reports_created(): + c = _logged_in_client() + resp = c.post( + "/api/mo/uni/tn-F8AProbe3.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AProbe3", "dn": "uni/tn-F8AProbe3"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "1" + assert data["imdata"][0]["fvTenant"]["attributes"]["status"] == "created" + + +# --------------------------------------------------------------------------- +# 4. Partial re-push (subset of original attrs, same values) -> unchanged +# --------------------------------------------------------------------------- + + +def test_partial_repush_same_values_is_unchanged(): + c = _logged_in_client() + c.post( + "/api/mo/uni/tn-F8AProbe4.json?rsp-subtree=modified", + json={ + "fvTenant": { + "attributes": {"name": "F8AProbe4", "dn": "uni/tn-F8AProbe4", "descr": "kept-in-store"} + } + }, + ) + # Re-push omits "descr" entirely (subset of the original posted attrs), + # but "name" (the only key still posted, besides dn) has the same value. + # The stored "descr" (never re-posted) must NOT be read/diffed here — + # only posted keys are compared. + resp = c.post( + "/api/mo/uni/tn-F8AProbe4.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AProbe4", "dn": "uni/tn-F8AProbe4"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0" + # The store still has the original descr (upsert never removes keys). + get = c.get("/api/mo/uni/tn-F8AProbe4.json") + assert get.json()["imdata"][0]["fvTenant"]["attributes"]["descr"] == "kept-in-store" + + +# --------------------------------------------------------------------------- +# 5. THE critical defaults-overlay guard: create-then-repush of a class WITH +# _CLASS_DEFAULTS (fvBD) must stay unchanged, not spuriously "modified" +# because the diff would otherwise see create-time defaults (mac/mtu/...) +# the caller never posted. +# --------------------------------------------------------------------------- + + +def test_repush_class_with_defaults_fvbd_is_unchanged(): + c = _logged_in_client() + body = { + "fvTenant": { + "attributes": {"name": "F8AProbe5", "dn": "uni/tn-F8AProbe5"}, + "children": [{"fvBD": {"attributes": {"name": "bd1"}}}], + } + } + first = c.post("/api/mo/uni/tn-F8AProbe5.json?rsp-subtree=modified", json=body) + assert first.status_code == 200 + # Sanity: fvBD's create-time defaults (mac/mtu/...) were actually applied + # to the store, so this test would catch a regression that silently + # dropped _CLASS_DEFAULTS instead of merely excluding it from the diff. + bd_get = c.get("/api/mo/uni/tn-F8AProbe5/BD-bd1.json") + bd_attrs = bd_get.json()["imdata"][0]["fvBD"]["attributes"] + assert bd_attrs["mac"] == "00:22:BD:F8:19:FF" + assert bd_attrs["mtu"] == "9000" + + # Re-push the EXACT same body (fvTenant unchanged, fvBD posted with only + # "name" — never mac/mtu, which only exist in the store as defaults). + second = c.post("/api/mo/uni/tn-F8AProbe5.json?rsp-subtree=modified", json=body) + assert second.status_code == 200 + data = second.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0" + + +# --------------------------------------------------------------------------- +# 6. Parent unchanged + child attr changed -> imdata contains the child, +# labelled "modified"; the unchanged parent is NOT in imdata. +# --------------------------------------------------------------------------- + + +def test_child_modified_parent_unchanged_included_in_imdata(): + c = _logged_in_client() + c.post( + "/api/mo/uni/tn-F8AProbe6.json?rsp-subtree=modified", + json={ + "fvTenant": { + "attributes": {"name": "F8AProbe6", "dn": "uni/tn-F8AProbe6"}, + "children": [{"fvBD": {"attributes": {"name": "bd1", "descr": "before"}}}], + } + }, + ) + resp = c.post( + "/api/mo/uni/tn-F8AProbe6.json?rsp-subtree=modified", + json={ + "fvTenant": { + "attributes": {"name": "F8AProbe6", "dn": "uni/tn-F8AProbe6"}, + "children": [{"fvBD": {"attributes": {"name": "bd1", "descr": "after"}}}], + } + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "1" + entry = data["imdata"][0] + assert "fvBD" in entry + assert entry["fvBD"]["attributes"]["status"] == "modified" + assert entry["fvBD"]["attributes"]["dn"] == "uni/tn-F8AProbe6/BD-bd1" + # The unchanged fvTenant parent must not appear anywhere in imdata. + assert not any("fvTenant" in e for e in data["imdata"]) + + +# --------------------------------------------------------------------------- +# 7. Delete still reports "deleted" (unchanged behavior) +# --------------------------------------------------------------------------- + + +def test_delete_still_reports_deleted(): + c = _logged_in_client() + c.post( + "/api/mo/uni/tn-F8AProbe7.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AProbe7", "dn": "uni/tn-F8AProbe7"}}}, + ) + resp = c.post( + "/api/mo/uni/tn-F8AProbe7.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AProbe7", "dn": "uni/tn-F8AProbe7", "status": "deleted"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "1" + assert data["imdata"][0]["fvTenant"]["attributes"]["status"] == "deleted" + assert c.get("/api/mo/uni/tn-F8AProbe7.json").json()["totalCount"] == "0" + + +# --------------------------------------------------------------------------- +# 8. No-param path guard: ?rsp-subtree absent still returns "created" on +# first create (this is also the path every existing pre-F8a test in +# this repo already exercises — none of them append ?rsp-subtree). +# --------------------------------------------------------------------------- + + +def test_rsp_subtree_absent_first_create_still_created(): + c = _logged_in_client() + resp = c.post( + "/api/mo/uni/tn-F8AProbe8.json", + json={"fvTenant": {"attributes": {"name": "F8AProbe8", "dn": "uni/tn-F8AProbe8"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "1" + assert data["imdata"][0]["fvTenant"]["attributes"]["status"] == "created" + + +# --------------------------------------------------------------------------- +# 9. Delete of an already-absent DN -> unchanged (F1 fold-in; store pop is a +# no-op, so real APIC reports idempotent no-change). +# --------------------------------------------------------------------------- + + +def test_delete_of_missing_dn_is_unchanged(): + c = _logged_in_client() + resp = c.post( + "/api/mo/uni/tn-F8ANeverExisted.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8ANeverExisted", "dn": "uni/tn-F8ANeverExisted", "status": "deleted"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0" + + +def test_delete_of_existing_dn_reports_deleted(): + c = _logged_in_client() + c.post( + "/api/mo/uni/tn-F8AToDelete.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AToDelete", "dn": "uni/tn-F8AToDelete"}}}, + ) + resp = c.post( + "/api/mo/uni/tn-F8AToDelete.json?rsp-subtree=modified", + json={"fvTenant": {"attributes": {"name": "F8AToDelete", "dn": "uni/tn-F8AToDelete", "status": "deleted"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "1" + assert data["imdata"][0]["fvTenant"]["attributes"]["status"] == "deleted" + + +# --------------------------------------------------------------------------- +# 10. status-pollution on RE-PUSH: posting status="created,modified" a second +# time must NOT force a false "modified" (status excluded from the diff). +# --------------------------------------------------------------------------- + + +def test_repush_status_pollution_is_unchanged(): + c = _logged_in_client() + body = {"fvTenant": {"attributes": {"name": "F8APoll", "dn": "uni/tn-F8APoll", "status": "created,modified", "descr": "x"}}} + first = c.post("/api/mo/uni/tn-F8APoll.json?rsp-subtree=modified", json=body) + assert first.status_code == 200 + assert first.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created" + second = c.post("/api/mo/uni/tn-F8APoll.json?rsp-subtree=modified", json=body) + assert second.status_code == 200 + data = second.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0" + + +# --------------------------------------------------------------------------- +# 11. ?rsp-subtree=no -> empty imdata even on a genuine create (matches real +# APIC, which echoes nothing when not asked for the subtree). +# --------------------------------------------------------------------------- + + +def test_rsp_subtree_no_returns_empty_on_create(): + c = _logged_in_client() + resp = c.post( + "/api/mo/uni/tn-F8ANoSubtree.json?rsp-subtree=no", + json={"fvTenant": {"attributes": {"name": "F8ANoSubtree", "dn": "uni/tn-F8ANoSubtree"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0"