Files
dtzp555-maxandClaude Opus 4.8 c2959ab2cb 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
2026-07-10 22:52:43 +10:00

692 lines
31 KiB
Python

"""Write handler for POST /api/mo/{dn}.json."""
from __future__ import annotations
import re
from aci_sim.build.fabric import loopback_ip, oob_ip
from aci_sim.build.interfaces import _HOST_PORTS, _UPLINK_START, _add_port
from aci_sim.build.neighbors import add_switch_adjacency
from aci_sim.mit.dn import pod_from_dn
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.validators import RULES
from aci_sim.topology.schema import Node
# Class -> RN template for children POSTed without an explicit ``dn``.
#
# Real APIC derives a child's RN from the class's RN *prefix*, not from the
# class name itself (see aci_sim/mit/dn.py's dn_class_hint, which maps
# the same prefixes back to classes for the read path). Templates below are
# taken verbatim from the DNs the builders emit (aci_sim/build/*.py),
# so POSTed objects land on the same canonical DN as builder-created ones:
#
# fvBD -> BD-{name} (build/tenants.py:_build_bd, dn=f"uni/tn-{t}/BD-{bd.name}")
# fvAp -> ap-{name} (build/tenants.py:_build_tenant, dn=f"uni/tn-{t}/ap-{ap.name}")
# fvAEPg -> epg-{name} (build/tenants.py:_build_epg, dn=.../ap-{ap_name}/epg-{epg.name})
# fvCtx -> ctx-{name} (build/tenants.py:_build_tenant, dn=f"uni/tn-{t}/ctx-{vrf.name}")
# fvSubnet -> subnet-[{ip}] (build/tenants.py:_build_bd, dn=.../BD-{bd.name}/subnet-[{cidr}])
# fvRsCtx -> rsctx (build/tenants.py:_build_bd, dn=.../BD-{bd.name}/rsctx, fixed RN)
# fvRsBd -> rsbd (build/tenants.py:_build_epg, dn=.../epg-{epg.name}/rsbd, fixed RN)
# fvRsBDToOut -> rsBDToOut-{tnL3extOutName}
# (build/tenants.py:_build_bd, dn=.../rsBDToOut-{l3out.name})
# fvRsDomAtt -> rsdomAtt-[{tDn}] (build/tenants.py:_build_epg, dn=.../rsdomAtt-[uni/phys-{phys_dom}])
# fvRsProv -> rsprov-{tnVzBrCPName}
# (build/tenants.py:_build_epg, dn=.../rsprov-{c})
# fvRsCons -> rscons-{tnVzBrCPName}
# (build/tenants.py:_build_epg, dn=.../rscons-{c})
# vzBrCP -> brc-{name} (build/tenants.py:_build_contract, dn=f"uni/tn-{t}/brc-{contract.name}")
# vzSubj -> subj-{name} (build/tenants.py:_build_contract, dn=.../brc-{name}/subj-{name})
# vzRsSubjFiltAtt -> rssubjFiltAtt-{tnVzFilterName}
# (build/tenants.py:_build_contract, dn=.../rssubjFiltAtt-{flt.name})
# vzFilter -> flt-{name} (build/tenants.py:_build_filter, dn=f"uni/tn-{t}/flt-{flt.name}")
# vzEntry -> e-{name} (build/tenants.py:_build_filter, dn=.../flt-{name}/e-{ename})
#
# Batch-1 additions (RN schemes taken verbatim from the same builders that
# now emit these classes — see build/access.py + build/l3out.py + build/
# tenants.py docstrings for the full DN derivation/attribute-source notes):
# infraAccPortP -> accportprof-{name} (build/access.py:_build_leaf_profile)
# infraHPortS -> hports-{name}-typ-range (build/access.py:_build_hports)
# infraPortBlk -> portblk-block1 (build/access.py:_build_hports; fixed —
# one block per selector in this sim)
# infraRsAccBaseGrp -> rsaccBaseGrp (build/access.py:_build_hports, fixed RN)
# infraAccBndlGrp -> accbundle-{name} (build/access.py:_build_bndl_grp)
# rtctrlProfile -> prof-{name} (build/l3out.py:_build_route_control)
# rtctrlCtxP -> ctx-{name} (build/l3out.py:_build_route_control)
# rtctrlSubjP -> subj-{name} (build/l3out.py:_build_tenant_subj)
# rtctrlMatchRtDest -> dest-[{ip}] (build/l3out.py:_build_tenant_subj)
# ipRouteP -> rt-[{ip}] (build/l3out.py:_build_node_profile)
# ipNexthopP -> nh-[{nhAddr}] (build/l3out.py:_build_node_profile)
# l3extMember -> mem-A / mem-B (build/l3out.py:_build_node_profile; side
# picks the RN suffix directly, not a name/attr)
# ospfExtP -> ospfExtP (build/l3out.py:_build_l3out_tree, fixed RN)
# fvRsPathAtt -> rspathAtt-[{tDn}] (build/tenants.py:_build_epg)
# vzRsSubjGraphAtt -> rsSubjGraphAtt (build/tenants.py:_build_contract, fixed RN)
#
# Batch-2 addition — fabricNodeIdentP, the ONE batch-2 class an actual Fabric
# Build playbook POSTs (CONTRACT.md §3 "Node registration reaction":
# initial_fabric_discovery/add_leaf_switch_pair/add_spine_switch all POST
# fabricNodeIdentP; ansible's aci_fabric_node module keys the object on the
# node id, matching both the RN template's "nodeId" attribute AND the real
# ACI RN uni/controller/nodeidentpol/nodep-{id} — see build/fabric.py's
# boot-seeding docstring, which seeds the SAME nodep-{id} scheme so a
# boot-seeded registration and a later POSTed one always land on the same DN
# instead of silently diverging into nodep-{id} vs nodep-{serial}):
# fabricNodeIdentP -> nodep-{nodeId} (build/fabric.py:build, dn=f"uni/controller/
# nodeidentpol/nodep-{node.id}")
#
# Each entry is (attribute-to-read-the-value-from, rn-format). A rn-format of
# None means the RN is fixed (no suffix, no attribute lookup).
_RN_TEMPLATES: dict[str, tuple[str | None, str]] = {
"fvBD": ("name", "BD-{}"),
"fvAp": ("name", "ap-{}"),
"fvAEPg": ("name", "epg-{}"),
"fvCtx": ("name", "ctx-{}"),
"fvSubnet": ("ip", "subnet-[{}]"),
"fvRsCtx": (None, "rsctx"),
"fvRsBd": (None, "rsbd"),
"fvRsBDToOut": ("tnL3extOutName", "rsBDToOut-{}"),
"fvRsDomAtt": ("tDn", "rsdomAtt-[{}]"),
"fvRsProv": ("tnVzBrCPName", "rsprov-{}"),
"fvRsCons": ("tnVzBrCPName", "rscons-{}"),
"vzBrCP": ("name", "brc-{}"),
"vzSubj": ("name", "subj-{}"),
"vzRsSubjFiltAtt": ("tnVzFilterName", "rssubjFiltAtt-{}"),
"vzFilter": ("name", "flt-{}"),
"vzEntry": ("name", "e-{}"),
"infraAccPortP": ("name", "accportprof-{}"),
"infraHPortS": ("name", "hports-{}-typ-range"),
"infraPortBlk": (None, "portblk-block1"),
"infraRsAccBaseGrp": (None, "rsaccBaseGrp"),
"infraAccBndlGrp": ("name", "accbundle-{}"),
"rtctrlProfile": ("name", "prof-{}"),
"rtctrlCtxP": ("name", "ctx-{}"),
"rtctrlSubjP": ("name", "subj-{}"),
"rtctrlMatchRtDest": ("ip", "dest-[{}]"),
"ipRouteP": ("ip", "rt-[{}]"),
"ipNexthopP": ("nhAddr", "nh-[{}]"),
"l3extMember": ("side", "mem-{}"),
"ospfExtP": (None, "ospfExtP"),
"fvRsPathAtt": ("tDn", "rspathAtt-[{}]"),
"vzRsSubjGraphAtt": (None, "rsSubjGraphAtt"),
"fabricNodeIdentP": ("nodeId", "nodep-{}"),
}
#: Class-keyed default attribute sets, filled ONLY on CREATE (matching real
#: APIC, which commits object defaults at create time; posted attrs always win).
#: Scoped to fvBD — closes the "BD host_route / EP-move show empty" fidelity gap.
_CLASS_DEFAULTS: dict[str, dict[str, str]] = {
"fvBD": {
"mac": "00:22:BD:F8:19:FF",
"type": "regular",
"arpFlood": "no",
"unkMacUcastAct": "proxy",
"unkMcastAct": "flood",
"v6unkMcastAct": "nd",
"multiDstPktAct": "bd-flood",
"epMoveDetectMode": "", # real create-default: GARP detection OFF
"hostBasedRouting": "no",
"limitIpLearnToSubnets": "yes",
"ipLearning": "yes",
"unicastRoute": "yes",
"intersiteBumTrafficAllow": "no",
"mtu": "9000",
},
}
class WriteValidationError(ValueError):
"""Raised when a POST body fails shape validation before any store mutation."""
def _child_dn(parent_dn: str, cls: str, attrs: dict, index: int) -> str:
"""Derive a non-empty, correctly-parented DN for a child that omits ``dn``.
ACI POST bodies may nest children without an explicit dn. Storing them at
dn="" clobbers (last-writer-wins). Real APIC derives the RN from the
class's RN *prefix*, not from the class name, so known classes use the
canonical template harvested from the builders (see ``_RN_TEMPLATES``
above) — this keeps POSTed objects on the same DN as builder-created ones
(e.g. fvBD -> "BD-{name}", not "fvBD-{name}").
"""
dn = attrs.get("dn")
if dn:
return dn
template = _RN_TEMPLATES.get(cls)
if template is not None:
attr_name, rn_format = template
if attr_name is None:
# Fixed RN (e.g. fvRsCtx -> "rsctx"): no per-instance suffix at all.
rn = rn_format
else:
value = str(attrs.get(attr_name) or index)
rn = rn_format.format(value)
return f"{parent_dn}/{rn}"
# Fallback for classes not covered by _RN_TEMPLATES: best-effort
# '{cls}-{name}' heuristic. This does NOT match real APIC's RN-prefix
# scheme, so it may not round-trip with a canonical GET; kept only so
# unknown/unlisted classes still get a stable, non-clobbering DN.
name = (
attrs.get("name")
or attrs.get("ip")
or attrs.get("tDn")
or attrs.get("tnFvBDName")
or str(index)
)
rn = f"[{name}]" if ("/" in name or "[" in name) else name
return f"{parent_dn}/{cls}-{rn}"
def _validate_node(cls: str, body: dict, path: str) -> None:
"""Validate one {className: {"attributes": dict, "children"?: list}} node.
Raises WriteValidationError with a descriptive message on any shape
violation. Does not touch the store — pure validation.
"""
if not isinstance(body, dict):
raise WriteValidationError(f"{path}: expected an object body for class {cls!r}, got {type(body).__name__}")
attributes = body.get("attributes", {})
if not isinstance(attributes, dict):
raise WriteValidationError(f"{path}: 'attributes' must be an object")
children = body.get("children", [])
if children is None:
children = []
if not isinstance(children, list):
raise WriteValidationError(f"{path}: 'children' must be a list")
for index, child_entry in enumerate(children):
child_path = f"{path}/children[{index}]"
if not isinstance(child_entry, dict):
raise WriteValidationError(
f"{child_path}: expected a single-key object mapping class name to body, "
f"got {type(child_entry).__name__}"
)
if len(child_entry) != 1:
raise WriteValidationError(
f"{child_path}: expected exactly one class key, got {len(child_entry)}"
)
child_cls = next(iter(child_entry))
child_body = child_entry[child_cls]
_validate_node(child_cls, child_body, child_path)
def _validate_planned(planned: list[tuple[str, dict]]) -> None:
"""Reject malformed/out-of-range property values a real APIC would 400 on
(error-107/801 style) — e.g. ``fvSubnet ip=".1/24"``,
``l3extRsPathL3OutAtt encap="vlan-9999"``, ``bgpAsP asn="0"``. The sim's
contract is to FAIL the way real gear fails, not to absorb it into the
MIT. Only values actually PRESENT on a non-delete write are validated (a
delete needs nothing but the DN — that stays the cleanup path for
anything malformed that predates this check).
Table-driven (F10): every ``(class, prop)`` pair checked here comes from
``aci_sim.rest_aci.validators.RULES`` — see that module's docstring and
section comments for the registry and its fail-safe default-allow
policy. A ``(class, prop)`` pair with NO entry in RULES is intentionally
NOT validated (unknown ⇒ allow, never reject)."""
for mo_cls, mo_attrs in planned:
if mo_attrs.get("status") == "deleted":
continue
for prop, value in mo_attrs.items():
validator = RULES.get((mo_cls, prop))
if validator is None or value is None:
continue # fail-safe: unregistered (class, prop) or unset value ⇒ allow
try:
validator(mo_cls, prop, value, mo_attrs)
except ValueError as reason:
raise WriteValidationError(
f"Invalid value {value!r} for property {prop!r} of {mo_cls} "
f"{mo_attrs.get('dn', '')!r}: {reason}"
) from None
def _plan_recursive(cls: str, attrs: dict, children: list, planned: list[tuple[str, dict]]) -> None:
"""Build the ordered list of (class, attrs) MOs to write, without touching the store.
Appends to *planned* in the same order the old eager code used to upsert,
so behavior (e.g. parent-before-child) is unchanged — only the timing of
the actual store mutation moves to after this whole plan succeeds.
"""
planned.append((cls, attrs))
# A deleted MO removes its whole subtree; do NOT plan children as orphans.
if attrs.get("status") == "deleted":
return
for index, child_entry in enumerate(children):
for child_cls, child_body in child_entry.items():
child_attrs = dict(child_body.get("attributes") or {})
child_attrs["dn"] = _child_dn(attrs["dn"], child_cls, child_attrs, index)
child_children = child_body.get("children") or []
_plan_recursive(child_cls, child_attrs, child_children, planned)
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
earlier siblings/ancestors partially written. So this first validates the
complete subtree (raising WriteValidationError before touching the store
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, 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
# path (root attrs are pre-extracted by apply(), but must still satisfy
# the same shape rules children do).
_validate_node(cls, {"attributes": attrs, "children": children}, path=attrs.get("dn", cls))
planned: list[tuple[str, dict]] = []
_plan_recursive(cls, attrs, children, planned)
_validate_planned(planned) # reject malformed values BEFORE any store mutation
# 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.
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))
results.append((mo_cls, mo_attrs, status))
return results
def materialize_node_registration(
store: MITStore,
*,
topo,
site,
node_id: int,
name: str,
role: str = "leaf",
) -> None:
"""Reaction: materialize a full node-registration MO set for *node_id*.
Mirrors what the real builders (build/fabric.py, build/cabling.py,
build/interfaces.py, build/health_faults.py) emit for a leaf that was
present at boot time, so a node registered dynamically via
fabricNodeIdentP or /_sim/add-leaf renders identically to one seeded from
topology.yaml:
- fabricNode (inventory row)
- topSystem (loopback/oob addresses, valid parseable IPv4 —
reuses build/fabric.py's loopback_ip/oob_ip so
the address scheme never drifts out of sync)
- healthInst (node health, matching health_faults.py's shape)
- fabricLink (+ lldpAdjEp/cdpAdjEp) to EVERY spine in the site, mirroring
build/cabling.py's leaf-uplink port assignment (eth1/{49+spine_idx})
- l1PhysIf/ethpmPhysIf inventory (fabric uplinks + host-access ports),
reusing build/interfaces.py's own `_add_port` helper so port shapes
never diverge from the boot-time build path
`pod` is derived from *site* (nodeidentpol DNs carry no pod of their
own) rather than hardcoded, so multi-pod/multi-site sims register nodes
under the correct pod.
"""
pod = site.pod
node_dn = f"topology/pod-{pod}/node-{node_id}"
store.upsert(MO(
"fabricNode",
dn=node_dn,
id=str(node_id),
name=name,
role=role,
adSt="on",
fabricSt="active",
model="N9K-C9332C",
serial="",
version="n9000-14.2(7f)",
))
store.upsert(MO(
"topSystem",
dn=f"{node_dn}/sys",
id=str(node_id),
name=name,
role=role,
version="n9000-14.2(7f)",
address=loopback_ip(pod, node_id),
oobMgmtAddr=oob_ip(pod, node_id),
fabricDomain=site.fabric_name or (topo.fabric.name if topo else ""),
state="in-service",
podId=str(pod),
))
store.upsert(MO(
"healthInst",
dn=f"{node_dn}/sys/health",
cur="95",
min="95",
max="100",
prev="95",
))
# Cable this node to every spine in the site (mirrors build/cabling.py's
# leaf-uplink port assignment: leaf uplink eth1/{49+spine_idx}, spine
# downlink port picked as the next free slot after its existing leaves).
spines = list(site.spine_nodes()) if site is not None else []
leaf_uplinks: list[tuple[int, int]] = []
for s_idx, spine in enumerate(spines):
leaf_slot, leaf_port = 1, _UPLINK_START + s_idx
# Spine-side port: next free downlink slot on that spine (after every
# existing leaf/border-leaf this site already cabled at boot time).
existing_downlinks = len(site.leaf_nodes()) + len(site.border_leaf_nodes())
spine_slot, spine_port = 1, existing_downlinks + 1
lnk_dn = (
f"topology/pod-{pod}"
f"/lnk-{spine.id}-{spine_slot}-{spine_port}-to-{node_id}-{leaf_slot}-{leaf_port}"
)
store.upsert(MO(
"fabricLink",
dn=lnk_dn,
n1=str(spine.id),
n2=str(node_id),
operSt="up",
operSpeed="100G",
linkType="leaf",
))
spine_port_str = f"eth{spine_slot}/{spine_port}"
leaf_port_str = f"eth{leaf_slot}/{leaf_port}"
spine_oob = oob_ip(pod, spine.id)
leaf_oob = oob_ip(pod, node_id)
# The dynamically-registered node has no Node schema instance of its
# own (only the id/name/role args this function received) — build a
# throwaway one so add_switch_adjacency can derive model/version/mac
# from it exactly like the boot-time builders do. model/version
# match the hardcoded fabricNode/topSystem values a few lines above.
new_node = Node(id=node_id, name=name, model="N9K-C9332C", version="n9000-14.2(7f)")
# Spine sees the new node as neighbor on spine_port_str
add_switch_adjacency(
store,
pod=pod,
local_node_id=spine.id,
local_port=spine_port_str,
remote_port=leaf_port_str,
neighbor=new_node,
neighbor_mgmt_ip=leaf_oob,
)
# The new node sees the spine as neighbor on leaf_port_str
add_switch_adjacency(
store,
pod=pod,
local_node_id=node_id,
local_port=leaf_port_str,
remote_port=spine_port_str,
neighbor=spine,
neighbor_mgmt_ip=spine_oob,
)
leaf_uplinks.append((leaf_slot, leaf_port))
# l1PhysIf inventory: fabric uplinks (one per spine) + host-access ports,
# reusing build/interfaces.py's own port-building helper so shapes never
# diverge from the boot-time build path.
for slot, port in leaf_uplinks:
_add_port(
store, pod, node_id, slot, port,
descr="Fabric uplink to spine",
mode="trunk",
with_fcot=True,
)
for hp in range(1, _HOST_PORTS + 1):
_add_port(
store, pod, node_id, 1, hp,
descr=f"Host port eth1/{hp}",
mode="access",
usage="access",
)
_BGP_PEERP_RE = re.compile(
r"uni/tn-(?P<tenant>[^/]+)/out-(?P<l3out>[^/]+)/.*"
r"paths-(?P<node>\d+)/pathep-\[[^\]]+\]\]/peerP-\[(?P<peer>[^\]]+)\]$"
)
def _materialize_l3out_bgp_session(
store: MITStore, peer_dn: str, addr: str, remote_asn: str
) -> None:
"""Reaction: a tenant L3Out ``bgpPeerP`` (config) was POSTed — materialize
the matching operational session MOs (``bgpPeer``/``bgpPeerEntry``/
``bgpPeerAfEntry``) so autoACI's ``_compute_session_tables`` (routers/
topology.py) L3Out BGP table finds it, exactly like the boot-time
topology-driven path already does for its own L3Outs (build/l3out.py's
``_upsert_ebgp_sessions``) — this covers the OTHER path, a tenant config
pushed live via POST /api/mo (cisco.aci ansible playbook / hidden_push.py),
which lands generic MOs here in writes.py instead of going through the
topology builders.
DN/attribute derivation (verified against routers/topology.py's
``_compute_session_tables``, ``_dn_dom`` and ``_dn_node`` helpers):
- node: parsed from the bgpPeerP's own DN (".../paths-{N}/pathep-...")
— this IS the L3Out border-leaf, same node the peer is deployed on.
- dom: MUST be ``dom-{tenant}:{vrf}`` (colon-separated) — topology.py's
``_dn_dom`` extracts the dom segment verbatim and then does
``if ":" not in dom: continue`` to skip fabric/overlay (``dom-
overlay-1``) peers, then ``tenant, _, vrf = dom.partition(":")``.
vrf is read from the L3Out's own ``l3extRsEctx.tnFvCtxName`` (already
in the store from the earlier ``aci_l3out`` POST) rather than
hardcoded, so it always matches whatever VRF the tenant push used.
- remoteAsn: topology.py reads it off the sibling ``bgpPeer.asn``
attribute (matched via the entry's parent dn) — sourced from the
bgpPeerP's own nested ``bgpAsP.asn`` child, i.e. the L3Out's actual
configured peer ASN, never hardcoded.
"""
m = _BGP_PEERP_RE.match(peer_dn)
if not m:
return
tenant = m.group("tenant")
l3out_name = m.group("l3out")
node_id = m.group("node")
l3out_dn = f"uni/tn-{tenant}/out-{l3out_name}"
vrf = ""
for ectx in store.children(l3out_dn, {"l3extRsEctx"}):
vrf = ectx.attrs.get("tnFvCtxName", "")
if vrf:
break
if not vrf:
# Can't place this peer under a tenant-VRF dom without a VRF name —
# nothing sane to derive it from, so skip rather than fudge a dom
# that would silently misclassify as fabric/overlay (no ":").
return
pod = pod_from_dn(peer_dn)
inst_dn = f"topology/pod-{pod}/node-{node_id}/sys/bgp/inst"
inst_mo = MO("bgpInst", dn=inst_dn)
dom_name = f"{tenant}:{vrf}"
dom_dn = f"{inst_dn}/dom-{dom_name}"
dom_mo = MO("bgpDom", dn=dom_dn, name=dom_name)
session_peer_dn = f"{dom_dn}/peer-[{addr}]"
peer_mo = MO(
"bgpPeer",
dn=session_peer_dn,
addr=addr,
asn=remote_asn,
type="ebgp",
peerRole="ce",
)
entry_dn = f"{session_peer_dn}/ent-[{addr}]"
peer_mo.add_child(MO(
"bgpPeerEntry",
dn=entry_dn,
addr=addr,
operSt="established",
connEst="1",
connDrop="0",
type="ebgp",
))
af_dn = f"{entry_dn}/af-[ipv4-ucast]"
peer_mo.add_child(MO(
"bgpPeerAfEntry",
dn=af_dn,
type="ipv4-ucast",
afId="ipv4-ucast",
acceptedPaths="3",
pfxSent="3",
tblVer="1",
))
dom_mo.add_child(peer_mo)
inst_mo.add_child(dom_mo)
store.upsert(inst_mo)
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": [...]}}
Returns (imdata, total).
*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
# Extract class name and body parts
cls = next(iter(body))
cls_body = body[cls]
attrs = dict(cls_body.get("attributes") or {})
children = cls_body.get("children") or []
# Honor attrs["dn"] if present, fall back to URL dn
effective_dn = attrs.get("dn") or dn
attrs["dn"] = effective_dn
# 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, _status in results:
if mo_cls != "fabricNodeIdentP":
continue
if mo_attrs.get("status") == "deleted":
continue
node_dn = mo_attrs.get("dn", "")
m = re.search(r"nodep-(\d+)$", node_dn)
if not m:
continue
node_id = int(m.group(1))
name = mo_attrs.get("name", f"leaf-{node_id}")
role = mo_attrs.get("role", mo_attrs.get("nodeType", "leaf"))
materialize_node_registration(
store, topo=topo, site=site, node_id=node_id, name=name, role=role,
)
# Tenant L3Out eBGP peer (bgpPeerP) reaction: materialize the operational
# bgpPeer/bgpPeerEntry/bgpPeerAfEntry session MOs so autoACI's L3Out BGP
# 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 `results` list — read it from there instead of a second
# store lookup.
for mo_cls, mo_attrs, _status in results:
if mo_cls != "bgpPeerP":
continue
if mo_attrs.get("status") == "deleted":
continue
peer_dn = mo_attrs.get("dn", "")
addr = mo_attrs.get("addr", "")
if not peer_dn or not addr:
continue
remote_asn = ""
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)
# 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)