mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-22 05:25:11 +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,166 @@
|
||||
"""Control-plane admin router mounted under /_sim on each APIC app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from aci_sim.control.persist import (
|
||||
deserialize_store,
|
||||
load_json,
|
||||
save_json,
|
||||
serialize_store,
|
||||
state_dir,
|
||||
)
|
||||
from aci_sim.mit.mo import MO
|
||||
|
||||
|
||||
def _apic_error(text: str, code: str = "103", status_code: int = 400) -> JSONResponse:
|
||||
"""Return an APIC error envelope as a JSONResponse.
|
||||
|
||||
Duplicated (rather than imported) from rest_aci.app to avoid a circular
|
||||
import: app.py imports make_admin_router from this module.
|
||||
"""
|
||||
body = {
|
||||
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
|
||||
"totalCount": "1",
|
||||
}
|
||||
return JSONResponse(status_code=status_code, content=body)
|
||||
|
||||
|
||||
def make_admin_router(state) -> APIRouter:
|
||||
"""Build the /_sim admin router.
|
||||
|
||||
state is an ApicSiteState dataclass; we mutate state.store directly.
|
||||
"""
|
||||
router = APIRouter(prefix="/_sim")
|
||||
_snapshots: dict[str, object] = {} # name → MITStore deepcopy
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset():
|
||||
state.store = copy.deepcopy(state.baseline)
|
||||
return {"status": "ok", "action": "reset"}
|
||||
|
||||
@router.post("/snapshot/{name}")
|
||||
async def snapshot(name: str):
|
||||
_snapshots[name] = copy.deepcopy(state.store)
|
||||
return {"status": "ok", "snapshot": name}
|
||||
|
||||
@router.post("/restore/{name}")
|
||||
async def restore(name: str):
|
||||
if name not in _snapshots:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "detail": f"snapshot '{name}' not found"},
|
||||
)
|
||||
state.store = copy.deepcopy(_snapshots[name])
|
||||
return {"status": "ok", "restored": name}
|
||||
|
||||
def _find_site(topo):
|
||||
"""Look up the site matching state.site in a freshly-loaded topology.
|
||||
|
||||
Matches by the stable Site.id first (the schema's documented stable
|
||||
key), falling back to Site.name for topologies that only vary node
|
||||
content but keep the same id. Returns None if the site no longer
|
||||
exists in the new topology at all.
|
||||
"""
|
||||
for candidate in topo.sites:
|
||||
if candidate.id == state.site.id:
|
||||
return candidate
|
||||
for candidate in topo.sites:
|
||||
if candidate.name == state.site.name:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@router.post("/reload")
|
||||
async def reload(request: Request):
|
||||
from aci_sim.topology.loader import load_topology
|
||||
from aci_sim.build.orchestrator import build_site
|
||||
from aci_sim.runtime.config import TOPOLOGY_PATH
|
||||
|
||||
topo = load_topology(TOPOLOGY_PATH)
|
||||
fresh_site = _find_site(topo)
|
||||
if fresh_site is None:
|
||||
return _apic_error(
|
||||
text=(
|
||||
f"Site '{state.site.name}' (id={state.site.id!r}) no longer "
|
||||
"exists in the reloaded topology"
|
||||
),
|
||||
code="103",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Use the FRESH site object from the just-loaded topology, not the
|
||||
# stale pre-reload state.site — otherwise edits to this site's nodes/
|
||||
# leaves in topology.yaml are silently ignored (finding #11).
|
||||
state.topo = topo
|
||||
state.site = fresh_site
|
||||
new_store = build_site(topo, fresh_site)
|
||||
state.store = new_store
|
||||
state.baseline = copy.deepcopy(new_store)
|
||||
return {"status": "ok", "action": "reload"}
|
||||
|
||||
@router.post("/save/{name}")
|
||||
async def save(name: str):
|
||||
"""Persist this site's MITStore to disk under *name*.
|
||||
|
||||
File is keyed by both *name* and ``state.site.id`` so a single
|
||||
SIM_STATE_DIR can hold saves for multiple sites without collision
|
||||
(mirrors the wrapper script's per-plane file naming).
|
||||
"""
|
||||
path = state_dir() / f"{name}.{state.site.id}.apic.json"
|
||||
data = serialize_store(state.store)
|
||||
save_json(path, data)
|
||||
return {"status": "ok", "file": str(path), "count": len(data)}
|
||||
|
||||
@router.post("/load/{name}")
|
||||
async def load(name: str):
|
||||
"""Restore this site's MITStore from a prior :func:`save`."""
|
||||
path = state_dir() / f"{name}.{state.site.id}.apic.json"
|
||||
if not path.exists():
|
||||
return _apic_error(
|
||||
f"state '{name}' not found for site {state.site.id}",
|
||||
status_code=404,
|
||||
)
|
||||
data = load_json(path)
|
||||
state.store = deserialize_store(data)
|
||||
return {"status": "ok", "count": len(data)}
|
||||
|
||||
@router.post("/add-leaf")
|
||||
async def add_leaf(request: Request):
|
||||
from aci_sim.rest_aci.writes import materialize_node_registration
|
||||
|
||||
body = await request.json()
|
||||
node_id = body.get("id")
|
||||
if node_id is None:
|
||||
existing = [int(mo.attrs.get("id", 0)) for mo in state.store.by_class("fabricNode")]
|
||||
node_id = max(existing, default=100) + 1
|
||||
name = body.get("name", f"leaf-{node_id}")
|
||||
# Shares the fabricNodeIdentP write-reaction's enrichment (finding #19):
|
||||
# fabricNode + topSystem + healthInst + fabricLink cabling to the
|
||||
# spines + l1PhysIf inventory, not just a bare fabricNode.
|
||||
materialize_node_registration(
|
||||
state.store,
|
||||
topo=state.topo,
|
||||
site=state.site,
|
||||
node_id=int(node_id),
|
||||
name=name,
|
||||
role=body.get("role", "leaf"),
|
||||
)
|
||||
return {"status": "ok", "node_id": node_id, "name": name}
|
||||
|
||||
@router.post("/remove-leaf")
|
||||
async def remove_leaf(request: Request):
|
||||
body = await request.json()
|
||||
node_id = body.get("id")
|
||||
if node_id is None:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "detail": "id required"})
|
||||
pod = state.site.pod
|
||||
dn = f"topology/pod-{pod}/node-{node_id}"
|
||||
mo = MO("fabricNode", dn=dn, status="deleted")
|
||||
state.store.upsert(mo)
|
||||
return {"status": "ok", "removed": node_id}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,122 @@
|
||||
"""File-backed state persistence for the sim's MITStore and NdoState.
|
||||
|
||||
Covers ALL planes — per-site APIC MITStores and the NDO model — so a whole
|
||||
fabric can be saved to disk and restored across a sim restart (sandbox/port
|
||||
mode has no other durability: everything else lives in memory only).
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- ``MO`` instances stored in a :class:`~aci_sim.mit.store.MITStore`
|
||||
are always childless (the store keeps the parent/child hierarchy only in
|
||||
its own ``_children`` index — see ``MITStore.add``'s docstring). So a
|
||||
faithful store round-trip only needs each MO's ``class_name`` + ``attrs``;
|
||||
replaying them through ``MITStore.add`` rebuilds the ancestor index and
|
||||
therefore all subtree queries.
|
||||
- ``NdoState`` is a plain ``@dataclass`` of JSON-friendly fields (lists/dicts
|
||||
of str/bool/int). ``apply_ndo`` mutates the SAME state object in place via
|
||||
``setattr`` — ``make_ndo_app`` closes over the ``state`` object identity,
|
||||
so replacing it with a new instance would leave the running app's routes
|
||||
pointed at stale data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aci_sim.mit.mo import MO
|
||||
from aci_sim.mit.store import MITStore
|
||||
from aci_sim.ndo.model import NdoState
|
||||
|
||||
#: NdoState fields that are persisted (mutable, JSON-friendly). Excludes
|
||||
#: nothing from the dataclass — every field NdoState carries is saved.
|
||||
FIELDS: list[str] = [
|
||||
"tenants",
|
||||
"schemas",
|
||||
"schema_details",
|
||||
"template_summaries",
|
||||
"tenant_policy_templates",
|
||||
"policy_states",
|
||||
"audit_records",
|
||||
"local_users",
|
||||
"remote_users",
|
||||
"extra_schemas",
|
||||
"sites",
|
||||
"fabric_connectivity",
|
||||
]
|
||||
|
||||
|
||||
def state_dir() -> Path:
|
||||
"""Return the base directory for persisted state, creating it if needed.
|
||||
|
||||
Defaults to ``~/.aci-sim/state``; override with ``SIM_STATE_DIR``
|
||||
(tests set this to an isolated ``tmp_path`` so nothing touches the
|
||||
real home directory).
|
||||
"""
|
||||
base = Path(os.environ.get("SIM_STATE_DIR", os.path.expanduser("~/.aci-sim/state")))
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def save_json(path: Path, obj: Any) -> None:
|
||||
"""Write *obj* to *path* as indented JSON."""
|
||||
with open(path, "w") as f:
|
||||
json.dump(obj, f, indent=2)
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
"""Read and return the JSON document at *path*."""
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MITStore (APIC plane) serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_store(store: MITStore) -> list[dict[str, Any]]:
|
||||
"""Flatten *store* to a JSON-friendly list of ``{class, attrs}`` dicts.
|
||||
|
||||
Stored MOs are childless (hierarchy lives only in the store's parent
|
||||
index — see ``MITStore.add``), so this list alone is sufficient to
|
||||
reconstruct the store via :func:`deserialize_store`.
|
||||
"""
|
||||
return [{"class": mo.class_name, "attrs": dict(mo.attrs)} for mo in store.all()]
|
||||
|
||||
|
||||
def deserialize_store(data: list[dict[str, Any]]) -> MITStore:
|
||||
"""Rebuild a :class:`MITStore` from :func:`serialize_store` output.
|
||||
|
||||
Replays each entry through ``MITStore.add``, which rebuilds the
|
||||
ancestor/``_children`` index as it goes — so subtree queries against the
|
||||
restored store behave exactly as they did before serialization.
|
||||
"""
|
||||
s = MITStore()
|
||||
for d in data:
|
||||
s.add(MO(d["class"], **d["attrs"]))
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NdoState (NDO plane) serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_ndo(state: NdoState) -> dict[str, Any]:
|
||||
"""Return a deep-copied, JSON-friendly dict of *state*'s persisted fields."""
|
||||
return {f: copy.deepcopy(getattr(state, f)) for f in FIELDS}
|
||||
|
||||
|
||||
def apply_ndo(state: NdoState, data: dict[str, Any]) -> None:
|
||||
"""Apply *data* (from :func:`serialize_ndo`) onto *state* IN PLACE.
|
||||
|
||||
Mutates the same object via ``setattr`` rather than returning a new
|
||||
``NdoState`` — ``make_ndo_app`` closes over this exact object, so
|
||||
replacing it would leave the running app's routes reading stale state.
|
||||
"""
|
||||
for f, v in data.items():
|
||||
setattr(state, f, copy.deepcopy(v))
|
||||
Reference in New Issue
Block a user