mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user