feat(rest-aci): faithful created/modified/unchanged write-status (F8a) + idempotent delete-of-missing (F1) (v0.25.0)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1
This commit is contained in:
dtzp555-max
2026-07-10 22:52:43 +10:00
co-authored by Claude Opus 4.8
parent 694454d9fc
commit c2959ab2cb
5 changed files with 470 additions and 23 deletions
+29
View File
@@ -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
+10 -1
View File
@@ -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.
+91 -21
View File
@@ -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: {"<cls>": {"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)
+1 -1
View File
@@ -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"
+339
View File
@@ -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"