Files
aci-sim/aci_sim/ndo/service_graph_validation.py

393 lines
18 KiB
Python

"""F4 + F12 — NDO server-side service-graph fidelity enforcements.
Summary of the two enforcements:
- **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).
Coverage-group bias (intentional fail-safe): the group below only
includes fabrics that already carry a `contracts[]` shadow entry for
*contract* — a fabric with NO shadow entry at all is excluded outright,
not counted as "not yet configured". From working state alone we can't
distinguish "this fabric hasn't received the contract shadow yet" from
"this fabric doesn't participate in this contract", so an all-but-one-
fabric-shadowless group collapses to `total <= 1` and passes rather than
risk a false 400. Live-gate testing (real NDO + cisco.mso, once
cisco.mso has built the shadow entries on every participating fabric)
confirms the real chain still correctly 400s a genuine partial redirect
write — this bias only ever widens the ambiguous states this check
deliberately stays silent on.
"""
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"
)