mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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:
co-authored by
Claude Opus 4.8
parent
694454d9fc
commit
c2959ab2cb
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user