Initial public release — aci-sim v0.16.0

aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
View File
+385
View File
@@ -0,0 +1,385 @@
"""Main APIC FastAPI application.
URL routing matches exactly what aci_connector.py builds:
- GET /api/class/{cls}.json
- GET /api/mo/{dn:path} (dn may contain slashes + brackets)
- POST /api/mo/{dn:path}
- DELETE /api/mo/{dn:path} (PR-9 — cisco.aci state=absent)
- GET/POST/DELETE /api/node/mo/{dn:path} (PR-10 — real-APIC alias, see below)
- GET /api/node/class/{rest:path}
- Auth: /api/aaaLogin.json, /api/aaaRefresh.json, /api/aaaLogout.json
- Control: /_sim/reset, /_sim/snapshot/{name}, etc.
PR-10 — /api/node/mo/{dn} alias (live E2E blocker, 11 failures). Real APIC
serves /api/node/mo/{dn}.json as a full equivalent of /api/mo/{dn}.json — it
is the form the APIC GUI's API Inspector emits and what cisco.aci's
aci_rest module (and some aci_connector.py callers) actually issue. The sim
only registered /api/mo/*, so a node-alias request fell through to FastAPI's
default 404 {"detail":"Not Found"} — a shape cisco.aci cannot parse
("APIC Error None: None", status=-1). Each verb below is registered on BOTH
paths, sharing one handler body (stacked route decorators) so behavior can
never drift between the canonical and alias forms. A catch-all for any other
unmatched /api/* path returns an APIC-shaped 400 error envelope instead of
FastAPI's {"detail":...} — see `_unmatched_api_path` below.
PR-9 DELETE semantics: cisco.aci's module_utils/aci.py (`delete_config()`)
only issues an HTTP DELETE after its own `get_existing()` GET has confirmed
the MO is present — `if not self.existing: return` short-circuits before
ever calling DELETE (verified read-only against upstream
plugins/module_utils/aci.py). So the module itself never exercises a
DELETE-on-already-absent-DN round trip. This sim still makes the route
idempotent (200 + empty imdata on a missing DN, same as a present one)
rather than 404, for two reasons: (1) it matches the MIT's natural "delete
what's not there = no-op" semantics used elsewhere in this codebase
(MITStore.upsert's status=deleted branch is unconditional, no existence
check), and (2) it is the safer default for any OTHER client (raw curl,
future modules) that DOES call DELETE without a prior GET — a 404 there
would make state=absent playbooks non-idempotent on second-run against
such a client, which is the one thing real APIC's actual DELETE endpoint
is documented to guarantee. See docs/CONTRACT.md §Ansible-compatibility.
"""
from __future__ import annotations
import asyncio
import copy
from dataclasses import dataclass
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from aci_sim.mit.store import MITStore
from aci_sim.query.engine import (
params_from_dict,
run_class_query,
run_mo_query,
run_node_scoped,
)
from aci_sim.query.filters import FilterParseError
from aci_sim.rest_aci import subscriptions as subs
from aci_sim.rest_aci.auth import (
_auth_error_403,
_lookup_session,
make_auth_router,
require_session,
)
from aci_sim.rest_aci.writes import WriteValidationError
from aci_sim.rest_aci.writes import apply as write_apply
from aci_sim.topology.schema import Site, Topology
@dataclass
class ApicSiteState:
name: str
site: Site
topo: Topology
store: MITStore
baseline: MITStore # deepcopy at construction time
def _apic_ok(imdata: list[dict], total: int) -> dict:
"""Wrap imdata in the APIC envelope with string totalCount."""
return {"imdata": imdata, "totalCount": str(total)}
def _apic_error(text: str, code: str = "103", status_code: int = 400) -> JSONResponse:
"""Return an APIC error envelope as a JSONResponse."""
body = {
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
"totalCount": "1",
}
return JSONResponse(status_code=status_code, content=body)
def _maybe_subscribe(
body: dict, request: Request, token: str | None, scope_kind: str, scope_value: str
) -> dict:
"""Opt-in subscription hook shared by all three query routes.
If the request carries ``?subscription=yes``, registers a subscription
for *token* watching *scope_kind*/*scope_value* and adds a top-level
``subscriptionId`` key to *body* (mirroring real APIC — the field sits
alongside ``imdata``/``totalCount``, not inside them). Without that query
param, *body* is returned completely untouched — no key added, no
registry write — so plain queries stay byte-identical to pre-subscription
behavior (backward-compat requirement).
"""
if request.query_params.get("subscription") != "yes":
return body
if not token:
# Should not happen — require_session already gated the route — but
# never register a subscription with no token to notify later.
return body
sid = subs.subscribe(token, scope_kind, scope_value)
body = dict(body)
body["subscriptionId"] = sid
return body
def make_apic_app(state: ApicSiteState) -> FastAPI:
app = FastAPI(title=f"aci-sim APIC {state.name}")
# --- Auth routes ---
app.include_router(make_auth_router())
# --- Control-plane admin routes ---
from aci_sim.control.admin import make_admin_router
app.include_router(make_admin_router(state))
# --- Class query ---
@app.get("/api/class/{cls}.json")
async def get_class(cls: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_class_query(state.store, cls, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
token = request.cookies.get("APIC-cookie")
return _maybe_subscribe(body, request, token, "class", cls)
# --- MO query (GET) ---
# dn:path captures "uni/tn-Corp.json" including any slashes and brackets.
# We must strip the trailing ".json".
# PR-10: /api/node/mo/{dn} is a full alias of /api/mo/{dn} on real APIC
# (API Inspector / aci_rest form) — same handler, stacked route.
@app.get("/api/mo/{dn:path}")
@app.get("/api/node/mo/{dn:path}")
async def get_mo(dn: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
if dn.endswith(".json"):
dn = dn[:-5]
# PR-9 FEATURE 6 (live blocker, verified against upstream
# module_utils/aci.py's api_call(): status != 200 is UNCONDITIONALLY
# fatal via fail_json — there is no "404 is fine for GET" special
# case). cisco.aci's state=present flow always does a GET-before-POST
# existence check first; on a brand-new object that GET's DN does not
# exist yet. Real APIC returns 200 + empty imdata for a well-formed
# DN that simply doesn't exist (not 404) — 404/error codes are for
# malformed requests, not absent objects. A 404 here made every
# aci_tenant/aci_* state=present playbook fail on its very first run
# ("APIC Error 103: Unable to find the object specified"). Superseded
# the prior 404-on-missing-DN design (originally justified as
# matching an autoACI ACINotFoundError expectation — that consumer
# is a different client with different semantics; cisco.aci is this
# simulator's primary use case per DESIGN.md and takes precedence).
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_mo_query(state.store, dn, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
token = request.cookies.get("APIC-cookie")
return _maybe_subscribe(body, request, token, "dn", dn)
# --- MO write (POST) ---
# PR-10: /api/node/mo/{dn} alias — see get_mo above.
@app.post("/api/mo/{dn:path}")
@app.post("/api/node/mo/{dn:path}")
async def post_mo(dn: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
if dn.endswith(".json"):
dn = dn[:-5]
try:
body = await request.json()
except Exception:
return _apic_error("Invalid JSON body", code="103", status_code=400)
if not body or not isinstance(body, dict):
return _apic_error("Empty or non-object body", code="103", status_code=400)
cls = next(iter(body))
if not isinstance(body[cls], dict):
return _apic_error("Invalid MO body structure", code="103", status_code=400)
# Subscriptions need created-vs-modified, which writes.apply's own
# response shape doesn't distinguish (it always reports "created"
# for a non-delete write — see writes.py's `apply()`). Determine it
# here, pre-write, purely for the push event; the HTTP response body
# 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
try:
imdata, total = write_apply(state.store, dn, body, topo=state.topo, site=state.site)
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.
# imdata is the write result shape [{cls: {"attributes": {"dn":..., "status": "created"|"deleted"}}}]
# from writes.apply — one entry per top-level POSTed class (children
# are written but not individually reported here, matching the
# existing non-subscription response shape).
for entry in imdata:
for mo_cls, mo_body in entry.items():
mo_attrs = mo_body.get("attributes", {})
mo_dn = mo_attrs.get("dn", dn)
raw_status = mo_attrs.get("status", "created")
if raw_status == "deleted":
push_status = "deleted"
elif mo_dn == effective_dn and pre_existing:
push_status = "modified"
else:
push_status = "created"
subs.notify(mo_cls, mo_dn, push_status, mo_attrs)
return _apic_ok(imdata, total)
# --- MO delete (DELETE) ---
# cisco.aci state=absent path: DELETE /api/mo/{dn}.json. Removes the DN's
# entire subtree from the store. Idempotent: a missing DN also returns
# 200 + empty imdata rather than 404 (see module docstring above for the
# real-module-behavior justification).
# PR-10: /api/node/mo/{dn} alias — see get_mo above.
@app.delete("/api/mo/{dn:path}")
@app.delete("/api/node/mo/{dn:path}")
async def delete_mo(dn: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
if dn.endswith(".json"):
dn = dn[:-5]
# Capture the whole subtree BEFORE deleting so push-on-change can
# notify for every removed MO (a DN-scoped subscription may be
# watching a descendant, and a class-scoped one may be watching any
# class present in the subtree, not just the root DN's own class).
existing = state.store.get(dn)
removed = ([existing] if existing is not None else []) + state.store.subtree(dn)
state.store.delete(dn)
for mo in removed:
subs.notify(mo.class_name, mo.dn, "deleted", mo.attrs)
return _apic_ok([], 0)
# --- Node-scoped class query ---
# rest captures everything after /api/node/class/
# e.g. rest = "topology/pod-1/node-101/faultInst.json" (node-scoped)
# rest = "topSystem.json" (fabric-wide)
#
# Real APIC also supports the fabric-wide form of this endpoint — no
# topology/pod-X/node-Y prefix, just the bare "{cls}.json" — which is
# equivalent to /api/class/{cls}.json (finding #12). Distinguish the two
# by whether *rest* (once ".json" is stripped) contains a "/": a
# node-scoped DN always does (it's a full topology/... path), while the
# fabric-wide form is a single path segment.
@app.get("/api/node/class/{rest:path}")
async def get_node_class(rest: str, request: Request):
if require_session(request) is None:
return _auth_error_403()
token = request.cookies.get("APIC-cookie")
stripped = rest[:-5] if rest.endswith(".json") else rest
if "/" not in stripped:
# Fabric-wide form: {cls}.json with no DN prefix at all.
cls = stripped
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_class_query(state.store, cls, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
return _maybe_subscribe(body, request, token, "class", cls)
parts = rest.rsplit("/", 1)
if len(parts) != 2:
return _apic_error("Malformed node-scoped URL", code="103", status_code=400)
node_dn = parts[0]
cls = parts[1]
if cls.endswith(".json"):
cls = cls[:-5]
try:
params = params_from_dict(dict(request.query_params))
imdata, total = run_node_scoped(state.store, node_dn, cls, params)
except FilterParseError as exc:
return _apic_error(str(exc), code="107", status_code=400)
body = _apic_ok(imdata, total)
return _maybe_subscribe(body, request, token, "dn", node_dn)
# --- Subscription refresh ---
# GET /api/subscriptionRefresh.json?id=<id> — keeps a subscription alive
# past its TTL (real APIC's default subscription lifetime is 90s,
# refreshed by polling this endpoint). Always 200s for a live session;
# an unknown/expired id still returns 200 with a small APIC-shaped
# imdata (real APIC does not error a stale refresh — the caller just
# re-subscribes on its next query if the id no longer resolves).
@app.get("/api/subscriptionRefresh.json")
async def subscription_refresh(request: Request):
if require_session(request) is None:
return _auth_error_403()
subscription_id = request.query_params.get("id", "")
subs.refresh(subscription_id)
return {"imdata": [], "totalCount": "0", "subscriptionId": subscription_id}
# --- Websocket push channel ---
# GET /socket<token> — real APIC's subscription websocket path, where
# <token> is the same APIC-cookie token minted by aaaLogin. FastAPI/
# Starlette route params can't match a bare suffix glued onto a fixed
# prefix with no separator (real APIC literally concatenates the token
# onto "/socket" — no slash), so this uses a `{token}` path param on the
# pattern "/socket{token}", which Starlette resolves correctly (the
# literal "/socket" prefix consumes up to where the param begins).
@app.websocket("/socket{token}")
async def subscription_socket(websocket: WebSocket, token: str):
session = _lookup_session(token)
if session is None:
# Unknown/expired token — reject before accept (real APIC closes
# the upgrade for an invalid session token).
await websocket.close(code=4403)
return
await websocket.accept()
queue = subs.register_connection(token)
async def _pump_events() -> None:
"""Drain the queue and push each event, forever."""
while True:
event = await queue.get()
await websocket.send_json(event)
async def _watch_for_disconnect() -> None:
"""Block on receive() so a client-initiated close is noticed
promptly even while _pump_events is idle waiting on an empty
queue (send_json alone only surfaces a disconnect on the NEXT
push, which could be arbitrarily far in the future — or never,
for a subscriber that stops getting matching events). receive()
does not raise on a disconnect message (only the receive_text/
receive_json/receive_bytes wrappers do) — it just returns it, so
this checks the message type itself and raises to match
_pump_events' failure mode.
"""
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
raise WebSocketDisconnect(code=message.get("code", 1000))
pump_task = asyncio.ensure_future(_pump_events())
watch_task = asyncio.ensure_future(_watch_for_disconnect())
try:
done, _pending = await asyncio.wait(
{pump_task, watch_task}, return_when=asyncio.FIRST_EXCEPTION
)
for task in done:
exc = task.exception()
if exc is not None and not isinstance(exc, WebSocketDisconnect):
raise exc
except WebSocketDisconnect:
pass
finally:
pump_task.cancel()
watch_task.cancel()
subs.drop_connection(token)
# --- Unmatched /api/* fallback ---
# PR-10: real APIC never emits FastAPI's default 404 {"detail":"Not
# Found"} shape — cisco.aci can't parse it (surfaces as "APIC Error
# None: None", status=-1). This handler only fires once routing has
# already failed to match every route above (including the /api/node/mo
# alias and /api/node/class/*), so it can never shadow a real route —
# it is Starlette's very last resort for an unmatched path, not a route
# itself. Scoped to /api/* only; anything outside that prefix (e.g. a
# typo'd /_sim/* control path) still gets the default FastAPI 404, since
# this sim's control plane is not part of the APIC REST contract.
@app.exception_handler(StarletteHTTPException)
async def _unmatched_api_path(request: Request, exc: StarletteHTTPException):
if exc.status_code == 404 and request.url.path.startswith("/api/"):
return _apic_error(
f"Invalid request path: {request.url.path}", code="400", status_code=400
)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
return app
+292
View File
@@ -0,0 +1,292 @@
"""Token store + auth routes for aaaLogin, aaaRefresh, aaaLogout.
Session tokens are minted on aaaLogin and validated (via `require_session`) by
every protected data route in app.py. Real APIC semantics emulated here:
- aaaLogin checks credentials (default admin/cisco, overridable via
SIM_USERNAME/SIM_PASSWORD env vars) → 401 APIC envelope on mismatch. The
login name may be plain ("admin") or domain-qualified
("apic:local\\admin", "local\\admin") — real APIC accepts both for its
local login domain; see `_bare_username()`.
- Tokens expire after SESSION_LIFETIME_SECONDS (default 600s); aaaRefresh
requires a live token and renews it to a full lifetime.
- Queries/writes without a valid, unexpired APIC-cookie → 403 APIC envelope
"Token was invalid (Error: Token timeout)".
- Malformed aaaLogin bodies never leak FastAPI's {"detail": ...} shape —
the route parses the request body manually and returns a 400 APIC
envelope instead of letting FastAPI raise RequestValidationError.
PR-9 addition — certificate signature auth (accept-mode). cisco.aci's
module_utils/aci.py supports password-less auth: when `private_key` is set,
every request's `cert_auth()` sets a single `Cookie` header carrying four
APIC-Certificate-* fields instead of (or alongside) the aaaLogin flow —
verified read-only against upstream `plugins/module_utils/aci.py`:
sig_dn = "uni/userext/user-{username}/usercert-{certificate_name}"
self.headers["Cookie"] = (
"APIC-Certificate-Algorithm=v1.0; "
"APIC-Certificate-DN={sig_dn}; "
"APIC-Certificate-Fingerprint=fingerprint; "
"APIC-Request-Signature={base64(RSA-SHA256(method+path+body))}"
)
This means a cert-auth playbook never calls aaaLogin at all — every single
request (GET/POST/DELETE) carries the Cookie header standalone. See
`require_session` below for the accept-mode implementation and
docs/DESIGN.md's "Certificate accept-mode trust model" note for what is
and is not verified.
"""
from __future__ import annotations
import os
import re
import secrets
import time
from dataclasses import dataclass
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
# Default lab credentials — overridable per CONTRACT.md §1 / README §Configuration.
SIM_USERNAME: str = os.environ.get("SIM_USERNAME", "admin")
SIM_PASSWORD: str = os.environ.get("SIM_PASSWORD", "cisco")
# Real APIC default session lifetime is 600s (refreshTimeoutSeconds).
SESSION_LIFETIME_SECONDS: float = 600.0
# Certificate-DN shape cisco.aci's cert_auth() builds:
# uni/userext/user-{username}/usercert-{certificate_name}
# Captured group 1 = the username, checked against SIM_USERNAME below.
_CERT_DN_RE = re.compile(r"^uni/userext/user-([^/]+)/usercert-[^/]+$")
# PR-15 addition — domain-qualified aaaLogin name. Real APIC accepts login
# names of the shape "apic:<loginDomain>\<username>" (e.g. "apic:local\admin")
# in addition to a bare username, per APIC's local/remote login-domain
# handling. aci-py's client posts the domain-qualified form by default.
# Captured group 1 = the domain (unused/ignored, any domain is accepted —
# this sim has no concept of configured login domains), group 2 = the bare
# username, which is what gets compared against SIM_USERNAME below.
_DOMAIN_QUALIFIED_NAME_RE = re.compile(r"^(?:apic:)?[^\\]+\\(.+)$")
def _bare_username(name: str) -> str:
"""Strip an optional `apic:<domain>\\` or `<domain>\\` prefix from a login name.
"apic:local\\admin" -> "admin"; "local\\admin" -> "admin"; "admin" -> "admin"
(no prefix present, returned unchanged).
"""
m = _DOMAIN_QUALIFIED_NAME_RE.match(name)
return m.group(1) if m else name
# SIM_CERT_STRICT — documented placeholder, NOT YET IMPLEMENTED (see
# docs/DESIGN.md). When unset/false (the only mode this sim supports today),
# a well-formed cert-cookie set authenticates its claimed user WITHOUT any
# RSA signature verification (accept-mode / trust-the-claimed-identity). A
# future strict mode would verify APIC-Request-Signature against a
# registered public key for that user — out of scope for PR-9.
SIM_CERT_STRICT: bool = os.environ.get("SIM_CERT_STRICT", "false").lower() in ("1", "true", "yes")
@dataclass
class _Session:
user: str
expires_at: float
# Module-level session store: token -> _Session. Shared across all APIC apps
# in this process (mirrors the pre-existing module-level behavior); each test
# builds a fresh FastAPI app/state per fixture but the token namespace is
# process-wide, which is fine since tokens are unguessable 128-hex-char values.
_sessions: dict[str, _Session] = {}
def _new_token() -> str:
return secrets.token_hex(64)
def _now() -> float:
return time.monotonic()
def _prune_expired() -> None:
"""Opportunistically drop expired tokens. Called on every store access."""
now = _now()
expired = [tok for tok, sess in _sessions.items() if sess.expires_at <= now]
for tok in expired:
del _sessions[tok]
def _create_session(user: str) -> str:
_prune_expired()
token = _new_token()
_sessions[token] = _Session(user=user, expires_at=_now() + SESSION_LIFETIME_SECONDS)
return token
def _lookup_session(token: str | None) -> _Session | None:
"""Return the live session for `token`, or None if missing/unknown/expired."""
_prune_expired()
if not token:
return None
return _sessions.get(token)
def _touch_session(token: str) -> bool:
"""Renew an existing session's expiry to a full lifetime. False if invalid."""
sess = _lookup_session(token)
if sess is None:
return False
sess.expires_at = _now() + SESSION_LIFETIME_SECONDS
return True
def _pop_session(token: str | None) -> None:
_prune_expired()
if token:
_sessions.pop(token, None)
def _cert_auth_user(request: Request) -> str | None:
"""Return the authenticated username for a well-formed cert-cookie request.
Accept-mode (documented trust model, see docs/DESIGN.md): if all four
APIC-Certificate-* cookies are present and APIC-Certificate-DN parses to
"uni/userext/user-{user}/usercert-{name}" with user == SIM_USERNAME, the
request is treated as authenticated WITHOUT verifying
APIC-Request-Signature's RSA-SHA256 bytes. Any of: a missing cookie, an
unparsable DN, or a DN whose user != SIM_USERNAME → returns None (caller
falls through to the normal cookie-session check, which will also fail
for a request that never called aaaLogin, yielding the standard 403).
This intentionally does NOT check SIM_CERT_STRICT — that flag is a
documented placeholder for a future signature-verifying mode and has no
effect yet (see auth.py module docstring + DESIGN.md).
"""
cookies = request.cookies
required = (
"APIC-Certificate-Algorithm",
"APIC-Certificate-Fingerprint",
"APIC-Certificate-DN",
"APIC-Request-Signature",
)
if not all(cookies.get(name) for name in required):
return None
cert_dn = cookies["APIC-Certificate-DN"]
m = _CERT_DN_RE.match(cert_dn)
if not m:
return None
user = m.group(1)
if user != SIM_USERNAME:
return None
return user
def require_session(request: Request) -> _Session | None:
"""Dependency-style helper: return the caller's live session or None.
Callers (app.py routes) turn a None into a 403 APIC envelope. Kept as a
plain function (not a FastAPI Depends) so routes can return the 403 body
in the exact APIC envelope shape rather than a generic exception page.
PR-9: also accepts certificate-signature auth (accept-mode) — a request
carrying a well-formed, matching-user cert-cookie set authenticates
without ever having called aaaLogin. A synthetic, non-expiring _Session
is returned for that request only (not stored — cheap to recompute per
request, and there is no real token to leak/reuse).
"""
token = request.cookies.get("APIC-cookie")
session = _lookup_session(token)
if session is not None:
return session
cert_user = _cert_auth_user(request)
if cert_user is not None:
return _Session(user=cert_user, expires_at=_now() + SESSION_LIFETIME_SECONDS)
return None
def _apic_error(text: str, code: str, status_code: int) -> JSONResponse:
body = {
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
"totalCount": "1",
}
return JSONResponse(status_code=status_code, content=body)
def _auth_error_403() -> JSONResponse:
return _apic_error("Token was invalid (Error: Token timeout)", code="403", status_code=403)
def _login_response(token: str) -> dict:
return {
"imdata": [
{
"aaaLogin": {
"attributes": {
"token": token,
"refreshTimeoutSeconds": str(int(SESSION_LIFETIME_SECONDS)),
}
}
}
],
"totalCount": "1",
}
def make_auth_router() -> APIRouter:
router = APIRouter()
@router.post("/api/aaaLogin.json")
async def aaa_login(request: Request, response: Response):
# Parse the body manually (not via a FastAPI `body: dict` parameter)
# so a malformed/non-dict body never triggers FastAPI's automatic
# RequestValidationError -> {"detail": [...]} leak. We want a 400
# APIC envelope for every malformed shape instead.
try:
raw = await request.json()
except Exception:
return _apic_error("Malformed request body", code="400", status_code=400)
if not isinstance(raw, dict):
return _apic_error("Malformed request body", code="400", status_code=400)
aaa_user = raw.get("aaaUser")
if not isinstance(aaa_user, dict):
return _apic_error("Malformed aaaUser body", code="400", status_code=400)
attrs = aaa_user.get("attributes")
if not isinstance(attrs, dict):
return _apic_error("Malformed aaaUser body", code="400", status_code=400)
username = attrs.get("name")
password = attrs.get("pwd")
bare_username = _bare_username(username) if isinstance(username, str) else username
if bare_username != SIM_USERNAME or password != SIM_PASSWORD:
return _apic_error(
"Authentication failed: invalid username or password",
code="401",
status_code=401,
)
token = _create_session(bare_username)
response.set_cookie("APIC-cookie", token)
return _login_response(token)
@router.get("/api/aaaRefresh.json")
async def aaa_refresh(request: Request, response: Response):
token = request.cookies.get("APIC-cookie")
if not token or not _touch_session(token):
return _auth_error_403()
response.set_cookie("APIC-cookie", token)
return _login_response(token)
@router.post("/api/aaaLogout.json")
async def aaa_logout(request: Request, response: Response):
token = request.cookies.get("APIC-cookie")
_pop_session(token)
response.delete_cookie("APIC-cookie")
return {"imdata": [], "totalCount": "0"}
return router
+212
View File
@@ -0,0 +1,212 @@
"""APIC query-subscription registry + websocket push-on-change (PR — subscriptions).
Real APIC's subscription mechanism (verified against the documented behavior
cisco.aci / ACI SDK clients and monitoring tools rely on):
1. **Subscribe**: any of the three query shapes (class/mo/node-class) accepts
`?subscription=yes`. The response is the NORMAL query result (imdata +
totalCount, unchanged) PLUS a top-level `"subscriptionId"` string. The
query itself also establishes the "current state" the subscription is
watching relative to.
2. **Websocket**: the client opens `GET /socket<token>` where `<token>` is
the same APIC-cookie token from aaaLogin. All of that session's live
subscriptions are pushed over this one connection.
3. **Push on change**: when a write (POST create/update or DELETE) commits,
APIC evaluates every live subscription's scope against the changed
MO(s). Any match gets a push:
`{"subscriptionId":[<id>,...],"imdata":[{<class>:{"attributes":{...,
"status":"created|modified|deleted","dn":...}}}]}`.
4. **Refresh**: `GET /api/subscriptionRefresh.json?id=<id>` keeps a
subscription alive past its TTL; real APIC's default subscription
lifetime is 90s, refreshed by polling this endpoint.
This module is the in-memory registry + notify/bridge glue. It has ZERO
FastAPI route decorators of its own — `app.py` wires the HTTP/websocket
routes and calls into this module's functions, matching how `auth.py` keeps
its session store separate from route registration.
Sync-write -> async-websocket bridge
-------------------------------------
`app.py`'s query/write handlers are `async def` but call straight into sync
store code — there's no actual concurrency boundary within a single request.
The awkward part is different: `notify()` must be callable from those
handlers (regular function-call context, not itself a coroutine) but the
actual send happens on a websocket that's being driven by its own `asyncio`
task (`websocket.receive()` loop). Two connections could be subscribed to
the same class, and a slow/stuck client must never block or break the write
request that triggered the notification.
The fix: give every websocket connection its own `asyncio.Queue`. `notify()`
is a plain sync function — it just does `queue.put_nowait(event)` (never
blocks, drops nothing since the queue is unbounded) for every connection
whose subscriptions match. The websocket route runs a small async task that
does `event = await queue.get(); await websocket.send_json(event)` in a loop.
This decouples "a write happened" (sync call stack) from "bytes went out on
a socket" (async task on that connection's own schedule) with no shared
event-loop reentrancy concerns and no risk of a write request awaiting
network I/O on some other client's socket.
`app.py`'s route actually runs two tasks per connection, raced via
``asyncio.wait(..., return_when=FIRST_EXCEPTION)``: one draining this
queue and pushing events, one blocked on ``websocket.receive()`` purely to
notice a client-initiated disconnect promptly (a lone queue-drain loop
would only discover a dead connection the next time it tried to push —
arbitrarily late, or never, for a subscriber sitting on a quiet class).
Either task finishing tears down both and calls `drop_connection`.
"""
from __future__ import annotations
import asyncio
import itertools
import time
from dataclasses import dataclass, field
# Real APIC's default subscription lifetime is 90s; refreshed via
# GET /api/subscriptionRefresh.json?id=<id>. Kept generous here since this
# sim has no eviction sweep beyond the refresh no-op / disconnect cleanup.
SUBSCRIPTION_LIFETIME_SECONDS: float = 90.0
_id_counter = itertools.count(1)
@dataclass
class _Connection:
"""One live `/socket<token>` websocket connection for a session token."""
token: str
queue: "asyncio.Queue[dict]" = field(default_factory=asyncio.Queue)
@dataclass
class Subscription:
"""A single registered subscription (one per `?subscription=yes` query).
scope is either:
- ("class", "<className>") — class query / node-class fabric-wide form
- ("dn", "<dn>") — mo query (dn-scoped; also matches the
DN's subtree so a child MO's change
still notifies a parent-DN subscriber)
"""
id: str
token: str
scope_kind: str # "class" | "dn"
scope_value: str
expires_at: float
# subscriptionId -> Subscription
_subscriptions: dict[str, Subscription] = {}
# token -> _Connection (at most one live websocket per session token)
_connections: dict[str, _Connection] = {}
def _now() -> float:
return time.monotonic()
def reset() -> None:
"""Test/isolation helper: drop all subscriptions + connections."""
_subscriptions.clear()
_connections.clear()
def _prune_expired() -> None:
now = _now()
expired = [sid for sid, sub in _subscriptions.items() if sub.expires_at <= now]
for sid in expired:
del _subscriptions[sid]
def subscribe(token: str, scope_kind: str, scope_value: str) -> str:
"""Register a new subscription for *token* watching *scope_kind*/*scope_value*.
Returns the new subscriptionId (a string, matching real APIC's shape).
"""
_prune_expired()
sid = str(next(_id_counter))
_subscriptions[sid] = Subscription(
id=sid,
token=token,
scope_kind=scope_kind,
scope_value=scope_value,
expires_at=_now() + SUBSCRIPTION_LIFETIME_SECONDS,
)
return sid
def refresh(subscription_id: str) -> bool:
"""Renew a subscription's TTL. Returns False if the id is unknown/expired."""
_prune_expired()
sub = _subscriptions.get(subscription_id)
if sub is None:
return False
sub.expires_at = _now() + SUBSCRIPTION_LIFETIME_SECONDS
return True
def register_connection(token: str) -> "asyncio.Queue[dict]":
"""Associate a websocket connection with *token*; returns its event queue.
Only one live connection per token is tracked (matches real APIC — a
session has one websocket). A reconnect replaces the prior queue.
"""
conn = _Connection(token=token)
_connections[token] = conn
return conn.queue
def drop_connection(token: str) -> None:
"""Remove the connection + any subscriptions owned by *token* (disconnect cleanup)."""
_connections.pop(token, None)
_prune_expired()
dead = [sid for sid, sub in _subscriptions.items() if sub.token == token]
for sid in dead:
del _subscriptions[sid]
def is_known_token(token: str) -> bool:
"""True if *token* has ever registered a connection (used only for tests/debug)."""
return token in _connections
def _dn_matches(scope_dn: str, changed_dn: str) -> bool:
"""True if *changed_dn* is *scope_dn* itself or lives in its subtree."""
return changed_dn == scope_dn or changed_dn.startswith(scope_dn + "/")
def notify(cls: str, dn: str, status: str, attributes: dict) -> None:
"""Push an event to every live subscription whose scope matches this change.
Called synchronously from the write path (POST/DELETE handlers in
app.py) right after the store commit. Never awaits, never raises for a
slow/absent client — `asyncio.Queue.put_nowait` on an unbounded queue
cannot block, and a token with no live connection (subscribed but the
websocket hasn't connected yet, or already disconnected) is skipped.
*attributes* should be the MO's attribute dict (will be shallow-copied)
with ``dn``/``status`` already set or overridable via the explicit args.
"""
_prune_expired()
matched: dict[str, list[str]] = {} # token -> [subscriptionId, ...]
for sid, sub in _subscriptions.items():
if sub.scope_kind == "class" and sub.scope_value == cls:
matched.setdefault(sub.token, []).append(sid)
elif sub.scope_kind == "dn" and _dn_matches(sub.scope_value, dn):
matched.setdefault(sub.token, []).append(sid)
if not matched:
return
attrs = dict(attributes)
attrs["dn"] = dn
attrs["status"] = status
event_body = {cls: {"attributes": attrs}}
for token, sids in matched.items():
conn = _connections.get(token)
if conn is None:
continue
event = {"subscriptionId": sids, "imdata": [event_body]}
conn.queue.put_nowait(event)
+469
View File
@@ -0,0 +1,469 @@
"""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.mo import MO
from aci_sim.mit.store import MITStore
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 _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]]:
"""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)`` plan so callers (``apply``)
can inspect it for reactions (e.g. a nested ``fabricNodeIdentP`` child)
without re-walking the body themselves.
"""
# 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)
# Validation passed for the entire subtree — now, and only now, mutate
# the store (400 on validation failure => zero side effects).
for mo_cls, mo_attrs in planned:
# 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))
return planned
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",
)
def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=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.
"""
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)
# 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)
if site is not None:
for mo_cls, mo_attrs in planned:
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,
)
status = "deleted" if attrs.get("status") == "deleted" else "created"
result = [{cls: {"attributes": {"dn": effective_dn, "status": status}}}]
return result, 1