mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
feat(ndo): enforce service-graph device-existence + uniform site redirect (v0.24.0)
Closes F4 + F12: the sim NDO silently accepted two writes that real NDO 4.x rejects server-side (the two documented walls behind the two-phase deploy): - F4: binding a site-local service graph whose serviceNodes[].device.dn does not exist on the target APIC -> now 400 "Service graph device <dev> does not exist in tenant <t> in Fabric <site>" (cross-plane check against the per-site APIC MITStore; single-process sandbox shares live store objects). - F12: partial per-fabric serviceGraphRelationship redirect coverage -> now 400 "must have uniform redirect policy configured on all fabrics"; validated on POST-request state (deepcopy->apply->validate->commit) so a single atomic all-fabric PATCH passes, matching real NDO. Coverage-based per (template, contract) — sibling templates with same-named contracts cannot cross-reject. Non-service-graph PATCHes keep the in-place fast path. Fail-safe default-allow; all-or-nothing on rejection. +33 tests (1131 passed). Live gate: MS-TN2 two-phase chain 12/12 green (no false-reject); phase2-only 400s with golden message + zero residue; single-fabric PATCH 400s while the atomic both-fabric control returns 200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1
This commit is contained in:
co-authored by
Claude Fable 5
parent
fe3d13221a
commit
c5f8d0f940
+37
-2
@@ -7,6 +7,7 @@ never validated on subsequent requests (accept present-or-absent).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import Any
|
||||
@@ -18,6 +19,12 @@ from aci_sim.control.persist import apply_ndo, load_json, save_json, serialize_n
|
||||
from .deploy_mirror import mirror_template_to_sites
|
||||
from .model import NdoState
|
||||
from .patch import PatchError, apply_json_patch, normalize_site, normalize_template
|
||||
from .service_graph_validation import (
|
||||
ServiceGraphValidationError,
|
||||
_validate_service_graph_device_refs,
|
||||
_validate_uniform_site_redirect,
|
||||
service_graph_relevant,
|
||||
)
|
||||
|
||||
|
||||
def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) -> FastAPI:
|
||||
@@ -748,9 +755,37 @@ def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) ->
|
||||
# PATCH client-side and never sends it, but stay tolerant.
|
||||
return detail
|
||||
|
||||
# F4 + F12 (design doc `_F4_F12_VALIDATION_DESIGN.md`): only PATCHes
|
||||
# that actually touch service-graph surface (a site-local
|
||||
# `serviceGraphs` add/replace, or a site-local contract's
|
||||
# `serviceGraphRelationship`) take the gated copy-validate-commit
|
||||
# path below. Every other PATCH (the ~95% that are BD/EPG/subnet/
|
||||
# contract-filter) stays on the original in-place fast path,
|
||||
# byte-identical to before this change.
|
||||
try:
|
||||
apply_json_patch(detail, ops)
|
||||
except PatchError as exc:
|
||||
if not service_graph_relevant(ops):
|
||||
apply_json_patch(detail, ops)
|
||||
else:
|
||||
# F4 is a pure reference check — device existence doesn't
|
||||
# depend on the mutation — so it pre-scans `ops` against the
|
||||
# CURRENT (pre-mutation) `detail` before anything is applied.
|
||||
# Raising here is naturally all-or-nothing (design §2.2).
|
||||
_validate_service_graph_device_refs(ops, apic_states, state, detail)
|
||||
|
||||
# F12 is a post-request-final-state check (design §3.1): it
|
||||
# cannot be evaluated per-op without false-rejecting the
|
||||
# legal atomic multi-fabric PATCH mid-batch. Apply the ops to
|
||||
# a deepcopy, validate the RESULT, and only commit the copy
|
||||
# back into state.schema_details on success — a 400 here
|
||||
# leaves the live stored schema completely untouched (and,
|
||||
# incidentally, fixes the pre-existing partial-write-on-
|
||||
# PatchError bug for service-graph PATCHes — design §3.2).
|
||||
working = copy.deepcopy(detail)
|
||||
apply_json_patch(working, ops)
|
||||
_validate_uniform_site_redirect(working, ops)
|
||||
state.schema_details[schema_id] = working
|
||||
detail = working
|
||||
except (PatchError, ServiceGraphValidationError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
detail["id"] = schema_id # ops must never be able to clobber the id
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""F4 + F12 — NDO server-side service-graph fidelity enforcements.
|
||||
|
||||
See `_F4_F12_VALIDATION_DESIGN.md` (repo root) for the full design. Summary:
|
||||
|
||||
- **F4 — device-existence gate.** When a site-local service-graph binding op
|
||||
(`/sites/{siteId}-{templateName}/serviceGraphs/...`) references an L4-L7
|
||||
device (`serviceNodes[].device.dn = uni/tn-<tenant>/lDevVip-<dev>`), that
|
||||
device must already exist as a `vnsLDevVip` MO on the TARGET SITE's APIC
|
||||
(cross-plane check via `apic_states`). A phase2-only bind (device never
|
||||
created in phase1) is the confirmed sim gap this closes.
|
||||
- **F12 — uniform per-fabric redirect gate.** A per-fabric redirect
|
||||
(`serviceGraphRelationship` carrying a `redirectPolicy`) written onto a
|
||||
site-local contract must land on EVERY fabric of that contract's template
|
||||
in the SAME request (NDO validates the final post-request state, not
|
||||
per-op) — a partial (single-fabric) write is illegal; one atomic
|
||||
multi-op PATCH covering every fabric is legal.
|
||||
|
||||
Both enforcements are fail-safe / default-allow (design §4): they only raise
|
||||
on a *proven* violation with the real-gear golden string; every ambiguity
|
||||
(unparsable DN, unsimulated site, missing key, empty op set, single-fabric
|
||||
topology) passes through untouched. Mounted from `ndo/app.py::patch_schema`
|
||||
only — `apply_json_patch` (`ndo/patch.py`) stays pure and unaware of either
|
||||
rule (the template PATCH route reuses it and has no service graphs).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ServiceGraphValidationError(Exception):
|
||||
"""Raised when a service-graph PATCH violates an NDO server-side
|
||||
structural fidelity rule (F4 device-existence or F12 uniform redirect).
|
||||
|
||||
Caught by `ndo/app.py::patch_schema` and mapped to
|
||||
`HTTPException(status_code=400, detail=str(exc))` — the same 400
|
||||
envelope `PatchError` already uses on that route.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared gate — does this PATCH's op list touch service-graph surface at all?
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `patch_schema` uses this to decide whether to take the gated
|
||||
# copy-validate-commit path (needed for F12's post-request-state check) or
|
||||
# leave the ~95% of PATCHes that are BD/EPG/subnet/contract-filter on the
|
||||
# current in-place fast path, byte-identical (design §3.2/§4).
|
||||
|
||||
|
||||
def _touches_service_graph(op: Any) -> bool:
|
||||
"""True if a single op's path names either service-graph surface:
|
||||
the site-local `serviceGraphs` collection (F4's trigger) or a
|
||||
site-local contract's `serviceGraphRelationship` (F12's trigger)."""
|
||||
if not isinstance(op, dict):
|
||||
return False
|
||||
path = op.get("path")
|
||||
if not isinstance(path, str):
|
||||
return False
|
||||
return "/serviceGraphs" in path or path.endswith("/serviceGraphRelationship")
|
||||
|
||||
|
||||
def service_graph_relevant(ops: list[dict] | None) -> bool:
|
||||
"""True if any op in *ops* touches service-graph surface — the gate for
|
||||
`patch_schema`'s copy-validate-commit path."""
|
||||
return any(_touches_service_graph(op) for op in (ops or []))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F4 — device-existence gate at service-graph bind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: `serviceNodes[].device.dn` shape (design §1.3):
|
||||
#: `uni/tn-<tenant>/lDevVip-<dev>`. Anything that doesn't match this exact
|
||||
#: device-ref shape is not a ref F4 owns — skip it (fail-safe).
|
||||
_DEVICE_DN_RE = re.compile(r"^uni/tn-(?P<tenant>[^/]+)/lDevVip-(?P<dev>.+)$")
|
||||
|
||||
|
||||
def _iter_device_dns(value: Any):
|
||||
"""Yield every `device.dn` string carried by a service-graph op's
|
||||
*value* — both the whole-object shape (`serviceNodes[*].device.dn`) and
|
||||
a top-level `value["device"]["dn"]` (forward-safety for a narrower
|
||||
single-node add/replace, design §2.3 step 3)."""
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
device = value.get("device")
|
||||
if isinstance(device, dict):
|
||||
dn = device.get("dn")
|
||||
if isinstance(dn, str) and dn:
|
||||
yield dn
|
||||
nodes = value.get("serviceNodes")
|
||||
if isinstance(nodes, list):
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_device = node.get("device")
|
||||
if isinstance(node_device, dict):
|
||||
dn = node_device.get("dn")
|
||||
if isinstance(dn, str) and dn:
|
||||
yield dn
|
||||
|
||||
|
||||
def _fabric_name(state: Any, site_id: str) -> str:
|
||||
"""Resolve *site_id* to its NDO fabric display name
|
||||
(`NdoState.sites[].name`, e.g. `"LAB1-IT-ACI"`), falling back to the
|
||||
bare site_id if the site isn't found (should not normally happen, but
|
||||
keep the golden-message builder total)."""
|
||||
for entry in getattr(state, "sites", None) or []:
|
||||
if isinstance(entry, dict) and str(entry.get("id")) == site_id:
|
||||
name = entry.get("name")
|
||||
if name:
|
||||
return name
|
||||
return site_id
|
||||
|
||||
|
||||
def _validate_service_graph_device_refs(
|
||||
ops: list[dict], apic_states: dict | None, state: Any, detail: dict | None
|
||||
) -> None:
|
||||
"""Pre-scan *ops* (BEFORE `apply_json_patch` mutates *detail*) for
|
||||
site-local service-graph device bindings and raise
|
||||
`ServiceGraphValidationError` if a referenced device doesn't exist as a
|
||||
`vnsLDevVip` MO on the target site's APIC store.
|
||||
|
||||
*detail* is the CURRENT (pre-mutation) schema doc — needed to resolve
|
||||
the `{siteId}-{templateName}` composite path segment to a concrete
|
||||
`siteId` via `detail["sites"]`, the same composite `_find_by_name`
|
||||
(`ndo/patch.py`) uses. Not part of the design doc's abbreviated 3-arg
|
||||
signature shorthand, but required by its own §2.3 algorithm (which
|
||||
reads `detail["sites"]`) — see this module's docstring / the
|
||||
implementer's report for this minor, functionally-necessary deviation.
|
||||
|
||||
Fail-safe on every ambiguity (design §4): missing ops/apic_states/
|
||||
detail, an unresolvable site composite, an unparsable device DN, a
|
||||
site absent from *apic_states* (not simulated) — all pass through.
|
||||
Only raises when a device DN parses AND its site IS simulated AND the
|
||||
store proves the device absent (or present under the wrong class).
|
||||
"""
|
||||
if not ops or not apic_states or not detail:
|
||||
return
|
||||
|
||||
sites = detail.get("sites")
|
||||
if not isinstance(sites, list) or not sites:
|
||||
return
|
||||
|
||||
for entry in ops:
|
||||
if not isinstance(entry, dict) or entry.get("op") not in ("add", "replace"):
|
||||
continue
|
||||
path = entry.get("path")
|
||||
if not isinstance(path, str) or not path.startswith("/sites/") or "/serviceGraphs" not in path:
|
||||
continue
|
||||
|
||||
# design §2.3 step 1: `seg = path.split("/")[2]` — the
|
||||
# "{siteId}-{templateName}" composite. Never split on "-"
|
||||
# (template names may contain it).
|
||||
segments = path.split("/")
|
||||
if len(segments) < 3:
|
||||
continue
|
||||
seg = segments[2]
|
||||
|
||||
site_entry = None
|
||||
for candidate in sites:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
if f"{candidate.get('siteId')}-{candidate.get('templateName')}" == seg:
|
||||
site_entry = candidate
|
||||
break
|
||||
if site_entry is None:
|
||||
# Unresolvable composite — can't prove anything. Fail-safe.
|
||||
continue
|
||||
|
||||
site_id = str(site_entry.get("siteId", ""))
|
||||
if not site_id:
|
||||
continue
|
||||
|
||||
for dn in _iter_device_dns(entry.get("value")):
|
||||
match = _DEVICE_DN_RE.match(dn)
|
||||
if not match:
|
||||
# Not a device ref this rule owns — fail-safe skip.
|
||||
continue
|
||||
|
||||
apic_state = apic_states.get(site_id)
|
||||
if apic_state is None:
|
||||
# Site not simulated in this process — can't prove
|
||||
# absence. Fail-safe skip (design §2.3 step 3 bullet 2).
|
||||
continue
|
||||
store = getattr(apic_state, "store", None)
|
||||
if store is None:
|
||||
continue
|
||||
|
||||
mo = store.get(dn)
|
||||
if mo is not None and getattr(mo, "class_name", None) == "vnsLDevVip":
|
||||
continue # device exists — legal (phase1 already built it)
|
||||
|
||||
tenant = match.group("tenant")
|
||||
dev = match.group("dev")
|
||||
fabric_name = _fabric_name(state, site_id)
|
||||
raise ServiceGraphValidationError(
|
||||
f"Service graph device {dev} does not exist in tenant {tenant} "
|
||||
f"in Fabric {fabric_name}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F12 — uniform per-fabric redirect gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Matches a site-local contract's redirect bind path exactly:
|
||||
#: `/sites/{siteId}-{templateName}/contracts/{contract}/serviceGraphRelationship`.
|
||||
#: Template-level graph binds (`/templates/...`) never match this (they
|
||||
#: don't start with `/sites/`), so the template-level "Bind Service Graph to
|
||||
#: Contract" task never trips F12 (design §3.3 step 1 / §4).
|
||||
#:
|
||||
#: The composite `{siteId}-{templateName}` segment is captured too (not just
|
||||
#: split off) — see `_resolve_template_name`, which resolves it back to a
|
||||
#: templateName so a touched op can be scoped to ITS OWN template (fixes the
|
||||
#: cross-template false-reject below).
|
||||
_CONTRACT_REDIRECT_PATH_RE = re.compile(
|
||||
r"^/sites/(?P<site_tmpl>[^/]+)/contracts/(?P<contract>[^/]+)/serviceGraphRelationship$"
|
||||
)
|
||||
|
||||
|
||||
def _basename(ref: Any) -> str:
|
||||
"""Bare object name from a canonical NDO string ref
|
||||
(`/schemas/.../contracts/con-X` -> `con-X`). `working` is the
|
||||
POST-`apply_json_patch` document, so every `contractRef` has already
|
||||
been through `_stringify_refs` — no dict-form handling needed here
|
||||
(unlike `deploy_mirror._basename`, which must also read PRE-patch dict
|
||||
refs)."""
|
||||
if isinstance(ref, str) and ref:
|
||||
return ref.rsplit("/", 1)[-1]
|
||||
return ""
|
||||
|
||||
|
||||
def _value_has_redirect_policy(value: Any) -> bool:
|
||||
"""True if a `serviceGraphRelationship` *value* carries at least one
|
||||
`serviceNodesRelationship[*].{consumerConnector|providerConnector}
|
||||
.redirectPolicy` — i.e. it's an actual redirect bind, not merely a
|
||||
graph-to-contract bind with no redirect (design §3.3 step 1 / §1.4).
|
||||
|
||||
Used BOTH to decide whether an incoming op counts as "touching" a
|
||||
redirect (trigger detection) AND, on the post-state `working` doc, to
|
||||
decide whether a given fabric's site-local contract shadow already
|
||||
carries a redirect (coverage detection) — one shared predicate keeps
|
||||
the two checks from silently drifting apart.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
nodes = value.get("serviceNodesRelationship")
|
||||
if not isinstance(nodes, list):
|
||||
return False
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
for connector_key in ("consumerConnector", "providerConnector"):
|
||||
connector = node.get(connector_key)
|
||||
if isinstance(connector, dict) and connector.get("redirectPolicy"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_template_name(sites: list, seg: str) -> str | None:
|
||||
"""Resolve a `{siteId}-{templateName}` composite path segment (*seg*)
|
||||
back to its bare templateName via *sites* (`working["sites"]`) — the
|
||||
same composite `_find_by_name` (`ndo/patch.py`) uses to address a
|
||||
site-local op's target entry in the first place, and the same
|
||||
resolution `_validate_service_graph_device_refs` (F4, above) already
|
||||
does against `detail["sites"]`. Never split on "-" (template names may
|
||||
legitimately contain it). Returns None if the composite can't be
|
||||
resolved (fail-safe — see caller)."""
|
||||
for candidate in sites or []:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
if f"{candidate.get('siteId')}-{candidate.get('templateName')}" == seg:
|
||||
return candidate.get("templateName")
|
||||
return None
|
||||
|
||||
|
||||
def _touched_redirect_contracts(sites: list, ops: list[dict] | None) -> set[tuple[str, str]]:
|
||||
"""`(templateName, contract)` pairs touched by a redirect-carrying
|
||||
site-local `serviceGraphRelationship` op in *ops* (design §3.3 step 1).
|
||||
|
||||
Carries the owning templateName alongside the bare contract name —
|
||||
two DIFFERENT templates in the same schema may legitimately carry a
|
||||
same-named contract (e.g. `con-X` in both "LAB1-LAB2" and "LAB1"), and
|
||||
a touched op only proves ITS OWN template was touched. A bare-name-only
|
||||
touched set (pre-fix) let `_validate_uniform_site_redirect` sweep every
|
||||
template's same-named contract into one coverage check, so a legal
|
||||
atomic write on template A's `con-X` could be 400'd by template B's
|
||||
unrelated, pre-existing non-uniform `con-X` state — a scope-invariant
|
||||
violation (design §3.3 step 3: "only contracts touched by THIS
|
||||
request... never retroactively reject unrelated/pre-existing state").
|
||||
*sites* (`working["sites"]`) resolves each op's composite path segment
|
||||
to a concrete templateName; an unresolvable composite is fail-safe
|
||||
skipped (can't prove which template was touched, so can't prove a
|
||||
violation either)."""
|
||||
touched: set[tuple[str, str]] = set()
|
||||
for op in ops or []:
|
||||
if not isinstance(op, dict) or op.get("op") not in ("add", "replace"):
|
||||
continue
|
||||
path = op.get("path")
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
match = _CONTRACT_REDIRECT_PATH_RE.match(path)
|
||||
if not match:
|
||||
continue
|
||||
if not _value_has_redirect_policy(op.get("value")):
|
||||
continue
|
||||
template_name = _resolve_template_name(sites, match.group("site_tmpl"))
|
||||
if template_name is None:
|
||||
continue
|
||||
touched.add((template_name, match.group("contract")))
|
||||
return touched
|
||||
|
||||
|
||||
def _site_configures_redirect(site_entry: dict, contract: str) -> bool:
|
||||
"""True if *site_entry*'s mirrored `contracts[]` shadow for *contract*
|
||||
already carries a redirect-policy-bearing `serviceGraphRelationship`."""
|
||||
for candidate in site_entry.get("contracts", []) or []:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
if _basename(candidate.get("contractRef")) != contract:
|
||||
continue
|
||||
return _value_has_redirect_policy(candidate.get("serviceGraphRelationship"))
|
||||
return False
|
||||
|
||||
|
||||
def _validate_uniform_site_redirect(working: dict, ops: list[dict]) -> None:
|
||||
"""Post-request-state check (design §3.1/§3.3): after *ops* have been
|
||||
applied into *working* (a deepcopy — see `patch_schema`'s
|
||||
copy-validate-commit), every (template, contract) pair touched by a
|
||||
redirect bind in *ops* must carry that redirect on ALL fabrics of ITS
|
||||
OWN template — never on only SOME. Raises `ServiceGraphValidationError`
|
||||
with the golden message on a partial (non-uniform) write.
|
||||
|
||||
"Uniform" is COVERAGE (presence of a redirectPolicy on every fabric),
|
||||
never DN equality — redirect-policy DNs are legitimately per-fabric
|
||||
(design §3.4). Only the (template, contract) pairs actually touched by
|
||||
THIS request's ops are checked, so pre-existing state, unrelated
|
||||
contracts, AND a differently-scoped same-named contract in a sibling
|
||||
template are never retroactively rejected (design §3.3 step 3 — see
|
||||
`_touched_redirect_contracts`'s docstring for the cross-template
|
||||
false-reject this scoping fixes). A template with only one associated
|
||||
fabric is trivially uniform (design §3.4 / §4).
|
||||
"""
|
||||
sites = working.get("sites")
|
||||
if not isinstance(sites, list):
|
||||
return
|
||||
|
||||
touched = _touched_redirect_contracts(sites, ops)
|
||||
if not touched:
|
||||
return
|
||||
|
||||
for template_name, contract in touched:
|
||||
# The coverage set is every site entry belonging to THIS touched
|
||||
# op's OWN template that also carries a mirrored contracts[] shadow
|
||||
# for this contract — never a same-named contract in a different
|
||||
# template (that was the bug: grouping by templateName across ALL
|
||||
# site entries carrying the bare contract name, regardless of
|
||||
# which template the incoming op actually touched).
|
||||
group = [
|
||||
site_entry
|
||||
for site_entry in sites
|
||||
if isinstance(site_entry, dict)
|
||||
and site_entry.get("templateName") == template_name
|
||||
and any(
|
||||
isinstance(c, dict) and _basename(c.get("contractRef")) == contract
|
||||
for c in (site_entry.get("contracts") or [])
|
||||
)
|
||||
]
|
||||
|
||||
total = len(group)
|
||||
if total <= 1:
|
||||
# Single-fabric topology — trivially uniform (design §3.4).
|
||||
continue
|
||||
configured = sum(1 for s in group if _site_configures_redirect(s, contract))
|
||||
if 0 < configured < total:
|
||||
raise ServiceGraphValidationError(
|
||||
"must have uniform redirect policy configured on all fabrics"
|
||||
)
|
||||
Reference in New Issue
Block a user