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) 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). (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 ## [0.24.0] - 2026-07-10
### Added ### Added
+10 -1
View File
@@ -201,8 +201,17 @@ def make_apic_app(state: ApicSiteState) -> FastAPI:
# below is unchanged from before this feature. # below is unchanged from before this feature.
effective_dn = (body[cls].get("attributes") or {}).get("dn") or dn effective_dn = (body[cls].get("attributes") or {}).get("dn") or dn
pre_existing = state.store.get(effective_dn) is not None 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: 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: except (WriteValidationError, AttributeError, TypeError, KeyError) as exc:
return _apic_error(f"Malformed MO body: {exc}", code="103", status_code=400) return _apic_error(f"Malformed MO body: {exc}", code="103", status_code=400)
# Push-on-change (subscriptions): notify after the store commit above. # 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) _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. """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 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, on any shape violation), builds the full ordered list of MOs to write,
and only then mutates the store. and only then mutates the store.
Returns the full ordered ``(class, attrs)`` plan so callers (``apply``) Returns the full ordered ``(class, attrs, status)`` plan so callers
can inspect it for reactions (e.g. a nested ``fabricNodeIdentP`` child) (``apply``) can inspect it for reactions (e.g. a nested
without re-walking the body themselves. ``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 # Re-wrap the already-parsed root attrs/children into the same node shape
# _validate_node expects, so root and descendants share one validation # _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 # Validation passed for the entire subtree — now, and only now, mutate
# the store (400 on validation failure => zero side effects). # the store (400 on validation failure => zero side effects).
results: list[tuple[str, dict, str]] = []
for mo_cls, mo_attrs in planned: 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 — # Real APIC commits a class's object defaults at CREATE time only —
# a later partial-update POST never resets an already-set attribute # 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 # 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) # 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 # 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. # layered on top of the defaults dict, so they always win.
dn = mo_attrs.get("dn") write_attrs = mo_attrs
if mo_attrs.get("status") != "deleted" and store.get(dn) is None: if mo_attrs.get("status") != "deleted" and existing is None:
mo_attrs = {**_CLASS_DEFAULTS.get(mo_cls, {}), **mo_attrs} write_attrs = {**_CLASS_DEFAULTS.get(mo_cls, {}), **mo_attrs}
store.upsert(MO(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( def materialize_node_registration(
@@ -548,7 +593,9 @@ def _materialize_l3out_bgp_session(
store.upsert(inst_mo) 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. """Apply a write (upsert or delete) from a POST /api/mo/{dn}.json body.
Body shape: {"<cls>": {"attributes": {...}, "children": [...]}} 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 *topo*/*site* are optional context needed by the fabricNodeIdentP
reaction (pod number, spine list for cabling) — passed through by the reaction (pod number, spine list for cabling) — passed through by the
caller the same way it already threads ``state.store`` here. 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: if not body:
return [], 0 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 effective_dn = attrs.get("dn") or dn
attrs["dn"] = effective_dn attrs["dn"] = effective_dn
# Perform the upsert/delete; get back the full ordered (class, attrs) # Perform the upsert/delete; get back the full ordered (class, attrs,
# plan so the fabricNodeIdentP reaction fires for a nested child too, # status) plan so the fabricNodeIdentP reaction fires for a nested
# not just when it's the top-level POSTed class (finding #19). # child too, not just when it's the top-level POSTed class (finding
planned = _upsert_recursive(store, cls, attrs, children) # #19), and so the changed-MO response (F8a) can be assembled below.
results = _upsert_recursive(store, cls, attrs, children)
if site is not None: if site is not None:
for mo_cls, mo_attrs in planned: for mo_cls, mo_attrs, _status in results:
if mo_cls != "fabricNodeIdentP": if mo_cls != "fabricNodeIdentP":
continue continue
if mo_attrs.get("status") == "deleted": 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 # table (routers/topology.py) finds it — see _materialize_l3out_bgp_session
# docstring. bgpAsP (the peer ASN) is POSTed as bgpPeerP's own nested # 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 # 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. # store lookup.
for mo_cls, mo_attrs in planned: for mo_cls, mo_attrs, _status in results:
if mo_cls != "bgpPeerP": if mo_cls != "bgpPeerP":
continue continue
if mo_attrs.get("status") == "deleted": 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: if not peer_dn or not addr:
continue continue
remote_asn = "" 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}/"): if as_cls == "bgpAsP" and as_attrs.get("dn", "").startswith(f"{peer_dn}/"):
remote_asn = as_attrs.get("asn", "") remote_asn = as_attrs.get("asn", "")
break break
_materialize_l3out_bgp_session(store, peer_dn, addr, remote_asn) _materialize_l3out_bgp_session(store, peer_dn, addr, remote_asn)
status = "deleted" if attrs.get("status") == "deleted" else "created" # F8a: build imdata from only the MOs that actually changed. Reactions
result = [{cls: {"attributes": {"dn": effective_dn, "status": status}}}] # (fabricNodeIdentP node materialization, bgpPeerP session
return result, 1 # 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] [project]
name = "aci-sim" 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)" description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
readme = "README.md" readme = "README.md"
license = "PolyForm-Noncommercial-1.0.0" 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"