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
+53
View File
@@ -0,0 +1,53 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: test (${{ matrix.os }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run test suite
run: pytest -q
lint:
name: ruff (non-blocking)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
# Non-blocking on purpose: the repo currently carries a pre-existing lint
# baseline of ~60 errors (mostly import-order/unused-import findings from
# incremental PR-by-PR development). Tighten this to a real gate (drop the
# `|| true` and/or add `--exit-non-zero-on-fix`) once that baseline is
# cleaned up — tracked as a follow-up, not a blocker for public release.
- name: ruff check (informational)
run: ruff check . || true
+14
View File
@@ -0,0 +1,14 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
build/
dist/
certs/*.pem
certs/*.key
certs/*.crt
*.egg-info/
.DS_Store
snapshots/
verify_findings.py
+997
View File
@@ -0,0 +1,997 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(pre-1.0: minor bumps may include breaking changes to the sim's behavior).
## [0.16.0] - 2026-07-05
### Changed
- **Project renamed `aci-fabric-sim` -> `aci-sim`.** Import package
`aci_fabric_sim` -> `aci_sim`, PyPI distribution name, GitHub repo, and the
`aci-sim` CLI entry point are now all unified under one name (previously
the package and CLI were `aci-sim`/`aci_sim` while the repo/distribution
metadata still said `aci-fabric-sim` in places). No behavior change —
every module path under `aci_sim/` and every documented command are
unchanged; this is a naming-consistency cleanup ahead of the repo going
public.
## [0.15.0] - 2026-07-05
### Added
- **NDO -> APIC deploy mirror.** `POST /mso/api/v1/task` (the deploy/undeploy
request `cisco.mso.ndo_schema_template_deploy` sends) was a pure
acknowledgment no-op — it never materialized the deployed schema
template's VRFs/BDs/ANPs/EPGs/binds into the target sites' APIC stores, so
a multi-site tenant created purely through NDO never appeared on the APIC
side. `mirror_template_to_sites()` (`aci_sim/ndo/deploy_mirror.py`) closes
this gap: given the NDO state, a template name, and a site_id ->
ApicSiteState map, it walks the owning schema's template plus each
associated SITE entry (host-based-routing overlay, domain associations,
static ports) and upserts the equivalent `fvCtx`/`fvBD`/`fvAp`/`fvAEPg` (+
children) MOs into every target site's `MITStore`, reusing the same
DN/attribute conventions the boot-time builders and the direct POST-write
path already use. Also creates a per-site `fvTenant` shadow so the tenant
itself — not just its child objects — is visible on each target site's
APIC after an NDO-driven deploy (previously only the site passed via
`--apic` in ad hoc testing got the tenant; the other site's `fvTenant` was
missing entirely).
- **`fvBD` create-time default attributes.** A POSTed BD now carries the
same default attribute set a real APIC assigns at creation
(`epMoveDetectMode="garp"`, `hostBasedRouting="no"`, `mtu="9000"`, …)
instead of only the attributes the caller explicitly set — so
host-based-routing and EP-move-detection now show up correctly in the
APIC BD view for a BD created via a plain POST, matching real-APIC
behavior.
## [0.14.0] - 2026-07-05
### Added
- **File-backed state persistence.** New `POST /_sim/save/{name}` and
`POST /_sim/load/{name}` routes on both the APIC plane
(`aci_sim/control/admin.py`) and the NDO plane (`aci_sim/ndo/app.py`)
serialize/restore each plane's store to/from disk under `SIM_STATE_DIR`
(default `~/.aci-sim/state`) — unlike the existing in-memory
`snapshot`/`restore`, this state survives a full sim restart.
`scripts/sim-state.sh {save|restore} <name>` is the whole-fabric wrapper:
it derives each plane's address the same way `scripts/sandbox-up.sh` does
and hits every plane's endpoint (both APIC sites + NDO) in one command.
New module `aci_sim/control/persist.py` implements the serialization: a
`MITStore` flattens to a JSON list of `{class, attrs}` dicts (MOs stored
in the MIT are always childless — the parent/child hierarchy lives only
in the store's own index — so replaying them through `MITStore.add`
faithfully rebuilds it); `NdoState`'s persisted fields are deep-copied to
a JSON-friendly dict and reapplied onto the same live object via
`setattr` (the running NDO app closes over that object's identity, so a
reload must mutate in place rather than swap in a new instance).
## [0.13.2] - 2026-07-04
### Fixed
- **NDO `serviceNodeRef` resolved to a string ref (was a dict).** The
service-graph -> contract bind path stored `serviceNodeRef` as a dict-form
ref while every other mirror/lookup path expects the canonical NDO string
ref shape (`/schemas/{id}/templates/{tmpl}/{category}/{name}`); re-running
the bind against an already-bound contract was not idempotent because the
two representations never compared equal. Fixed to resolve/store the
string form, matching the rest of the NDO ref conventions.
## [0.13.1] - 2026-07-04
### Fixed
- **`pip install` was completely broken — now fixed (critical, packaging).** A
from-scratch clean-venv E2E revealed that `pip install aci-sim` / `pip
install .` produced an unusable package: (1) **zero runtime dependencies** were
declared to pip — `pyproject.toml` had `dynamic = ["dependencies"]` with no
`[tool.setuptools.dynamic]` source, so fastapi/uvicorn/pydantic/pyyaml were
never installed (the CLI crashed on the first third-party import, `import
yaml`); and (2) **every subpackage was omitted**`packages =
["aci_sim"]` shipped only the top-level module, dropping
`topology`/`build`/`rest_aci`/`ndo`/`mit`/`runtime`/`query`/`control`. The dev
workflow (`pip install -e .` + `pip install -r requirements.txt`) masked both.
Fix: static `[project.dependencies]` (fastapi/uvicorn[standard]/pydantic/pyyaml),
a `[project.optional-dependencies].dev` extra (httpx/pytest/pytest-asyncio), and
`[tool.setuptools.packages.find] include = ["aci_sim*"]` to ship every
subpackage. Verified end-to-end: a fresh venv `pip install .` now yields a
working `aci-sim` CLI + importable package.
## [0.13.0] - 2026-07-04
### Added
- **Admin account in the setup wizard + `aci-sim run` credential enforcement.**
`aci-sim init`'s new **Step 0: Admin account** prompts for the fabric admin
username/password (default `admin`/`cisco`) and whether NDO shares the APIC
account (default yes), writing them into a new topology.yaml `auth:` section
(`schema.Auth`: `username`/`password` + optional `ndo_username`/`ndo_password`).
`aci-sim run` resolves these into `SIM_USERNAME`/`SIM_PASSWORD` for the
supervisor via the new `cli.resolve_admin_credentials()`, so the APIC plane
then ENFORCES them (`aaaLogin` returns 401 on a username/password mismatch).
**Precedence** (highest first): an explicit `SIM_USERNAME`/`SIM_PASSWORD`
already in the environment wins (the CI/override escape hatch), else the
topology `auth:` section, else `auth.py`'s own `admin`/`cisco` default.
`aci-sim run` prints a one-line auth-source banner at startup (the username +
where it came from, never the password).
- The NDO plane stays **lenient by design**`/login` and `/api/v1/auth/login`
accept any credential and never validate the bearer token (cisco.mso/aci-py
client compatibility). The shared account is recorded/reported but not
enforced there; a split `ndo_username`/`ndo_password` is informational.
### Notes
- Purely additive / backward-compatible: a topology.yaml with **no** `auth:`
section still validates (`Topology.auth: Auth | None = None`) and `aci-sim
run` injects nothing — behavior is identical to 0.12.0.
- 24 new tests (`tests/test_admin_account.py`) cover the wizard's `auth:`
output (defaults / custom / split-NDO), `resolve_admin_credentials`
precedence, `build_run_env_argv` injection + filesystem purity, end-to-end
`aaaLogin` enforcement of a rotated password (incl. the stale default now
failing) with NDO still lenient, and no-`auth:` backward-compat.
## [0.12.0] - 2026-07-04
### Added
- **LLDP/CDP neighbor fidelity — enriched adjacencies + materialized container MOs + CLI.**
`lldpAdjEp`/`cdpAdjEp` previously carried only `sysName`/`mgmtIp`; both are now enriched with
real-APIC-shaped fields — `lldpAdjEp` gains `portIdV` (the NEIGHBOR's own port), `portIdT`,
`chassisIdT`, `chassisIdV` (a deterministic per-node MAC via new `build/neighbors.py:node_mac`),
`sysDesc` (`"{model} running {version}"` for a switch neighbor, `"Cisco APIC"` for a controller),
`capability`, `id`, `ttl`; `cdpAdjEp` gains `devId` (== `sysName`), `portId`, `platId`, `ver`,
`cap`. Purely additive — `sysName`/`mgmtIp` are unchanged, so every pre-existing consumer
(autoACI, this repo's own tests) keeps working untouched.
New container classes `lldpInst`/`lldpIf`/`cdpInst`/`cdpIf` are now materialized (one `*Inst`
per switch node with any adjacency, one `*If` per adjacency-bearing local port), fixing a bug
where a deep-root subtree query (`GET /api/mo/topology/pod-1/node-101/sys/lldp/inst.json?
query-target=subtree&target-subtree-class=lldpAdjEp`) returned `totalCount=0` — the query
engine's `run_mo_query` short-circuits to `([], 0)` when the root DN has no MO of its own, and
`.../sys/lldp/inst` previously had none. `/api/class/lldpInst.json`/`lldpIf.json`/`cdpInst.json`/
`cdpIf.json` now also return real results.
All three adjacency-construction sites (`build/cabling.py` spine<->leaf links, `build/fabric.py`
APIC<->leaf attachment, `rest_aci/writes.py`'s dynamic node-registration reaction) now share one
helper module, `build/neighbors.py` (`add_adjacency`/`add_switch_adjacency` +
`ensure_lldp_containers`/`ensure_cdp_containers`), so a dynamically-registered node (via POST
`fabricNodeIdentP` or `/_sim/add-leaf`) gets the identical enriched/materialized treatment a
boot-time-seeded node gets — verified by `tests/test_lldp_cdp_fidelity.py`'s dynamic-path tests.
New CLI subcommand `aci-sim lldp [topology] [--site NAME] [--node ID] [--cdp] [--json]` prints a
`show lldp neighbors`-style grouped table (or CDP with `--cdp`, or structured JSON with `--json`).
`tests/test_lldp_cdp_fidelity.py` (20 tests): container materialization, the subtree-query
regression fix, bidirectional `portIdV` consistency across a spine<->leaf link, `cdpAdjEp.devId
== sysName`, backward-compat field presence, the dynamic-registration path, and the CLI (text/
`--json`/`--cdp`/a graceful non-existent node id).
## [0.11.0] - 2026-07-04
### Added
- **APIC query subscriptions + websocket push-on-change.** Real APIC's subscription mechanism,
the piece the "Monitoring/observability integration" use case (README §1) needed to move past
polling-only. `GET /api/class/{cls}.json`, `GET /api/mo/{dn}.json` (+ `/api/node/mo/{dn}` alias),
and `GET /api/node/class/{...}` all now accept `?subscription=yes`, returning the *unchanged*
query result plus a top-level `subscriptionId` string (opt-in only — a plain query with no
`?subscription=yes` is byte-identical to before this feature, no key added, no behavior change).
`GET /socket<token>` (real APIC's literal path shape, `<token>` = the `APIC-cookie` session
token) opens a websocket; when a subscribed class or DN/subtree changes via `POST`/`DELETE`,
a `{"subscriptionId":[...],"imdata":[{<class>:{"attributes":{...,"status":"created|modified|
deleted","dn":...}}}]}` event pushes over that connection. `GET /api/subscriptionRefresh.json?
id=<id>` keeps a subscription alive past its 90s TTL (matching real APIC), always 200 even for
an unknown id. Disconnecting the websocket drops that connection's subscriptions. New module
`aci_sim/rest_aci/subscriptions.py` (in-memory registry + notify — module-level state,
same pattern as `auth.py`'s session store); write handlers stay fully synchronous and simply
call `notify()`, which enqueues onto each live connection's own `asyncio.Queue` — the websocket
route drains that queue independently, so a write request never awaits network I/O on some
other client's socket. `tests/test_subscriptions.py` (12 tests): subscribe returns
`subscriptionId`; a plain query has zero shape change; websocket created/modified/deleted push
for a subscribed class; an unsubscribed class does not push; DN-scoped subscriptions match
subtree writes; refresh returns 200 (known and unknown id); an unknown-token websocket is
rejected; disconnect cleans up subscriptions. See README §10a and `docs/CONTRACT.md` §2a for
the full contract.
## [0.10.0] - 2026-07-04
### Added
- **OOB management gateway — a real MO instead of a discarded value.** Every node already got
`topSystem.oobMgmtAddr` (the OOB IP), but no gateway was modeled anywhere; `aci-sim init`'s wizard
even asked for one per site, but discarded the answer (no schema field, no MO — just an
informational summary line). Real APIC models the OOB address + gateway in the special `mgmt`
tenant (`uni/tn-mgmt/mgmtp-default/oob-default`, a `mgmtOoB` EPG) with one `mgmtRsOoBStNode` child
per node. New `Site.oob_gateway: Optional[str]` schema field (optional, default `None`, validated
as a real IP address) plus a new builder `aci_sim/build/mgmt.py` (registered in
`build/orchestrator.py`) that emits the minimal faithful scaffolding for every site:
`fvTenant`(mgmt) → `mgmtMgmtP`(default) → `mgmtOoB`(default) → one `mgmtRsOoBStNode`(tDn,addr,gw)
per node (switches AND controllers), `addr` = the node's OOB IP + mask (mask from
`fabric.oob_subnet` if set, else `/24` by default — deliberately not `fabric.oob_pool`'s wider
`/16`, which spans multiple pods/sites), `gw` = `site.oob_gateway` (empty string, never an invented
default, when unset).
- `aci-sim init`'s wizard now WRITES each site's answered OOB gateway into
`topology_dict["sites"][i]["oob_gateway"]` instead of discarding it; `aci-sim show` surfaces
`oob_gateway=<value|->` per site.
- `tests/test_oob_gateway.py` (25 tests): schema default/validation, builder MO shape (mgmtRsOoBStNode
per node, addr/gw correctness, mask derivation, empty-gw-when-unset), wizard write-through
(defaults run, custom answers, multi-site per-site gateways), and backward compatibility (repo
`topology.yaml` builds unchanged, existing `topSystem`/`fabricNode` attrs untouched, new
mgmt-tenant scaffolding is purely additive).
### Changed
- `docs/CONTRACT.md`: new "Mgmt tenant — OOB management" entry cataloging `fvTenant`(mgmt)/
`mgmtMgmtP`/`mgmtOoB`/`mgmtRsOoBStNode`.
- `README.md` §6c (Tier-3 config reference) documents `site.oob_gateway`; §5 `aci-sim show`/
`aci-sim init` sample output updated to include `oob_gateway=...`.
## [0.9.0] - 2026-07-04
### Added
- **`aci-sim graph` — self-contained SVG/HTML topology diagram.** New `aci_sim/graph.py`
renders the *built* fabric (spines, leaves, border leaves with vPC-pair grouping, per-site
controllers, and — for multi-site topologies with `isn.enabled: true` — a single ISN cloud node
with spine→ISN uplinks) as hand-generated, dependency-free SVG: no CDN, no local server, no npm
build step. `-o FILE.html` wraps the SVG in a minimal inline-CSS HTML shell (double-click to open
in any browser); `-o FILE.svg` writes raw SVG. Layout is a hand-computed tiered ACI hierarchy
(spine row → leaf row → border-leaf row → controller row per site, sites placed side by side, ISN
cloud centered above/between them for multi-site) plus a role→color legend and a title bar
(fabric name, site count, node count). Purely additive: no change to `topology/schema.py` or any
`build/*.py` builder — `graph.py` only reads the validated `Topology` model and re-derives the
same spine×leaf mesh `build/cabling.py::cabling_links()` produces, so the diagram always matches
the fabric that would actually be built.
- **Visual language matched to autoACI's topology view** (studied read-only on autoACI's
`frontend/src/composables/useTopologyStyles.js` + `backend/routers/topology.py`; NOT imported —
autoACI's implementation is Vue+cytoscape, this is a standalone Python reimplementation of the
same colors/hierarchy): spine = `#2563eb` blue, leaf/border-leaf = `#16a34a` green, controller =
`#d97706` amber, ISN cloud = `#475569` slate; rounded-rect nodes with a subtle drop shadow;
cabling links solid gray, ISN links dashed orange, vPC-peer links dotted slate.
- Complements `aci-sim show` (text inventory) with a visual counterpart — `show` for machine/human
text detail, `graph` for at-a-glance topology shape.
- `tests/test_graph.py` (18 tests): node/link counts against the repo's default 2-site topology,
ISN-cloud presence for multi-site and absence for a generated 1-site topology, a generated 3-site
topology rendering all 3 sites, role-color presence, well-formed-XML checks via `xml.etree`, and
`aci-sim graph` CLI coverage (HTML/SVG output, default paths, missing-file and bad-extension
errors) — no browser required anywhere in the suite.
## [0.8.1] - 2026-07-04
### Added
- **CI gate example + Use Cases docs.** New `examples/ci/`: `assert_mit.py` (a tiny MIT-end-state
assertion helper — `class:`/`mo:`/`absent:`/`attr:` checks, exit code = failed-check count, works
against the sim OR a real APIC) and `github-actions-aci-gate.yml` (copy-pasteable start-sim →
run-automation → assert workflow). README §1 gains a "More applications" table (SDK/provider
testing, change pre-flight, REST-contract testing, multi-tool interop, monitoring, scale) with
honest ready/partial/extension status, plus an explicit out-of-scope note.
## [0.8.0] - 2026-07-04
### Added
- **`aci-sim init` — interactive APIC-setup-style topology wizard (PR-22).** Modeled on the real APIC
first-boot setup dialog: prompts each field with a suggested default shown in `[brackets]` (ENTER
accepts it, or type a custom value), then writes a `topology.yaml` and auto-runs `aci-sim validate` on
the result. Flow: Step 0 deployment type (`1) Single fabric` / `2) Multi-site` — real APIC's "multi-pod"
choice is deliberately NOT offered, since this sim builds no true multi-pod fabric); Step 1 per-site
fields (name, site ID, BGP AS, pod ID, controller count 1-5, APIC OOB mgmt IP, then — if
controllers > 1 — one prompt per additional controller each suggesting the previous IP + 1 via
`ipaddress.ip_address(prev) + 1`, mapping to PR-21's `Site.controller_ips`; OOB gateway asked once per
site; TEP pool/infra VLAN/GIPo pool/OOB subnet/spine-leaf-border counts); Step 2 multi-site-only
(NDO mgmt IP, ISN OSPF area/MTU); Step 3 summary + confirm + write + auto-validate. Reuses
`generate_topology()` — the SAME generator `aci-sim new` calls — as a thin interactive front-end; no
topology-emission logic is duplicated.
- **Non-interactive wizard modes**: `--defaults` accepts every suggested default without prompting, and is
also auto-enabled whenever stdin is not a TTY (so `aci-sim init` never hangs in CI/scripts even without
the flag). `--answers FILE` accepts a YAML or JSON file of pre-filled field-name -> value answers;
listed fields are used verbatim, unlisted fields still prompt/default as usual — enables fully scripted,
reproducible topology generation (e.g. `site1_controllers: "3"` to script a specific cluster size).
- **`generate_topology()` widened (PR-22, additive, backward compatible)**: new optional `site_overrides`
parameter — a `list[dict]` (one entry per site) shallow-merged onto each auto-derived site definition
(name/id/apic_host/mgmt_ip/fabric_name/asn/pod/controllers/controller_ips) — lets a caller (the wizard)
express per-site heterogeneity, including PR-21's `controller_ips`, which the existing flat
`--controllers`/`--apic-ip-base` flags cannot express per-site. `leaves_per_site`/`spines_per_site`/
`border_pairs` now also accept a `list[int]` (one count per site) in addition to the pre-existing
single `int` applied uniformly to every site — needed because the wizard asks spine/leaf/border-pair
counts per site. Both widenings are fully backward compatible: every pre-PR-22 caller (including
`aci-sim new` and every existing test) passes a plain `int`/omits `site_overrides`, and gets
byte-identical output to before.
### Changed
- CLI help/argparse: new `init` subcommand registered alongside `validate`/`show`/`run`/`new`.
## [0.7.0] - 2026-07-04
### Changed
- **BREAKING (node counts): `Site.controllers` default 3 → 1 — single-APIC-per-site by design (PR-21).**
APIC cluster size is irrelevant to Ansible playbook *deployment* testing (a playbook targets exactly one
APIC endpoint); cluster size only matters for cluster-health/appliance-vector tooling such as autoACI's
`health_score.py`. `controllers` remains fully configurable, now range-validated **1-5**
(`Topology.normalize_and_validate`) — set `controllers: 3` on a site to restore the old default /
exercise multi-controller `infraWiNode`/cluster-health semantics. This changes every site's generated
`fabricNode`/`topSystem` count (2 per site at the new default vs. the old one), `fabricLink`/
`lldpAdjEp`/`cdpAdjEp` counts for the APIC↔leaf attachment, and `infraWiNode` count (`controllers²`).
All existing test assertions and e2e scripts encoding the old counts were recomputed and updated — see
`docs/DESIGN.md`'s "PR-21" section for the full before/after accounting.
- `aci-sim new`'s `--controllers` flag and `generate_topology()`'s `controllers` parameter both default to
`1` now (was `3`), matching the new `Site.controllers` schema default.
- The shipped root `topology.yaml`'s commented `# controllers: N` example line now shows the current
default (`1`) instead of the pre-PR-21 default (`3`); a new commented block shows the
`controllers: 3` + `controller_ips: [...]` opt-in shape for multi-controller topologies.
### Added
- **`Site.controller_ips: Optional[list[str]]` — explicit per-controller OOB mgmt IPs (PR-21).** When
set, `controller_ips[i]` is controller `i+1`'s `topSystem.oobMgmtAddr` verbatim (length must equal
`controllers`, each entry a valid IP — validated). When unset (default), OOB IPs are auto-derived
**sequentially from `site.mgmt_ip`**: controller 1 = `mgmt_ip`, controller 2 = `mgmt_ip`+1, controller 3
= `mgmt_ip`+2, etc., via the new `Site.controller_oob_ips()` helper (`topology/schema.py`). Falls back
to the legacy per-node `oob_ip(pod, cid)` scheme when `mgmt_ip` is also unset, so a topology setting
neither field keeps byte-identical controller OOB addresses to pre-PR-21. Wired into
`build/fabric.py`'s controller `topSystem.oobMgmtAddr` and the leaf-side `lldpAdjEp`/`cdpAdjEp.mgmtIp`
(kept consistent — both read the same per-call derived list). Surfaced in `aci-sim show`/`show --json`
as `controller_ips`. The sim's REST server still binds to exactly one address per site (`mgmt_ip`,
controller-1 / the cluster VIP) regardless of `controllers` — it never runs N separate APIC processes;
`controllers`/`controller_ips` only affect the simulated fabricNode/topSystem/infraWiNode data.
- README "Configuration" section (§6a) now documents the single-APIC design intent explicitly, and a new
`docs/DESIGN.md` "PR-21" section covers the full before/after node-count accounting and verification.
## [0.6.0] - 2026-07-04
### Added
- **Tier-3 fine-tuning parameters (PR-20) — the last planned param tier.**
New optional `topology.yaml` fields — all defaulted to pre-existing
hardcoded behavior (or the real ACI factory default where no prior
hardcoded value existed), so the existing repo `topology.yaml` validates
and builds unchanged:
- `bd.mac` (per-BD, optional) and `fabric.default_bd_mac` (fabric-wide,
default `"00:22:BD:F8:19:FF"` — the exact literal `build/tenants.py`
already hardcoded for every BD pre-PR-20) — now threaded into
`fvBD.mac`, replacing that hardcode. Resolution order: `bd.mac or
fabric.default_bd_mac`. Byte-identical output when both are unset.
- `l3out.csw_peer.keepalive`/`.hold` (defaults `60`/`180`, real ACI
defaults) — threaded into `build/l3out.py`'s `bgpPeerP.keepAliveIntvl`/
`holdIntvl` (previously not carried on that MO at all). `hold` must
exceed `keepalive`.
- `isn.ospf_hello`/`.ospf_dead` (defaults `10`/`40`, real ACI defaults) —
threaded into `build/underlay.py`'s `ospfIf.helloIntvl`/`deadIntvl`
(previously not carried on that MO at all). `ospf_dead` must exceed
`ospf_hello`.
- `isn.bfd_min_rx`/`.bfd_min_tx`/`.bfd_multiplier` (defaults `50`/`50`/
`3`, real ACI defaults) — store + range-validate + surface only. This
sim builds no `bfdIfP`-style MO anywhere (grepped `build/*.py`: zero
matches for "bfd"), so these are explicitly **store-only**, documented
as such in `aci-sim show`'s own output.
- `fabric.oob_subnet` (validate-only; must fall within
`192.168.0.0/16`) and `fabric.inb_subnet` (store-only; must fall
within a private RFC1918 range) — `oob_ip()` still derives every
node's OOB address from `(pod, node_id)` only (documented pre-existing
limitation, unchanged); this sim has no in-band mgmt MO at all, so
`inb_subnet` is explicitly store-only.
- `fabric.default_apic_version` (fabric-wide override; default `None` =
unchanged behavior) — takes precedence over each site's own
`site.apic_version` (PR-9/PR-18 lineage, already fully wired and
already independent of switch-node `Node.version`) in
`build/fabric.py`'s controller `fabricNode`/`topSystem`/
`firmwareCtrlrRunning`. **Finding while implementing this PR:**
`site.apic_version` already drove those MOs independently of switch
version before this PR — the actual gap closed here is (1) a
fabric-wide override knob and (2) `aci-sim show` never surfaced
`apic_version` at all pre-PR-20 (now shown per-site as the resolved
effective value).
- `aci-sim new` gained `--isn-ospf-hello`, `--isn-ospf-dead`,
`--isn-bfd-min-rx`, `--isn-bfd-min-tx`, `--isn-bfd-multiplier`,
`--default-bd-mac` flags; `aci-sim show` now prints
`oob_subnet`/`inb_subnet` (labeled store-only)/`default_bd_mac`,
`default_apic_version` (when set), ISN `ospf_hello`/`ospf_dead`, ISN
BFD timers (labeled store-only), and each site's effective
`apic_version`.
- New tests: `tests/test_pr20_tier3.py` (41 tests). Full suite: 713
passed (672 pre-existing + 41 new), zero regressions.
- See `docs/DESIGN.md`'s "PR-20 — Tier-3 fine-tuning parameters" section
for the full design rationale and an explicit statement of which
backward-compat verification was performed in this sandboxed
development environment vs. deferred to the main thread's independent
production-chain re-run (no SSH access to the hardware test host from
this environment).
## [0.5.0] - 2026-07-04
### Added
- **Tier-2 topology-elasticity parameters (PR-19).** New optional
`topology.yaml` fields — all defaulted to pre-existing hardcoded behavior,
so the existing repo `topology.yaml` validates and builds unchanged:
- `isn.ospf_area` (default `"0.0.0.0"`, accepts dotted-decimal or
decimal-integer notation) and `isn.mtu` (default `9150`, range
576-9216) — now threaded into `build/underlay.py`'s `ospfIf`/
`ospfAdjEp` `area` and `build/interfaces.py`'s dedicated ISN/IPN spine
uplink `l1PhysIf.mtu`, replacing hardcoded `"0.0.0.0"`/`9216` literals
at those specific call sites. Ordinary fabric ports keep their existing
9216 MTU.
- `access.vmm_domains[]` (default: empty list) — a VMM domain model
(`vcenter_ip`, `vcenter_dvs`, `vlan_pool`, `datacenter`) that builds a
`vmmDomP` (+ `vmmCtrlrP`/`vmmUsrAccP` when `vcenter_ip` is set, +
`infraRsVlanNs` when `vlan_pool` is set) in `build/access.py`. Verified
(read-only, hardware test host) that this is a **pure addition**: the real
aci-py `bind_epg_to_vmm_domain` MS-TN1 chain writes an NDO schema
`domainAssociations` entry referencing the domain by DN string only —
no existing builder or production chain reads a `vmmDomP`/`vmmCtrlrP`
object today, hence the empty-list default (unlike `phys_domains`/
`l3_domains`/`aaeps`, which each seed one entry). See `docs/DESIGN.md`
PR-19 section for the full verification trail.
- **Leaf/spine count elasticity bug fix**: `aci-sim new`'s node-ID
generator (`generate_topology()`) had no guard against
`--leaves-per-site`/`--spines-per-site` exceeding the node-ID scheme's
documented ~100-per-site ceiling, or `--border-pairs` pushing the
border block past the next site's leaf range — all three previously
produced a `topology.yaml` dict that failed deep inside
`Topology.normalize_and_validate` with a generic "node ids collide"
error. Now rejected at generation time with a clear message naming the
offending flag. Verified: `--leaves-per-site 8 --spines-per-site 4`
(and `--border-pairs` > 1) validate and build clean; the exact boundary
(100 leaves/spines, 49 border-pairs at default counts) still works;
101/50 respectively are cleanly rejected.
- `isn.peer_mesh: partial` re-examined and kept as a documented
validate-and-reject (unchanged decision from PR-17/18) — no sensible
default partition exists without additional per-site config the schema
doesn't carry.
- `aci-sim new` gained `--isn-ospf-area`, `--isn-mtu` flags; `aci-sim
show` now prints ISN enabled/peer_mesh/ospf_area/mtu and any configured
VMM domains.
- New tests: `tests/test_pr19_tier2.py` (37 tests) + 6 additions to
`tests/test_cli.py`.
## [0.4.0] - 2026-07-04
### Added
- **Tier-1 fabric configuration parameters (PR-18).** New optional
`topology.yaml` fields — all defaulted to the pre-existing hardcoded
behavior, so the existing repo `topology.yaml` validates and builds
unchanged:
- `fabric.tep_pool` (default `"10.0.0.0/16"`), `fabric.infra_vlan`
(default `3967`, range 1-4094), `fabric.gipo_pool` (default
`"225.0.0.0/15"`) — stored, CIDR/range-validated, and surfaced via
`aci-sim show`/`aci-sim new`. No builder currently derives a per-node
address or VLAN attribute from these (see `docs/DESIGN.md` PR-18
section) — store-only until a future PR wires them into a builder.
- `site.controllers` (APIC cluster size, default 3) and `site.pod`
(default 1) already existed and were already fully wired through every
relevant builder (`fabricNode`/`topSystem`/`infraWiNode`/
`firmwareCtrlrRunning`/health/endpoints); PR-18 verified this end-to-end
and added `aci-sim new --controllers N`.
- Deterministic non-empty node serials: `build/fabric.py`'s new
`default_serial(site_id, node_id)` (`f"SAL{site_id}{node_id:04d}"`)
replaces the previous inline `SIM{node.id:08d}` fallback used whenever
an auto-generated `Node.serial` is empty, so `fabricNode.serial` /
`fabricNodeIdentP.serial` always carry a real, deterministic serial for
`cisco.aci.aci_fabric_node` to register against. Explicit YAML
`serial:` values still override.
- `aci-sim new` gained `--controllers`, `--tep-pool`, `--infra-vlan`,
`--gipo-pool` flags; `aci-sim show` now prints each node's resolved
serial and each site's controller count, plus the fabric-wide
tep_pool/infra_vlan/gipo_pool.
- Verified N-site behavior: a 3-site scaffold (`aci-sim new --sites 3`)
builds with zero node-ID collisions and per-site `infraWiNode` counts
matching the configured controller count. **Documented limit**: ISN
inter-site peering and stretched-tenant HOME/REMOTE endpoint placement
still rely on `Topology.other_site()`, which is 2-site-only semantics
(pre-existing, not introduced by this PR) — with 3+ sites, ISN is not a
true full mesh. See README §6a / §8 and `docs/DESIGN.md` PR-18 section.
- New tests: `tests/test_pr18_tier1.py`.
## [0.3.0] - 2026-07-04
### Added
- **`aci-sim` topology CLI (PR-17).** A new console script
(`aci_sim/cli.py`, `[project.scripts] aci-sim`) that makes
`topology.yaml` easier to author, validate, preview, and run — purely
additive: it does not change the topology schema, builders, or NDO/REST
code, and `topology.yaml` remains the single source of truth. Four
subcommands:
- `aci-sim validate [TOPOLOGY]` — runs the same `load_topology()`/Pydantic
validation the sim already performs at startup, printing a concise
pass/fail summary (sites, leaves/spines/border-leaves per site, tenant
count) instead of a raw traceback. This is the CI gate.
- `aci-sim show [TOPOLOGY]` — prints the **derived** inventory (not just
an echo of the YAML): per-site APIC host/mgmt IP/ASN/pod/fabric name, a
table of every node with its derived loopback/OOB IP (via
`build/fabric.py`'s `loopback_ip`/`oob_ip`), the NDO mgmt IP, and the
actual host:port each controller will listen on (port mode or sandbox
mode, read from `runtime/config.py`). `--json` for machine-readable
output.
- `aci-sim run [TOPOLOGY]` — a thin wrapper around `python -m
aci_sim.runtime.supervisor`: validates the topology up front,
generates self-signed certs on first run (mirrors
`scripts/gen_certs.sh`), sets `TOPOLOGY_PATH`, passes through
`SIM_BIND`/`SIM_SANDBOX`/etc. from the environment, and execs the
supervisor — `scripts/run.sh` is unchanged and keeps working.
- `aci-sim new` — scaffolds a fresh, minimal, valid `topology.yaml` (to
stdout or `-o FILE`) from flags (`--sites`, `--leaves-per-site`,
`--spines-per-site`, `--border-pairs`, `--asn`, `--fabric-name-prefix`,
`--ndo-ip`, `--apic-ip-base`), using the same node-ID scheme documented
in `topology/schema.py` (leaves base 101 +200/site, spines base 201
+200/site, border leaves placed outside both ranges in vPC pairs). The
generated file always passes `aci-sim validate` and boots.
- New `tests/test_cli.py` (20 tests): validate positive/negative
(duplicate node id, bad CIDR), show text + `--json` asserting derived
IPs for a known node, `new --sites 3 --leaves-per-site 4` round-tripped
through the real Pydantic model, and `run`'s env/argv construction via
a pure `build_run_env_argv` helper (no live socket binding in tests).
- README gets a new "## 5. CLI" section (subsequent sections renumbered
6-11); `deploy/README.md`/Quickstart note `aci-sim run` as the
preferred entrypoint alongside `scripts/run.sh`.
## [0.2.7] - 2026-07-04
### Fixed
- **NDO site-local BD duplication — mirror vs site-module dedup (PR-16).**
`aci_sim/ndo/patch.py::_mirror_template_bd_to_sites` (PR-15)
auto-creates a site-local BD shadow with a full-path STRING `bdRef` the
moment a template BD is added. Separately, `mso_schema_site_bd.py`'s
"BD does not exist yet" branch PATCHes `add /sites/{siteId}-{template}/
bds/-` (the RFC-6902 *append* form) with a DICT-shaped `bdRef`. The
append branch of `apply_json_patch` never consulted `_find_by_name`/ref
resolution before appending, and `_find_by_name`'s own ref-lookup only
recognized the string ref shape — so the two writers never converged on
one entry, and `sites[].bds[]` ended up with two rows for the same
template BD (one empty mirror, one carrying the real subnet) —
confirmed against a reference NDO schema (`App1-Net-LAB0`, Acme
App1-Net tenant) after `create_tenant`+`create_bd`.
Fixed by deduping a **nameless site-local shadow** strictly by its
single IDENTITY ref (`bds`→`bdRef`, `anps`→`anpRef`, `epgs`→`epgRef`,
`contracts`→`contractRef`; `_IDENTITY_REF_BY_COLLECTION`), resolved in
both string and dict shapes via a new `_bare_ref_name()` helper. A new
`_find_shadow_by_identity()` is the append-path dedup; `_find_by_name`'s
ref-lookup now fires only for nameless items and only over identity
refs. A NAMED object (a template-level BD add) is matched only by
name/displayName and always appends.
IMPORTANT — the first cut of this fix iterated **all** `*Ref` keys
(including `vrfRef`), which collapsed every template's BDs into a single
entry because distinct BDs share one `vrfRef` (`vrf-L3_LAB0`); an
independent full-chain Acme run caught it (`create_bd` failed
`"Provided BD 'bd-App2-Net-...' does not exist"`). The shipped fix
restricts ref-matching to identity refs of nameless shadows only —
guarded by `test_distinct_template_bds_sharing_vrfref_do_not_collapse`.
Verified end-to-end on real ACI hardware: Acme App1-Net
`create_tenant.yml`/`create_bd.yml` both `failed=0`; live NDO schema
`App1-Net-LAB0` shows template BD counts `LAB1-LAB2=6`, `LAB1=14`,
`LAB2=10` (30, no collapse) and exactly one site-local BD entry per
template BD (no duplicate). Covers the analogous ANP/contract
site-mirror paths too — no regression against the PR-12/PR-13 mirror
suites. 9 tests in `tests/test_pr16_bd_mirror_dedup.py`.
## [0.2.6] - 2026-07-04
### Added
- **Domain-qualified `aaaLogin` name + template-BD site mirroring for
aci-py (PR-15).** Two confirmed sim gaps from a real `aci-py` (pure-Python
Ansible-to-ACI compiler) run against the hardware sandbox:
1. **APIC login rejects a domain-qualified login name.** `aci-py` posts
`POST /api/aaaLogin.json` with `aaaUser.attributes.name =
"apic:local\admin"` — a valid real-APIC login format
(`apic:<loginDomain>\<username>`). The sim's `aaaLogin` handler
(`aci_sim/rest_aci/auth.py`) exact-matched the raw `name`
attribute against `SIM_USERNAME`, so every domain-qualified login
401'd even with the correct password — confirmed live against the
sandbox (`name="apic:local\admin"` -> 401, `name="admin"` -> 200
before this fix). Added `_bare_username()`, which strips an optional
`apic:<domain>\` / `<domain>\` prefix before the credential compare,
so both the plain and domain-qualified forms authenticate identically;
a wrong username in either form still 401s.
2. **NDO schema PATCH 400 mid `create_tenant`/`create_bd`.** `aci-py`'s
`mso_schema_site_bd` shim always PATCHes a site-local BD shadow with
`op: replace` (never `add`), because real NDO 4.x auto-creates that
shadow ("BDDelta") the moment a template-level BD is added to a
template that already has sites attached — a fresh `add` there 409s
on real hardware, so the shim does a GET-then-replace instead (its own
docstring documents this real-hardware behavior). The sim never
mirrored a template BD `add` into `sites[].bds[]` (unlike ANPs/EPGs
since PR-12 and contracts since PR-13 — the same "auto-mirror on
template add" pattern, just missing the BD collection), so the
site-BD `replace` 400'd with `"replace: 'bd-FW_LAB0' not found at
'/sites/1-LAB1-LAB2/bds/bd-FW_LAB0'"` — confirmed verbatim against a
real hardware aci-py MS-TN2 run before this fix. Added
`_mirror_template_bd_to_sites` (`aci_sim/ndo/patch.py`,
PATCH-time, parallel to `_mirror_template_anp_to_sites` /
`_mirror_template_contract_to_sites`) and the equivalent boot-time
mirror in `build_ndo_model` (`aci_sim/ndo/model.py`), so a
topology.yaml tenant that ships with BDs already configured also
boots with the site shadow pre-mirrored.
- VERDICT: both gaps are genuine sim fidelity gaps, not aci-py bugs —
aci-py's domain-qualified login name and its GET-then-replace
site-BD shim both mirror documented real-APIC/real-NDO behavior.
- Confirmed end-to-end on real ACI hardware: aci-py's `live_chain.py`
driver against `environments/lab-multisite-sim` now reaches
**MS-TN2: 13/13 steps rc=0** (was 9 ok / 4 fail before this PR — the 4
failures were exactly the login 401s and the `replace: 'bd-*' not
found` 400) and **MS-TN1: 11/11 rc=0** (1 skip, no regression).
- 13 new tests in `tests/test_pr15_acipy_gaps.py` covering both plain and
domain-qualified (`apic:local\admin` and `local\admin`) aaaLogin forms,
a domain-qualified wrong-user/wrong-password still-401 check, the
template-BD-add-mirrors-into-site PATCH behavior (single site, multiple
sites, idempotency, non-associated-site isolation), the full
add-then-replace sequence aci-py actually emits, and the boot-time
mirror for topology.yaml tenants that ship with BDs already configured.
## [0.2.5] - 2026-07-04
### Added
- **Classic ND bare `/login` + `/logout` endpoints (aci-py, cisco.nd)
(PR-14).** aci-py's NDO connector and the `cisco.nd` httpapi plugin POST
to the classic Nexus Dashboard bare `/login` (body `{userName,
userPasswd, domain}`, JWT in `token`/`jwttoken`) alongside the newer
`/api/v1/auth/login`. The sim only had the latter, so aci-py 404'd on
every NDO login. Mirrored the existing auth handler at bare `/login` +
`/logout`.
## [0.2.4] - 2026-07-04
### Added
- **NDO tenant-policy-template DHCP surface + schema serviceGraphs for
cisco.mso (PR-13).** Two confirmed sim gaps from a real aci-ansible run
against ACI hardware, both the last NDO write surfaces blocking the multi-site
(MS) suite:
1. **Tenant-policy-template CREATE flow.** `prereq_tenantpol.yml`'s
`cisco.mso.ndo_template` task (`delegate_to: localhost`) 404'd on
`GET /api/v1/templates/summaries` — a BARE path with no `/mso` prefix.
Root cause: `delegate_to: localhost` forces `ansible-mso`'s
`MSOModule` down its direct-HTTP branch instead of the persistent
`ansible.netcommon.httpapi` connection every other task on the same
playbook host uses, and that branch never adds the `/mso` prefix.
Added a full generalized template store (`POST`/`GET`/`PATCH`/`DELETE
/templates{,/summaries,/objects,/{id}}`, both bare and `/mso`-prefixed)
so `ndo_template` can create a tenant-policy template that persists,
`cisco.mso.ndo_dhcp_relay_policy` (`create_dhcp_relay`) can add a DHCP
relay policy against it (schema-template EPGs now carry a `uuid` field
for provider refs — previously absent), and
`cisco.mso.ndo_schema_template_bd_dhcp_policy` (`bind_dhcp_relay_to_bd`)
can resolve/bind it to a BD via the new `templates/objects?type=
dhcpRelay&{uuid|name}=...` cross-template lookup route. Confirmed
end-to-end on a real hardware run: MS-TN1/MS-TN2 `create_dhcp_relay` +
`bind_dhcp_relay_to_bd` now reach `failed=0`.
2. **Schema serviceGraphs surface.** The mso-model role's "Create service
graph" task (`custom_mso_schema_service_graph.py`, a legacy
pre-`MSOTemplate`/`MSOSchema` module) bare-subscripts
`schema_obj.get('sites')[site_idx]['serviceGraphs']` — the
TEMPLATE-level `serviceGraphs` bare-subscript was already covered by
PR-11, but the SITE-level sibling was not, hitting `KeyError:
'serviceGraphs'` on a real hardware MS-TN2 `create_tenant` pass-2 run
(`-e automate_contract_graph=true`). Added `SITE_TOP_LEVEL_DEFAULTS`
(`aci_sim/ndo/patch.py`) to backfill `serviceGraphs` (and
`contracts`, see below) on every schema `sites[]` entry; `GET
/mso/api/v1/schemas` now returns full nested schema detail (matching
real NDO) instead of a trimmed `{name}`-only summary, since this same
legacy module bare-subscripts straight into that response; added
`GET schemas/service-node-types` and a `tenantId` field on every
schema template (both required by the same module). Chasing this to
green on real ACI hardware surfaced a DEEPER gap: once the
`serviceGraphs` KeyError was fixed, the mso-model role's raw
`cisco.mso.mso_rest`-driven "Atomic PATCH — bind service-graph
redirect on ALL fabrics" task addresses a SITE-LOCAL contract by bare
name (`/sites/{siteId}-{t}/contracts/{c}/serviceGraphRelationship`) —
the sim never mirrored template-level contracts into
`sites[].contracts[]` at all (unlike ANPs/EPGs, mirrored since PR-12),
hitting `PatchError: path segment 'con-Firewall_LAB0' not found in
list`. Added `_mirror_template_contract_to_sites` (PATCH-time) and the
equivalent boot-time mirror in `build_ndo_model`. Confirmed end-to-end
on a real hardware run: MS-TN2 `create_tenant` pass-2 (01b) now reaches
`failed=0`.
- 26 new tests in `tests/test_pr13_ndo_dhcp_svcgraph.py` covering the
bare/mso-prefixed template routes, the full `ndo_template` create→lookup
round trip, DHCP relay policy add/read via `templates/objects`,
`bind_dhcp_relay_to_bd`'s full PATCH sequence, site-level `serviceGraphs`
normalization, the full schema-detail `GET /schemas` shape, and the
site-local contracts mirroring + atomic redirect PATCH.
## [0.2.3] - 2026-07-03
### Added
- **NDO site-local ANP/EPG + static-port schema surface.** PR-11 closed the
schema write round-trip for template-level objects but left the last
MS-TN1 gap: `bind_epg_to_static_port`
(`cisco.mso.mso_schema_site_anp_epg_staticport`) crashed client-side with
`'NoneType' object has no attribute 'details'`. Two compounding root
causes, both fixed:
1. Real NDO stores a self-referencing `anpRef` string on every
template-level ANP object itself (and `epgRef` on every EPG) —
confirmed because `ansible-mso`'s `mso_schema_template_anp.py`/
`_anp_epg.py` explicitly strip it client-side before an idempotency
comparison (`if "anpRef" in mso.previous: del mso.previous["anpRef"]`
— dead code unless the server sends one back). `MSOSchema.
set_site_anp()`/`set_site_anp_epg()` (`ansible-mso`'s
`module_utils/schema.py`) key their site-local lookup off exactly
this field; without it the lookup always compares against `None`.
`normalize_template()` (`aci_sim/ndo/patch.py`) now backfills
`anpRef`/`epgRef` on every `anps[]`/`epgs[]` entry (takes an optional
`schema_id` param to build them); `build_ndo_model()`
(`aci_sim/ndo/model.py`) calls it at boot too.
2. Real NDO 4.x auto-populates a schema's site-local
`sites[].anps[].epgs[]` the moment a template-level ANP/EPG is added
to a template that already has a site attached — the sim never did
this mirroring, so `set_site_anp()`/`set_site_anp_epg()` always
returned `None` and the module's own dead-on-4.x fallback path (its
own source comment: *"Coverage misses this two conditionals when
testing on 4.x and above"*) crashed instead. `apply_json_patch` now
auto-mirrors: `add /templates/{t}/anps/-` creates a matching
`{anpRef, epgs:[]}` site-anp in every associated site; `add
/templates/{t}/anps/{a}/epgs/-` creates a matching **full-shaped**
site-EPG `{epgRef, subnets:[], staticPorts:[], staticLeafs:[],
domainAssociations:[]}` under it. The full child-collection shape is
mandatory — every sibling `mso_schema_site_anp_epg_*` module
bare-subscripts its own array (a missing key is a hard `KeyError`,
not a 4xx): `_domain` reads `domainAssociations`, `_staticleaf` reads
`staticLeafs`, `_staticport` reads `staticPorts`, `_subnet` reads
`subnets`. (An earlier iteration that omitted `domainAssociations`
regressed `bind_epg_to_physical_domain`/`_vmm_domain` on ACI hardware with
`KeyError: 'domainAssociations'`.) Single source of truth for the
shape is `SITE_OBJECT_DEFAULTS["epgs"]`, consumed by `_new_site_epg()`
(mirror path) and `normalize_site()` (backfill path).
`build_ndo_model()` performs the same mirroring at boot for
topology-seeded schemas. Both idempotent.
`epgRef`'s canonical string form uniquely nests two name segments
(`/schemas/{id}/templates/{t}/anps/{a}/epgs/{e}`, confirmed against
`ansible-mso`'s `module_utils/mso.py` `epg_ref()`) — handled by a
dedicated `_stringify_refs` branch since the existing single-category
`_REF_NAME_FIELDS` table only fits one name field. `_REF_LOOKUP_KEYS`
gained `epgRef` so a site-epg (ref-only, no `name` of its own) resolves
by its bare EPG name the same way site-local bds/anps already did
(PR-11). Verified end-to-end against a real-hardware multi-site E2E run:
the full MS-TN1 chain (`create_tenant``create_bd`
`create_application``bind_epg_to_static_port`) now reaches `failed=0`
on every host, closing the CONTRACT.md §7a "known gap" note. New
`tests/test_pr12_site_local.py`.
## [0.2.2] - 2026-07-03
### Added
- **NDO schema write round-trip (POST/PATCH).** PR-10's schema surface was
read-only (a static per-tenant seed plus a POST that stored a schema no
PATCH could reach). PR-11 makes the schema store fully writable, matching
the exact request sequence every `cisco.mso` schema-object module
performs (verified against `ansible-mso`'s `plugins/module_utils/mso.py` +
`schema.py` and confirmed on a real multi-site aci-ansible E2E run against
ACI hardware): `GET schemas/list-identity` (displayName→id, already existed) →
`GET schemas/{id}` (already existed) → `PATCH schemas/{id}` with a
JSON-Patch-shaped op list (`{"op":"add"/"replace"/"remove","path":...,
"value":...}`). New `aci_sim/ndo/patch.py` implements a general
applier (`apply_json_patch`) supporting three path-addressing conventions
cisco.mso modules use: numeric index / `"-"` append (RFC 6902), a *named*
segment resolved against a `name`/`displayName` field
(`/templates/LAB1/bds/bd-App1_LAB1`), a *synthetic composite* key
`"{siteId}-{templateName}"` unique to the top-level `sites[]` array
(`/sites/1-LAB1/bds/-` — every `mso_schema_site_*` module builds this
literally), and a *`*Ref`-name* lookup for site-local child objects that
carry no `name` of their own (`/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-`
resolves `bd-App1_LAB1` against the site-local BD's `bdRef` string).
`normalize_template()`/`normalize_site()` backfill every collection-default
key (`vrfs`/`bds`/`anps.epgs`/`contracts`/`filters`/`externalEpgs`/
`intersiteL3outs`/`serviceGraphs`/`subnets`/...) real NDO defaults
server-side but several `cisco.mso` create/add payloads omit — a missing
key there is a hard crash client-side (`'NoneType' object is not
iterable`, bare `KeyError`, or `TypeError: sequence item 0: expected str
instance, dict found` when a sibling module `", ".join()`s a `*Ref` field
the sim had stored as a dict instead of NDO's canonical string form
`/schemas/{id}/templates/{tmpl}/{category}/{name}` — all three confirmed
on real hardware create_tenant/create_bd runs before the fix).
- **`GET /api/config/class/remoteusers` + `/localusers`.** `cisco.mso.mso_
tenant`'s tenant-create flow always resolves the tenant's `users` list via
`lookup_users()`, which on ND platforms falls back to these legacy
`/api/config/class/*` routes when the modern `/nexus/infra/api/aaa/v4/*`
endpoints aren't available. The sim only served the modern route's absence
as a 404, so `nd_request()` had no `"code"`/`"messages"` in the error body
and aborted with `"ND Error: Unknown error no error code in decoded
payload"` — verified on a real hardware create_tenant run. Now seeded with a
local `admin` user in ND's `{"items":[{"spec":{...}}]}` aggregate shape.
- **Bare `GET /api/v1/sites`.** `cisco.mso.ndo_template`'s TenantPol prereq
lookup hits this un-prefixed path (no `/mso`); the sim only served the
`/mso/api/v1/sites` form. Same data, added alongside PR-10's bare
`/api/v1/tenants`.
- **`PUT`/`POST /mso/api/v1/tenants{,/{id}}`.** `cisco.mso.mso_tenant`
updates a tenant in place (`PUT .../tenants/{id}`) whenever the tenant
already exists in `GET /mso/api/v1/tenants` — true for every tenant this
sim seeds from the topology. Previously unimplemented (404).
- **`GET /mso/api/v1/schemas/{id}/validate`, `execute|status/schema/{id}/
template/{name}`, `POST /mso/api/v1/task`.** The full schema-template
deploy surface: `cisco.mso.mso_schema_template_deploy` (legacy) validates
then calls `GET execute|status/schema/.../template/...`; the module
aci-ansible's `mso-model` role actually invokes,
`cisco.mso.ndo_schema_template_deploy`, instead validates then `POST
/mso/api/v1/task` with `{schemaId, templateName, isRedeploy}` — confirmed
against the collection installed on real ACI hardware (differs from the upstream
`master`-branch module source). All three were 404 before this PR.
### Fixed
- `GET /mso/api/v1/schemas` / `list-identity` no longer double-lists (or
silently drops) a schema after it's mutated by `PATCH` — the summary
projection is now kept in sync in place rather than appended as a
duplicate "extra" entry.
### Verified against a real-hardware E2E run
MS-TN1 (multi-site tenant) now reaches `failed=0` for `create_tenant`,
`create_bd`, `create_application`, `bind_epg_to_physical_domain`,
`bind_epg_to_vmm_domain`, and `bind_epg_to_secure_application`. Remaining
known gap: `bind_epg_to_static_port` needs site-local ANP/EPG object
creation (`mso_schema_site_anp`/`_anp_epg`), not implemented in this PR —
tracked for PR-12 (see docs/CONTRACT.md §7 for the precise failure
signature).
### Tests
- New `tests/test_pr11_ndo_write.py` (31 tests): `apply_json_patch` op
semantics (add/replace/remove, index/name/composite-key/ref-name
resolution, auto-vivification safety), `normalize_template`/
`normalize_site` collection-default backfilling, `*Ref` dict→string
normalization, and full schema create→PATCH→GET round-trips through the
FastAPI routes (including the exact site-local BD + BD-subnet sequence a
real E2E run exercises).
## [0.2.1] - 2026-07-03
### Fixed
- **`/api/node/mo/{dn}` alias (11 E2E failures).** Real APIC serves
`/api/node/mo/{dn}.json` as a full equivalent of `/api/mo/{dn}.json` (the
form the APIC GUI's API Inspector and some `cisco.aci` call paths emit).
The sim only registered `/api/mo/*`, so an alias request fell through to
FastAPI's default 404 `{"detail":"Not Found"}` — a shape `cisco.aci`
cannot parse (surfaces as `"APIC Error None: None"`, `status=-1`). GET,
POST, and DELETE are now all registered on both paths via stacked route
decorators sharing one handler, so behavior can never drift between the
canonical and alias forms.
- **APIC-shaped 400 for unmatched `/api/*` paths.** Any request under
`/api/*` that doesn't match a real route now returns an APIC error
envelope (`imdata`/`error`/`code`) instead of FastAPI's `{"detail":...}` —
real APIC never emits that shape. Implemented as a Starlette 404 exception
handler scoped to the `/api/*` prefix, so it only fires once routing has
already failed and can never shadow a real route.
- **NDO site names now use the fabric name, not the short topology name.**
`GET /mso/api/v1/sites` previously reported the topology's internal short
site name (`"LAB1"`/`"LAB2"`); real NDO registers sites under their
fabric name (`"LAB1-IT-ACI"`/`"LAB2-IT-ACI"`), and `cisco.mso.mso_tenant`
failed with `"Site 'LAB1-IT-ACI' is not a valid site name"` against the
short form. Verified against a real-gear `cisco.mso.mso_tenant` run.
`aci_sim/ndo/model.py`'s site builder now exposes
`Site.fabric_name` (falling back to the short name if unset); internal
site-id joins (`tenant.sites`, schema site associations) are unaffected
since they key off the topology's short name, never the NDO-facing
display name.
- **NDO schema `list-identity` + bare `/api/v1/tenants`.** Added
`GET /mso/api/v1/schemas/list-identity` — the lightweight
schema-enumeration endpoint every `cisco.mso` schema module calls first to
resolve a schema `displayName` to its `id` (verified against
`ansible-mso`'s `plugins/module_utils/mso.py`:
`query_objs("schemas/list-identity", key="schemas", displayName=schema)`,
which reads `json["schemas"][]` entries with at least `id` + `displayName`).
Declared *before* the parameterized `GET /mso/api/v1/schemas/{schema_id}`
route so `"list-identity"` is never matched as a schema id (previously
would 404 with `{"detail":"Schema 'list-identity' not found"}`). Also
added `GET /api/v1/tenants`, backing `cisco.mso.ndo_template`'s tenant
prereq lookup, returning the same tenant list as the existing
`/mso/api/v1/tenants`. Both are read-only additions — the schema/template
*write* surface (`PATCH` deploy) is an intentional follow-up.
- New regression suite `tests/test_pr10_ansible_gaps.py` (9 tests) covers
all of the above; `tests/test_ndo.py`'s site-name assertions updated to
join by fabric name.
## [0.2.0] - 2026-07-03
### Added
- Public-release preparation: rewritten README (purpose, architecture, both
run modes, configuration reference, error-handling contract, known
limitations), MIT `LICENSE`, this `CHANGELOG.md`, GitHub Actions CI
(`ci.yml`), a validated `examples/ansible/` playbook set (smoke +
teardown), and a project-root `CLAUDE.md` release-kit overlay.
### Changed
- Hygiene pass: removed a real internal hostname/username pair from
`docs/DESIGN.md`'s batch-2/3 verification note and genericized an example
path in `deploy/aci-sim.service`; no functional/behavioral change.
## [0.1.12] - 2026-07-03
### Added
- Ansible `cisco.aci` fidelity: `DELETE /api/mo/{dn}.json` (`state=absent`,
server-side idempotent), `GET` on a nonexistent-but-well-formed DN now
returns 200-empty instead of 404 (unblocks `state=present` GET-before-write
playbooks), `status="deleted"` cascade regression coverage,
`annotation`/`ownerKey`/`ownerTag`/`nameAlias` passthrough regression
coverage, and certificate-signature auth in accept-mode (claimed-identity
trust, no signature verification — documented trust-model caveat).
## [0.1.11] - 2026-07-03
### Added
- Default loopback bind (`SIM_BIND`, defaults to `127.0.0.1`) so the
unauthenticated `/_sim` control plane is never LAN-exposed by accident.
- Linux sandbox-mode support (`ip addr add/del … dev lo`) alongside the
existing macOS `lo0 alias` path in `sandbox-up.sh`/`sandbox-down.sh`.
- Effective-port environment overrides and topology config validation.
- systemd `--user` unit and launchd plist deployment docs (`deploy/`).
## [0.1.10] - 2026-07-03
### Added
- Mutation-killing numeric-comparison test coverage and minimum-row
assertions on HTTP fault-path tests, closing gaps a mutation-testing pass
identified in the query engine's comparison operators.
## [0.1.9] - 2026-07-03
### Fixed
- `/_sim/reload` now rebuilds against the freshly-loaded `Site` object
(matched by id, falling back to name) instead of a stale pre-reload
reference, so topology edits actually take effect.
- The `fabricNodeIdentP` node-registration reaction now materializes an
enriched MO set (fabricNode + topSystem + healthInst + fabricLink/
lldpAdjEp/cdpAdjEp cabling + port inventory), not just a bare fabricNode.
- `GET /api/node/class/{class}.json` (no DN prefix) now correctly resolves
as the fabric-wide equivalent of `/api/class/{class}.json`.
## [0.1.8] - 2026-07-03
### Added
- Seeded batch-2/3 contract classes: port-channel/vPC member relations
(`pcAggrIf`, `pcRsMbrIfs`), transceiver/optics inventory (`ethpmFcot`),
APIC cluster health (`infraWiNode`), zoning-rule/pcTag plumbing
(`actrlRule`, `fvEpP`), EVPN VPN routes (`bgpVpnRoute`, `bgpPath`), and
boot-seeded node registration (`fabricNodeIdentP`). Deliberately did NOT
build `infraNodeIdentP` (no real consumer; a different concept from
`fabricNodeIdentP` despite the similar name).
## [0.1.7] - 2026-07-03
### Added
- Seeded batch-1 previously-catalogued-but-unbuilt contract classes:
access-policy cluster (`infraAccPortP`/`infraHPortS`/`infraPortBlk`/
`infraRsAccBaseGrp`/`infraAccBndlGrp`), route-control cluster
(`rtctrlProfile`/`rtctrlCtxP`/`rtctrlSubjP`/`rtctrlMatchRtDest`/
`rtctrlSetComm`/`rtctrlSetPref`), static routes (`ipRouteP`/`ipNexthopP`),
RIB/ARP (`uribv4Route`/`uribv4Nexthop`/`arpAdjEp`), `ospfIf`, and static
bindings (`fvRsPathAtt`, `vzRsSubjGraphAtt`).
## [0.1.6] - 2026-07-03
### Fixed
- Topology-consistency findings: real ports built for L3Out sub-interfaces
and spine OSPF/ISN uplinks (previously referenced but never built),
endpoint/APIC port separation on shared leaves, correct multi-site
endpoint semantics (home-site front-panel attachment vs. remote tunnel-
learned), BD `unicastRoute` now reflects actual subnet presence, and
controller health (`healthInst`) seeded for every controller node.
## [0.1.5] - 2026-07-03
### Fixed
- Auth is now enforced end-to-end: token validation, credential checking
against `SIM_USERNAME`/`SIM_PASSWORD`, session expiry
(`refreshTimeoutSeconds`), and APIC error envelopes (401/403) on every
query/write route instead of accepting any request.
## [0.1.4] - 2026-07-03
### Fixed
- Valid IPv4 derivation for node IDs greater than 255 (previous scheme
overflowed the last octet). Seeded the `userprofile`/`aaaUserDomain`
login-probe MO so the CONTRACT §1 subtree query resolves.
## [0.1.3] - 2026-07-03
### Fixed
- Write path is now atomic (validate-then-commit): a malformed node
anywhere in a POST body's tree rejects the whole write with a 400
envelope and leaves the store untouched. Canonical RN synthesis for
dn-less children (e.g. `fvBD` → `BD-{name}`) instead of a generic
`{class}-{name}` guess, avoiding orphan duplicate DNs.
## [0.1.2] - 2026-07-03
### Fixed
- Query engine: subtree-scope filtering now applies `query-target-filter`
to the whole scoped set (root + descendants) rather than pre-filtering
the root out of scope; `page`/`page-size` parameter validation (400 on
non-numeric/out-of-range values instead of silent truncation); stricter
filter-expression parsing (malformed/truncated expressions → 400 instead
of a 500 or silent no-op).
## [0.1.1] - 2026-07-02
### Fixed
- `rsp-subtree=full` nesting (recursive children, not one level).
- Comparison filter operators (`gt`/`lt`/`ge`/`le`/`bw`) with numeric-vs-
lexical comparison semantics.
## [0.1.0] - 2026-07-01
### Added
- Initial project scaffold: MIT store + query engine (P1/P2), topology
schema + default `topology.yaml`, fabric/tenant builders (P3/P4), the APIC
REST app + runtime supervisor + `/_sim` control API (P5), the NDO plane
(P6), and an end-to-end verification harness (P7) — see `docs/CONTRACT.md`
and `docs/DESIGN.md` for the full design record.
+23
View File
@@ -0,0 +1,23 @@
# aci-sim — project notes for Claude
A faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + one
NDO/MSO plane), driven entirely by `topology.yaml`. Python 3.11+, FastAPI +
uvicorn + Pydantic v2, in-memory MIT object store, no external dependencies.
Full architecture/contract detail: `README.md`, `docs/CONTRACT.md` (the
authoritative ACI/NDO REST behavior contract), `docs/DESIGN.md` (module map +
design decisions + phase-by-phase build history).
Two run modes: **port mode** (`scripts/run.sh`, shared bind + distinct ports
8443/8444/8445) and **sandbox mode** (`scripts/sandbox-up.sh`, one loopback IP
per device on `:443`, needed for NDO auto-discovery clients that hardcode
port 443). Primary public use case: running Ansible `cisco.aci` playbooks
against it directly — see `examples/ansible/`.
Test suite: `.venv/bin/python -m pytest -q` (888 tests, must stay green).
Lint: `ruff check .` (non-blocking in CI — pre-existing baseline, see
`.github/workflows/ci.yml`).
Version lives in `pyproject.toml`; changes are logged in `CHANGELOG.md`
(newest entry at the top, `Keep a Changelog` format). The ACI/NDO object
catalog is documented in `docs/CONTRACT.md` §6; environment variables in
`README.md` §6.
+90
View File
@@ -0,0 +1,90 @@
# Contributing to aci-sim
Thanks for considering a contribution. This project is a REST simulator, not
a production service, so the bar is: does it faithfully match real APIC/NDO
behavior, and does the test suite stay green.
## Dev setup
```bash
git clone https://github.com/dtzp555-max/aci-sim.git
cd aci-sim
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e '.[dev]' # installs aci_sim + fastapi/uvicorn/pydantic/pyyaml + httpx/pytest
```
## Running the tests
```bash
.venv/bin/python -m pytest -q
```
The suite currently has **888 tests** and must stay green. If you add a
feature or fix a bug, add or extend tests under `tests/` in the same PR —
most existing tests are organized by PR/feature (`tests/test_pr*.py`,
`tests/test_<feature>.py`); follow that convention for new ones.
`tests/verify_autoaci.py` (run via `scripts/verify.sh`) is a separate
verification harness that replays real ACI/NDO query patterns end-to-end
against a *running* sim instance — it's not part of the plain `pytest`
collection and isn't required for a normal PR, but it's worth running if
your change touches the query engine or the write path.
## Linting (informational only)
There's a `ruff` config in `pyproject.toml` (`[tool.ruff]`), and CI runs
`ruff check .` — but as a **non-blocking** job (see
`.github/workflows/ci.yml`'s `lint` job). There's a pre-existing baseline of
lint findings in the repo today; you don't need to fix unrelated ones in
your PR, but please don't add new ones in the code you touch.
```bash
.venv/bin/ruff check .
```
## `topology.yaml` drives everything
`topology.yaml` is the single source of truth for the whole simulated
fabric — sites, nodes, tenants, VRFs/BDs/EPGs, contracts, L3Outs, ISN,
seeded faults, and access-policy defaults. Every plane (both sites' APIC
stores and the NDO model) is derived from the *same* `Topology` object at
build time (`aci_sim/build/orchestrator.py`, `aci_sim/ndo/model.py`), which
is what keeps tenant/VRF/BD names and IDs consistent between a site's APIC
and NDO's view of it. If you're adding a new object or knob:
1. Add the field to `aci_sim/topology/schema.py` (Pydantic v2) — optional,
with a default that reproduces the pre-existing hardcoded behavior, so
existing `topology.yaml` files keep validating and building
byte-identical output.
2. Wire it into the relevant builder(s) under `aci_sim/build/`.
3. If it should show up in NDO's view too, wire it into `aci_sim/ndo/model.py`.
4. Run `aci-sim validate` against the repo's own `topology.yaml` and against
`aci-sim new`-generated topologies to confirm nothing regressed.
See `docs/DESIGN.md` for the module map and the design rationale behind
past additions, and `docs/CONTRACT.md` for the exact REST behavior contract
(error envelopes, query semantics, the object catalog) new code needs to
match.
## What PRs should (and shouldn't) do
- Keep `pytest -q` green — this is the actual acceptance bar, not a
suggestion.
- Prefer additive, backward-compatible changes to `topology.yaml`'s schema
(new optional fields with safe defaults) over changes that would break an
existing topology file.
- Match the existing code style — small, single-purpose builder functions,
docstrings that explain *why* (not just what), and tests that assert on
the exact REST response shape, not just "it didn't crash."
- **Do not reintroduce any real/customer data** — hostnames, IPs, tenant or
site names, credentials, or anything else identifying a real deployment.
This repo only uses generic placeholders (`LAB0`/`LAB1`/`LAB2`,
`App1`-`App5`, `MS-TN1`/`SF-TN1`, `10.192.x`/`10.10.x`, and similar) —
keep new examples, tests, and docs consistent with that convention.
## Filing issues
Bug reports are most useful with: the exact request (method + path + body)
that produced the wrong behavior, what you expected (ideally referencing
real APIC/NDO documented behavior), and what the sim actually returned.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 aci-sim contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1057
View File
File diff suppressed because it is too large Load Diff
View File
View File
+291
View File
@@ -0,0 +1,291 @@
"""build/access.py — VLAN pools, physical/L3 domains, AAEPs, access port IPGs,
and (batch-1) the leaf interface-policy cluster: infraAccPortP/infraHPortS/
infraPortBlk/infraRsAccBaseGrp/infraAccBndlGrp.
Site-independent: call once per topology (site arg accepted for API uniformity
but not used in the selection logic).
Interface-policy cluster (CONTRACT.md §6 "Access policy") — real ACI DN/RN
conventions, verified against the existing infra tree this module already
builds (uni/infra/... prefix) and against autoACI's access_policy.py (read via
prior audit, see docs/CONTRACT.md header):
infraAccPortP uni/infra/accportprof-{name}
infraHPortS {accportp}/hports-{name}-typ-range
infraPortBlk {hports}/portblk-block{n}
infraRsAccBaseGrp {hports}/rsaccBaseGrp
infraAccBndlGrp uni/infra/funcprof/accbundle-{name}
Port ranges in infraPortBlk MUST match ports that actually exist in the
l1PhysIf inventory interfaces.py builds (eth1/1.._HOST_PORTS on leaves) — the
whole cluster is built once per border-leaf vPC pair (the topology's only vPC
domains, per fabric.py's vpcDom/vpcIf) plus one profile per regular-leaf pair,
so infraAccBndlGrp(lagT="node") lines up with a real vpcDom this same site
already has.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import AAEP, L3Domain, PhysDomain, Site, Topology, VlanPool, VMMDomain
from aci_sim.build.interfaces import _HOST_PORTS
def _build_vlan_pool(pool: VlanPool) -> MO:
"""Build fvnsVlanInstP + fvnsEncapBlk child."""
pool_dn = f"uni/infra/vlanns-[{pool.name}]-static"
blk_dn = (
f"{pool_dn}"
f"/from-[vlan-{pool.from_vlan}]-to-[vlan-{pool.to_vlan}]"
)
pool_mo = MO("fvnsVlanInstP", dn=pool_dn, name=pool.name, allocMode="static")
blk_mo = MO(
"fvnsEncapBlk",
dn=blk_dn,
allocMode="static",
**{"from": f"vlan-{pool.from_vlan}", "to": f"vlan-{pool.to_vlan}"},
)
pool_mo.add_child(blk_mo)
return pool_mo
def _build_phys_dom(phys_dom: PhysDomain, first_pool_dn: str) -> MO:
"""Build physDomP + optional infraRsVlanNs child."""
dom_mo = MO("physDomP", dn=f"uni/phys-{phys_dom.name}", name=phys_dom.name)
if first_pool_dn:
dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"uni/phys-{phys_dom.name}/rsvlanNs",
tDn=first_pool_dn,
))
return dom_mo
def _build_l3_dom(l3_dom: L3Domain, first_pool_dn: str) -> MO:
"""Build l3extDomP + optional infraRsVlanNs child."""
l3dom_mo = MO("l3extDomP", dn=f"uni/l3dom-{l3_dom.name}", name=l3_dom.name)
if first_pool_dn:
l3dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"uni/l3dom-{l3_dom.name}/rsvlanNs",
tDn=first_pool_dn,
))
return l3dom_mo
def _build_vmm_domain(vmm: VMMDomain, first_pool_dn: str) -> MO:
"""Build vmmDomP (+ vmmCtrlrP + vmmUsrAccP if vcenter_ip set, + infraRsVlanNs).
Tier-2 (PR-19). Real ACI DN scheme (verified against aci-py's
`_domain_dn_class` shim — see docs/DESIGN.md PR-19 note):
vmmDomP uni/vmmp-VMware/dom-{name}
vmmCtrlrP {vmmDomP dn}/ctrlr-{name} hostOrIp=vcenter_ip, rootContName=datacenter
vmmUsrAccP {vmmDomP dn}/usracc-{name} placeholder credential-profile ref (no
real credentials in a sim — name-only, matches the pattern
phys/l3 domains use for their own no-secret child objects)
Only "VMware" (vmmProvP=VMware) is modeled — the sole provider the
aci-py `bind_epg_to_vmm_domain` playbook chain (and the DN map in
`mso_schema_site_anp_epg_domain.py`, `dn_map["vmmDomain"] =
"uni/vmmp-VMware/dom-{0}"`) actually exercises today.
vmmCtrlrP/vmmUsrAccP are only emitted when `vcenter_ip` is set — a bare
`{name: ...}` VMM domain entry (no vCenter attrs) produces just the
vmmDomP + optional infraRsVlanNs, same shape as a phys/l3 domain with no
extra config, so a topology author who only wants a named domain isn't
forced to supply vCenter connection details.
"""
dom_dn = f"uni/vmmp-VMware/dom-{vmm.name}"
dom_mo = MO("vmmDomP", dn=dom_dn, name=vmm.name)
if vmm.vcenter_ip:
ctrlr_dn = f"{dom_dn}/ctrlr-{vmm.name}"
ctrlr_mo = MO(
"vmmCtrlrP",
dn=ctrlr_dn,
name=vmm.name,
hostOrIp=vmm.vcenter_ip,
rootContName=vmm.datacenter or vmm.name,
dvsVersion="unmanaged",
)
if vmm.vcenter_dvs:
ctrlr_mo.attrs["dvsName"] = vmm.vcenter_dvs
dom_mo.add_child(ctrlr_mo)
dom_mo.add_child(MO(
"vmmUsrAccP",
dn=f"{ctrlr_dn}/usracc-{vmm.name}",
name=vmm.name,
))
if vmm.vlan_pool and first_pool_dn:
# first_pool_dn is topo.access.vlan_pools[0] (the same convention
# phys/l3 domains use below); Tier-2 vmm.vlan_pool is validated
# against the real pool-name set in schema.py's normalize_and_validate
# but this builder still binds the module-wide first pool DN (the
# existing phys/l3-domain convention), matching a dynamic VMM pool
# binding by name rather than resolving a distinct pool DN per VMM
# domain — sufficient for the sim's single-pool-per-topology norm.
dom_mo.add_child(MO(
"infraRsVlanNs",
dn=f"{dom_dn}/rsvlanNs",
tDn=first_pool_dn,
))
return dom_mo
def _build_aaep(
aaep: AAEP,
phys_domains: list[PhysDomain],
l3_domains: list[L3Domain],
) -> MO:
"""Build infraAttEntityP + infraRsDomP children for all domains."""
aaep_dn = f"uni/infra/attentp-{aaep.name}"
aaep_mo = MO("infraAttEntityP", dn=aaep_dn, name=aaep.name)
for phys_dom in phys_domains:
tdn = f"uni/phys-{phys_dom.name}"
aaep_mo.add_child(MO(
"infraRsDomP",
dn=f"{aaep_dn}/rsdomP-[{tdn}]",
tDn=tdn,
))
for l3_dom in l3_domains:
tdn = f"uni/l3dom-{l3_dom.name}"
aaep_mo.add_child(MO(
"infraRsDomP",
dn=f"{aaep_dn}/rsdomP-[{tdn}]",
tDn=tdn,
))
return aaep_mo
def _build_port_ipg(aaep: AAEP) -> MO:
"""Build infraAccPortGrp + infraRsAttEntP child for one AAEP."""
ipg_name = f"{aaep.name}-ipg"
ipg_dn = f"uni/infra/funcprof/accportgrp-{ipg_name}"
ipg_mo = MO(
"infraAccPortGrp",
dn=ipg_dn,
name=ipg_name,
lagT="not-aggregated",
)
ipg_mo.add_child(MO(
"infraRsAttEntP",
dn=f"{ipg_dn}/rsattEntP",
tDn=f"uni/infra/attentp-{aaep.name}",
))
return ipg_mo
def _build_bndl_grp(name: str, lag_t: str) -> MO:
"""Build infraAccBndlGrp (funcprof bundle IPG) — vPC (lagT=node) or PC (lagT=link)."""
dn = f"uni/infra/funcprof/accbundle-{name}"
return MO("infraAccBndlGrp", dn=dn, name=name, lagT=lag_t)
def _build_hports(
accportp_dn: str,
sel_name: str,
from_port: int,
to_port: int,
bndl_grp_dn: str,
) -> MO:
"""Build infraHPortS (type=range) + infraPortBlk + infraRsAccBaseGrp children.
RN scheme verified against this module's own uni/infra/... conventions
(accportprof-/hports-/portblk-):
infraHPortS hports-{sel_name}-typ-range
infraPortBlk portblk-block1 (single block covering the range)
infraRsAccBaseGrp rsaccBaseGrp (fixed RN — one target per selector)
"""
hports_dn = f"{accportp_dn}/hports-{sel_name}-typ-range"
hports_mo = MO("infraHPortS", dn=hports_dn, name=sel_name, type="range")
hports_mo.add_child(MO(
"infraPortBlk",
dn=f"{hports_dn}/portblk-block1",
fromPort=str(from_port),
toPort=str(to_port),
fromCard="1",
))
hports_mo.add_child(MO(
"infraRsAccBaseGrp",
dn=f"{hports_dn}/rsaccBaseGrp",
tDn=bndl_grp_dn,
))
return hports_mo
def _build_leaf_profile(profile_name: str, sel_name: str, bndl_grp_dn: str) -> MO:
"""Build one infraAccPortP with one infraHPortS selector spanning the
leaf's host-port range (eth1/1.._HOST_PORTS — the exact inventory
interfaces.py builds), so every infraPortBlk from/to port exists as a
real l1PhysIf."""
accportp_dn = f"uni/infra/accportprof-{profile_name}"
accportp_mo = MO("infraAccPortP", dn=accportp_dn, name=profile_name)
accportp_mo.add_child(_build_hports(accportp_dn, sel_name, 1, _HOST_PORTS, bndl_grp_dn))
return accportp_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit access-policy MOs (site-independent, build once)."""
# Batch-1 fix: uni/infra previously had no MO of its own — only
# descendant DNs (uni/infra/vlanns-.../, uni/infra/attentp-.../, etc.),
# which the store's ancestor-walk (_register) links structurally but
# never materializes as a real object. That made
# GET /api/mo/uni/infra.json (any rsp-subtree mode) 404, even though the
# generic query engine's rsp-subtree=full traversal otherwise works fine
# (verified: /api/class/infraAccPortP.json?rsp-subtree=full already
# returns the nested infraHPortS/infraPortBlk/infraRsAccBaseGrp tree).
# infraInfra is the real ACI class for this container (CONTRACT.md §6
# "Infra write targets"); seeding it here is a one-line, no-behavior-
# change fix so `GET /api/mo/uni/infra.json?rsp-subtree=full` (needed to
# see the whole access-policy tree in one call, same as autoACI's
# ipg_detail/access_policy plugins would) returns 200 with a real root.
store.add(MO("infraInfra", dn="uni/infra", name=""))
first_pool_dn = (
f"uni/infra/vlanns-[{topo.access.vlan_pools[0].name}]-static"
if topo.access.vlan_pools else ""
)
for pool in topo.access.vlan_pools:
store.add(_build_vlan_pool(pool))
for phys_dom in topo.access.phys_domains:
store.add(_build_phys_dom(phys_dom, first_pool_dn))
for l3_dom in topo.access.l3_domains:
store.add(_build_l3_dom(l3_dom, first_pool_dn))
# Tier-2 (PR-19): VMM domains — optional, defaults to an empty list, so
# a topology with no `access.vmm_domains` produces zero vmmDomP MOs
# (byte-identical to pre-PR-19 output).
for vmm in topo.access.vmm_domains:
store.add(_build_vmm_domain(vmm, first_pool_dn))
for aaep in topo.access.aaeps:
store.add(_build_aaep(aaep, topo.access.phys_domains, topo.access.l3_domains))
for aaep in topo.access.aaeps:
store.add(_build_port_ipg(aaep))
# ---- Batch-1: leaf-interface-policy cluster --------------------------
# One vPC bundle group per border-leaf vPC domain (fabric.py already
# builds the matching vpcDom/vpcIf for these same pairs), one leaf
# interface-profile per leaf pair (regular leaves + each border-leaf
# vPC pair), each with a single range selector spanning the real
# host-port inventory (eth1/1.._HOST_PORTS).
seen_vpc_domains: set[str] = set()
for bl in site.border_leaves:
if bl.vpc_domain in seen_vpc_domains:
continue
seen_vpc_domains.add(bl.vpc_domain)
bndl_name = f"{bl.vpc_domain}-vpc"
store.add(_build_bndl_grp(bndl_name, lag_t="node"))
bndl_grp_dn = f"uni/infra/funcprof/accbundle-{bndl_name}"
profile_name = f"{site.name}-{bl.vpc_domain}"
store.add(_build_leaf_profile(profile_name, f"sel-{bl.vpc_domain}", bndl_grp_dn))
leaves = site.leaf_nodes()
if len(leaves) >= 2:
pair_name = f"{site.name}-leafpair"
pc_bndl_name = f"{pair_name}-pc"
store.add(_build_bndl_grp(pc_bndl_name, lag_t="link"))
pc_bndl_grp_dn = f"uni/infra/funcprof/accbundle-{pc_bndl_name}"
store.add(_build_leaf_profile(pair_name, f"sel-{pair_name}", pc_bndl_grp_dn))
+94
View File
@@ -0,0 +1,94 @@
"""build/cabling.py — fabricLink, lldpAdjEp, cdpAdjEp derived from the cabling graph.
fabricLink DN (verified against autoACI/backend/routers/topology.py lines 381-388):
topology/pod-{pod}/lnk-{n1}-{s1}-{p1}-to-{n2}-{s2}-{p2}
where "lnk-{n1}-{s1}-{p1}-to-...".split("-") = [n1, s1, p1, ...]
→ source_port = f"eth{s1}/{p1}" (spc[1], spc[2])
n1/n2 attributes carry the plain node IDs.
Port assignment (auto mode):
Spines: uplinks eth1/1, eth1/2, ... (one per downlink, in order)
Leaves/border-leaves: uplinks eth1/49, eth1/50, ... (one per spine, in order)
"""
from __future__ import annotations
from aci_sim.build.fabric import oob_ip
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, Site, Topology
def cabling_links(site: Site) -> list[tuple[int, int, int, int, int, int]]:
"""Return (n1_id, slot1, port1, n2_id, slot2, port2) for every fabric link.
Convention: n1=spine, n2=leaf/border-leaf.
Spine port: eth1/{leaf_idx+1}
Leaf uplink: eth1/{49+spine_idx}
"""
spines = site.spine_nodes()
downlinks = site.leaf_nodes() + site.border_leaf_nodes()
links: list[tuple[int, int, int, int, int, int]] = []
for l_idx, leaf in enumerate(downlinks):
for s_idx, spine in enumerate(spines):
links.append((
spine.id, 1, l_idx + 1, # spine eth1/(leaf_idx+1)
leaf.id, 1, 49 + s_idx, # leaf eth1/(49+spine_idx)
))
return links
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fabricLink + lldpAdjEp + cdpAdjEp derived from the cabling graph."""
pod = site.pod
# Build a lookup: node_id -> Node (for name/loopback lookups)
node_map: dict[int, Node] = {n.id: n for n in site.all_nodes()}
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
lnk_dn = (
f"topology/pod-{pod}"
f"/lnk-{n1}-{s1}-{p1}-to-{n2}-{s2}-{p2}"
)
store.add(MO(
"fabricLink",
dn=lnk_dn,
n1=str(n1),
n2=str(n2),
operSt="up",
operSpeed="100G",
linkType="leaf",
))
# --- LLDP & CDP adjacencies on BOTH endpoints ---
node1 = node_map.get(n1)
node2 = node_map.get(n2)
if node1 is None or node2 is None:
continue
port1 = f"eth{s1}/{p1}"
port2 = f"eth{s2}/{p2}"
oob1 = oob_ip(pod, n1)
oob2 = oob_ip(pod, n2)
# Node1 sees Node2 as neighbor on port1 (Node2's own port is port2)
add_switch_adjacency(
store,
pod=pod,
local_node_id=n1,
local_port=port1,
remote_port=port2,
neighbor=node2,
neighbor_mgmt_ip=oob2,
)
# Node2 sees Node1 as neighbor on port2 (Node1's own port is port1)
add_switch_adjacency(
store,
pod=pod,
local_node_id=n2,
local_port=port2,
remote_port=port1,
neighbor=node1,
neighbor_mgmt_ip=oob1,
)
+313
View File
@@ -0,0 +1,313 @@
"""build/endpoints.py — fvCEp, fvIp, epmMacEp, epmIpEp, fvIfConn.
Port allocation avoids the APIC-controller-reserved port range: fabric.py wires
each APIC-<cid> (cid 1..site.controllers) to eth1/<cid> on the first two leaves
(site.leaf_nodes()[:2]) — the same leaves endpoints are placed on. Endpoint
host ports therefore start right AFTER that reserved range so no port ever
hosts both a controller lldpAdjEp and an endpoint fabricPathDn (see
interfaces.py's _HOST_PORTS=8 host-port inventory for the ceiling).
Multi-site (stretched-tenant) endpoint semantics (see docs/DESIGN.md):
A stretched tenant (tenant.stretch=true, sites: [A, B]) is built independently
per site by this same build() function, once per site's MITStore. Real ACI
never shows the identical endpoint as locally-learned-on-a-front-panel-port at
BOTH sites simultaneously: exactly one site has it physically attached (HOME,
lcC contains "learned", fabricPathDn = a real front-panel pathep); the other
site only knows about it via the multi-site overlay/ISN tunnel (REMOTE,
lcC="learned,vxlan", fabricPathDn = a tunnelIf path toward the home site's
spine-proxy TEP). The HOME site is chosen deterministically per endpoint
(alternating over tenant.sites by the endpoint's position in the per-tenant
sequence) so both sites' independent builds agree on which one is home
without needing to share state.
"""
from __future__ import annotations
import ipaddress
import zlib
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import BD, Site, Tenant, Topology
from aci_sim.build.fabric import loopback_ip
from aci_sim.build.interfaces import _HOST_PORTS
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _vnid(name: str, base: int) -> int:
return base + (zlib.crc32(name.encode()) & 0xFFFF)
def _mac(counter: int) -> str:
aa = (counter >> 16) & 0xFF
bb = (counter >> 8) & 0xFF
cc = counter & 0xFF
return f"00:50:56:{aa:02X}:{bb:02X}:{cc:02X}"
def _host_ip(bd: BD, index: int) -> str:
"""Return host at position 10+index within the first BD subnet."""
if not bd.subnets:
return "0.0.0.0"
net = ipaddress.ip_network(bd.subnets[0], strict=False)
hosts = list(net.hosts())
pos = 10 + index
return str(hosts[pos % len(hosts)])
def _emit_epm_mos(
store: MITStore,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port_ifid: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
flags: str = "local",
) -> None:
"""Emit epmMacEp, epmIpEp, and (for real front-panel ports) fvIfConn."""
ctx_bd = f"ctx-[vxlan-{vrf_vnid}]/bd-[vxlan-{bd_vnid}]"
node_sys = f"topology/pod-{pod}/node-{leaf_id}/sys"
store.add(MO(
"epmMacEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/mac-{mac}",
addr=mac,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
encap=f"vlan-{epg_vlan}",
))
store.add(MO(
"epmIpEp",
dn=f"{node_sys}/{ctx_bd}/db-ep/ip-[{ip}]",
addr=ip,
ifId=port_ifid,
flags=flags,
createTs="2026-01-01T00:00:00.000+00:00",
))
if flags == "local":
store.add(MO(
"fvIfConn",
dn=(
f"{node_sys}/phys-[{port_ifid}]"
f"/epgconn-[{epg_ref}]"
),
encap=f"vlan-{epg_vlan}",
))
def _emit_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
leaf_id: int,
port: int,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp+fvIfConn for one LOCAL (home-site)
endpoint — physically attached to a real front-panel leaf port."""
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned",
fabricPathDn=(
f"topology/pod-{pod}/paths-{leaf_id}/pathep-[eth1/{port}]"
),
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, leaf_id, f"eth1/{port}",
epg_ref, vrf_vnid, bd_vnid, flags="local",
)
def _ensure_tunnel_if(
store: MITStore,
pod: int,
spine_id: int,
remote_pod: int,
remote_spine_id: int,
seen: set[tuple[int, int]],
) -> str:
"""Ensure a tunnelIf MO exists on *spine_id* toward the remote site's
spine-proxy TEP (represented by remote_spine_id's loopback); return its
interface id (e.g. "tunnel401"), used to build the fvCEp fabricPathDn.
Real ACI multi-site: remote-learned endpoints are reachable via a VXLAN
tunnel from the local spine-proxy to the remote site's spine-proxy anycast
TEP. We model one tunnelIf per (local spine, remote spine) pair, numbered
deterministically by the remote spine's id so the DN is stable and
idempotent across multiple endpoints sharing the same tunnel.
"""
key = (spine_id, remote_spine_id)
tunnel_id = f"tunnel{remote_spine_id}"
if key not in seen:
seen.add(key)
dest_tep = loopback_ip(remote_pod, remote_spine_id)
store.add(MO(
"tunnelIf",
dn=f"topology/pod-{pod}/node-{spine_id}/sys/tunnel-[{tunnel_id}]",
id=tunnel_id,
dest=dest_tep,
src=loopback_ip(pod, spine_id),
operSt="up",
type="logical-l3-multicast",
layer="l3-multicast",
))
return tunnel_id
def _emit_remote_endpoint(
store: MITStore,
cep_dn: str,
mac: str,
ip: str,
epg_vlan: int,
pod: int,
tunnel_spine_id: int,
tunnel_id: str,
epg_ref: str,
vrf_vnid: int,
bd_vnid: int,
) -> None:
"""Emit fvCEp+fvIp+epmMacEp+epmIpEp for one REMOTE (peer-site) endpoint —
learned via the multi-site overlay tunnel, not a real front-panel port."""
fabric_path_dn = (
f"topology/pod-{pod}/paths-{tunnel_spine_id}/pathep-[{tunnel_id}]"
)
cep_mo = MO(
"fvCEp",
dn=cep_dn,
mac=mac,
ip="0.0.0.0",
encap=f"vlan-{epg_vlan}",
lcC="learned,vxlan",
fabricPathDn=fabric_path_dn,
)
cep_mo.add_child(MO(
"fvIp",
dn=f"{cep_dn}/ip-[{ip}]",
addr=ip,
))
store.add(cep_mo)
_emit_epm_mos(
store, mac, ip, epg_vlan, pod, tunnel_spine_id, tunnel_id,
epg_ref, vrf_vnid, bd_vnid, flags="learned,vxlan",
)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit endpoint + EPM MOs for every EPG deployed at *site*.
MAC/VLAN sequences are per-store (per site/APIC) and assume the intended
one-MITStore-per-APIC model; merging two site stores would collide DNs/MACs.
"""
pod = site.pod
leaves = site.leaf_nodes()
per_epg = topo.endpoints.per_epg
first_vlan = topo.access.vlan_pools[0].from_vlan if topo.access.vlan_pools else 100
# APIC-{cid} controllers (cid 1..site.controllers) are wired to eth1/{cid}
# on the first two leaves (fabric.py: apic_leaves = site.leaf_nodes()[:2]).
# Reserve that port range on those leaves; endpoints start right after it.
apic_reserved_leaf_ids = {n.id for n in site.leaf_nodes()[:2]}
apic_port_start = 1 + site.controllers
free_host_ports = max(_HOST_PORTS - site.controllers, 1)
global_epg_index = 0
ep_counter = 0
tunnels_seen: set[tuple[int, int]] = set()
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
bd_by_name = {bd.name: bd for bd in tenant.bds}
# A stretched tenant's endpoints are HOME on exactly one of its sites
# (see module docstring). Non-stretched (site-local) tenants have no
# remote counterpart — every endpoint is simply local, as before.
is_stretched = tenant.stretch and len(tenant.sites) > 1
remote_site = None
if is_stretched:
try:
remote_site = topo.other_site(site.name) if site.name in tenant.sites else None
except KeyError:
remote_site = None
for ap in tenant.aps:
for epg in ap.epgs:
bd = bd_by_name.get(epg.bd)
epg_vlan = first_vlan + global_epg_index
global_epg_index += 1
if bd is None or not bd.subnets:
continue
vrf_vnid = _vnid(f"{t}:{bd.vrf}", 2900000)
bd_vnid = _vnid(f"{t}:{bd.name}", 15000000)
epg_ref = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}"
for i in range(per_epg):
# Deterministic HOME-site assignment: alternates over
# tenant.sites by this endpoint's position in the
# per-tenant sequence, so LAB1's and LAB2's independent
# build() calls agree on which site is home without
# sharing state.
home_site_name = (
tenant.sites[ep_counter % len(tenant.sites)]
if is_stretched else site.name
)
mac = _mac(ep_counter)
ep_counter += 1
ip = _host_ip(bd, i)
cep_dn = f"uni/tn-{t}/ap-{ap.name}/epg-{epg.name}/cep-{mac}"
if not is_stretched or home_site_name == site.name:
leaf = leaves[i % len(leaves)]
leaf_id = leaf.id
if leaf_id in apic_reserved_leaf_ids:
# Skip past the APIC-reserved eth1/1..eth1/{controllers}
# range so no endpoint double-books a controller port.
port = apic_port_start + (i % free_host_ports)
else:
port = (i % _HOST_PORTS) + 1
_emit_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
leaf_id, port, epg_ref, vrf_vnid, bd_vnid,
)
elif remote_site is not None:
# This site is the PEER for this endpoint: learned via
# the multi-site overlay tunnel, not a front-panel port.
local_spines = site.spine_nodes()
home_spines = remote_site.spine_nodes()
if not local_spines or not home_spines:
continue
tunnel_spine = local_spines[i % len(local_spines)]
home_spine = home_spines[i % len(home_spines)]
tunnel_id = _ensure_tunnel_if(
store, pod, tunnel_spine.id,
remote_site.pod, home_spine.id, tunnels_seen,
)
_emit_remote_endpoint(
store, cep_dn, mac, ip, epg_vlan, pod,
tunnel_spine.id, tunnel_id, epg_ref, vrf_vnid, bd_vnid,
)
+343
View File
@@ -0,0 +1,343 @@
"""build/fabric.py — fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf.
Batch-2 additions (CONTRACT.md §6 "Fabric/topology", "Infra write targets"; DN/
attribute shapes verified read-only against autoACI's vpc_status.py/
health_score.py — see docs/DESIGN.md "Batch-2" section):
pcAggrIf {vpcDom}/aggr-[po{bl.id}] (port-channel details; vpc_status.py
matches it to vpcIf by (node, name==ipg_name) to get the po id)
pcRsMbrIfs {pcAggrIf}/rsmbrIfs-[{ifDn}] (member-port relation; CONTRACT.md
catalogued this as "vpcRsMbrIfs" — WRONG, corrected to pcRsMbrIfs,
the real ACI class vpc_status.py actually queries; see CONTRACT.md §6
changelog note)
infraWiNode topology/pod-{p}/node-{m}/av/node-{c} (appliance-vector: this
controller's view of cluster member {c}'s health)
fabricNodeIdentP uni/controller/nodeidentpol/nodep-{id} (boot-seeded registration
for every real fabricNode, so GETs return registered nodes without a
prior POST; consistent with writes.py's existing add-leaf reaction,
which also keys nodep-{id} on the node id, not the serial)
Address derivation (see loopback_ip/oob_ip docstrings for the full scheme).
node_id is expected in [1, 4095] (ACI node IDs are 3-4 decimal digits; this
covers every ID our topology.yaml or a hand-authored one could reasonably use).
node_id <= 255 (legacy/unchanged — BACKWARD COMPATIBLE, existing labs keep
their addresses):
loopback / topSystem.address: 10.<pod>.<node_id>.1
OOB mgmt / topSystem.oobMgmtAddr: 192.168.<pod>.<node_id>
node_id > 255 (new — needed once a site's node IDs exceed 255, e.g. LAB2's
301-402, which would otherwise overflow the last IPv4 octet
and produce invalid addresses like 10.1.401.1):
loopback: 10.<pod>.<node_id % 256>.<1 + node_id // 256>
- third octet reuses the legacy last-octet slot (node_id % 256)
- fourth octet is bumped to 2, 3, ... instead of the legacy "1", so it
can never collide with a node_id <= 255 loopback (those always end
in ".1"); node_id // 256 is <= 15 for node_id <= 4095, keeping the
fourth octet in [2, 16], well inside the valid range.
OOB mgmt: 192.<168 + node_id // 256>.<pod>.<node_id % 256>
- second octet is bumped from 168 to 169, 170, ... (never 168, which
is reserved for the legacy node_id <= 255 scheme), so it can never
collide with a legacy oob address either.
- node_id // 256 is <= 15 for node_id <= 4095, keeping the second
octet in [169, 183], still a valid unicast IPv4 octet.
Both branches are unique per (pod, node_id): within a single pod the pair
(node_id % 256, node_id // 256) is a bijection of node_id, and the fourth
octet (loopback) / second octet (oob) values used by the > 255 branch never
overlap with the values the <= 255 branch produces.
"""
from __future__ import annotations
from aci_sim.build.neighbors import add_adjacency, node_mac
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_LEGACY_MAX_NODE_ID = 255
def loopback_ip(pod: int, node_id: int) -> str:
"""Node loopback address.
node_id <= 255: 10.<pod>.<node_id>.1 (legacy, unchanged)
node_id > 255: 10.<pod>.<node_id % 256>.<1 + node_id // 256>
See module docstring for why this can never produce an invalid octet
(>255) or collide with the legacy scheme.
"""
if node_id <= _LEGACY_MAX_NODE_ID:
return f"10.{pod}.{node_id}.1"
return f"10.{pod}.{node_id % 256}.{1 + node_id // 256}"
def oob_ip(pod: int, node_id: int) -> str:
"""OOB management address.
node_id <= 255: 192.168.<pod>.<node_id> (legacy, unchanged)
node_id > 255: 192.<168 + node_id // 256>.<pod>.<node_id % 256>
See module docstring for why this can never produce an invalid octet
(>255) or collide with the legacy scheme.
"""
if node_id <= _LEGACY_MAX_NODE_ID:
return f"192.168.{pod}.{node_id}"
return f"192.{168 + node_id // 256}.{pod}.{node_id % 256}"
def default_serial(site_id: str, node_id: int) -> str:
"""Deterministic non-empty serial for an auto-generated node (PR-18).
Format: SAL{site_id}{node_id:04d} — e.g. site "1" node 101 -> "SAL10101".
Ansible's `cisco.aci.aci_fabric_node` keys node registration on serial;
prior to PR-18 an unset `Node.serial` fell back to the non-deterministic-
looking (but actually deterministic) "SIM{node.id:08d}". This helper
replaces that fallback with a scheme that also encodes the site, so two
sites' node-101 (impossible today given the 200-offset ID scheme, but a
hand-authored topology could still reuse ids across unrelated tools)
produce visibly distinct serials. Explicit YAML `serial:` values on a
Node always take precedence — this is only the fallback for `serial=""`.
"""
return f"SAL{site_id}{node_id:04d}"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fabricNode, topSystem, fabricHealthTotal, vpcDom/vpcIf for *site*."""
pod = site.pod
fabric_name = site.fabric_name or topo.fabric.name # per-site fabricDomain (site grouping)
spine_ids = {n.id for n in site.spine_nodes()}
for node in site.all_nodes():
role = "spine" if node.id in spine_ids else "leaf"
node_dn = f"topology/pod-{pod}/node-{node.id}"
store.add(MO(
"fabricNode",
dn=node_dn,
id=str(node.id),
name=node.name,
role=role,
model=node.model,
serial=node.serial or default_serial(site.id, node.id),
version=node.version,
adSt="on",
fabricSt="active",
))
store.add(MO(
"topSystem",
dn=f"{node_dn}/sys",
id=str(node.id),
name=node.name,
role=role,
version=node.version,
address=loopback_ip(pod, node.id),
oobMgmtAddr=oob_ip(pod, node.id),
fabricDomain=fabric_name,
state="in-service",
podId=str(pod),
))
# APIC controllers — the APIC cluster (role=controller, node IDs 1..N).
# Real ACI exposes these as fabricNode + topSystem role=controller; autoACI
# reads fabricDomain + version from the controller topSystem at login and
# renders the controllers as nodes in Topology.
fabric_domain = site.fabric_name or fabric_name
# Tier-3 (PR-20): fabric.default_apic_version is a fabric-wide override
# for the APIC controller version, taking precedence over this site's
# own site.apic_version when set (None/omitted = unchanged pre-PR-20
# behavior: each site keeps using its own site.apic_version, already
# independently settable from switch-node Node.version since PR-18).
apic_version = topo.fabric.default_apic_version or site.apic_version
# PR-21: per-controller OOB mgmt IP. Site.controller_oob_ips() returns
# either the explicit `controller_ips` list, or a sequential derivation
# from `mgmt_ip` (controller 1 = mgmt_ip, controller 2 = mgmt_ip+1, ...).
# An empty list means neither is available (mgmt_ip unset) — fall back
# to the legacy per-node oob_ip(pod, cid) scheme, unchanged pre-PR-21
# behavior for topologies that never set mgmt_ip. The sim's REST server
# always binds to `mgmt_ip` regardless of `controllers` (controller-1 /
# cluster VIP) — this loop only affects the *simulated* fabricNode/
# topSystem/infraWiNode data, never which address is actually served.
controller_oob_ips = site.controller_oob_ips()
for cid in range(1, site.controllers + 1):
ctrl_dn = f"topology/pod-{pod}/node-{cid}"
apic_name = f"{site.name}-ACI-APIC{cid:02d}"
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
store.add(MO(
"fabricNode",
dn=ctrl_dn,
id=str(cid),
name=apic_name,
role="controller",
model="APIC-SERVER-M3",
serial=f"FCH00APIC{cid:03d}",
version=apic_version,
adSt="on",
fabricSt="active",
))
store.add(MO(
"topSystem",
dn=f"{ctrl_dn}/sys",
id=str(cid),
name=apic_name,
role="controller",
version=apic_version,
address=f"10.0.{pod}.{cid}",
oobMgmtAddr=oob_addr,
fabricDomain=fabric_domain,
state="in-service",
podId=str(pod),
))
# PR-9 FEATURE 5 — firmwareCtrlrRunning: cisco.aci and other clients
# read the running APIC firmware version off this class (real ACI
# RN "ctrlrfwstatuscont/ctrlrrunning", one per controller), rather
# than (or in addition to) topSystem.version. Trivially seedable
# from the same apic_version topSystem already uses above (Tier-3,
# PR-20: fabric.default_apic_version or site.apic_version), so the
# two never drift apart.
store.add(MO(
"firmwareCtrlrRunning",
dn=f"{ctrl_dn}/ctrlrfwstatuscont/ctrlrrunning",
version=apic_version,
node=str(cid),
))
# Batch-2: infraWiNode — appliance vector. Each APIC in the cluster carries
# its OWN view of every cluster member's health (real ACI: the "av"
# subtree under each controller's topSystem); health_score.py's cluster-
# fitness check reads infraWiNode.health across ALL of these, so every
# (viewer, member) pair is seeded "fully-fit" — a healthy, fully-formed
# cluster, matching this sim's other all-healthy defaults (fabricHealthTotal
# cur=100, healthInst cur=hd.node).
for viewer_cid in range(1, site.controllers + 1):
for member_cid in range(1, site.controllers + 1):
store.add(MO(
"infraWiNode",
dn=f"topology/pod-{pod}/node-{viewer_cid}/av/node-{member_cid}",
health="fully-fit",
state="in-service",
))
# APIC ↔ leaf attachments. Real APICs are dual-homed to a leaf pair; expose
# them as fabricLink (autoACI draws Topology edges from fabricLink n1/n2) plus
# a leaf-side lldpAdjEp so the leaf "sees" the controller.
apic_leaves = site.leaf_nodes()[:2]
for cid in range(1, site.controllers + 1):
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
for li, leaf in enumerate(apic_leaves):
apic_port = li + 1 # APIC NIC: eth1/1 → first leaf, eth1/2 → second leaf
leaf_port = cid # leaf downlink to APIC-<cid>: eth1/<cid>
store.add(MO(
"fabricLink",
dn=f"topology/pod-{pod}/lnk-{cid}-1-{apic_port}-to-{leaf.id}-1-{leaf_port}",
n1=str(cid),
n2=str(leaf.id),
operSt="up",
operSpeed="10G",
linkType="controller",
))
add_adjacency(
store,
pod=pod,
local_node_id=leaf.id,
local_port=f"eth1/{leaf_port}",
# The APIC's own NIC port (its "eth1/{apic_port}") — real ACI
# LLDP/CDP always reports the remote port on the neighbor's
# side, even when the neighbor is a controller rather than a
# switch. No fabricLink-style back-edge exists for the APIC
# side to look this up from, so it's derived the same way
# the fabricLink DN above already encodes it (apic_port).
remote_port=f"eth1/{apic_port}",
neighbor_name=f"{site.name}-ACI-APIC{cid:02d}",
neighbor_mgmt_ip=oob_addr,
neighbor_kind="controller",
neighbor_mac=node_mac(pod, cid),
# cdpAdjEp.ver: APIC firmware version is readily available
# here (the same `apic_version` this function already
# resolved above for the controller's own topSystem/
# firmwareCtrlrRunning), so surface it rather than "".
neighbor_version=apic_version,
)
# fabricHealthTotal — fabric-wide health summary
store.add(MO("fabricHealthTotal", dn="topology/health", cur="100"))
# vpcDom + vpcIf per border-leaf; group by vpc_domain to assign stable IDs
#
# Batch-2: pcAggrIf (port-channel details) + pcRsMbrIfs (member-port
# relation) per vPC leg, consistent with the vpcIf this loop already
# builds for the same (bl, dom_num) pair. vpc_status.py matches pcAggrIf
# to vpcIf by (node, name) — name is the IPG name, id is "poN" — so
# pcAggrIf.name reuses vpcIf's own "po{bl.id}" string as the IPG name
# (this sim has no separate infraAccBndlGrp-derived IPG name to borrow;
# access.py's vPC infraAccBndlGrp is named "{vpc_domain}-vpc", a
# different string, so pcAggrIf.name intentionally matches vpcIf.name
# instead — the field vpc_status.py actually joins on). Member ports:
# two real host-facing l1PhysIf per leg (eth1/1, eth1/2 — interfaces.py
# always builds these on every leaf/border-leaf), giving each vPC leg a
# believable 2-member port-channel without colliding with the dedicated
# L3Out port (eth1/48) or fabric uplinks.
_VPC_MEMBER_PORTS = ("eth1/1", "eth1/2")
domain_ids: dict[str, int] = {}
for bl in site.border_leaves:
vdom = bl.vpc_domain
if vdom not in domain_ids:
domain_ids[vdom] = len(domain_ids) + 1
dom_num = domain_ids[vdom]
vpc_dn = (
f"topology/pod-{pod}/node-{bl.id}"
f"/local/svc-vpc-stabdoms/domainid-{dom_num}"
)
store.add(MO(
"vpcDom",
dn=vpc_dn,
id=str(dom_num),
peerSt="peerOk",
))
po_name = f"po{bl.id}"
store.add(MO(
"vpcIf",
dn=f"{vpc_dn}/vpcif-[{po_name}]",
name=po_name,
operSt="up",
))
pc_aggr_dn = f"{vpc_dn}/aggr-[{po_name}]"
store.add(MO(
"pcAggrIf",
dn=pc_aggr_dn,
name=po_name,
id=po_name,
operSt="up",
operSpeed="100G",
descr=f"vPC {po_name} on {bl.name}",
))
for member_port in _VPC_MEMBER_PORTS:
member_if_dn = f"topology/pod-{pod}/node-{bl.id}/sys/phys-[{member_port}]"
store.add(MO(
"pcRsMbrIfs",
dn=f"{pc_aggr_dn}/rsmbrIfs-[{member_if_dn}]",
tDn=member_if_dn,
parentSKey=po_name,
))
# Batch-2: fabricNodeIdentP — boot-seeded node registration for every
# real switch fabricNode (spines/leaves/border-leaves; NOT controllers —
# real ACI's nodeidentpol/Fabric Membership registers switches joining
# the fabric, not the APICs themselves), so a GET returns the already-
# registered nodes without requiring a prior POST. RN "nodep-{id}" keys
# on the node id (not serial) to stay consistent with writes.py's
# existing _materialize_fabric_node reaction, which parses the id back
# out of this same "nodep-(\\d+)$" pattern — seeding here and the POST
# reaction in writes.py must agree on the identifier scheme or a boot-
# seeded nodep-{id} and a later POSTed nodep-{serial} would silently
# diverge into two different DNs for logically the same registration.
for node in site.all_nodes():
store.add(MO(
"fabricNodeIdentP",
dn=f"uni/controller/nodeidentpol/nodep-{node.id}",
serial=node.serial or default_serial(site.id, node.id),
nodeId=str(node.id),
name=node.name,
role="leaf" if node.id not in spine_ids else "spine",
))
+113
View File
@@ -0,0 +1,113 @@
"""build/health_faults.py — healthInst, faultInst, coopPol/coopInst, configExportP.
healthInst DN (read by topology.py line 160-163):
topology/pod-{pod}/node-{id}/sys/health
→ connector.query_dn(dn) → item.get("healthInst", {}).get("attributes", {})
faultInst DNs must:
1. Start with topology/pod-{pod}/node-{id}/ (for node-scoped queries)
2. Contain /node-{id}/ (for topology.py line 196 grouping regex)
Required attrs: severity, code, lc, type, subject, descr, created, lastTransition
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_CREATED_TS = "2026-01-01T00:00:00.000+00:00"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit health/fault/coop/config MOs for *site*."""
pod = site.pod
faults_cfg = topo.faults
hd = faults_cfg.health_defaults
# ---- Per-node healthInst --------------------------------------------------
for node in site.all_nodes():
store.add(MO(
"healthInst",
dn=f"topology/pod-{pod}/node-{node.id}/sys/health",
cur=str(hd.node),
min=str(hd.node),
max="100",
prev=str(hd.node),
))
# ---- Per-controller healthInst --------------------------------------------
# Real APIC exposes controller (role=controller, node IDs 1..N) health at
# the same .../sys/health DN shape as switch nodes; fabric.py already
# builds a topSystem at topology/pod-{pod}/node-{cid}/sys for each
# controller, so this just adds the matching healthInst there too.
for cid in range(1, site.controllers + 1):
store.add(MO(
"healthInst",
dn=f"topology/pod-{pod}/node-{cid}/sys/health",
cur=str(hd.node),
min=str(hd.node),
max="100",
prev=str(hd.node),
))
# ---- Per-tenant healthInst (for tenants deployed in this site) -----------
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
store.add(MO(
"healthInst",
dn=f"uni/tn-{tenant.name}/health",
cur=str(hd.tenant),
min=str(hd.tenant),
max="100",
prev=str(hd.tenant),
))
# ---- Seeded faultInst (node-local DNs for node-scoped query support) -----
# Map node_id → pod for this site
site_node_pods: dict[int, int] = {n.id: pod for n in site.all_nodes()}
for seed in faults_cfg.seed:
node_pod = site_node_pods.get(seed.node)
if node_pod is None:
continue # this fault belongs to a different site's node
# seed.code already carries the full ACI fault code (e.g. "F0532") —
# do NOT prepend another "F" (that produced invalid "FF0532").
fault_dn = (
f"topology/pod-{node_pod}/node-{seed.node}"
f"/local/svc-policyelem-id-0/uni/fault-{seed.code}"
)
store.add(MO(
"faultInst",
dn=fault_dn,
severity=seed.severity,
code=seed.code,
descr=seed.descr,
created=_CREATED_TS,
lastTransition=_CREATED_TS,
lc="raised",
type="operational",
subject="node",
))
# ---- coopPol + coopInst --------------------------------------------------
store.add(MO("coopPol", dn="uni/fabric/pol-coop", name="coop"))
for node in site.all_nodes():
store.add(MO(
"coopInst",
dn=f"topology/pod-{pod}/node-{node.id}/local/svc-coop-id-0/uni/epp/fv-[coop]/node-{node.id}",
operSt="formed",
))
# ---- configExportP + configJob (backup/export stubs) ---------------------
store.add(MO(
"configExportP",
dn="uni/fabric/configexp-defaultOneTime",
name="defaultOneTime",
))
store.add(MO(
"configJob",
dn="uni/fabric/configexp-defaultOneTime/job-defaultOneTime",
operSt="success",
executeTime=_CREATED_TS,
))
+191
View File
@@ -0,0 +1,191 @@
"""build/interfaces.py — l1PhysIf, ethpmPhysIf, eqptcapacityPolUsage5min per node.
Port layout:
Fabric uplink ports: same assignment as cabling.py (derived independently)
- Spines: eth1/1, eth1/2, ... (one per downlink node)
- Leaves/BLs: eth1/49, eth1/50, ... (one per spine)
Host-facing ports on leaves/border-leaves: eth1/1 eth1/8 (simulated access)
L3Out routed sub-interface port on border-leaves ONLY: eth1/48 (dedicated,
outside the host-access range — matches real ACI convention of a dedicated
front-panel port for the L3Out CSW uplink rather than sharing a host port).
ISN/IPN uplink ports on spines ONLY, multi-site fabrics only: eth1/49, 50, ...
(one per spine, dedicated — outside the fabric-uplink range eth1/1-4, which
is fully consumed by intra-fabric spine↔leaf links). Real ACI spines facing
a multi-site ISN use dedicated front-panel ports for the IPN uplink, distinct
from the leaf-facing fabric ports; underlay.py's ospfAdjEp references these.
ethpmPhysIf DN must contain "phys-[eth{s}/{p}]" — read by topology.py line 730:
port = dn.split("phys-[")[1].split("]")[0] → "eth1/1"
l1PhysIf DN is under the same sys/ subtree; id attribute carries the port name.
Batch-2 addition (CONTRACT.md §6 "Interfaces"): ethpmFcot — SFP/transceiver
info, sibling of ethpmPhysIf under the same phys-[eth{s}/{p}] port DN (own RN
"fcot", per real ACI's ethpmFcot placement one level under ethpmPhysIf's own
"phys" RN's parent). Attrs verified read-only against autoACI's
interface_comprehensive.py: typeName, guiName, vendorName, vendorSn,
actualType (decoded via that plugin's own `_decode_sfp_field`/`_port_from_dn`
helpers, which just strip a "phys-[" bracket segment off the dn — same
placement l1PhysIf/ethpmPhysIf already use). Only emitted for fabric-uplink
and L3Out routed ports (real optics-bearing ports); plain host-access ports
are left without an ethpmFcot, matching real ACI labs where copper/DAC host
ports often report no transceiver inventory while fabric/WAN-facing optical
ports do — a deliberate realism choice, not an omission.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.cabling import cabling_links
_UPLINK_START = 49 # first leaf-side uplink port index
_HOST_PORTS = 8 # number of host-facing ports on leaves
_L3OUT_PORT = 48 # dedicated border-leaf L3Out routed sub-interface port
def _add_port(
store: MITStore,
pod: int,
node_id: int,
slot: int,
port: int,
descr: str = "",
mode: str = "trunk",
usage: str = "fabric",
with_fcot: bool = False,
mtu: str = "9216",
) -> None:
"""Add l1PhysIf + ethpmPhysIf (+ optional ethpmFcot) for one port.
`mtu` defaults to the fabric intra-fabric MTU (9216); the ISN/IPN uplink
port (multi-site only) passes `topo.isn.mtu` instead (Tier-2, PR-19) —
real ACI's inter-pod/inter-site default is 9150, distinct from the
intra-fabric 9216 ceiling.
"""
port_id = f"eth{slot}/{port}"
sys_dn = f"topology/pod-{pod}/node-{node_id}/sys"
phys_dn = f"{sys_dn}/phys-[{port_id}]"
# l1PhysIf — administrative config
store.add(MO(
"l1PhysIf",
dn=phys_dn,
id=port_id,
adminSt="up",
descr=descr,
mtu=mtu,
mode=mode,
))
# ethpmPhysIf — operational state; DN must contain "phys-[eth…]"
store.add(MO(
"ethpmPhysIf",
dn=f"{phys_dn}/phys",
operSt="up",
operSpeed="100G",
usage=usage,
lastLinkStChg="00:00:00:00.000",
resetCtr="0",
))
# Batch-2: ethpmFcot — SFP/transceiver inventory, sibling of ethpmPhysIf
# (own "fcot" RN under the same phys-[eth…] port DN — interface_comprehensive.py's
# _port_from_dn strips the same "phys-[" bracket segment this DN shares with
# ethpmPhysIf, so both resolve to the same port string). Only real
# optics-bearing ports (fabric uplinks, ISN uplinks, L3Out routed ports)
# get one — see module docstring for the realism rationale.
if with_fcot:
store.add(MO(
"ethpmFcot",
dn=f"{phys_dn}/fcot",
typeName="QSFP-100G-SR4",
guiName="QSFP-100G-SR4",
vendorName="CISCO-FINISAR",
vendorSn=f"FNS{pod:02d}{node_id:04d}{port:02d}",
actualType="qsfp-100g-sr4",
operSt="up",
operSpeed="100G",
))
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit l1PhysIf + ethpmPhysIf for all nodes, plus eqptcapacityPolUsage5min."""
pod = site.pod
spine_ids = {n.id for n in site.spine_nodes()}
# Collect port assignments from the cabling graph
# spine_ports[node_id] = set of (slot, port) used as fabric uplinks
# leaf_ports[node_id] = set of (slot, port) used as fabric uplinks
spine_uplinks: dict[int, list[tuple[int, int]]] = {n.id: [] for n in site.spine_nodes()}
leaf_uplinks: dict[int, list[tuple[int, int]]] = {
n.id: [] for n in site.leaf_nodes() + site.border_leaf_nodes()
}
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
if n1 in spine_ids:
spine_uplinks[n1].append((s1, p1))
leaf_uplinks[n2].append((s2, p2))
else:
spine_uplinks[n2].append((s2, p2))
leaf_uplinks[n1].append((s1, p1))
# Spines — fabric uplinks, plus (multi-site only) dedicated ISN uplinks
for si, spine in enumerate(site.spine_nodes()):
for slot, port in spine_uplinks[spine.id]:
_add_port(
store, pod, spine.id, slot, port,
descr=f"Fabric uplink eth{slot}/{port}",
mode="trunk",
with_fcot=True,
)
if len(topo.sites) > 1:
isn_port = _UPLINK_START + si
_add_port(
store, pod, spine.id, 1, isn_port,
descr=f"ISN/IPN uplink eth1/{isn_port}",
mode="routed",
usage="isn",
with_fcot=True,
mtu=str(topo.isn.mtu),
)
border_leaf_ids = {bl.id for bl in site.border_leaves}
# Leaves + border-leaves — fabric uplinks + host-facing ports
for leaf in site.leaf_nodes() + site.border_leaf_nodes():
# Fabric uplink ports
for slot, port in leaf_uplinks[leaf.id]:
_add_port(
store, pod, leaf.id, slot, port,
descr="Fabric uplink to spine",
mode="trunk",
with_fcot=True,
)
# Host-facing access ports: eth1/1 … eth1/_HOST_PORTS
for hp in range(1, _HOST_PORTS + 1):
_add_port(
store, pod, leaf.id, 1, hp,
descr=f"Host port eth1/{hp}",
mode="access",
usage="access",
)
# Border-leaves only: dedicated L3Out routed sub-interface port,
# outside the host-access range — l3out.py's l3extRsPathL3OutAtt
# tDn references this exact port so it resolves against a real
# l1PhysIf (real ACI convention: dedicated front-panel port for the
# L3Out CSW uplink, not shared with regular host/EPG traffic).
if leaf.id in border_leaf_ids:
_add_port(
store, pod, leaf.id, 1, _L3OUT_PORT,
descr=f"L3Out routed uplink eth1/{_L3OUT_PORT}",
mode="routed",
usage="l3out",
with_fcot=True,
)
# eqptcapacityPolUsage5min — capacity stats (contract-rendering metrics)
store.add(MO(
"eqptcapacityPolUsage5min",
dn=f"topology/pod-{pod}/node-{leaf.id}/sys/eqptcapacity/polUsage5min",
polUsage="42",
polUsageCap="100",
polUsageCum="38",
polUsageCapCum="100",
))
+294
View File
@@ -0,0 +1,294 @@
"""build/l3out.py — l3extOut tree + eBGP operational session MOs.
Batch-1 additions (CONTRACT.md §6 "Route-control", "L3Out (control)"):
route-control (rtctrlProfile/rtctrlCtxP/rtctrlSubjP/rtctrlMatchRtDest) +
static routes (ipRouteP/ipNexthopP) + l3extMember + ospfExtP. DN/RN shapes
verified against autoACI's plugins (read-only, prior-audit trust per
CONTRACT.md header; see docs/DESIGN.md "Batch-1" section for the specific
attrs read by route_control.py / l3out_detail.py):
rtctrlProfile uni/tn-{t}/out-{l3out}/prof-{name} (export profile, one per L3Out)
rtctrlCtxP {profile}/ctx-{name}
rtctrlSubjP uni/tn-{t}/subj-{name} (tenant-level match rule —
route_control.py queries it with wcard(dn,"tn-{tenant}"), NOT
scoped under any one L3Out)
rtctrlMatchRtDest {subjp}/dest-[{ip}]
rtctrlSetComm under the ctx (real ACI RN: "scomm")
rtctrlSetPref under the ctx (real ACI RN: "spref")
ipRouteP {rsnodeL3OutAtt}/rt-[{prefix}] (static route toward the CSW peer)
ipNexthopP {ipRouteP}/nh-[{addr}]
l3extMember {l3extRsPathL3OutAtt}/mem-{A|B} (only if the path is vPC-style;
this topology's L3Out path is a single port per border-leaf — see
_build_node_profile's path_tdn — so we emit one member, side="A",
on that existing single path, per the batch-1 placement note.)
ospfExtP uni/tn-{t}/out-{l3out}/ospfExtP (added to the SAME L3Out as
the existing bgpExtP — real ACI allows OSPF+BGP coexistence on one
L3Out, and this topology has no OSPF-only L3Out, so extending the
existing one is the simpler faithful choice; see DESIGN.md.)
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import L3Out, Site, Tenant, Topology
from aci_sim.build.fabric import loopback_ip
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _owning_site(l3out: L3Out, topo: Topology) -> str | None:
if l3out.site is not None:
return l3out.site
for s in topo.sites:
site_bl_ids = {bl.id for bl in s.border_leaves}
if any(bl_id in site_bl_ids for bl_id in l3out.border_leaves):
return s.name
return None
def _build_node_profile(l3out_dn: str, l3out: L3Out, site: Site, pod: int) -> MO:
"""Build l3extLNodeP (with node attachments) + l3extLIfP (paths + BGP peer)."""
site_bl_ids = {bl.id: bl for bl in site.border_leaves}
bl_ids_here = [bid for bid in l3out.border_leaves if bid in site_bl_ids]
np_name = f"{l3out.name}_nodeProfile"
np_dn = f"{l3out_dn}/lnodep-{np_name}"
np_mo = MO("l3extLNodeP", dn=np_dn, name=np_name)
for bl_id in bl_ids_here:
rtr_id = loopback_ip(pod, bl_id)
rsnode_dn = f"{np_dn}/rsnodeL3OutAtt-[topology/pod-{pod}/node-{bl_id}]"
rsnode_mo = MO(
"l3extRsNodeL3OutAtt",
dn=rsnode_dn,
tDn=f"topology/pod-{pod}/node-{bl_id}",
rtrId=rtr_id,
)
# Batch-1: static default route toward this border-leaf's CSW peer.
# ipRouteP.ip carries the prefix (autoACI's l3out_detail.py/route_control.py
# read attrs["ip"], not "prefix" — that's uribv4Route); ipNexthopP.nhAddr is
# the CSW interconnect IP the eBGP session builder (_upsert_ebgp_sessions)
# already peers with, so the static route is consistent with the live BGP
# session on the same border leaf.
if l3out.csw_peer:
prefix = "0.0.0.0/0"
route_dn = f"{rsnode_dn}/rt-[{prefix}]"
route_mo = MO("ipRouteP", dn=route_dn, ip=prefix, pref="1")
nh_addr = l3out.csw_peer.peer_ip
route_mo.add_child(MO(
"ipNexthopP",
dn=f"{route_dn}/nh-[{nh_addr}]",
nhAddr=nh_addr,
))
rsnode_mo.add_child(route_mo)
np_mo.add_child(rsnode_mo)
ip_name = f"{l3out.name}_ifProfile"
ip_dn = f"{np_dn}/lifp-{ip_name}"
ip_mo = MO("l3extLIfP", dn=ip_dn, name=ip_name)
for bl_id in bl_ids_here:
bl_obj = site_bl_ids[bl_id]
csw = bl_obj.csw
path_tdn = f"topology/pod-{pod}/paths-{bl_id}/pathep-[eth1/48]"
path_dn = f"{ip_dn}/rspathL3OutAtt-[{path_tdn}]"
path_mo = MO(
"l3extRsPathL3OutAtt",
dn=path_dn,
tDn=path_tdn,
encap=f"vlan-{csw.vlan}",
addr=f"{csw.local_ip}/30",
ifInstT="sub-interface",
)
# Batch-1: this L3Out's path is a single port per border-leaf (not a
# vPC-bundled path — see path_tdn above), so per the batch-1 placement
# note we emit exactly one l3extMember, side="A", on the existing path.
path_mo.add_child(MO(
"l3extMember",
dn=f"{path_dn}/mem-A",
side="A",
addr=f"{csw.local_ip}/30",
))
ip_mo.add_child(path_mo)
if l3out.csw_peer:
peer_ip = l3out.csw_peer.peer_ip
peer_dn = f"{ip_dn}/peerP-[{peer_ip}]"
# Tier-3 (PR-20): keepalive/hold — real ACI defaults 60s/180s,
# previously not carried anywhere on this MO. l3out.csw_peer's
# keepalive/hold feed bgpPeerP.privateAsCtrl-adjacent timer attrs.
peer_mo = MO(
"bgpPeerP",
dn=peer_dn,
addr=peer_ip,
peerCtrl="",
keepAliveIntvl=str(l3out.csw_peer.keepalive),
holdIntvl=str(l3out.csw_peer.hold),
)
peer_mo.add_child(MO(
"bgpAsP",
dn=f"{peer_dn}/as",
asn=str(l3out.csw_peer.asn),
))
ip_mo.add_child(peer_mo)
np_mo.add_child(ip_mo)
return np_mo
def _build_route_control(t: str, l3out: L3Out, l3out_dn: str) -> MO:
"""Build rtctrlProfile (export profile) + rtctrlCtxP + rtctrlSetComm/SetPref
children, one per L3Out. RN scheme (verified against this module's own
uni/tn-{t}/out-{l3out}/... conventions; "scomm"/"spref" are the real ACI
RNs for rtctrlSetComm/rtctrlSetPref — Cisco's fixed short-form RNs, not
name-suffixed like most other RN templates in this file):
rtctrlProfile {l3out_dn}/prof-{name}
rtctrlCtxP {profile}/ctx-{name}
rtctrlSetComm {ctx}/scomm
rtctrlSetPref {ctx}/spref
"""
prof_name = f"{l3out.name}_export"
prof_dn = f"{l3out_dn}/prof-{prof_name}"
prof_mo = MO("rtctrlProfile", dn=prof_dn, name=prof_name)
ctx_name = f"{l3out.name}_ctx"
ctx_dn = f"{prof_dn}/ctx-{ctx_name}"
ctx_mo = MO("rtctrlCtxP", dn=ctx_dn, name=ctx_name, action="permit")
ctx_mo.add_child(MO("rtctrlSetComm", dn=f"{ctx_dn}/scomm", community=f"no-advertise:{t}"))
ctx_mo.add_child(MO("rtctrlSetPref", dn=f"{ctx_dn}/spref", localPref="200"))
prof_mo.add_child(ctx_mo)
return prof_mo
def _build_tenant_subj(t: str, l3out: L3Out, bd_subnets: list[str]) -> MO | None:
"""Build one tenant-level rtctrlSubjP (match rule) + rtctrlMatchRtDest
children, referenced from this L3Out's rtctrlCtxP.
rtctrlSubjP is tenant-level (uni/tn-{t}/subj-{name}), NOT nested under any
one L3Out's DN — autoACI's route_control.py queries it with
wcard(rtctrlSubjP.dn, "tn-{tenant}") rather than scoping it to an L3Out
(verified against the plugin source). Match destinations reuse a real BD
subnet from the tenant so the redistributed prefix is believable.
"""
if not bd_subnets:
return None
subj_name = f"{l3out.name}_match"
subj_dn = f"uni/tn-{t}/subj-{subj_name}"
subj_mo = MO("rtctrlSubjP", dn=subj_dn, name=subj_name)
subj_mo.add_child(MO(
"rtctrlMatchRtDest",
dn=f"{subj_dn}/dest-[{bd_subnets[0]}]",
ip=bd_subnets[0],
))
return subj_mo
def _build_l3out_tree(t: str, l3out: L3Out, site: Site, l3_dom_name: str, bd_subnets: list[str]) -> MO:
pod = site.pod
l3out_dn = f"uni/tn-{t}/out-{l3out.name}"
l3out_mo = MO("l3extOut", dn=l3out_dn, name=l3out.name, enforceRtctrl="export")
l3out_mo.add_child(MO(
"l3extRsEctx",
dn=f"{l3out_dn}/rsectx",
tnFvCtxName=l3out.vrf,
tDn=f"uni/tn-{t}/ctx-{l3out.vrf}",
))
l3out_mo.add_child(MO(
"l3extRsL3DomAtt",
dn=f"{l3out_dn}/rsl3DomAtt",
tDn=f"uni/l3dom-{l3_dom_name}",
))
l3out_mo.add_child(MO("bgpExtP", dn=f"{l3out_dn}/bgpExtP"))
# Batch-1: ospfExtP added to the SAME L3Out (coexists with bgpExtP above —
# real ACI supports OSPF+BGP on one L3Out; see module docstring).
l3out_mo.add_child(MO(
"ospfExtP",
dn=f"{l3out_dn}/ospfExtP",
areaId="0.0.0.1",
areaType="regular",
))
l3out_mo.add_child(_build_route_control(t, l3out, l3out_dn))
l3out_mo.add_child(_build_node_profile(l3out_dn, l3out, site, pod))
for ext_epg in l3out.ext_epgs:
inst_dn = f"{l3out_dn}/instP-{ext_epg.name}"
inst_mo = MO("l3extInstP", dn=inst_dn, name=ext_epg.name, prefGrMemb="exclude")
for cidr in ext_epg.subnets:
inst_mo.add_child(MO(
"l3extSubnet",
dn=f"{inst_dn}/extsubnet-[{cidr}]",
ip=cidr,
scope="import-security",
))
l3out_mo.add_child(inst_mo)
return l3out_mo
def _upsert_ebgp_sessions(
t: str, l3out: L3Out, site: Site, store: MITStore
) -> None:
pod = site.pod
site_bl_ids = {bl.id: bl for bl in site.border_leaves}
bl_ids_here = [bid for bid in l3out.border_leaves if bid in site_bl_ids]
for bl_id in bl_ids_here:
bl_obj = site_bl_ids[bl_id]
csw = bl_obj.csw
rtr_id = loopback_ip(pod, bl_id)
inst_dn = f"topology/pod-{pod}/node-{bl_id}/sys/bgp/inst"
inst_mo = MO("bgpInst", dn=inst_dn, asn=str(site.asn))
dom_name = f"{t}:{l3out.vrf}"
dom_dn = f"{inst_dn}/dom-{dom_name}"
dom_mo = MO("bgpDom", dn=dom_dn, name=dom_name)
peer_dn = f"{dom_dn}/peer-[{csw.peer_ip}]"
peer_mo = MO(
"bgpPeer",
dn=peer_dn,
addr=csw.peer_ip,
asn=str(csw.asn),
type="ebgp",
peerRole="ce",
)
entry_dn = f"{peer_dn}/ent-[{csw.peer_ip}]"
peer_mo.add_child(MO(
"bgpPeerEntry",
dn=entry_dn,
addr=csw.peer_ip,
operSt="established",
rtrId=rtr_id,
connEst="1",
connDrop="0",
type="ebgp",
))
dom_mo.add_child(peer_mo)
inst_mo.add_child(dom_mo)
store.upsert(inst_mo)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit l3extOut trees + eBGP session MOs for L3Outs owned by *site*."""
l3_dom_name = topo.access.l3_domains[0].name if topo.access.l3_domains else "l3dom-1"
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
bd_subnets = [cidr for bd in tenant.bds for cidr in bd.subnets]
for l3out in tenant.l3outs:
if _owning_site(l3out, topo) != site.name:
continue
store.add(_build_l3out_tree(t, l3out, site, l3_dom_name, bd_subnets))
_upsert_ebgp_sessions(t, l3out, site, store)
# Batch-1: tenant-level match rule (rtctrlSubjP), added independently
# of the l3out tree (autoACI queries it as tenant-scoped, not nested
# under any one L3Out — see _build_tenant_subj docstring).
subj_mo = _build_tenant_subj(t, l3out, bd_subnets)
if subj_mo is not None:
store.add(subj_mo)
+122
View File
@@ -0,0 +1,122 @@
"""build/mgmt.py — mgmt tenant OOB scaffolding: fvTenant(mgmt)/mgmtMgmtP/mgmtOoB/mgmtRsOoBStNode.
Real APIC models the OOB management address + gateway for every fabric node
in the special `mgmt` tenant, NOT on the node's own topSystem (topSystem only
carries the address `oobMgmtAddr` never a gateway; see build/fabric.py).
The real object tree (verified against APIC's own MIT, CONTRACT.md-style):
uni/tn-mgmt fvTenant (name="mgmt")
mgmtp-default mgmtMgmtP (name="default")
oob-default mgmtOoB (name="default")
rsooBStNode-[topology/pod-<p>/node-<id>] mgmtRsOoBStNode
tDn=topology/pod-<p>/node-<id>
addr=<node's OOB IP>/<mask>
gw=<site.oob_gateway or "">
This is the ONLY place a real ACI object carries an OOB gateway attribute
prior to this builder, `Site`/the init wizard collected an OOB gateway
(cli.py's `run_wizard`) but discarded it (no schema field, no MO); this
builder is what makes that value land on a real MO instead.
Scope (deliberately minimal/faithful): one `mgmtRsOoBStNode` per node this
site actually has (switches spines/leaves/border-leaves AND controllers),
matching the module list `build/fabric.py` already builds `fabricNode`/
`topSystem` for. No other mgmt-tenant children (no `mgmtInB`, no
`mgmtRsOoBCtx`, no `mgmtInstP`, etc.) those model in-band mgmt / EPG
contract-scope wiring that no autoACI/aci-py chain audited for this sim reads
(same "store scaffolding a caller doesn't consume is scope-creep" judgment
call as `Fabric.inb_subnet`'s docstring, topology/schema.py).
Mask derivation (`addr=<ip>/<mask>`): real ACI's OOB address is configured
with an explicit prefix at first-boot (the same dialog this sim's `aci-sim
init` wizard mirrors), and is virtually always a /24 (one OOB subnet per
pod/site) even though the fabric-wide address *pool* is much larger. This
sim's own `oob_ip(pod, node_id)` (build/fabric.py) reflects exactly that
shape every node's OOB address is `192.168.<pod>.<node_id>`, i.e. already
confined to a /24 per pod (the pod occupies the third octet; the pool's
`/16` only bounds the SECOND octet across all pods). So the mask is derived,
in priority order:
1. `fabric.oob_subnet` (Tier-3, PR-20) if the topology author explicitly
set it their stated intent overrides the derived default, even if it
happens to be wider than /24 (e.g. a deliberately shared /16 OOB VLAN).
2. `/24` otherwise (default) matches the real per-pod OOB subnet shape
`oob_ip()` already produces, NOT `fabric.oob_pool`'s `/16` (which
describes the whole multi-pod address space, not any single node's
subnet using it directly would put every node in a /16 that spans
pods it was never actually part of).
`fabric.oob_subnet`, when set, is already validated (Topology.
normalize_and_validate) to be a real CIDR inside 192.168.0.0/16, so
`.prefixlen` is always safe to read.
Gateway (`gw=...`): `site.oob_gateway` verbatim if set, else the EMPTY
STRING never a derived/invented default. A derived default (e.g. ".1" of
the OOB subnet) would look like real config an operator entered, when no
operator entered anything; an empty `gw` faithfully says "not configured",
which is exactly the real APIC behavior before its own first-boot gateway
question is answered. This also keeps the change purely additive: a
topology.yaml that never sets `oob_gateway` builds byte-identical
mgmtRsOoBStNode.gw ("") on every rebuild.
"""
from __future__ import annotations
import ipaddress
from aci_sim.build.fabric import oob_ip
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_DEFAULT_OOB_PREFIXLEN = 24 # real-ACI-shaped per-pod OOB subnet (see module docstring)
def _oob_prefixlen(topo: Topology) -> int:
"""Effective OOB subnet prefix length (see module docstring priority order)."""
if topo.fabric.oob_subnet is not None:
return ipaddress.ip_network(topo.fabric.oob_subnet, strict=False).prefixlen
return _DEFAULT_OOB_PREFIXLEN
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit the mgmt-tenant OOB scaffolding for *site*: fvTenant(mgmt) →
mgmtMgmtP(default) mgmtOoB(default) one mgmtRsOoBStNode per node."""
prefixlen = _oob_prefixlen(topo)
gw = site.oob_gateway or ""
tenant_mo = MO("fvTenant", dn="uni/tn-mgmt", name="mgmt", descr="")
mgmtp_mo = MO("mgmtMgmtP", dn="uni/tn-mgmt/mgmtp-default", name="default")
oob_mo = MO("mgmtOoB", dn="uni/tn-mgmt/mgmtp-default/oob-default", name="default")
pod = site.pod
# Switches (spines/leaves/border-leaves): OOB address from the same
# oob_ip(pod, node_id) derivation build/fabric.py used for topSystem.
for node in site.all_nodes():
node_dn = f"topology/pod-{pod}/node-{node.id}"
addr = f"{oob_ip(pod, node.id)}/{prefixlen}"
oob_mo.add_child(MO(
"mgmtRsOoBStNode",
dn=f"{oob_mo.dn}/rsooBStNode-[{node_dn}]",
tDn=node_dn,
addr=addr,
gw=gw,
))
# Controllers (APIC cluster, node IDs 1..N): OOB address from the same
# controller_oob_ips()/oob_ip(pod, cid) resolution build/fabric.py uses
# for the controller topSystem (explicit controller_ips, else derived
# from mgmt_ip, else legacy per-node oob_ip(pod, cid) fallback).
controller_oob_ips = site.controller_oob_ips()
for cid in range(1, site.controllers + 1):
ctrl_dn = f"topology/pod-{pod}/node-{cid}"
oob_addr = controller_oob_ips[cid - 1] if controller_oob_ips else oob_ip(pod, cid)
addr = f"{oob_addr}/{prefixlen}"
oob_mo.add_child(MO(
"mgmtRsOoBStNode",
dn=f"{oob_mo.dn}/rsooBStNode-[{ctrl_dn}]",
tDn=ctrl_dn,
addr=addr,
gw=gw,
))
mgmtp_mo.add_child(oob_mo)
tenant_mo.add_child(mgmtp_mo)
store.add(tenant_mo)
+230
View File
@@ -0,0 +1,230 @@
"""build/neighbors.py — shared LLDP/CDP adjacency + container-MO helper.
There are THREE call sites that construct lldpAdjEp/cdpAdjEp adjacencies:
- build/cabling.py (spine<->leaf fabric links, both directions)
- build/fabric.py (APIC<->leaf attachment, leaf-side adjacency)
- rest_aci/writes.py (dynamic leaf<->spine adjacencies for a POST-registered
node, via store.upsert on an already-built store)
All three must emit an IDENTICAL, consistent field set (real-APIC-shaped
lldpAdjEp/cdpAdjEp) AND materialize the lldpInst/lldpIf/cdpInst/cdpIf
container MOs the adjacency hangs off of without this, the container's own
DN (".../sys/lldp/inst") has no MO of its own, and a deep-root subtree query
(`GET .../sys/lldp/inst.json?query-target=subtree&target-subtree-class=
lldpAdjEp`) returns totalCount=0 even though the adjacency itself is fully
reachable via a plain class query. See `aci_sim.query.engine.
run_mo_query`: `store.get(dn) is None` short-circuits to `([], 0)` before the
subtree walk even starts.
This module centralizes both concerns behind one function
(`add_adjacency`) so the three call sites can never drift apart (e.g. one
site adding `portIdV` and another forgetting it).
Store-method choice: every write below goes through ``store.upsert(...)``,
never ``store.add``. Verified against ``aci_sim/mit/store.py``:
``upsert`` on a DN that doesn't exist yet takes the exact same path ``add``
does (construct a fresh ``MO``, store it, call ``_register``) the two are
behaviorally identical on an empty/fresh store. ``upsert`` additionally
merges into an existing entry instead of clobbering it, which is required
here because lldpInst/lldpIf/cdpInst/cdpIf are shared containers written
once per node/port but visited twice (once per directed adjacency at a
spine<->leaf link, per-APIC at the controller-attach site). Callers that
already exclusively use ``store.add`` (cabling.py, fabric.py boot-time build)
lose nothing by switching: the store is empty at those DNs on first visit,
so ``upsert`` behaves exactly like ``add`` there too.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Node
# APIC controllers report this fixed platform id on real hardware.
APIC_PLATFORM_ID = "APIC-SERVER-M3"
def node_mac(pod: int, node_id: int) -> str:
"""Deterministic, unique, valid MAC for *node_id* in *pod*.
Used as lldpAdjEp.chassisIdV (the neighbor's chassis MAC) and as
lldpIf.mac (the local interface's reported MAC) — real LLDP always
carries a chassis MAC, so a believable, stable value beats an empty
string. Scheme: 00:1B:0D:<pod>:<node_id high byte>:<node_id low byte>.
00:1B:0D is an arbitrary-but-fixed OUI-shaped prefix (not a real
allocation) chosen purely so the value looks like a plausible MAC;
pod/node_id are masked into single bytes so the address is always
well-formed regardless of how large either input is.
"""
return f"00:1B:0D:{pod & 0xFF:02X}:{(node_id >> 8) & 0xFF:02X}:{node_id & 0xFF:02X}"
def _lldp_inst_dn(pod: int, node_id: int) -> str:
return f"topology/pod-{pod}/node-{node_id}/sys/lldp/inst"
def _cdp_inst_dn(pod: int, node_id: int) -> str:
return f"topology/pod-{pod}/node-{node_id}/sys/cdp/inst"
def ensure_lldp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
"""Idempotently materialize lldpInst (per-node) + lldpIf (per-port).
Safe to call once per directed adjacency that lands on this node/port
``store.upsert`` merges into the same DN instead of duplicating it.
"""
inst_dn = _lldp_inst_dn(pod, node_id)
store.upsert(MO(
"lldpInst",
dn=inst_dn,
adminSt="enabled",
name="default",
holdTime="120",
initDelayTime="2",
txFreq="30",
ctrl="rx-en,tx-en",
optTlvSel="mgmt-addr,port-desc,port-vlan,sys-cap,sys-desc,sys-name",
))
store.upsert(MO(
"lldpIf",
dn=f"{inst_dn}/if-[{local_port}]",
id=local_port,
adminRxSt="enabled",
adminTxSt="enabled",
operRxSt="up",
operTxSt="up",
mac=node_mac(pod, node_id),
portDesc="",
portVlan="0",
sysDesc="",
wiring="valid",
))
def ensure_cdp_containers(store: MITStore, pod: int, node_id: int, local_port: str) -> None:
"""Idempotently materialize cdpInst (per-node) + cdpIf (per-port)."""
inst_dn = _cdp_inst_dn(pod, node_id)
store.upsert(MO(
"cdpInst",
dn=inst_dn,
adminSt="enabled",
name="default",
holdIntvl="180",
txFreq="60",
ver="v2",
ctrl="",
))
store.upsert(MO(
"cdpIf",
dn=f"{inst_dn}/if-[{local_port}]",
id=local_port,
adminSt="enabled",
operSt="up",
))
def add_adjacency(
store: MITStore,
*,
pod: int,
local_node_id: int,
local_port: str,
remote_port: str,
neighbor_name: str,
neighbor_mgmt_ip: str,
neighbor_kind: str,
neighbor_model: str = "",
neighbor_version: str = "",
neighbor_mac: str = "",
) -> None:
"""Emit one directed lldpAdjEp+cdpAdjEp (local node sees *neighbor* on
*local_port*) AND ensure the lldpInst/lldpIf/cdpInst/cdpIf containers for
(local_node_id, local_port) exist.
*neighbor_kind* is ``"switch"`` (spine/leaf/border-leaf) or
``"controller"`` (APIC) drives the sysDesc/capability/platId/cap field
shapes below (real APIC neighbors don't report bridge/router capability).
*neighbor_mac* is the neighbor's chassis MAC (chassisIdV). Every call site
passes one (derived via ``node_mac`` from the neighbor's node id), so the
``""`` default is never hit in practice; it exists only so an omitted MAC
yields a syntactically valid (if uninformative) empty chassisIdV rather
than a KeyError.
"""
base = f"topology/pod-{pod}/node-{local_node_id}/sys"
if neighbor_kind == "controller":
sys_desc = "Cisco APIC"
capability = ""
plat_id = APIC_PLATFORM_ID
cap = ""
else:
sys_desc = f"{neighbor_model} running {neighbor_version}"
capability = "bridge,router"
plat_id = neighbor_model
cap = "bridge,router"
store.upsert(MO(
"lldpAdjEp",
dn=f"{base}/lldp/inst/if-[{local_port}]/adj-1",
sysName=neighbor_name,
mgmtIp=neighbor_mgmt_ip,
portIdV=remote_port,
# 802.1AB Port-ID subtype 5: portIdV carries a named interface
# (e.g. "eth1/1"), which real ACI reports as interfaceName — NOT
# locallyAssigned (subtype 7), which is for opaque port-id strings.
portIdT="interfaceName",
chassisIdT="mac",
chassisIdV=neighbor_mac,
sysDesc=sys_desc,
capability=capability,
id="1",
ttl="120",
))
store.upsert(MO(
"cdpAdjEp",
dn=f"{base}/cdp/inst/if-[{local_port}]/adj-1",
sysName=neighbor_name,
mgmtIp=neighbor_mgmt_ip,
devId=neighbor_name,
portId=remote_port,
platId=plat_id,
ver=neighbor_version,
cap=cap,
))
ensure_lldp_containers(store, pod, local_node_id, local_port)
ensure_cdp_containers(store, pod, local_node_id, local_port)
def add_switch_adjacency(
store: MITStore,
*,
pod: int,
local_node_id: int,
local_port: str,
remote_port: str,
neighbor: Node,
neighbor_mgmt_ip: str,
) -> None:
"""Convenience wrapper for the common switch<->switch case (cabling.py,
fabric.py's dynamic-registration mirror in writes.py): derives
model/version/mac from the neighbor ``Node`` directly instead of making
every call site repeat ``neighbor_model=neighbor.model,
neighbor_version=neighbor.version, neighbor_mac=node_mac(pod, neighbor.id)``.
``neighbor_mgmt_ip`` is still passed explicitly because callers derive it
via ``oob_ip()`` (or, for a dynamically-registered node, a value already
computed by the caller) rather than something derivable from ``Node``
alone.
"""
add_adjacency(
store,
pod=pod,
local_node_id=local_node_id,
local_port=local_port,
remote_port=remote_port,
neighbor_name=neighbor.name,
neighbor_mgmt_ip=neighbor_mgmt_ip,
neighbor_kind="switch",
neighbor_model=neighbor.model,
neighbor_version=neighbor.version,
neighbor_mac=node_mac(pod, neighbor.id),
)
+89
View File
@@ -0,0 +1,89 @@
"""Orchestrator — assemble a full per-site MITStore from the topology.
Every sub-builder is a pure function of (topology, site) that only ADDS MOs and
never reads another builder's output, so the order below is for readability only.
"""
from __future__ import annotations
from aci_sim.build import (
access,
cabling,
endpoints,
fabric,
health_faults,
interfaces,
l3out,
mgmt,
overlay,
routing,
tenants,
underlay,
userprofile,
zoning,
)
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
# fabric first (nodes), then wiring/underlay/overlay, then tenant config,
# then L3Out (needs border leaves), endpoints, and finally health/faults.
#
# Batch-1 note: `endpoints` now runs BEFORE `tenants` (moved from after
# `l3out`) so tenants.py's fvRsPathAtt (static binding) can read back the
# real fvCEp/fabricPathDn each EPG's endpoints already got from the store,
# instead of re-deriving port assignment logic independently (which risked
# silently drifting out of sync with endpoints.py). Every other builder
# remains a pure "only ADDS MOs, never reads another builder's output"
# function per the orchestrator's original contract — endpoints.py itself
# still only adds and doesn't read from tenants/access/l3out, so moving it
# earlier is safe (its only cross-module dependency, _HOST_PORTS, comes from
# `interfaces`, which still runs first).
#
# Batch-2 note: `zoning` runs AFTER both `tenants` (needs fvAEPg.pcTag/
# fvCtx.scope, and reuses tenants.py's pctag_for/scope_id_for helpers) and
# `endpoints` (needs fvCEp to know which leaves host each EPG's local
# endpoints) — same read-back pattern as tenants.py's fvRsPathAtt, so zoning
# is placed right after tenants in the list (endpoints already ran earlier).
# `overlay.build_vpn_routes` (bgpVpnRoute/bgpPath) has the same fvRsBd/fvCEp
# read-back dependency, so it also runs post-tenants/endpoints — as a SECOND
# call into the `overlay` module, alongside (not instead of) its early
# `overlay.build` call which only needs fabric/topology data and stays in
# its original early slot for BGP peer setup.
#
# OOB-gateway PR: `mgmt` builds the mgmt-tenant OOB scaffolding
# (fvTenant(mgmt)/mgmtMgmtP/mgmtOoB/mgmtRsOoBStNode) that carries
# site.oob_gateway on a real MO. It only needs topology data (site.all_nodes(),
# oob_ip(), site.controller_oob_ips()) — no read-back from another builder's
# store output — so, like every other builder here, it is placed for
# readability only; it is grouped right after `fabric` since both build
# node-identity/OOB data.
_BUILDERS = [
fabric,
mgmt,
cabling,
underlay,
overlay,
interfaces,
endpoints,
tenants,
zoning,
access,
l3out,
routing,
health_faults,
userprofile,
]
def build_site(topo: Topology, site: Site) -> MITStore:
"""Build and return a fully-populated MITStore for one site."""
store = MITStore()
for mod in _BUILDERS:
mod.build(topo, site, store)
overlay.build_vpn_routes(topo, site, store)
return store
def build_all(topo: Topology) -> dict[str, MITStore]:
"""Build every site → ``{site_name: MITStore}``."""
return {site.name: build_site(topo, site) for site in topo.sites}
+286
View File
@@ -0,0 +1,286 @@
"""build/overlay.py — intra-site BGP-EVPN + ISN inter-site eBGP peers.
Intra-site: each leaf (and border-leaf) has bgpPeer/bgpPeerEntry/bgpPeerAfEntry
to EVERY spine loopback (spines are route-reflectors).
ISN (critical): on EACH spine's sys/bgp/inst, add bgpPeer for every remote-site
spine loopback with addr=<remote_loopback>/32 and asn=<remote_site.asn>.
ISN detection in autoACI topology.py (lines 493-511):
addr_raw = attrs.get("addr", "") # e.g. "10.1.401.1/32"
peer_asn = attrs.get("asn", "") # e.g. "65002" (remote site ASN)
if "/32" in addr_raw and peer_asn and peer_asn != spine_local_asn:
# → synthesize ISN cloud node + spine→ISN links
Domain used for all EVPN peers: dom-overlay-1 (no ":" skipped by L3Out BGP table).
IMPORTANT: store.subtree() traverses via the _children index. Every intermediate
DN in the path must have its own MO registered so the parentchild link exists.
We therefore add a bgpDom MO at each "…/inst/dom-overlay-1" before adding peers.
Batch-2 additions (CONTRACT.md §6 "Overlay/BGP"): bgpVpnRoute + bgpPath the
EVPN route family autoACI's fabric_bgp_evpn.py "vpn_routes" view two-pass
parses (verified read-only against that plugin: pass 1 collects bgpVpnRoute
by dn under a node-scoped subtree query; pass 2 matches bgpPath children back
to their parent route via `path_dn.rsplit("/path-", 1)[0]`, so a bgpPath's RN
MUST start with "path-" directly under its route's own dn — no extra bracket
segments in between). DN family:
bgpVpnRoute {spine_inst_dn}/dom-overlay-1/af-{afi}/rt-[{prefix}]
bgpPath {route_dn}/path-{nexthop-slug}
Seeded per-spine (the EVPN route-reflector role, per this module's own
`evpn_rr` convention) for each BD subnet deployed at this site, with one path
per leaf/border-leaf that actually hosts a locally-learned endpoint for that
BD (read back from endpoints.py's fvCEp — same read-back technique
tenants.py/zoning.py already use) a spine's VPN route table only carries
paths toward leaves it has real reachability for, not a leaf with zero
endpoints in that BD. Runs regardless of ISN enablement (real ACI's
per-VRF EVPN route table exists in a single-site fabric too, unlike the
ISN-specific inter-site peer section below).
"""
from __future__ import annotations
import re
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.fabric import loopback_ip
_DOM = "overlay-1"
_CEP_NODE_RE = re.compile(r"/paths-(\d+)/pathep-\[eth\d+/\d+\]$")
def _ensure_dom(inst_dn: str, store: MITStore, seen: set[str]) -> str:
"""Add bgpDom at inst_dn/dom-{_DOM} once; return the dom DN."""
dom_dn = f"{inst_dn}/dom-{_DOM}"
if dom_dn not in seen:
store.add(MO("bgpDom", dn=dom_dn, name=_DOM))
seen.add(dom_dn)
return dom_dn
def _add_peer(
store: MITStore,
dom_dn: str,
peer_addr: str,
peer_asn: str,
peer_type: str,
rtr_id: str,
accepted_paths: str = "10",
) -> None:
"""Emit bgpPeer + bgpPeerEntry + bgpPeerAfEntry for one BGP session."""
peer_dn = f"{dom_dn}/peer-[{peer_addr}]"
entry_dn = f"{peer_dn}/ent-[{peer_addr}]"
af_dn = f"{entry_dn}/af-[l2vpn-evpn]"
store.add(MO(
"bgpPeer",
dn=peer_dn,
addr=peer_addr,
asn=peer_asn,
type=peer_type,
peerRole="internal",
))
store.add(MO(
"bgpPeerEntry",
dn=entry_dn,
addr=peer_addr,
operSt="established",
rtrId=rtr_id,
lastFlapTs="00:00:00:00.000",
connEst="1",
connDrop="0",
type=peer_type,
))
store.add(MO(
"bgpPeerAfEntry",
dn=af_dn,
type="l2vpn-evpn",
afId="l2vpn-evpn",
acceptedPaths=accepted_paths,
pfxSent="5",
tblVer="1",
))
def _route_reflectors(topo: Topology, site: Site) -> list:
"""Resolve `topo.fabric.evpn_rr` (#27) to the actual RR node list.
Only "spine" is a meaningful topology for this simulator today Site has
no other role that could sanely act as a BGP-EVPN route-reflector (leaves
and border-leaves are always RR clients in a Clos fabric). Honoring the
field means failing fast on an unsupported value instead of silently
building spine-RR topology regardless of what the YAML says.
"""
if topo.fabric.evpn_rr == "spine":
return site.spine_nodes()
raise ValueError(
f"fabric.evpn_rr={topo.fabric.evpn_rr!r} is not supported — only "
f"'spine' is implemented as a BGP-EVPN route-reflector role."
)
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit intra-site EVPN peers + ISN inter-site peers."""
pod = site.pod
spines = _route_reflectors(topo, site)
clients = site.leaf_nodes() + site.border_leaf_nodes()
seen_doms: set[str] = set()
# -------------------------------------------------------------------------
# Intra-site: each client leaf → each spine loopback
# -------------------------------------------------------------------------
for leaf in clients:
leaf_inst_dn = f"topology/pod-{pod}/node-{leaf.id}/sys/bgp/inst"
leaf_lb = loopback_ip(pod, leaf.id)
dom_dn = _ensure_dom(leaf_inst_dn, store, seen_doms)
for spine in spines:
spine_lb = loopback_ip(pod, spine.id)
_add_peer(
store, dom_dn,
peer_addr=spine_lb,
peer_asn=str(site.asn),
peer_type="internal",
rtr_id=leaf_lb,
)
# Spines also hold the reverse view of each leaf peer (RR perspective)
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
spine_lb = loopback_ip(pod, spine.id)
dom_dn = _ensure_dom(spine_inst_dn, store, seen_doms)
for leaf in clients:
leaf_lb = loopback_ip(pod, leaf.id)
_add_peer(
store, dom_dn,
peer_addr=leaf_lb,
peer_asn=str(site.asn),
peer_type="internal",
rtr_id=spine_lb,
)
# -------------------------------------------------------------------------
# ISN: each local spine → each remote-site spine loopback (/32 + remote ASN)
# -------------------------------------------------------------------------
if not topo.isn.enabled:
return
try:
remote_site = topo.other_site(site.name)
except KeyError:
return # single-site topology → nothing to do
# #27 / Tier-2 (PR-19): isn.peer_mesh is otherwise read by no builder
# (topology/schema.py's validator only checks it == "full" to gate the
# ">=2 sites" rule). Every local spine peers every remote spine below IS
# the "full" mesh. `partial` mesh (a subset of spine pairs peering,
# e.g. for large N-site fabrics wanting to limit ISN fan-out) has NO
# sensible default partition without additional per-site config this
# schema doesn't carry (which spines pair with which — that's not
# inferable from spine count alone), so PR-19 keeps this a documented
# validate-and-reject rather than inventing a partition scheme no
# topology.yaml author asked for: fail fast rather than silently
# building full-mesh (or an arbitrary subset) for an unimplemented value.
if topo.isn.peer_mesh != "full":
raise ValueError(
f"isn.peer_mesh={topo.isn.peer_mesh!r} is not supported — only "
f"'full' (every local spine peers every remote spine) is implemented. "
f"'partial' is intentionally rejected (see build/overlay.py module "
f"docs) rather than silently built as full-mesh or an undefined subset."
)
remote_asn = str(remote_site.asn)
remote_pod = remote_site.pod
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
spine_lb = loopback_ip(pod, spine.id)
# dom_dn is already ensured above (intra-site peers were added first)
dom_dn = _ensure_dom(spine_inst_dn, store, seen_doms)
for remote_spine in remote_site.spine_nodes():
remote_lb = loopback_ip(remote_pod, remote_spine.id)
# ISN peer addr MUST end with /32 — topology.py checks:
# if "/32" in addr_raw and peer_asn != spine_local_asn → ISN cloud
isn_addr = f"{remote_lb}/32"
_add_peer(
store, dom_dn,
peer_addr=isn_addr,
peer_asn=remote_asn,
peer_type="inter-site",
rtr_id=spine_lb,
)
def _leaves_hosting_bd(store: MITStore, tenant_name: str, bd_name: str) -> set[int]:
"""Return leaf node ids with >=1 locally-learned endpoint in *bd_name*,
scanning fvCEp dns for any EPG bound to that BD (fvRsBd.tnFvBDName)
same read-back technique tenants.py/zoning.py already use against
endpoints.py's store output."""
epg_dns: set[str] = set()
tn_prefix = f"uni/tn-{tenant_name}/"
for rsbd in store.by_class("fvRsBd"):
if rsbd.attrs.get("tnFvBDName") != bd_name:
continue
if not rsbd.dn.startswith(tn_prefix):
continue
epg_dns.add(rsbd.dn.rsplit("/rsbd", 1)[0])
leaves: set[int] = set()
for epg_dn in epg_dns:
prefix = f"{epg_dn}/cep-"
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
m = _CEP_NODE_RE.search(cep.attrs.get("fabricPathDn", ""))
if m:
leaves.add(int(m.group(1)))
return leaves
def build_vpn_routes(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit bgpVpnRoute + bgpPath (batch-2) — the per-spine EVPN VPN route
table for this site's deployed BD subnets.
Runs AFTER `tenants` and `endpoints` in the orchestrator order (unlike
`build()` above, which only needs fabric/topology data and stays early)
because it reads back fvRsBd/fvCEp to find which leaves actually host
each BD's endpoints — see module docstring for the two-pass DN shape
fabric_bgp_evpn.py's "vpn_routes" view requires.
"""
pod = site.pod
spines = _route_reflectors(topo, site)
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
for bd in tenant.bds:
if not bd.subnets:
continue
target_leaves = _leaves_hosting_bd(store, tenant.name, bd.name)
if not target_leaves:
continue
for spine in spines:
spine_inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
dom_dn = f"{spine_inst_dn}/dom-{_DOM}"
for cidr in bd.subnets:
afi = "vpnv4" # IPv4-unicast EVPN route family (real ACI: af-vpnv4/af-vpnv6)
route_dn = f"{dom_dn}/af-{afi}/rt-[{cidr}]"
route_mo = MO(
"bgpVpnRoute",
dn=route_dn,
pfx=cidr,
rd=f"{site.asn}:{tenant.name}",
)
for leaf_id in sorted(target_leaves):
leaf_lb = loopback_ip(pod, leaf_id)
route_mo.add_child(MO(
"bgpPath",
dn=f"{route_dn}/path-{leaf_id}",
nh=leaf_lb,
asPath="",
localPref="100",
metric="0",
origin="igp",
type="internal",
))
store.add(route_mo)
+167
View File
@@ -0,0 +1,167 @@
"""build/routing.py — uribv4Route/uribv4Nexthop (per-leaf RIB) + arpAdjEp
(per-endpoint ARP table).
Batch-1 additions (CONTRACT.md §6 "Routing tables/control", "COOP/EPM"):
uribv4Route topology/pod-{p}/node-{n}/sys/uribv4/dom-{vrf}/db-rt/rt-[{prefix}]
uribv4Nexthop {route}/nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}]
arpAdjEp topology/pod-{p}/node-{n}/sys/arp/inst/dom-{vrf}/db-ip/adj-[{ip}]
DN/attribute shapes verified (read-only) against autoACI's
backend/plugins/route_table.py + routing_state.py (per CONTRACT.md header
trust, don't re-explore):
- uribv4Route.attrs["prefix"] is read (NOT "ip" that's ipRouteP/l3extSubnet/
fvSubnet's attribute name); VRF is parsed from the `dom-{name}` DN segment
via `part[4:]` after `part.startswith("dom-")`.
- uribv4Nexthop's parent-route DN is recovered via
`_RE_NH_PARENT = re.compile(r"^(.*/rt-\\[[^\\]]+\\])/nh-")` (route_table.py /
routing_state.py, both files, identical regex) so the nexthop RN MUST
start with "nh-" immediately after the route's own "rt-[...]" segment, and
the "nh-" RN's own bracket content is opaque to this regex (any number of
"-[...]" groups after "nh-" parses fine); we use the 4-group form the
module docstring's own comment illustrates:
".../rt-[10.100.210.1/32]/nh-[direct]-[10.100.210.1/32]-[lo2]-[vrf]"
i.e. nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}].
- arpAdjEp: routing_state.py reads attrs["ifId"] and attrs["physIfId"]
(Interface / Physical Interface columns) plus ip/mac/operSt (CONTRACT.md).
VRF "dom" naming reuses the exact `{tenant}:{vrf}` convention l3out.py's
_upsert_ebgp_sessions already established for bgpDom, so uribv4Route's VRF
column lines up with the same dom name a route_table.py VRF-scoped query
would be filtered on.
"""
from __future__ import annotations
import re
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.fabric import loopback_ip
_CEP_PORT_RE = re.compile(r"/node-(\d+)/sys/.*/db-ep/mac-")
_FRONT_PANEL_IF_RE = re.compile(r"paths-\d+/pathep-\[(eth\d+/\d+)\]$")
def _owning_site(l3out, topo: Topology) -> str | None:
if l3out.site is not None:
return l3out.site
for s in topo.sites:
site_bl_ids = {bl.id for bl in s.border_leaves}
if any(bl_id in site_bl_ids for bl_id in l3out.border_leaves):
return s.name
return None
def _build_route(node_dn: str, vrf_dom: str, prefix: str, nh_addr: str, ifname: str) -> MO:
"""Build one uribv4Route + a single uribv4Nexthop child.
Nexthop RN uses the 4-bracket form _RE_NH_PARENT expects:
nh-[{proto}]-[{addr}]-[{ifname}]-[{vrf}].
"""
base = f"{node_dn}/uribv4/dom-{vrf_dom}/db-rt"
route_dn = f"{base}/rt-[{prefix}]"
route_mo = MO("uribv4Route", dn=route_dn, prefix=prefix, pref="1")
nh_dn = f"{route_dn}/nh-[static]-[{nh_addr}]-[{ifname}]-[{vrf_dom}]"
route_mo.add_child(MO(
"uribv4Nexthop",
dn=nh_dn,
nhAddr=nh_addr,
ifName=ifname,
))
return route_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit per-leaf uribv4Route/Nexthop RIB entries + per-endpoint arpAdjEp.
Runs after `endpoints` (orchestrator order) so fvCEp/fvIp data already in
the store can be reused for arpAdjEp instead of re-deriving MAC/IP/port
assignment independently.
"""
pod = site.pod
# ---- uribv4Route/Nexthop -------------------------------------------
# One VRF-scoped RIB per (tenant, vrf) deployed at this site, seeded on
# every leaf/border-leaf: BD subnets as directly-connected routes (nexthop
# = this node's own loopback, ifname = a real BD-facing host port so the
# interface resolves), plus (border leaves only) a 0.0.0.0/0 default route
# via the L3Out's CSW peer — matching the ipRouteP static default route
# l3out.py's _build_node_profile already seeds on the same node.
all_leaves = site.leaf_nodes() + site.border_leaf_nodes()
border_leaf_ids = {bl.id for bl in site.border_leaves}
node_loopback = {n.id: loopback_ip(pod, n.id) for n in all_leaves}
for tenant in topo.tenants:
if site.name not in tenant.sites:
continue
t = tenant.name
for vrf in tenant.vrfs:
vrf_dom = f"{t}:{vrf.name}"
bd_subnets = [cidr for bd in tenant.bds if bd.vrf == vrf.name for cidr in bd.subnets]
for leaf in all_leaves:
node_dn = f"topology/pod-{pod}/node-{leaf.id}/sys"
lb = node_loopback[leaf.id]
for cidr in bd_subnets:
store.add(_build_route(node_dn, vrf_dom, cidr, lb, "vlan1"))
if leaf.id in border_leaf_ids:
bl = next(bl for bl in site.border_leaves if bl.id == leaf.id)
for l3out in tenant.l3outs:
if l3out.vrf != vrf.name:
continue
if _owning_site(l3out, topo) != site.name:
continue
if leaf.id not in l3out.border_leaves:
continue
store.add(_build_route(
node_dn, vrf_dom, "0.0.0.0/0",
bl.csw.peer_ip, "eth1/48",
))
# ---- arpAdjEp — one per existing (locally-learned) endpoint ---------
# Reuse the real fvCEp/fvIp MAC+IP data endpoints.py already wrote for
# this site's store; only front-panel (local) endpoints get an ARP
# adjacency (a REMOTE/tunnel-learned endpoint has no local ARP entry on
# this fabric — real ACI resolves those via the overlay, not local ARP).
for cep in store.by_class("fvCEp"):
path_dn = cep.attrs.get("fabricPathDn", "")
m = _FRONT_PANEL_IF_RE.search(path_dn)
if not m:
continue
node_m = re.search(r"/paths-(\d+)/", path_dn)
if not node_m:
continue
node_id = node_m.group(1)
if int(node_id) not in {n.id for n in all_leaves}:
continue
if_name = m.group(1)
mac = cep.attrs.get("mac", "")
# fvIp children carry the endpoint's IP(s); dn = f"{cep.dn}/ip-[{ip}]"
for ip_mo in store.children(cep.dn, {"fvIp"}):
ip_addr = ip_mo.attrs.get("addr", "")
if not ip_addr:
continue
# tenant/vrf for the dom segment: derive from cep_dn's "tn-{t}" and
# this site's tenant/BD/VRF config (matches the RIB dom naming above).
tn_m = re.search(r"uni/tn-([^/]+)/", cep.dn)
tname = tn_m.group(1) if tn_m else "common"
tenant = next((tt for tt in topo.tenants if tt.name == tname), None)
vrf_name = tenant.vrfs[0].name if tenant and tenant.vrfs else "default"
vrf_dom = f"{tname}:{vrf_name}"
arp_dn = (
f"topology/pod-{pod}/node-{node_id}/sys/arp/inst"
f"/dom-{vrf_dom}/db-ip/adj-[{ip_addr}]"
)
store.add(MO(
"arpAdjEp",
dn=arp_dn,
ip=ip_addr,
mac=mac,
ifId=if_name,
physIfId=if_name,
operSt="up",
))
+379
View File
@@ -0,0 +1,379 @@
"""build/tenants.py — fvTenant, fvCtx, fvBD, fvAp/fvAEPg, vzBrCP, vzFilter.
MAC/VLAN sequences and endpoint addresses are per-store (per site/APIC); this
module assumes the intended one-MITStore-per-APIC model. Merging two site
stores would collide DNs and attribute values.
Tier-3 (PR-20): `fvBD.mac` now reflects `bd.mac` (per-BD override) falling
back to `fabric.default_bd_mac` (fabric-wide default, itself defaulting to
the real ACI default gateway MAC `00:22:BD:F8:19:FF` the literal this
module hardcoded for every BD pre-PR-20; see `_build_bd`).
Batch-1 additions (CONTRACT.md §6 "Rels", "Contracts"):
fvRsPathAtt uni/tn-{t}/ap-{ap}/epg-{epg}/rspathAtt-[{pathDn}]
vzRsSubjGraphAtt {vzSubj}/rsSubjGraphAtt
fvRsPathAtt needs a static binding whose tDn is the SAME front-panel path an
EPG's own endpoints already use (endpoints.py assigns EP ports), so this
module reads back the real fvCEp.fabricPathDn the endpoints builder already
wrote for that EPG rather than re-deriving port-assignment logic
independently (see orchestrator.py: `endpoints` now runs before `tenants`
specifically to make this store read-back possible; a stretched EPG that has
no locally-learned (front-panel) endpoint at this site i.e. every endpoint
here is REMOTE/tunnel-learned gets no fvRsPathAtt, since a static binding
must reference a real local port, not a tunnel).
Batch-2 additions (CONTRACT.md §6 "Tenant (control)"): fvAEPg now carries a
stable `pcTag` (real ACI's per-EPG "sclass" used for zoning-rule matching) —
previously absent from this sim entirely (verified: no prior pcTag anywhere
in aci_sim). Assigned deterministically per (tenant, ap, epg) via
`_pctag_for` (a small CRC32-derived integer in a believable ACI sclass range,
avoiding the well-known reserved system pcTags 1/13/14/15/16384 autoACI's
zoning_rules.py hardcodes) so it is stable across rebuilds without needing
external state. `fvEpP` (uni/epp/fv-[{epg_dn}]) is seeded alongside each EPG,
carrying the SAME pcTag plus a per-VRF `scopeId` (build/zoning.py derives its
own actrlRule.scopeId from the identical per-VRF scheme, so the two line up
see that module's docstring) and `epgPKey` = the EPG's own dn (zoning_rules.py
extracts the friendly EPG name back out of epgPKey via its trailing "epg-"
segment).
"""
from __future__ import annotations
import re
import zlib
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import (
BD,
Contract,
EPG,
Filter,
FilterEntry,
L3Out,
Site,
Tenant,
Topology,
)
_FRONT_PANEL_PATH_RE = re.compile(r"paths-\d+/pathep-\[eth\d+/\d+\]$")
# Batch-2 pcTag/scopeId scheme (shared with build/zoning.py's actrlRule —
# import these two helpers from here rather than re-deriving the formula, so
# an actrlRule's sPcTag/dPcTag/scopeId always match the fvAEPg/fvEpP/fvCtx
# values a zoning-rule consumer would resolve them against).
#
# Real ACI pcTags ("sclass") are 16-bit-ish integers; autoACI's
# zoning_rules.py hardcodes 5 well-known SYSTEM_PCTAGS (1, 13, 14, 15, 16384)
# that must never collide with a real EPG/VRF pcTag, so both helpers below
# add a fixed offset clear of that reserved set.
_PCTAG_BASE = 32770 # clear of the highest reserved system pcTag (16384)
_SCOPE_BASE = 2097152 # clear of any plausible pcTag range
def pctag_for(key: str) -> str:
"""Deterministic pcTag ("sclass") for an EPG/VRF/ExtEPG, keyed by a
stable string (e.g. the object's own dn). CRC32-derived so it's stable
across rebuilds without external state, same technique endpoints.py's
`_vnid` already uses for VNIDs."""
return str(_PCTAG_BASE + (zlib.crc32(key.encode()) & 0xFFFF))
def scope_id_for(tenant: str, vrf: str) -> str:
"""Deterministic scopeId (real ACI: the VRF's zoning-scope id) for a
(tenant, vrf) pair every EPG/actrlRule sharing that VRF shares this
scopeId, matching real ACI's per-VRF zoning scope semantics."""
return str(_SCOPE_BASE + (zlib.crc32(f"{tenant}:{vrf}".encode()) & 0xFFFF))
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _owning_site(l3out: L3Out, topo: Topology) -> str | None:
"""Return the site name that owns this L3Out."""
if l3out.site is not None:
return l3out.site
for s in topo.sites:
site_bl_ids = {bl.id for bl in s.border_leaves}
if any(bl_id in site_bl_ids for bl_id in l3out.border_leaves):
return s.name
return None
def _entry_name(entry: FilterEntry, idx: int) -> str:
if entry.from_port != "unspecified":
return f"{entry.proto}-{entry.from_port}"
return f"{entry.proto}-{idx}"
def _build_bd(topo: Topology, site: Site, t: str, bd: BD, l3outs: list[L3Out]) -> MO:
"""Build fvBD + fvRsCtx, fvSubnet, and fvRsBDToOut children."""
l2s = bd.l2stretch
# unicastRoute reflects whether the BD actually routes traffic, i.e. has
# fvSubnet children — NOT l2stretch. A stretched (l2stretch) BD can still
# carry routed subnets (this topology's stretched BDs do); NDO's schema
# side (ndo/model.py) always reports unicastRouting=True for every BD with
# subnets, so the two planes must agree. A true L2-only BD (no subnets)
# correctly stays unicastRoute="no".
routed = bool(bd.subnets)
# Tier-3 (PR-20): bd.mac (per-BD override) falls back to
# fabric.default_bd_mac, which itself defaults to the real ACI default
# gateway MAC "00:22:BD:F8:19:FF" — the literal this module hardcoded
# for every BD pre-PR-20. An unset bd.mac + unset fabric.default_bd_mac
# produces the identical "00:22:BD:F8:19:FF" as before this PR.
bd_mac = bd.mac or topo.fabric.default_bd_mac
bd_mo = MO(
"fvBD",
dn=f"uni/tn-{t}/BD-{bd.name}",
name=bd.name,
arpFlood="yes" if l2s else "no",
unicastRoute="yes" if routed else "no",
ipLearning="yes",
unkMacUcastAct="flood" if l2s else "proxy",
unkMcastAct="flood",
v6unkMcastAct="nd",
multiDstPktAct="bd-flood",
epMoveDetectMode="garp",
hostBasedRouting="no",
limitIpLearnToSubnets="yes",
mtu="9000",
mac=bd_mac,
intersiteBumTrafficAllow="yes" if l2s else "no",
type="regular",
)
bd_mo.add_child(MO(
"fvRsCtx",
dn=f"uni/tn-{t}/BD-{bd.name}/rsctx",
tnFvCtxName=bd.vrf,
))
for cidr in bd.subnets:
bd_mo.add_child(MO(
"fvSubnet",
dn=f"uni/tn-{t}/BD-{bd.name}/subnet-[{cidr}]",
ip=cidr,
scope="public,shared",
))
for l3out in l3outs:
if l3out.vrf != bd.vrf:
continue
if _owning_site(l3out, topo) != site.name:
continue
bd_mo.add_child(MO(
"fvRsBDToOut",
dn=f"uni/tn-{t}/BD-{bd.name}/rsBDToOut-{l3out.name}",
tnL3extOutName=l3out.name,
))
return bd_mo
def _find_static_binding_path(store: MITStore, epg_ref: str) -> tuple[str, str] | None:
"""Return (pathDn, encap) for one LOCAL (front-panel) endpoint already
written for *epg_ref* by endpoints.py, or None if this EPG has no
locally-learned endpoint at this site (e.g. a stretched EPG that is
entirely REMOTE/tunnel-learned here).
fvCEp dn is f"{epg_ref}/cep-{mac}"; we scan the store's fvCEp class list
(cheap endpoint counts are small) rather than requiring endpoints.py to
expose an index, keeping the two modules loosely coupled.
"""
prefix = f"{epg_ref}/cep-"
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
path_dn = cep.attrs.get("fabricPathDn", "")
if _FRONT_PANEL_PATH_RE.search(path_dn):
return path_dn, cep.attrs.get("encap", "")
return None
def _build_epg(t: str, ap_name: str, epg: EPG, phys_dom: str, store: MITStore) -> MO:
"""Build fvAEPg + fvRsBd, fvRsDomAtt, fvRsProv, fvRsCons, fvRsPathAtt children.
Batch-2: fvAEPg.pcTag is assigned here (keyed by the EPG's own dn via
`pctag_for`) so it is available the moment the EPG exists; the matching
fvEpP (which needs the owning VRF for its scopeId) is built separately in
_build_tenant, where the BD->VRF mapping is already in scope.
"""
epg_dn = f"uni/tn-{t}/ap-{ap_name}/epg-{epg.name}"
epg_mo = MO(
"fvAEPg",
dn=epg_dn,
name=epg.name,
prefGrMemb="exclude",
pcEnfPref="unenforced",
isAttrBasedEPg="no",
floodOnEncap="disabled",
pcTag=pctag_for(epg_dn),
)
epg_mo.add_child(MO(
"fvRsBd",
dn=f"{epg_dn}/rsbd",
tnFvBDName=epg.bd,
))
epg_mo.add_child(MO(
"fvRsDomAtt",
dn=f"{epg_dn}/rsdomAtt-[uni/phys-{phys_dom}]",
tDn=f"uni/phys-{phys_dom}",
instrImedcy="lazy",
))
for c in epg.contracts.provide:
epg_mo.add_child(MO(
"fvRsProv",
dn=f"{epg_dn}/rsprov-{c}",
tnVzBrCPName=c,
))
for c in epg.contracts.consume:
epg_mo.add_child(MO(
"fvRsCons",
dn=f"{epg_dn}/rscons-{c}",
tnVzBrCPName=c,
))
# Batch-1: fvRsPathAtt — static binding to the SAME front-panel path this
# EPG's own (already-built) endpoints use.
binding = _find_static_binding_path(store, epg_dn)
if binding is not None:
path_dn, encap = binding
epg_mo.add_child(MO(
"fvRsPathAtt",
dn=f"{epg_dn}/rspathAtt-[{path_dn}]",
tDn=path_dn,
encap=encap,
mode="regular",
instrImedcy="lazy",
))
return epg_mo
def _build_contract(t: str, contract: Contract, attach_graph: bool) -> MO:
"""Build vzBrCP + vzSubj + vzRsSubjFiltAtt (+ batch-1 vzRsSubjGraphAtt) children.
*attach_graph*: attach a vzRsSubjGraphAtt to this ONE contract's subject
(per the batch-1 placement note only one existing subject gets a
service-graph attachment; vnsAbsGraph itself is out of scope, so
tnVnsAbsGraphName references a plausible graph name only).
"""
subj_dn = f"uni/tn-{t}/brc-{contract.name}/subj-{contract.name}"
brc_mo = MO(
"vzBrCP",
dn=f"uni/tn-{t}/brc-{contract.name}",
name=contract.name,
scope=contract.scope,
prio="unspecified",
targetDscp="unspecified",
)
subj_mo = MO(
"vzSubj",
dn=subj_dn,
name=contract.name,
revFltPorts="yes",
prio="unspecified",
)
for flt in contract.filters:
subj_mo.add_child(MO(
"vzRsSubjFiltAtt",
dn=f"{subj_dn}/rssubjFiltAtt-{flt.name}",
tnVzFilterName=flt.name,
))
if attach_graph:
subj_mo.add_child(MO(
"vzRsSubjGraphAtt",
dn=f"{subj_dn}/rsSubjGraphAtt",
tnVnsAbsGraphName=f"{contract.name}_graph",
))
brc_mo.add_child(subj_mo)
return brc_mo
def _build_filter(t: str, flt: Filter) -> MO:
"""Build vzFilter + vzEntry children."""
flt_mo = MO("vzFilter", dn=f"uni/tn-{t}/flt-{flt.name}", name=flt.name)
for idx, entry in enumerate(flt.entries):
ename = _entry_name(entry, idx)
flt_mo.add_child(MO(
"vzEntry",
dn=f"uni/tn-{t}/flt-{flt.name}/e-{ename}",
name=ename,
etherT=entry.ether_type,
prot=entry.proto,
dFromPort=entry.from_port,
dToPort=entry.to_port,
sFromPort="unspecified",
sToPort="unspecified",
stateful="yes",
))
return flt_mo
def _build_tenant(topo: Topology, site: Site, tenant: Tenant, store: MITStore) -> MO:
t = tenant.name
phys_dom = topo.access.phys_domains[0].name if topo.access.phys_domains else "phys-dom-1"
tenant_mo = MO("fvTenant", dn=f"uni/tn-{t}", name=t, descr="")
# fvCtx per VRF — batch-2: pcTag (VRF-level sclass; zoning_rules.py's
# vzAny-scoped lookup falls back to this when no EPG-level pcTag matches)
# + scopeId (this VRF's own zoning scope — every actrlRule/fvEpP sharing
# this VRF shares the same scopeId, per scope_id_for's docstring).
for vrf in tenant.vrfs:
tenant_mo.add_child(MO(
"fvCtx",
dn=f"uni/tn-{t}/ctx-{vrf.name}",
name=vrf.name,
pcEnfPref="enforced",
bdEnforcedEnable="no",
ipDataPlaneLearning="enabled",
pcEnfDir="ingress",
pcTag=pctag_for(f"uni/tn-{t}/ctx-{vrf.name}"),
scope=scope_id_for(t, vrf.name),
))
# fvBD per BD
for bd in tenant.bds:
tenant_mo.add_child(_build_bd(topo, site, t, bd, tenant.l3outs))
# fvAp / fvAEPg (+ batch-2 fvEpP, a separate top-level MO under uni/epp/,
# NOT nested under the tenant tree — real ACI's EPG-profile class lives
# in its own uni/epp/ subtree, mirrored here as a sibling store.add call
# rather than a tenant_mo child).
bd_vrf = {bd.name: bd.vrf for bd in tenant.bds}
for ap in tenant.aps:
ap_mo = MO("fvAp", dn=f"uni/tn-{t}/ap-{ap.name}", name=ap.name)
for epg in ap.epgs:
epg_mo = _build_epg(t, ap.name, epg, phys_dom, store)
ap_mo.add_child(epg_mo)
vrf_name = bd_vrf.get(epg.bd, "")
store.add(MO(
"fvEpP",
dn=f"uni/epp/fv-[{epg_mo.dn}]",
epgPKey=epg_mo.dn,
pcTag=epg_mo.attrs["pcTag"],
scopeId=scope_id_for(t, vrf_name) if vrf_name else "",
))
tenant_mo.add_child(ap_mo)
# vzBrCP contracts — batch-1: attach vzRsSubjGraphAtt to exactly ONE
# existing contract subject (the tenant's first contract, if any).
graph_target = tenant.contracts[0].name if tenant.contracts else None
for contract in tenant.contracts:
tenant_mo.add_child(_build_contract(t, contract, attach_graph=(contract.name == graph_target)))
# vzFilter (deduplicated by name)
seen: set[str] = set()
for contract in tenant.contracts:
for flt in contract.filters:
if flt.name in seen:
continue
seen.add(flt.name)
tenant_mo.add_child(_build_filter(t, flt))
return tenant_mo
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit fvTenant trees for every tenant deployed at *site*."""
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
store.add(_build_tenant(topo, site, tenant, store))
+106
View File
@@ -0,0 +1,106 @@
"""build/underlay.py — bgpInst (local ASN), isisAdjEp + ospfAdjEp.
Intra-fabric underlay IGP is IS-IS: isisAdjEp is emitted between every directly
cabled spineleaf pair. OSPF is used ONLY by spines toward the inter-site network
(IPN/ISN) and ONLY in a multi-site fabric leaves never peer OSPF with the ISN.
bgpInst carries the site ASN and anchors all BGP peers (overlay.py adds children).
Tier-3 (PR-20): ospfIf also carries `helloIntvl`/`deadIntvl`, fed by
`isn.ospf_hello`/`isn.ospf_dead` (real ACI defaults 10/40) previously not
carried on this MO at all.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
from aci_sim.build.fabric import loopback_ip
from aci_sim.build.cabling import cabling_links
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit bgpInst per node + OSPF/IS-IS adjacencies along fabric links."""
pod = site.pod
spine_ids = {n.id for n in site.spine_nodes()}
# bgpInst per node — carries the site ASN; overlay.py adds bgpPeer children
for node in site.all_nodes():
store.add(MO(
"bgpInst",
dn=f"topology/pod-{pod}/node-{node.id}/sys/bgp/inst",
asn=str(site.asn),
))
# isisDom per node (IS-IS is the ACI underlay IGP)
for node in site.all_nodes():
store.add(MO(
"isisDom",
dn=f"topology/pod-{pod}/node-{node.id}/sys/isis/inst-default/dom-overlay-1",
name="overlay-1",
operSt="up",
))
# IS-IS adjacencies — the intra-fabric underlay, between directly-cabled
# spine↔leaf pairs only. Convention: n1=spine, n2=leaf (same as cabling.py).
for n1, s1, p1, n2, s2, p2 in cabling_links(site):
spine_id, leaf_id = (n1, n2) if n1 in spine_ids else (n2, n1)
spine_port = f"eth{s1}/{p1}" if n1 in spine_ids else f"eth{s2}/{p2}"
leaf_port = f"eth{s2}/{p2}" if n1 in spine_ids else f"eth{s1}/{p1}"
spine_lb = loopback_ip(pod, spine_id)
leaf_lb = loopback_ip(pod, leaf_id)
spine_isis_base = f"topology/pod-{pod}/node-{spine_id}/sys/isis/inst-default/dom-overlay-1"
store.add(MO(
"isisAdjEp",
dn=f"{spine_isis_base}/if-[{spine_port}]/adj-[{leaf_lb}]",
operSt="up", sysId=leaf_lb, lastTrans="00:00:00", numAdjTrans="1",
))
leaf_isis_base = f"topology/pod-{pod}/node-{leaf_id}/sys/isis/inst-default/dom-overlay-1"
store.add(MO(
"isisAdjEp",
dn=f"{leaf_isis_base}/if-[{leaf_port}]/adj-[{spine_lb}]",
operSt="up", sysId=spine_lb, lastTrans="00:00:00", numAdjTrans="1",
))
# OSPF adjacencies — ONLY spine↔IPN/ISN, and ONLY in a multi-site fabric.
# Leaves never run OSPF to the ISN. One adjacency per spine toward the IPN,
# on eth1/{49+si} — interfaces.py builds a matching dedicated ISN uplink
# l1PhysIf on each spine (multi-site only) at that exact port, so this
# ospfAdjEp interface always resolves against a real port inventory entry.
# autoACI reads: iface = dn.split("/if-[")[1]; neighbor = peerIp or nbrId.
if len(topo.sites) > 1:
ipn_ip = f"172.16.{site.id}.254" # the IPN/ISN router this site's spines peer with
# Tier-2 (PR-19): isn.ospf_area feeds ospfIf.area/ospfAdjEp.area — was
# a hardcoded "0.0.0.0" literal; a topology author overriding
# isn.ospf_area now sees it reflected in the actual OSPF object
# instead of a value the schema stored but no builder honored.
ospf_area = topo.isn.ospf_area
# Tier-3 (PR-20): isn.ospf_hello/ospf_dead (real ACI defaults 10/40)
# feed ospfIf.helloIntvl/deadIntvl — previously not carried on this
# MO at all.
ospf_hello = str(topo.isn.ospf_hello)
ospf_dead = str(topo.isn.ospf_dead)
for si, spine in enumerate(site.spine_nodes()):
ospf_base = f"topology/pod-{pod}/node-{spine.id}/sys/ospf/inst-default/dom-overlay-1"
store.add(MO("ospfDom", dn=ospf_base, name="overlay-1", operSt="up"))
# Batch-1: ospfIf nested between ospfDom and ospfAdjEp — same
# eth1/{49+si} ISN uplink port interfaces.py already builds a
# dedicated l1PhysIf for, so the existing ospfAdjEp below sits on
# a real, matching ospfIf (consumer: autoACI's fabric_ospf.py,
# which reads ospfIf.id (falling back to ifId) + area + operSt).
if_port = f"eth1/{49 + si}"
if_dn = f"{ospf_base}/if-[{if_port}]"
store.add(MO(
"ospfIf",
dn=if_dn,
id=if_port,
area=ospf_area,
operSt="up",
helloIntvl=ospf_hello,
deadIntvl=ospf_dead,
))
store.add(MO(
"ospfAdjEp",
dn=f"{if_dn}/adj-[{ipn_ip}]",
operSt="full", peerIp=ipn_ip, nbrId=ipn_ip, area=ospf_area,
))
+42
View File
@@ -0,0 +1,42 @@
"""build/userprofile.py — aaaUserEp login-probe MO (uni/userprofile-<user>).
CONTRACT.md §1: during/after login autoACI probes
GET /api/mo/uni/userprofile-<user>.json?query-target=subtree&target-subtree-class=aaaUserDomain
This must not 404 and must return >=1 aaaUserDomain row.
Real APIC exposes a per-user aaaUserEp at uni/userprofile-<user> with an
aaaUserDomain child (name="all" is the default/full-access domain every local
user gets). The simulator only ever authenticates as "admin" (see
rest_aci/auth.py, which defaults aaaLogin's username to "admin"), so the
simplest faithful variant is a single static admin profile rather than
dynamically creating one per logged-in user this is the same set of MOs a
real APIC would already have provisioned for the built-in admin account, no
matter who authenticates.
Site-independent: call once per topology (site arg accepted for API
uniformity but not used in the selection logic), same convention as
build/access.py.
"""
from __future__ import annotations
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Topology
_ADMIN_USER = "admin"
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit uni/userprofile-admin (aaaUserEp) + its aaaUserDomain child."""
profile_dn = f"uni/userprofile-{_ADMIN_USER}"
profile_mo = MO(
"aaaUserEp",
dn=profile_dn,
name=_ADMIN_USER,
)
profile_mo.add_child(MO(
"aaaUserDomain",
dn=f"{profile_dn}/userdomain-all",
name="all",
))
store.add(profile_mo)
+161
View File
@@ -0,0 +1,161 @@
"""build/zoning.py — actrlRule (batch-2, CONTRACT.md §6 "Contracts").
actrlRule topology/pod-{p}/node-{n}/sys/actrl/scope-[{scopeId}]/rule-[{ruleId}]
DN/attribute shapes verified read-only against autoACI's zoning_rules.py,
contract_debug.py, and ep_connectivity.py (all three read actrlRule; see
docs/DESIGN.md "Batch-2" section for the specific attrs each parses):
- zoning_rules.py: node-scoped subtree query under `.../sys/actrl`
(target-subtree-class=actrlRule), reads scopeId/sPcTag/dPcTag/fltId/
action/direction/prio/ctrctName/operSt/dn. Also cross-references fvEpP's
(scopeId, pcTag) -> friendly-name map (tenants.py already seeds this).
- contract_debug.py: same node-scoped subtree query, filters rows whose
ctrctName contains one of the contract names on the debugged path.
- ep_connectivity.py: FABRIC-WIDE class query with an
`or(eq(actrlRule.sPcTag,...),eq(actrlRule.dPcTag,...))` filter i.e.
actrlRule must also be reachable via a plain GET /api/class/actrlRule.json
(not only via node-scoped subtree), so this module both nests the rule
under its owning node's /sys/actrl tree (for the subtree query shape) AND
the generic class-query index (store.add already registers by class
regardless of nesting, so both access patterns are satisfied by one MO).
Rule derivation: one permit rule per (contract subject filter, provider EPG,
consumer EPG) triple, emitted on every leaf where BOTH the provider and
consumer EPG have at least one locally-learned endpoint (a real leaf only
compiles zoning rules for EPGs it actually has traffic for) read back from
the fvCEp data endpoints.py already wrote (same read-back pattern
tenants.py's fvRsPathAtt already established; zoning.py runs after both
tenants and endpoints in the orchestrator, so both stores are populated).
sPcTag/dPcTag/scopeId reuse the exact pctag_for/scope_id_for scheme
tenants.py's fvAEPg/fvEpP/fvCtx already assign, so a zoning-rule consumer's
pcTag/scopeId cross-reference against fvEpP always resolves.
Structural-container fix (same class of gap access.py's batch-1 infraInfra
fix addressed): `.../sys/actrl` and `.../sys/actrl/scope-[{scopeId}]` are
each seeded as a real MO (classes `actrlRuleCont`/`actrlScope` placeholder
names; no consumer queries them BY class, only actrlRule descendants
underneath, so any believable class name is faithful here) without a real
MO at the exact `.../sys/actrl` DN, GET /api/mo/{node}/sys/actrl.json (the
precise subtree-query shape all three consumers use) 404s, because the
store's ancestor-walk only links intermediate DNs structurally in the
children index and never materializes a real MO for them on its own (see
mit/store.py's MITStore._register docstring).
"""
from __future__ import annotations
import re
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.topology.schema import Site, Tenant, Topology
from aci_sim.build.tenants import pctag_for, scope_id_for
_CEP_NODE_RE = re.compile(r"/paths-(\d+)/pathep-\[eth\d+/\d+\]$")
def _is_deployed(tenant: Tenant, site: Site) -> bool:
return site.name in tenant.sites
def _leaves_with_local_endpoints(store: MITStore, epg_dn: str) -> set[int]:
"""Return the set of leaf node ids that have >=1 locally-learned (real
front-panel) endpoint for *epg_dn*, read back from endpoints.py's fvCEp
data (same technique tenants.py's _find_static_binding_path uses)."""
prefix = f"{epg_dn}/cep-"
leaves: set[int] = set()
for cep in store.by_class("fvCEp"):
if not cep.dn.startswith(prefix):
continue
path_dn = cep.attrs.get("fabricPathDn", "")
m = _CEP_NODE_RE.search(path_dn)
if m:
leaves.add(int(m.group(1)))
return leaves
def build(topo: Topology, site: Site, store: MITStore) -> None:
"""Emit actrlRule MOs derived from existing contract/filter/EPG data."""
pod = site.pod
# Real MOs for the ".../sys/actrl" and ".../sys/actrl/scope-[...]"
# structural containers — without these, GET /api/mo/{node}/sys/actrl.json
# (the exact DN zoning_rules.py/contract_debug.py/ep_connectivity.py query
# with subtree=True) 404s: the store's ancestor-walk only links these DNs
# structurally in the children index, it does not materialize a real MO
# for them (see mit/store.py's _register docstring) — same class of gap
# access.py's batch-1 infraInfra fix addressed for uni/infra.
seen_actrl_dn: set[str] = set()
seen_scope_dn: set[str] = set()
for tenant in topo.tenants:
if not _is_deployed(tenant, site):
continue
t = tenant.name
# epg_dn -> EPG (only EPGs actually deployed under this tenant's APs)
epg_by_name = {
epg.name: (ap.name, epg)
for ap in tenant.aps
for epg in ap.epgs
}
for contract in tenant.contracts:
providers = [
(ap_name, epg) for name, (ap_name, epg) in epg_by_name.items()
if contract.name in epg.contracts.provide
]
consumers = [
(ap_name, epg) for name, (ap_name, epg) in epg_by_name.items()
if contract.name in epg.contracts.consume
]
if not providers or not consumers:
continue
for prov_ap, prov_epg in providers:
prov_dn = f"uni/tn-{t}/ap-{prov_ap}/epg-{prov_epg.name}"
prov_vrf = None
for bd in tenant.bds:
if bd.name == prov_epg.bd:
prov_vrf = bd.vrf
if prov_vrf is None:
continue
prov_tag = pctag_for(prov_dn)
scope_id = scope_id_for(t, prov_vrf)
prov_leaves = _leaves_with_local_endpoints(store, prov_dn)
for cons_ap, cons_epg in consumers:
cons_dn = f"uni/tn-{t}/ap-{cons_ap}/epg-{cons_epg.name}"
cons_tag = pctag_for(cons_dn)
cons_leaves = _leaves_with_local_endpoints(store, cons_dn)
# Real ACI compiles the (bidirectional-capable) zoning
# rule pair onto every leaf hosting either side's
# endpoints — the leaf enforces the policy for its own
# local traffic regardless of which side it hosts.
target_leaves = prov_leaves | cons_leaves
if not target_leaves:
continue
for flt in contract.filters:
for leaf_id in sorted(target_leaves):
actrl_dn = f"topology/pod-{pod}/node-{leaf_id}/sys/actrl"
if actrl_dn not in seen_actrl_dn:
seen_actrl_dn.add(actrl_dn)
store.add(MO("actrlRuleCont", dn=actrl_dn))
scope_dn = f"{actrl_dn}/scope-[{scope_id}]"
if scope_dn not in seen_scope_dn:
seen_scope_dn.add(scope_dn)
store.add(MO("actrlScope", dn=scope_dn, scopeId=scope_id))
rule_id = f"{contract.name}-{flt.name}"
store.add(MO(
"actrlRule",
dn=f"{scope_dn}/rule-[{rule_id}]",
scopeId=scope_id,
sPcTag=prov_tag,
dPcTag=cons_tag,
fltId=flt.name,
action="permit",
direction="bi",
prio="pol_default",
ctrctName=contract.name,
operSt="enabled",
))
+1768
View File
File diff suppressed because it is too large Load Diff
View File
+166
View File
@@ -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
+122
View File
@@ -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))
+389
View File
@@ -0,0 +1,389 @@
"""aci_sim.graph — self-contained SVG/HTML topology diagram renderer.
Renders the BUILT fabric (spines, leaves, border-leaves, controllers, the
spine<->leaf cabling mesh, vPC pairs, and for multi-site topologies the
ISN cloud) as a single, dependency-free SVG. No CDN, no server, no npm: the
output is plain hand-generated SVG markup, optionally wrapped in a minimal
`<html><style>...</style><body><svg>...</svg></body></html>` shell.
This is a STANDALONE Python reimplementation of the visual language used by
autoACI's Vue+cytoscape topology view (frontend/src/composables/
useTopologyStyles.js + backend/routers/topology.py) same role->color
palette and tiered spine/leaf/border-leaf/controller hierarchy with an ISN
cloud between sites but it imports nothing from autoACI and does not
touch this repo's schema or builders. It only reads the already-validated
`Topology` model (topology/schema.py) and re-derives the same spine<->leaf
mesh build/cabling.py's `cabling_links()` produces.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from xml.sax.saxutils import escape
from aci_sim.build.cabling import cabling_links
from aci_sim.topology.schema import Site, Topology
# ---------------------------------------------------------------------------
# Role -> color palette, matched to autoACI's useTopologyStyles.js
# ---------------------------------------------------------------------------
ROLE_COLORS: dict[str, tuple[str, str]] = {
# role: (fill, border) — matches autoACI's cytoscape stylesheet exactly.
"spine": ("#2563eb", "#1d4ed8"),
"leaf": ("#16a34a", "#15803d"),
"border-leaf": ("#16a34a", "#15803d"), # same green family as leaf in autoACI (role="leaf")
"controller": ("#d97706", "#b45309"),
"isn": ("#475569", "#334155"),
}
LEGEND_ORDER = ["spine", "leaf", "border-leaf", "controller", "isn"]
LEGEND_LABELS = {
"spine": "Spine",
"leaf": "Leaf",
"border-leaf": "Border Leaf",
"controller": "Controller (APIC)",
"isn": "ISN Cloud",
}
FONT = "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif"
NODE_W = 96
NODE_H = 40
H_GAP = 130 # horizontal spacing between nodes in the same tier
V_GAP = 130 # vertical spacing between tiers
SITE_GAP = 220 # extra horizontal gap between sites
MARGIN = 60
LEGEND_H = 46
TITLE_H = 56
@dataclass
class GNode:
id: str
label: str
role: str
site: str | None
x: float = 0.0
y: float = 0.0
@dataclass
class GLink:
source: str
target: str
kind: str = "cabling" # cabling | isn | vpc
@dataclass
class Graph:
nodes: list[GNode] = field(default_factory=list)
links: list[GLink] = field(default_factory=list)
fabric_name: str = ""
site_count: int = 0
def node_count(self) -> int:
return len(self.nodes)
# ---------------------------------------------------------------------------
# Graph construction (pure data — no rendering here)
# ---------------------------------------------------------------------------
def _site_nodes(site: Site) -> tuple[list[GNode], list[GNode], list[GNode], list[GNode]]:
"""Return (spines, leaves, border_leaves, controllers) as GNode lists for a site."""
spines = [
GNode(id=f"n{n.id}", label=f"{n.name}\n({n.id})", role="spine", site=site.name)
for n in site.spine_nodes()
]
leaves = [
GNode(id=f"n{n.id}", label=f"{n.name}\n({n.id})", role="leaf", site=site.name)
for n in site.leaf_nodes()
]
border_leaves = [
GNode(id=f"n{bl.id}", label=f"{bl.name}\n({bl.id})", role="border-leaf", site=site.name)
for bl in site.border_leaves
]
controllers = [
GNode(
id=f"ctrl-{site.name}-{i + 1}",
label=f"APIC{i + 1}\n{site.name}",
role="controller",
site=site.name,
)
for i in range(site.controllers)
]
return spines, leaves, border_leaves, controllers
def build_graph(topo: Topology) -> Graph:
"""Derive the node/link graph from a validated Topology.
Node IDs use the fabric's real node id (`n{id}`) for spines/leaves/
border-leaves, so cabling links (which are keyed by real node id) join up
directly; controllers get a synthetic id since they have no fabricLink.
"""
graph = Graph(fabric_name=topo.fabric.name, site_count=len(topo.sites))
isn_enabled = topo.isn.enabled and len(topo.sites) > 1
isn_node = GNode(id="isn-cloud", label="ISN", role="isn", site=None) if isn_enabled else None
for site in topo.sites:
spines, leaves, border_leaves, controllers = _site_nodes(site)
graph.nodes.extend(spines + leaves + border_leaves + controllers)
# Spine<->leaf/border-leaf cabling mesh — reuse the exact same
# derivation build/cabling.py's fabricLink builder uses, so the
# diagram always matches the built fabric's real cabling graph.
for n1, _s1, _p1, n2, _s2, _p2 in cabling_links(site):
graph.links.append(GLink(source=f"n{n1}", target=f"n{n2}", kind="cabling"))
# vPC pairs: border leaves sharing vpc_domain get a dashed peer-link
# for visual grouping (mirrors autoACI's edge.vpc-link).
by_domain: dict[str, list[int]] = {}
for bl in site.border_leaves:
by_domain.setdefault(bl.vpc_domain, []).append(bl.id)
for ids in by_domain.values():
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
graph.links.append(GLink(source=f"n{ids[i]}", target=f"n{ids[j]}", kind="vpc"))
# Spine -> ISN cloud uplinks (autoACI: role="isn" node with spine
# source links, backend/routers/topology.py isn_connections).
if isn_node is not None:
for spine in spines:
graph.links.append(GLink(source=spine.id, target=isn_node.id, kind="isn"))
if isn_node is not None:
graph.nodes.append(isn_node)
_layout(graph, topo)
return graph
def _layout(graph: Graph, topo: Topology) -> None:
"""Hand-computed tiered ACI hierarchy layout (mirrors autoACI's preset layout).
Tiers (top to bottom): ISN cloud (multi-site only) -> spine -> leaf ->
border-leaf -> controller. Sites are spread side by side; each tier's
nodes are centered within their site's horizontal band.
"""
site_names = [s.name for s in topo.sites]
multi_site = len(site_names) > 1
by_site: dict[str, dict[str, list[GNode]]] = {
name: {"spine": [], "leaf": [], "border-leaf": [], "controller": []} for name in site_names
}
isn_node = None
for n in graph.nodes:
if n.role == "isn":
isn_node = n
continue
by_site[n.site][n.role].append(n)
def place_row(nodes: list[GNode], site_x: float, y: float) -> None:
if not nodes:
return
width = (len(nodes) - 1) * H_GAP
start_x = site_x - width / 2
for i, n in enumerate(nodes):
n.x = start_x + i * H_GAP
n.y = y
y_isn = 0.0
y_spine = V_GAP * 1 if not multi_site else V_GAP * 1.6
y_leaf = y_spine + V_GAP
y_border = y_leaf + V_GAP
y_ctrl = y_border + V_GAP
for idx, name in enumerate(site_names):
site_x = idx * SITE_GAP + idx * H_GAP * 2 # extra spread accounts for wide tiers
tiers = by_site[name]
# Recompute site_x based on the widest tier so sites don't overlap.
place_row(tiers["spine"], site_x, y_spine)
place_row(tiers["leaf"], site_x, y_leaf)
place_row(tiers["border-leaf"], site_x, y_border)
place_row(tiers["controller"], site_x, y_ctrl)
# Second pass: re-center each site block using the actual widest tier so
# multi-site layouts don't visually collide when leaf counts differ.
site_extents: dict[str, tuple[float, float]] = {}
for name in site_names:
tiers = by_site[name]
xs = [n.x for row in tiers.values() for n in row]
if xs:
site_extents[name] = (min(xs) - NODE_W, max(xs) + NODE_W)
else:
site_extents[name] = (0.0, 0.0)
cursor = 0.0
for name in site_names:
lo, hi = site_extents[name]
shift = cursor - lo
for row in by_site[name].values():
for n in row:
n.x += shift
cursor = hi + shift + SITE_GAP
if isn_node is not None:
all_x = [n.x for name in site_names for row in by_site[name].values() for n in row]
isn_node.x = (min(all_x) + max(all_x)) / 2 if all_x else 0.0
isn_node.y = y_isn
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _wrap_label(label: str) -> list[str]:
return label.split("\n")
def _node_svg(n: GNode) -> str:
fill, border = ROLE_COLORS.get(n.role, ("#64748b", "#334155"))
x = n.x - NODE_W / 2
y = n.y - NODE_H / 2
lines = _wrap_label(n.label)
text_parts = []
line_h = 13
start_y = n.y - (len(lines) - 1) * line_h / 2
for i, line in enumerate(lines):
text_parts.append(
f'<text x="{n.x:.1f}" y="{start_y + i * line_h:.1f}" text-anchor="middle" '
f'dominant-baseline="middle" class="node-label">{escape(line)}</text>'
)
return (
f'<g class="node node-{n.role}" data-id="{escape(n.id)}">'
f'<rect x="{x:.1f}" y="{y:.1f}" width="{NODE_W}" height="{NODE_H}" rx="8" ry="8" '
f'fill="{fill}" stroke="{border}" stroke-width="1.5" class="node-shadow"/>'
+ "".join(text_parts)
+ "</g>"
)
def _link_svg(link: GLink, pos: dict[str, GNode]) -> str:
a = pos.get(link.source)
b = pos.get(link.target)
if a is None or b is None:
return ""
cls = {
"cabling": "link-cabling",
"isn": "link-isn",
"vpc": "link-vpc",
}.get(link.kind, "link-cabling")
return f'<line x1="{a.x:.1f}" y1="{a.y:.1f}" x2="{b.x:.1f}" y2="{b.y:.1f}" class="{cls}"/>'
def _legend_svg(x: float, y: float, roles_present: list[str]) -> str:
parts = [f'<g class="legend" transform="translate({x:.1f},{y:.1f})">']
swatch = 14
gap = 150
for i, role in enumerate(roles_present):
fill, border = ROLE_COLORS[role]
lx = i * gap
parts.append(
f'<rect x="{lx}" y="0" width="{swatch}" height="{swatch}" rx="3" ry="3" '
f'fill="{fill}" stroke="{border}" stroke-width="1"/>'
f'<text x="{lx + swatch + 6}" y="{swatch - 2}" class="legend-label">{escape(LEGEND_LABELS[role])}</text>'
)
parts.append("</g>")
return "".join(parts)
def _svg_style() -> str:
return (
"<style>"
f".node-label {{ font-family: {FONT}; font-size: 9px; font-weight: 600; fill: #ffffff; }}"
f".legend-label {{ font-family: {FONT}; font-size: 11px; fill: #334155; }}"
f".title-text {{ font-family: {FONT}; font-size: 16px; font-weight: 700; fill: #0f172a; }}"
f".subtitle-text {{ font-family: {FONT}; font-size: 11px; fill: #64748b; }}"
".node-shadow { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.25)); }"
".link-cabling { stroke: #d1d5db; stroke-width: 1.5; opacity: 0.8; }"
".link-isn { stroke: #f59e0b; stroke-width: 1.5; stroke-dasharray: 5,4; opacity: 0.85; }"
".link-vpc { stroke: #94a3b8; stroke-width: 1.5; stroke-dasharray: 2,3; opacity: 0.7; }"
".site-label { font-family: " + FONT + "; font-size: 12px; font-weight: 600; fill: #64748b; }"
"</style>"
)
def _svg_body(graph: Graph, topo: Topology) -> tuple[str, int, int]:
pos = {n.id: n for n in graph.nodes}
if graph.nodes:
min_x = min(n.x for n in graph.nodes) - NODE_W
max_x = max(n.x for n in graph.nodes) + NODE_W
min_y = min(n.y for n in graph.nodes) - NODE_H
max_y = max(n.y for n in graph.nodes) + NODE_H
else:
min_x = min_y = 0.0
max_x = max_y = 0.0
width = int(max_x - min_x + 2 * MARGIN)
height = int(max_y - min_y + 2 * MARGIN + TITLE_H + LEGEND_H)
# Shift everything so the drawing starts at (MARGIN, MARGIN + TITLE_H).
offset_x = MARGIN - min_x
offset_y = MARGIN + TITLE_H - min_y
for n in graph.nodes:
n.x += offset_x
n.y += offset_y
roles_present = [r for r in LEGEND_ORDER if any(n.role == r for n in graph.nodes)]
links_svg = "".join(_link_svg(link, pos) for link in graph.links)
nodes_svg = "".join(_node_svg(n) for n in graph.nodes)
node_count = graph.node_count()
subtitle = f"{graph.site_count} site(s), {node_count} node(s)"
title_svg = (
f'<text x="{MARGIN}" y="28" class="title-text">{escape(graph.fabric_name)} — Topology</text>'
f'<text x="{MARGIN}" y="46" class="subtitle-text">{escape(subtitle)}</text>'
)
legend_y = height - LEGEND_H + 16
legend_svg = _legend_svg(MARGIN, legend_y, roles_present)
body = (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
f'width="{width}" height="{height}" font-family="{FONT}">'
+ _svg_style()
+ f'<rect x="0" y="0" width="{width}" height="{height}" fill="#f8fafc"/>'
+ title_svg
+ links_svg
+ nodes_svg
+ legend_svg
+ "</svg>"
)
return body, width, height
def render_topology(topo: Topology, fmt: str = "html") -> str:
"""Render *topo* as a self-contained SVG or HTML string.
fmt="svg" -> raw `<svg>...</svg>` markup.
fmt="html" -> `<html><body><svg>...</svg></body></html>` with inline CSS.
No network access, no external assets the returned string is fully
self-contained and safe to open directly in any browser.
"""
graph = build_graph(topo)
svg, width, _height = _svg_body(graph, topo)
if fmt == "svg":
return svg
if fmt != "html":
raise ValueError(f"Unsupported format {fmt!r}; expected 'svg' or 'html'")
title = escape(f"{topo.fabric.name} — Topology")
return (
f'<html lang="en"><head><meta charset="utf-8"/><title>{title}</title>'
"<style>"
"body { margin: 0; padding: 24px; background: #eef2f7; "
"font-family: -apple-system, 'Segoe UI', system-ui, sans-serif; }"
f"svg {{ display: block; margin: 0 auto; max-width: 100%; height: auto; background: #f8fafc; "
"border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }"
"</style>"
f"</head><body>{svg}</body></html>"
)
View File
+115
View File
@@ -0,0 +1,115 @@
"""Bracket-aware DN utilities.
ACI Distinguished Names are slash-separated, but path components inside
square brackets must never be split on the slash. Examples:
uni/tn-T/BD-b/subnet-[10.0.0.1/24]
uni/tn-T/pathep-[eth1/9]
uni/phys-X/rsdomAtt-[uni/phys-X]
uni/infra/.../rslldpIfAtt-[pathep-[eth1/9]] (nested brackets)
"""
from __future__ import annotations
import re
def dn_split(dn: str) -> list[str]:
"""Split a DN into RN components, never splitting inside [...].
Returns a list of RN strings; returns [] for an empty DN.
"""
parts: list[str] = []
depth = 0
current: list[str] = []
for ch in dn:
if ch == "[":
depth += 1
current.append(ch)
elif ch == "]":
depth -= 1
current.append(ch)
elif ch == "/" and depth == 0:
parts.append("".join(current))
current = []
else:
current.append(ch)
if current:
parts.append("".join(current))
return parts
def parent_dn(dn: str) -> str:
"""Return the parent DN (everything except the last RN).
Returns "" for top-level DNs (no parent).
"""
parts = dn_split(dn)
if len(parts) <= 1:
return ""
return "/".join(parts[:-1])
def rn_of(dn: str) -> str:
"""Return the last RN of a DN."""
parts = dn_split(dn)
return parts[-1] if parts else ""
def name_from_dn(dn: str) -> str:
"""Extract the name from the last RN (part after the first '-').
For bracket RNs like `subnet-[10.0.0.1/24]`, returns `10.0.0.1/24`.
For plain RNs like `tn-T`, returns `T`.
For RNs with no dash (e.g. `uni`), returns the whole RN.
"""
rn = rn_of(dn)
# Bracket form: foo-[...] → extract content of innermost [...] span
m = re.match(r"[^-]+-\[(.+)\]$", rn)
if m:
return m.group(1)
# Plain dash: foo-bar → bar
idx = rn.find("-")
if idx >= 0:
return rn[idx + 1:]
return rn
def pod_from_dn(dn: str) -> str:
"""Extract the pod number from a DN.
Searches for `/pod-N` in the DN; returns "1" as the default when absent.
"""
m = re.search(r"/pod-(\d+)(?:/|$)", dn)
if m:
return m.group(1)
# Also handle DN that starts with "pod-N"
m2 = re.match(r"pod-(\d+)(?:/|$)", dn)
if m2:
return m2.group(1)
return "1"
def dn_class_hint(rn: str) -> str | None:
"""Guess the ACI class from an RN prefix (best-effort).
Used by the REST layer to build default class names from path components.
Returns None if the prefix is not recognised.
"""
_PREFIX_MAP: dict[str, str] = {
"uni": "polUni",
"tn-": "fvTenant",
"ctx-": "fvCtx",
"BD-": "fvBD",
"subnet-": "fvSubnet",
"ap-": "fvAp",
"epg-": "fvAEPg",
"cep-": "fvCEp",
"node-": "fabricNode",
"sys": "topSystem",
"health": "healthInst",
"fault-": "faultInst",
"lnk-": "fabricLink",
}
for prefix, cls in _PREFIX_MAP.items():
if rn == prefix or (prefix.endswith("-") and rn.startswith(prefix)):
return cls
return None
+50
View File
@@ -0,0 +1,50 @@
"""MO (Managed Object) — building block of the ACI Managed Information Tree."""
from __future__ import annotations
from typing import Any
class MO:
"""A single node in the ACI MIT.
Attributes
----------
class_name : str e.g. ``"fvTenant"``
attrs : dict[str, str] must include ``"dn"``
children : list[MO] direct children in this MO's subtree
"""
def __init__(self, class_name: str, **attrs: str) -> None:
self.class_name = class_name
self.attrs: dict[str, str] = dict(attrs)
self.children: list[MO] = []
@property
def dn(self) -> str:
return self.attrs.get("dn", "")
def add_child(self, mo: MO) -> None:
"""Append *mo* to this MO's children list."""
self.children.append(mo)
def flat(self) -> list[MO]:
"""Return self + all descendants in depth-first order."""
result: list[MO] = [self]
for child in self.children:
result.extend(child.flat())
return result
def to_imdata(self) -> dict[str, Any]:
"""Produce the APIC wire shape for this MO.
``{"class_name": {"attributes": {...}, "children": [...]}}``
The ``children`` key is omitted when the MO has no children.
"""
body: dict[str, Any] = {"attributes": dict(self.attrs)}
if self.children:
body["children"] = [c.to_imdata() for c in self.children]
return {self.class_name: body}
def __repr__(self) -> str:
return f"MO({self.class_name!r}, dn={self.dn!r})"
+174
View File
@@ -0,0 +1,174 @@
"""MITStore — DN-keyed in-memory object store for ACI MOs."""
from __future__ import annotations
import copy
from aci_sim.mit.mo import MO
from aci_sim.mit.dn import parent_dn
class MITStore:
"""Maintains a ``dn -> MO`` map and a ``parent_dn -> [child dn]`` index.
Iteration order is deterministic (insertion order of DNs).
The store is deep-copyable for baseline/snapshot workflows.
"""
def __init__(self) -> None:
self._mos: dict[str, MO] = {} # dn → MO
self._children: dict[str, list[str]] = {} # parent_dn → [child dn]
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _register(self, dn: str) -> None:
"""Register *dn* AND ensure its full ancestor chain is linked (idempotent).
Walking the whole chain (not just the immediate parent) lets :meth:`subtree`
traverse through structural intermediates that were never added as MOs e.g.
a deep ``epmMacEp`` at ``/sys/ctx-[]/bd-[]/db-ep/mac-`` whose ctx/bd/db-ep
containers have no MO of their own. Without this, ``subtree(node/sys)`` could
not reach it.
"""
self._children.setdefault(dn, [])
cur = dn
while True:
pdn = parent_dn(cur)
if not pdn or pdn == cur:
break
siblings = self._children.setdefault(pdn, [])
if cur not in siblings:
siblings.append(cur)
cur = pdn
def _remove_subtree(self, dn: str) -> None:
"""Remove *dn* and all its descendants from both maps."""
for child_dn in list(self._children.get(dn, [])):
self._remove_subtree(child_dn)
self._mos.pop(dn, None)
self._children.pop(dn, None)
pdn = parent_dn(dn)
siblings = self._children.get(pdn)
if siblings is not None:
try:
siblings.remove(dn)
except ValueError:
pass
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def add(self, mo: MO) -> None:
"""Insert *mo* (and recursively its ``.children``) without merging.
Stores a *childless* copy hierarchy lives only in the parent index
(same invariant as :meth:`upsert`). Otherwise a stored tree MO would
carry a second copy of its subtree via ``MO.to_imdata()``, leaking
``children`` onto non-subtree queries and duplicating descendants on
subtree queries.
"""
dn = mo.dn
self._mos[dn] = MO(mo.class_name, **mo.attrs)
self._register(dn)
for child in mo.children:
self.add(child)
def upsert(self, mo: MO) -> None:
"""Merge *mo*'s attrs into an existing entry or insert it.
Recurses into ``mo.children``.
If ``mo.attrs["status"] == "deleted"``, removes the DN and its entire
subtree instead of merging.
"""
dn = mo.dn
if mo.attrs.get("status") == "deleted":
self._remove_subtree(dn)
return
existing = self._mos.get(dn)
if existing is None:
new_mo = MO(mo.class_name, **mo.attrs)
self._mos[dn] = new_mo
self._register(dn)
else:
existing.attrs.update(mo.attrs)
for child in mo.children:
self.upsert(child)
def get(self, dn: str) -> MO | None:
"""Return the MO at *dn*, or ``None`` if not present."""
return self._mos.get(dn)
def delete(self, dn: str) -> None:
"""Remove *dn* and its entire subtree from the store.
Idempotent: deleting a *dn* that is not present (or was already
removed) is a silent no-op, matching ``upsert``'s ``status="deleted"``
branch (also unconditional no existence check) and real APIC's
DELETE /api/mo/{dn}.json used by cisco.aci's ``state=absent`` path
(PR-9). Public wrapper around the same ``_remove_subtree`` helper
``upsert`` already uses, so callers outside this module (the DELETE
HTTP route) don't need to reach into a private method.
"""
self._remove_subtree(dn)
def children(self, dn: str, classes: set[str] | None = None) -> list[MO]:
"""Return direct children of *dn*, optionally filtered by class names."""
result: list[MO] = []
for cdn in self._children.get(dn, []):
mo = self._mos.get(cdn)
if mo is None:
continue
if classes is None or mo.class_name in classes:
result.append(mo)
return result
def child_dns(self, dn: str) -> list[str]:
"""Return the raw child-DN list of *dn* in insertion order.
Includes structural intermediates that have no MO of their own (see
:meth:`_register`). Used by the query engine to reconstruct the nested
subtree for ``rsp-subtree=full`` without flattening the hierarchy.
"""
return list(self._children.get(dn, []))
def subtree(self, dn: str, classes: set[str] | None = None) -> list[MO]:
"""Return all descendants of *dn* (not *dn* itself), depth-first.
If *classes* is given, only include MOs whose class_name is in the set;
but always recurse into ALL children regardless (mirrors APIC behaviour
where a class filter on rsp-subtree-class traverses the full tree).
"""
acc: list[MO] = []
self._collect_subtree(dn, classes, acc)
return acc
def _collect_subtree(self, dn: str, classes: set[str] | None, acc: list[MO]) -> None:
for cdn in self._children.get(dn, []):
mo = self._mos.get(cdn)
if mo is not None and (classes is None or mo.class_name in classes):
acc.append(mo)
# Recurse even through MO-less structural intermediates so deeply
# nested descendants (e.g. epm* under uninstantiated containers) are reached.
self._collect_subtree(cdn, classes, acc)
def by_class(self, cls: str) -> list[MO]:
"""Return all MOs whose ``class_name == cls``, in insertion order."""
return [mo for mo in self._mos.values() if mo.class_name == cls]
def all(self) -> list[MO]:
"""Return all MOs in insertion order."""
return list(self._mos.values())
# ------------------------------------------------------------------
# Deep-copy support (for baseline / snapshot)
# ------------------------------------------------------------------
def __deepcopy__(self, memo: dict) -> MITStore:
new = MITStore.__new__(MITStore)
new._mos = copy.deepcopy(self._mos, memo)
new._children = copy.deepcopy(self._children, memo)
return new
View File
+785
View File
@@ -0,0 +1,785 @@
"""
NDO FastAPI application Phase 6.
`make_ndo_app(state)` returns a FastAPI app that implements every §7 endpoint
from CONTRACT.md. Bearer auth is lenient: tokens are issued on login but
never validated on subsequent requests (accept present-or-absent).
"""
from __future__ import annotations
import hashlib
import uuid
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from aci_sim.control.persist import apply_ndo, load_json, save_json, serialize_ndo, state_dir
from .deploy_mirror import mirror_template_to_sites
from .model import NdoState
from .patch import PatchError, apply_json_patch, normalize_site, normalize_template
def make_ndo_app(state: NdoState, apic_states: dict[str, Any] | None = None) -> FastAPI:
"""Build and return the NDO FastAPI application backed by *state*.
*apic_states* (optional defaults to ``None``, keeping every existing
single-arg call site valid) maps site_id -> ``ApicSiteState``. When
provided, ``POST /mso/api/v1/task`` (deploy/undeploy) mirrors the
deployed template's VRFs/BDs/ANPs/EPGs into the target sites' APIC
MITStores via ``deploy_mirror.mirror_template_to_sites`` see that
module's docstring for the SIM GAP this closes. Typed as a plain
``dict[str, Any]`` (not ``dict[str, ApicSiteState]``) to avoid importing
``aci_sim.rest_aci.app`` here, which would risk a circular import
(that module doesn't currently import ``ndo.app``, but the two live in
sibling packages wired together only by ``runtime/supervisor.py`` this
keeps ``ndo/app.py`` decoupled from the APIC app module's import graph).
"""
app = FastAPI(title="aci-sim NDO", version="4.2.2")
# ------------------------------------------------------------------
# Authentication
# ------------------------------------------------------------------
@app.post("/api/v1/auth/login")
async def login(request: Request):
"""Accept any credentials and return a bearer token."""
body = await request.json()
token = f"sim-ndo-{uuid.uuid4().hex[:16]}"
return {
"token": token,
"userName": body.get("userName", "admin"),
"domain": body.get("domain", "local"),
}
@app.post("/login")
async def nd_bare_login(request: Request):
"""Classic Nexus Dashboard bare-login endpoint.
Real ND serves ``POST /login`` (body ``{userName, userPasswd, domain}``)
alongside the newer ``/api/v1/auth/login``. Clients that use the ND
platform login directly (aci-py's NDO connector, cisco.nd httpapi's
session bookkeeping) POST here and read the JWT from ``token`` /
``jwttoken``.
"""
body = await request.json()
token = f"sim-ndo-{uuid.uuid4().hex[:16]}"
return {
"token": token,
"jwttoken": token,
"userName": body.get("userName", "admin"),
"domain": body.get("domain", "local"),
}
@app.post("/logout")
async def nd_bare_logout():
"""Classic ND bare-logout — mirrors POST /login."""
return {"status": "ok"}
# ------------------------------------------------------------------
# Platform version
# ------------------------------------------------------------------
@app.get("/api/v1/platform/version")
async def platform_version():
# Dotted form per CONTRACT.md §7 — matches real Nexus Dashboard;
# autoACI's ndo_connector.py treats this as an opaque passthrough string.
return {"version": "4.2.2"}
# ------------------------------------------------------------------
# Sites
# ------------------------------------------------------------------
@app.get("/mso/api/v1/sites/fabric-connectivity")
async def get_fabric_connectivity():
"""Per-site connectivity status.
Must be declared BEFORE the generic /{site_id} route (if any)
so FastAPI's literal-first match picks it up correctly.
"""
return state.fabric_connectivity
@app.get("/mso/api/v1/sites")
async def get_sites():
return {"sites": state.sites}
# ------------------------------------------------------------------
# Tenants
# ------------------------------------------------------------------
@app.get("/mso/api/v1/tenants")
async def get_tenants():
return {"tenants": state.tenants}
# PR-10: cisco.mso.ndo_template's `lookup_tenant()` prereq lookup (used
# by TenantPol tenant-policy templates) resolves through this bare
# /api/v1/tenants path in addition to the /mso-prefixed form above —
# backs both with the same tenant list so a caller using either shape
# sees identical data (see docs/CONTRACT.md §7).
@app.get("/api/v1/tenants")
async def get_tenants_bare():
return {"tenants": state.tenants}
def _find_tenant(tenant_id: str) -> dict | None:
for t in state.tenants:
if t["id"] == tenant_id:
return t
return None
@app.put("/mso/api/v1/tenants/{tenant_id}")
async def update_tenant(tenant_id: str, request: Request):
"""Update an existing tenant in place — cisco.mso.mso_tenant's
"tenant already exists" branch (PR-11).
Every tenant this sim seeds comes straight from the topology (see
build_ndo_model), so a real E2E run's `mso_tenant` task always finds
the tenant already present via `GET /mso/api/v1/tenants` and takes
this PUT-to-update path rather than POST-to-create confirmed on a
real hardware run: `PUT https://.../mso/api/v1/tenants/{id}` sim 404
(route didn't exist) → "MSO Error:". Accept the module's full
payload (description/displayName/siteAssociations/userAssociations)
and merge it into the stored tenant dict, then echo it back per
MSOModule.request()'s 200/201/202 "parse and return the body" path.
"""
tenant = _find_tenant(tenant_id)
if tenant is None:
raise HTTPException(
status_code=404, detail=f"Tenant '{tenant_id}' not found"
)
body = await request.json()
tenant.update(body)
tenant["id"] = tenant_id
return tenant
@app.post("/mso/api/v1/tenants")
async def create_tenant(request: Request):
"""Create a brand-new tenant not already in the seeded topology."""
body = await request.json()
new_id = body.get("id") or f"tenant-{uuid.uuid4().hex[:16]}"
tenant = dict(body)
tenant["id"] = new_id
tenant.setdefault("siteAssociations", [])
state.tenants.append(tenant)
return tenant
# PR-11: `cisco.mso.ndo_template`'s prereq lookup for TenantPol templates
# (`prereq_tenantpol.yml`'s `ndo_template` task) resolves sites through
# this BARE path too — confirmed against a real hardware run that hit
# `GET https://.../api/v1/sites` (no `/mso` prefix) → 404. Same data as
# the canonical `/mso/api/v1/sites` route above.
@app.get("/api/v1/sites")
async def get_sites_bare():
return {"sites": state.sites}
# ------------------------------------------------------------------
# ND-platform user class queries — PR-11
# ------------------------------------------------------------------
# cisco.mso's MSOModule.lookup_users()/lookup_remote_users() query the
# modern `/nexus/infra/api/aaa/v4/{local,remote}users` endpoints first
# (ignore_not_found_error=True) and, only when BOTH come back as an empty
# dict, fall back to these legacy `/api/config/class/*` routes — see
# ansible-mso plugins/module_utils/mso.py. The sim doesn't implement the
# v4 aaa routes, so httpapi's connection plugin treats their absence as
# "not found" → empty dict → the fallback below is exactly what's hit.
# A real ACI-hardware E2E run confirmed the failure signature: GET
# /api/config/class/remoteusers → sim 404 {"detail":"Not Found"} → nd_request()
# has no "code"/"messages" in that body → "ND Error: Unknown error no
# error code in decoded payload", aborting mso_tenant's Create a tenant task.
@app.get("/api/config/class/remoteusers")
async def get_remote_users_class():
return state.remote_users
@app.get("/api/config/class/localusers")
async def get_local_users_class():
return state.local_users
# ------------------------------------------------------------------
# Schemas — list and detail
# ------------------------------------------------------------------
@app.get("/mso/api/v1/schemas")
async def get_schemas():
"""Return the FULL schema detail list (not the `list-identity`
lightweight summary below).
PR-13: real NDO's plain `GET /schemas` returns every schema's
complete nested doc (`templates[].{bds,anps,serviceGraphs,...}`,
top-level `sites[]`) this is precisely why `schemas/list-identity`
exists as a separate, lighter-weight enumeration endpoint (per its
own PR-10 docstring below: callers that only need id/displayName
use that one instead of paying for the full payload here). Several
OLDER `cisco.mso` community modules that predate the `MSOTemplate`/
`MSOSchema` module_utils classes e.g. `mso-model`'s
`custom_mso_schema_service_graph.py` (`mso.get_obj('schemas',
displayName=schema)`) bare-subscript straight into this response
(`schema_obj.get('templates')[idx]['serviceGraphs']`,
`schema_obj.get('sites')[idx]['serviceGraphs']`), so a trimmed
`{name}`-only projection here would KeyError/IndexError on those
exact modules even with every other collection-default fix in
place; a real hardware MS-TN2 `create_tenant` pass-2 run confirmed
this class of failure (`KeyError: 'serviceGraphs'`).
"""
all_ids = [s["id"] for s in state.schemas] + [s["id"] for s in state.extra_schemas]
full = [state.schema_details[sid] for sid in all_ids if sid in state.schema_details]
return {"schemas": full}
# PR-10: cisco.mso's MSOModule.lookup_schema() calls this lightweight
# enumeration endpoint FIRST to resolve a schema displayName -> id
# (verified against ansible-mso plugins/module_utils/mso.py:
# `self.query_objs("schemas/list-identity", key="schemas", displayName=schema)`,
# which reads json["schemas"][] entries with at least id + displayName).
# CRITICAL ORDERING: this MUST be declared before the parameterized
# GET /mso/api/v1/schemas/{schema_id} route below, or FastAPI/Starlette
# matches "list-identity" as a schema_id path param first (that bug was
# observed as a 404 body {"detail":"Schema 'list-identity' not found"}).
# Same store as GET /mso/api/v1/schemas — every schema this sim knows
# about (seeded + runtime-POSTed) is enumerable here too.
@app.get("/mso/api/v1/schemas/list-identity")
async def get_schemas_list_identity():
all_schemas = list(state.schemas) + list(state.extra_schemas)
return {
"schemas": [
{"id": s["id"], "displayName": s.get("displayName", s.get("name", ""))}
for s in all_schemas
]
}
# PR-13: `custom_mso_schema_service_graph.py` (mso-model role's "Create
# service graph" task) resolves a service node's display type
# (Firewall/Load Balancer/Other) to a stable id via
# `mso.get_obj('schemas/service-node-types', key='serviceNodeTypes',
# displayName=node_type)` before adding the service-graph node. Same
# routing-order requirement as `list-identity` above: this literal path
# segment MUST be declared before the parameterized `/schemas/{schema_id}`
# route below or FastAPI/Starlette matches "service-node-types" as a
# schema id instead (observed as a 404 body {"detail":"Schema
# 'service-node-types' not found"}).
@app.get("/mso/api/v1/schemas/service-node-types")
@app.get("/api/v1/schemas/service-node-types")
async def get_service_node_types():
return {
"serviceNodeTypes": [
{"id": "svc-node-type-firewall", "displayName": "Firewall"},
{"id": "svc-node-type-adc", "displayName": "Load Balancer"},
{"id": "svc-node-type-other", "displayName": "Other"},
]
}
@app.get("/mso/api/v1/schemas/{schema_id}/policy-states")
async def get_policy_states(schema_id: str):
"""Per-schema deployment / drift state.
Declared before the bare `/{schema_id}` route so FastAPI routes
`/schemas/X/policy-states` here rather than treating `policy-states`
as part of the schema id.
"""
ps = state.policy_states.get(schema_id)
if ps is None:
# Caller may have POSTed a schema whose id we stored in policy_states;
# fall back to a healthy default rather than 404.
ps = [{"status": "synced", "drift": False}]
return {"policyStates": ps}
# PR-11: both `cisco.mso.mso_schema_template_deploy` (legacy) and
# `cisco.mso.ndo_schema_template_deploy` (the one aci-ansible's mso-model
# role actually calls, confirmed against the collection installed on
# real ACI hardware) call `mso.validate_schema(schema_id)` — `GET
# schemas/{id}/validate` — before every deploy/redeploy. The return
# value is discarded by the caller, only the status code matters; a real
# hardware run 404'd here because the route didn't exist at all. Declared
# before the bare `/{schema_id}` route for the same routing-order reason
# as policy-states above.
@app.get("/mso/api/v1/schemas/{schema_id}/validate")
async def validate_schema(schema_id: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"errors": [], "warnings": []}
@app.get("/mso/api/v1/schemas/{schema_id}")
async def get_schema(schema_id: str):
"""Full schema detail — templates with VRFs/BDs/EPGs/contracts/filters."""
detail = state.schema_details.get(schema_id)
if detail is None:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return detail
# PR-11: legacy `mso_schema_template_deploy`'s deploy/undeploy request —
# `GET execute/schema/{id}/template/{name}` (deploy/undeploy) or
# `GET status/schema/{id}/template/{name}` (state=status). The response
# is splatted straight into `mso.exit_json(**status)`, so it must be a
# JSON object (dict), not a list/scalar. Kept alongside the POST /task
# route below since both modules ship in the same collection and either
# could be used by a given playbook.
@app.get("/mso/api/v1/execute/schema/{schema_id}/template/{template_name}")
async def execute_schema_template(schema_id: str, template_name: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"status": "success", "schemaId": schema_id, "templateName": template_name}
@app.get("/mso/api/v1/status/schema/{schema_id}/template/{template_name}")
async def status_schema_template(schema_id: str, template_name: str):
if schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
return {"status": "success", "schemaId": schema_id, "templateName": template_name}
# PR-11: `cisco.mso.ndo_schema_template_deploy` — the module aci-ansible's
# mso-model/tasks/template_deploy.yml role actually invokes (confirmed
# against the collection installed on real ACI hardware, which differs slightly from
# the legacy mso_schema_template_deploy.py) — sends deploy/redeploy/
# undeploy as `POST /mso/api/v1/task` with body
# `{"schemaId":...,"templateName":...,"isRedeploy":bool}` (or `undeploy:
# [siteId,...]`), and `state=query` as `GET status/schema/{id}/template/
# {name}` (same route as the legacy module's status query above). A real
# hardware run 404'd on `POST .../mso/api/v1/task` — the route didn't exist.
@app.post("/mso/api/v1/task")
async def post_task(request: Request):
body = await request.json()
schema_id = body.get("schemaId")
if schema_id is not None and schema_id not in state.schema_details:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
task_id = f"task-{uuid.uuid4().hex[:12]}"
template_name = body.get("templateName")
# Deploy mirror (confirmed SIM GAP): `ndo_schema_template_deploy`
# sends `undeploy: [siteId, ...]` (a non-empty list) to tear a
# template down from those sites; every other shape (deploy /
# `isRedeploy: true` redeploy) is a create/refresh. `apic_states`
# is None in every pre-existing test/call site (default arg), so
# this stays a strict no-op there — unchanged behavior.
if apic_states is not None and template_name:
undeploy = bool(body.get("undeploy"))
mirror_template_to_sites(
state, template_name, apic_states, schema_id=schema_id, undeploy=undeploy,
)
return {
"id": task_id,
"schemaId": schema_id,
"templateName": template_name,
"status": "success",
}
# ------------------------------------------------------------------
# Template summaries and detail — PR-13 generalized template store
# ------------------------------------------------------------------
# `cisco.mso.ndo_template`'s CREATE flow (`prereq_tenantpol.yml`'s
# `Create TenantPol-<tenant> tenant policy template` task) needs a real
# POST + PATCH round trip against a persistent template store, not just
# the single fixed tenantPolicy template PR-10/PR-11 seeded. And because
# that task carries `delegate_to: localhost` while every OTHER
# `cisco.mso`/`ndo_*` task in the same playbook run does not, the two
# code paths hit DIFFERENT URL shapes for the identical logical
# endpoint:
# - `delegate_to: localhost` tasks build `MSOModule` with no
# persistent httpapi connection (`module._socket_path is None`), so
# `MSOModule.request()` takes its direct-HTTP branch and never adds
# the `/mso` prefix at all: `GET/POST https://host/api/v1/templates*`
# (confirmed on a real hardware run: `prereq_tenantpol.yml`'s
# `ndo_template` task 404'd on exactly this bare path).
# - every other task on this same playbook host runs over the
# persistent `ansible.netcommon.httpapi` connection
# (`ansible_network_os: cisco.nd.nd`), whose platform tag routes
# through `NDO_API_VERSION_PATH_FORMAT = "/mso/api/{v}/{path}"` —
# e.g. `create_dhcp_relay`'s `ndo_dhcp_relay_policy` task.
# Both shapes must therefore back the SAME mutable store so a template
# created via the bare path is visible to a later mso-prefixed lookup
# (and vice versa) — registered via a shared handler, matching the
# existing `/api/v1/tenants` ↔ `/mso/api/v1/tenants` / `/api/v1/sites`
# ↔ `/mso/api/v1/sites` pattern PR-10/PR-11 already established.
def _template_summary_view(doc: dict) -> dict:
return {
"templateId": doc.get("templateId", ""),
"templateName": doc.get("displayName", ""),
"templateType": doc.get("templateType", ""),
}
def _sync_template_summary(doc: dict) -> None:
"""Keep `state.template_summaries` in sync with a stored template
doc mutate the existing summary entry in place if present, else
append. Mirrors `_schema_summary`/`_find_summary`'s schema-side
pattern below."""
view = _template_summary_view(doc)
for existing in state.template_summaries:
if existing.get("templateId") == view["templateId"]:
existing.update(view)
return
state.template_summaries.append(view)
async def _get_template_summaries():
"""Return a list of {templateId, templateName, templateType}.
ndo_connector.get_dhcp_policy_map() handles both a plain list and
{"templates": [...]} we return the list directly. Query params
(templateName/templateType/schemaName/schemaId) are accepted but
filtering happens client-side in `MSOTemplate`/`query_objs()`, so
this route always returns the full list same as every other
`query_objs()`-backed enumeration endpoint in this file.
"""
return state.template_summaries
async def _get_template(template_id: str):
"""Return a specific template detail (e.g. the tenantPolicy DHCP template)."""
doc = state.tenant_policy_templates.get(template_id)
if doc is None:
raise HTTPException(
status_code=404, detail=f"Template '{template_id}' not found"
)
return doc
def _backfill_policy_uuids(doc: dict, template_id: str) -> None:
"""Assign a stable `uuid` to every dhcpRelayPolicies/
dhcpOptionPolicies entry that lacks one.
`ndo_dhcp_relay_policy`'s add payload is only `{name, providers,
description?}` (no `uuid` real NDO assigns that server-side).
`ndo_schema_template_bd_dhcp_policy`'s `get_dhcp_relay_policy_uuid()`/
`get_dhcp_option_policy_uuid()` (the `bind_dhcp_relay_to_bd`
playbook step) then requires a non-empty `uuid` on the matched
entry `mso.fail_json()`s otherwise so a query-back immediately
after the add must already see one.
"""
container = doc.get("tenantPolicyTemplate", {}).get("template", {})
tenant_id = container.get("tenantId", "")
for list_key in ("dhcpRelayPolicies", "dhcpOptionPolicies"):
for item in container.get(list_key, []) or []:
if isinstance(item, dict) and not item.get("uuid"):
seed = "{}-uuid-{}-{}-{}".format(list_key, template_id, tenant_id, item.get("name", ""))
item["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
async def _create_template(request: Request):
"""Create a new template (`ndo_template`'s "does not exist yet"
branch POST payload shape: `{displayName, templateType,
<typeContainer>: {template:{...}, sites:[{siteId}]}}`, e.g.
`tenantPolicyTemplate: {template:{tenantId}, sites:[{siteId}]}}`).
Assigns a stable-looking id, stores the full doc (so a later
`ndo_dhcp_relay_policy` PATCH against `templates/{id}` finds it),
and mirrors a summary entry into `template_summaries` so the next
`templates/summaries?templateName=...` lookup (by any caller,
bare-path or mso-prefixed) resolves it.
"""
body = await request.json()
new_id = f"template-{uuid.uuid4().hex[:20]}"
doc = dict(body)
doc["templateId"] = new_id
doc.setdefault("displayName", body.get("displayName", ""))
doc.setdefault("templateType", body.get("templateType", ""))
_backfill_policy_uuids(doc, new_id)
state.tenant_policy_templates[new_id] = doc
_sync_template_summary(doc)
return doc
async def _patch_template(template_id: str, request: Request):
"""Apply a JSON-Patch op list to a stored template (e.g.
`ndo_dhcp_relay_policy`'s `add /tenantPolicyTemplate/template/
dhcpRelayPolicies/-`, or `ndo_template`'s site add/remove ops)."""
doc = state.tenant_policy_templates.get(template_id)
if doc is None:
raise HTTPException(
status_code=404, detail=f"Template '{template_id}' not found"
)
body = await request.json()
ops = body if isinstance(body, list) else body.get("ops", [])
if not ops:
return doc
try:
apply_json_patch(doc, ops)
except PatchError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
doc["templateId"] = template_id
_backfill_policy_uuids(doc, template_id)
_sync_template_summary(doc)
return doc
async def _delete_template(template_id: str):
state.tenant_policy_templates.pop(template_id, None)
state.template_summaries[:] = [
s for s in state.template_summaries if s.get("templateId") != template_id
]
return {}
#: `type`→(store-path-segment, name-key) for the generic cross-template
#: object lookup below.
_TEMPLATE_OBJECT_TYPES: dict[str, tuple[str, str]] = {
"dhcpRelay": ("dhcpRelayPolicies", "name"),
"dhcpOption": ("dhcpOptionPolicies", "name"),
}
async def _get_template_objects(request: Request):
"""Cross-template object lookup — `GET templates/objects?type=
{dhcpRelay|dhcpOption|epg|externalEpg}&{uuid=...|name=...}`.
Real NDO exposes this as a flat cross-cutting search over every
template's policy objects; `ansible-mso`'s `MSOTemplate.
get_template_object_by_uuid()` (`plugins/module_utils/template.py`)
and `ndo_schema_template_bd_dhcp_policy.py`'s
`get_dhcp_relay_policy_uuid()`/`get_dhcp_relay_label_name()` both
call this exact route the former to resolve a DHCP relay/option
policy's UUID by name (`create_dhcp_relay`'s NDO 4.x path), the
latter to resolve a stored `dhcpLabels[].ref` UUID back to a name
(`bind_dhcp_relay_to_bd`'s query-back-after-PATCH step). Confirmed
against a real hardware run: both hit `GET /mso/api/v1/templates/
objects?type=dhcpRelay&name=...` mid-playbook.
`type=epg`/`type=externalEpg` additionally search every SCHEMA
template's `anps[].epgs[]` / `externalEpgs[]` (not just the
tenantPolicy templates) `ndo_dhcp_relay_policy`'s
`insert_dhcp_relay_policy_relation_name()` resolves a stored
`epgRef`/`externalEpgRef` UUID back to a display name this way on
every query/present round trip.
"""
obj_type = request.query_params.get("type", "")
uuid_q = request.query_params.get("uuid")
name_q = request.query_params.get("name")
results: list[dict] = []
if obj_type in _TEMPLATE_OBJECT_TYPES:
list_key, name_key = _TEMPLATE_OBJECT_TYPES[obj_type]
for tmpl_doc in state.tenant_policy_templates.values():
tenant_id = (
tmpl_doc.get("tenantPolicyTemplate", {}).get("template", {}).get("tenantId")
)
container = tmpl_doc.get("tenantPolicyTemplate", {}).get("template", {})
for item in container.get(list_key, []) or []:
if not isinstance(item, dict):
continue
entry = dict(item)
entry.setdefault("tenantId", tenant_id)
results.append(entry)
elif obj_type in ("epg", "externalEpg"):
array_key = "externalEpgs" if obj_type == "externalEpg" else None
for detail in list(state.schema_details.values()):
for tmpl in detail.get("templates", []):
if array_key:
for item in tmpl.get("externalEpgs", []) or []:
if isinstance(item, dict):
results.append(item)
else:
for anp in tmpl.get("anps", []) or []:
for epg in anp.get("epgs", []) or []:
if isinstance(epg, dict):
results.append(epg)
if uuid_q is not None:
match = next((r for r in results if r.get("uuid") == uuid_q), None)
return match or {}
if name_q is not None:
matches = [r for r in results if r.get(_TEMPLATE_OBJECT_TYPES.get(obj_type, ("", "name"))[1]) == name_q]
return matches
return results
for prefix in ("/mso/api/v1", "/api/v1"):
app.add_api_route(f"{prefix}/templates/summaries", _get_template_summaries, methods=["GET"])
app.add_api_route(f"{prefix}/templates/objects", _get_template_objects, methods=["GET"])
app.add_api_route(f"{prefix}/templates", _create_template, methods=["POST"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _get_template, methods=["GET"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _patch_template, methods=["PATCH"])
app.add_api_route(f"{prefix}/templates/{{template_id}}", _delete_template, methods=["DELETE"])
# ------------------------------------------------------------------
# Audit records (both canonical and fallback paths)
# ------------------------------------------------------------------
async def _audit_records(count: int = 50):
return {"auditRecords": state.audit_records[:count]}
app.add_api_route(
"/api/v1/audit-records",
_audit_records,
methods=["GET"],
)
app.add_api_route(
"/mso/api/v1/audit-records",
_audit_records,
methods=["GET"],
)
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def _schema_summary(detail: dict) -> dict:
"""Project a full schema detail dict down to the list/GET-many shape.
Kept in sync on every write (POST create, PATCH mutate) so GET
/mso/api/v1/schemas and GET /mso/api/v1/schemas/list-identity always
reflect the current templates[].name set mso_schema_template.py's
`get_obj("schemas", displayName=schema)` inspects exactly this.
"""
return {
"id": detail["id"],
"displayName": detail.get("displayName", detail.get("name", "")),
"name": detail.get("name", detail.get("displayName", "")),
"templates": [
{"name": t.get("name", "")} for t in detail.get("templates", [])
],
}
def _find_summary(schema_id: str) -> dict | None:
"""Locate a schema's summary dict in whichever list holds it."""
for s in state.schemas:
if s["id"] == schema_id:
return s
for s in state.extra_schemas:
if s["id"] == schema_id:
return s
return None
@app.post("/mso/api/v1/schemas")
async def create_schema(request: Request):
"""Store a new schema and return its assigned id + status.
Two shapes reach this route:
- `mso_schema.py` (state=present, no templates): POST
`{"displayName":..., "description":...}` an empty schema, no
`id`/`templates` in the body.
- `mso_schema_template.py`'s "schema does not exist yet" branch:
POST `{"displayName": schema, "templates": [{name, displayName,
tenantId}], "sites": []}` the schema is born already carrying
its first template. This is the path a real hardware run takes
for the per-tenant-region `<tenant>-<region>` schemas (e.g.
"MS-TN1-LAB0") the schema must round-trip through GET
/schemas/list-identity (by displayName) and GET /schemas/{id}
for the subsequent mso_schema_template_bd/_anp/... PATCH calls
to find it (PR-11).
"""
body = await request.json()
new_id = f"schema-{uuid.uuid4().hex[:16]}"
display_name = body.get("displayName", body.get("name", "unnamed"))
schema_name = body.get("name", display_name)
# Full detail (returned by GET /schemas/{id}) — store the POSTed
# body verbatim (templates/sites and all) plus the assigned id, so
# every field a later PATCH op path might reference already exists.
detail = dict(body)
detail["id"] = new_id
detail["displayName"] = display_name
detail["name"] = schema_name
detail.setdefault("templates", [])
detail.setdefault("sites", [])
for tmpl in detail["templates"]:
normalize_template(tmpl, new_id)
for site in detail["sites"]:
if isinstance(site, dict):
site.setdefault("bds", [])
site.setdefault("anps", [])
normalize_site(site)
summary = _schema_summary(detail)
state.extra_schemas.append(summary)
state.schema_details[new_id] = detail
state.policy_states[new_id] = [{"status": "synced", "drift": False}]
return {"id": new_id, "displayName": display_name, "status": "active"}
@app.patch("/mso/api/v1/schemas/{schema_id}")
async def patch_schema(schema_id: str, request: Request):
"""Apply a JSON-Patch op list to the stored schema and return the doc.
Every cisco.mso schema-object module (mso_schema_template,
mso_schema_template_bd/_anp/_anp_epg, mso_schema_site, ...) mutates a
schema this way: look it up (list-identity id, then GET by id),
then PATCH with a small list of `{op, path, value}` entries such as
`{"op":"add","path":"/templates/LAB1/bds/-","value":{...}}`. See
aci_sim/ndo/patch.py for the applier and docs/CONTRACT.md §7
for the full request-sequence writeup (PR-11).
"""
detail = state.schema_details.get(schema_id)
if detail is None:
raise HTTPException(
status_code=404, detail=f"Schema '{schema_id}' not found"
)
body = await request.json()
ops = body if isinstance(body, list) else body.get("ops", [])
if not ops:
# cisco.mso's own MSOModule.request() short-circuits an empty-ops
# PATCH client-side and never sends it, but stay tolerant.
return detail
try:
apply_json_patch(detail, ops)
except PatchError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
detail["id"] = schema_id # ops must never be able to clobber the id
# An "add" op can introduce a brand-new template dict (mso_schema_
# template.py's "template does not exist" branch PATCHes
# {"op":"add","path":"/templates/-","value":{name,displayName,
# tenantId}} — no vrfs/bds/... keys). Re-normalize every template
# after each PATCH so downstream MSOSchema.set_template_vrf() etc.
# never meets a missing collection key (same rule as create_schema).
for tmpl in detail.get("templates", []):
normalize_template(tmpl, schema_id)
# Same rule for the top-level `sites[]` array — mso_schema_site_bd.py
# et al PATCH child objects into `/sites/{siteId-templateName}/bds/-`
# etc. without every collection default (PR-11).
for site in detail.get("sites", []):
if isinstance(site, dict):
site.setdefault("bds", [])
site.setdefault("anps", [])
normalize_site(site)
# Keep the summary (GET /schemas, /schemas/list-identity) in sync —
# template adds/removes and displayName/name replace ops all need to
# be visible to the next lookup_schema() call in the same playbook.
# Mutate whichever summary list already holds this id in place, so
# GET /schemas never double-lists or drops the entry.
updated_summary = _schema_summary(detail)
existing_summary = _find_summary(schema_id)
if existing_summary is not None:
existing_summary.update(updated_summary)
else:
# Shouldn't normally happen (every schema_details entry is
# created alongside a summary), but degrade gracefully.
state.extra_schemas.append(updated_summary)
state.policy_states.setdefault(schema_id, [{"status": "synced", "drift": False}])
return detail
# ------------------------------------------------------------------
# State persistence (/_sim) — mirrors the APIC plane's admin router
# (aci_sim/control/admin.py), which the NDO app doesn't otherwise
# mount, so it's registered directly here.
# ------------------------------------------------------------------
@app.post("/_sim/save/{name}")
async def sim_save(name: str):
"""Persist the NDO state to disk under *name*."""
path = state_dir() / f"{name}.ndo.json"
save_json(path, serialize_ndo(state))
return {"status": "ok", "file": str(path)}
@app.post("/_sim/load/{name}")
async def sim_load(name: str):
"""Restore the NDO state from a prior :func:`sim_save`."""
path = state_dir() / f"{name}.ndo.json"
if not path.exists():
raise HTTPException(status_code=404, detail=f"state '{name}' not found")
apply_ndo(state, load_json(path))
return {"status": "ok"}
@app.post("/mso/api/v1/deploy")
async def deploy(request: Request):
"""Acknowledge a deploy request and return an in-progress record."""
deploy_id = f"deploy-{uuid.uuid4().hex[:12]}"
return {"id": deploy_id, "status": "in-progress"}
return app
+435
View File
@@ -0,0 +1,435 @@
"""NDO -> APIC deploy mirror.
Confirmed SIM GAP: `POST /mso/api/v1/task` (the deploy/undeploy request
`cisco.mso.ndo_schema_template_deploy` sends see ndo/app.py's docstring on
that route) has always been a pure ack no-op: it never materializes the
deployed schema template's VRFs/BDs/ANPs/EPGs/binds into the TARGET SITES'
APIC MITStores. A real E2E Ansible run never direct-POSTs those MOs to the
APIC for a multi-site tenant either it relies entirely on NDO's deploy to
push them down. So today, multi-site EPGs exist in the NDO schema doc but
never appear on the APIC side of this sim, which breaks any playbook/test
that reads EPGs back from the APIC after an NDO-driven multi-site deploy.
`mirror_template_to_sites()` closes that gap: given the NDO state, a
template name, and a site_id -> ApicSiteState map, it walks the owning
schema's template (vrfs/bds/anps/epgs) plus each associated SITE entry
(hostBasedRouting overlay, domainAssociations, staticPorts) and upserts the
equivalent fvCtx/fvBD/fvAp/fvAEPg (+ children) MOs into every target site's
MITStore reusing the exact DN/attribute conventions build/tenants.py's
boot-time builders and rest_aci/writes.py's POST-write path already
establish, so a mirrored MO is indistinguishable from a boot-built or
directly-POSTed one.
Pure and HTTP-free: no FastAPI/Request dependency, so it's unit-testable
without spinning up either app.
"""
from __future__ import annotations
from typing import Any
from aci_sim.build.tenants import pctag_for
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.writes import _CLASS_DEFAULTS
from .model import NdoState
#: Leaf object-name keys inside a DICT-form NDO ref, in priority order. An
#: epgRef carries BOTH anpName and epgName, so epgName is checked first (the
#: EPG's own name wins); every other ref dict carries only its own *Name key.
#: schemaId/templateName are deliberately excluded.
_REF_NAME_KEYS = ("epgName", "bdName", "vrfName", "contractName", "anpName", "name")
def _basename(ref: Any) -> str:
"""Return the object name for an NDO `*Ref`, handling BOTH forms it uses.
- STRING ref `/schemas/<id>/templates/<tmpl>/bds/<bdName>` -> last segment.
- DICT ref `{"vrfName": ..., "schemaId": ..., "templateName": ...}` -> the
leaf object-name key (see `_REF_NAME_KEYS`). aci-ansible's cisco.mso
modules store the dict shape; aci-py stringifies its refs so the mirror
must accept either or it AttributeErrors (`'dict'.rsplit`) on a redeploy
of an aci-ansible multi-site tenant.
The last segment / leaf name already matches the APIC MO name (see
ndo/model.py's docstring: NDO names are derived verbatim from the APIC
fvBD/fvAp/fvAEPg names at build time).
"""
if isinstance(ref, dict):
for key in _REF_NAME_KEYS:
value = ref.get(key)
if isinstance(value, str) and value:
return value
return ""
if not ref:
return ""
return ref.rsplit("/", 1)[-1]
def _tenant_name(state: NdoState, tenant_id: str) -> str:
"""Resolve a template's `tenantId` to the tenant's APIC-facing name."""
for tenant in state.tenants:
if tenant.get("id") == tenant_id:
return tenant.get("name", "")
return ""
def _find_template(
state: NdoState, template_name: str, schema_id: str | None = None
) -> tuple[dict | None, dict | None]:
"""Locate (schema_detail, template_doc) for a template named *template_name*.
If *schema_id* is given, resolve ONLY within that schema template names
are NOT unique across schemas (every multi-site tenant's schema commonly
has templates literally named e.g. 'LAB1-LAB2'/'LAB1'/'LAB2'), so a
cross-schema scan risks mirroring the WRONG tenant. When *schema_id* is
None, fall back to the legacy scan-every-schema behavior (kept for
existing callers/tests that don't have a schema id handy).
"""
if schema_id is not None:
sd = state.schema_details.get(schema_id)
if sd is None:
return None, None
for tmpl in sd.get("templates", []):
if tmpl.get("name") == template_name:
return sd, tmpl
return sd, None
for schema_detail in state.schema_details.values():
for tmpl in schema_detail.get("templates", []):
if tmpl.get("name") == template_name:
return schema_detail, tmpl
return None, None
def _site_entries_for_template(schema_detail: dict, template_name: str) -> list[dict]:
"""Return every top-level `sites[]` entry belonging to *template_name*."""
return [
site
for site in schema_detail.get("sites", [])
if isinstance(site, dict) and site.get("templateName") == template_name
]
def _find_site_bd(site_entry: dict, bd_name: str) -> dict | None:
for bd in site_entry.get("bds", []) or []:
if isinstance(bd, dict) and _basename(bd.get("bdRef")) == bd_name:
return bd
return None
def _find_site_epg(site_entry: dict, anp_name: str, epg_name: str) -> dict | None:
for anp in site_entry.get("anps", []) or []:
if not isinstance(anp, dict) or _basename(anp.get("anpRef")) != anp_name:
continue
for epg in anp.get("epgs", []) or []:
if isinstance(epg, dict) and _basename(epg.get("epgRef")) == epg_name:
return epg
return None
def _mirror_bd(store: MITStore, tenant: str, bd: dict, site_bd: dict | None) -> None:
"""Upsert fvCtx-referencing fvBD (+ fvRsCtx/fvSubnet children) for one
template BD, overlaid with the SITE bd's hostBasedRouting."""
bd_name = bd.get("name", "")
vrf_name = _basename(bd.get("vrfRef"))
bd_dn = f"uni/tn-{tenant}/BD-{bd_name}"
l2_unknown_ucast = bd.get("l2UnknownUnicast", "flood")
unicast_routing = bool(bd.get("unicastRouting", True))
host_based_routing = bool(site_bd.get("hostBasedRouting")) if site_bd else False
attrs: dict[str, Any] = dict(_CLASS_DEFAULTS.get("fvBD", {}))
attrs.update(
{
"dn": bd_dn,
"name": bd_name,
"epMoveDetectMode": bd.get("epMoveDetectMode", ""),
"arpFlood": "yes" if bd.get("arpFlood") else "no",
"unkMacUcastAct": l2_unknown_ucast,
"unicastRoute": "yes" if unicast_routing else "no",
"hostBasedRouting": "yes" if host_based_routing else "no",
"intersiteBumTrafficAllow": "yes" if bd.get("intersiteBumTrafficAllow") else "no",
}
)
bd_mo = MO("fvBD", **attrs)
if vrf_name:
bd_mo.add_child(MO(
"fvRsCtx",
dn=f"{bd_dn}/rsctx",
tnFvCtxName=vrf_name,
))
for subnet in bd.get("subnets", []) or []:
if not isinstance(subnet, dict):
continue
ip = subnet.get("ip")
if not ip:
continue
scope = subnet.get("scope") or "public,shared"
bd_mo.add_child(MO(
"fvSubnet",
dn=f"{bd_dn}/subnet-[{ip}]",
ip=ip,
scope=scope,
preferred="yes" if subnet.get("primary") else "no",
))
# Merge-upsert (NOT delete-then-recreate) on purpose: the BD may carry an
# externally-POSTed `epClear` attr (the clear_remote_mac stub a playbook
# posts directly to the APIC) that a delete-then-recreate would drop.
# store.upsert() merges attrs/children onto any existing MO at this dn,
# so that attr survives a redeploy. Contrast with _mirror_epg below,
# which deletes-then-recreates because EPGs have no such externally-set
# state to preserve.
store.upsert(bd_mo)
def _mirror_epg(store: MITStore, tenant: str, anp_name: str, epg: dict, site_epg: dict | None) -> None:
"""Upsert fvAEPg (+ fvRsBd/fvRsProv/fvRsCons/fvSubnet/fvRsDomAtt/
fvRsPathAtt children) for one template EPG, overlaid with the SITE epg's
domainAssociations/staticPorts (bind data usually empty until a bind
playbook runs)."""
epg_name = epg.get("name", "")
epg_dn = f"uni/tn-{tenant}/ap-{anp_name}/epg-{epg_name}"
epg_mo = MO(
"fvAEPg",
dn=epg_dn,
name=epg_name,
prefGrMemb="include" if epg.get("preferredGroup") else "exclude",
pcTag=pctag_for(epg_dn),
pcEnfPref="unenforced",
isAttrBasedEPg="no",
floodOnEncap="disabled",
)
bd_name = _basename(epg.get("bdRef"))
if bd_name:
epg_mo.add_child(MO(
"fvRsBd",
dn=f"{epg_dn}/rsbd",
tnFvBDName=bd_name,
))
for rel in epg.get("contractRelationships", []) or []:
if not isinstance(rel, dict):
continue
contract = _basename(rel.get("contractRef"))
if not contract:
continue
if rel.get("relationshipType") == "provider":
epg_mo.add_child(MO(
"fvRsProv",
dn=f"{epg_dn}/rsprov-{contract}",
tnVzBrCPName=contract,
))
elif rel.get("relationshipType") == "consumer":
epg_mo.add_child(MO(
"fvRsCons",
dn=f"{epg_dn}/rscons-{contract}",
tnVzBrCPName=contract,
))
for subnet in epg.get("subnets", []) or []:
if not isinstance(subnet, dict):
continue
ip = subnet.get("ip")
if not ip:
continue
epg_mo.add_child(MO(
"fvSubnet",
dn=f"{epg_dn}/subnet-[{ip}]",
ip=ip,
scope=subnet.get("scope") or "public,shared",
preferred="yes" if subnet.get("primary") else "no",
))
if site_epg is not None:
for dom in site_epg.get("domainAssociations", []) or []:
if not isinstance(dom, dict):
continue
t_dn = dom.get("dn") or dom.get("domainRef") or dom.get("tDn")
if not t_dn:
continue
epg_mo.add_child(MO(
"fvRsDomAtt",
dn=f"{epg_dn}/rsdomAtt-[{t_dn}]",
tDn=t_dn,
instrImedcy="lazy",
))
for path in site_epg.get("staticPorts", []) or []:
if not isinstance(path, dict):
continue
path_dn = path.get("path") or path.get("dn") or path.get("tDn")
if not path_dn:
continue
epg_mo.add_child(MO(
"fvRsPathAtt",
dn=f"{epg_dn}/rspathAtt-[{path_dn}]",
tDn=path_dn,
encap=path.get("encap", ""),
mode=path.get("mode", "regular"),
instrImedcy=path.get("deploymentImmediacy", "lazy"),
))
# Redeploy idempotency: delete-then-recreate (NOT a merge-upsert like
# _mirror_bd). store.upsert() merges attrs/children onto an existing MO,
# so a contract/bind removed in NDO since the last deploy would otherwise
# leave a stale fvRsProv/fvRsDomAtt/etc. child behind forever. This is
# safe because multi-site EPGs have NO externally-posted children of
# their own — every child under this dn comes from this mirror, so
# wiping the subtree first and rebuilding it clean can't drop anything
# an outside POST relied on (contrast with the BD's epClear, above).
store.upsert(MO("fvAEPg", dn=epg_dn, status="deleted"))
store.upsert(epg_mo)
def _template_object_dns(tenant: str, template: dict) -> list[tuple[str, str]]:
"""Every tenant-scoped (dn, class_name) pair this template owns (its
VRFs/BDs/ANPs/EPGs) used to scope an undeploy's delete to just this
template's objects, never the whole `uni/tn-<T>` tenant (a schema may
hold more than one template against the same tenant).
Deletion in the store is DN-based (status="deleted" removes by dn
regardless of the class passed), so the class here only matters for
fidelity the emitted delete MO now carries the object's real class
instead of a hardcoded 'fvBD' for everything.
"""
dns: list[tuple[str, str]] = []
for vrf in template.get("vrfs", []) or []:
name = vrf.get("name") if isinstance(vrf, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/ctx-{name}", "fvCtx"))
for bd in template.get("bds", []) or []:
name = bd.get("name") if isinstance(bd, dict) else None
if name:
dns.append((f"uni/tn-{tenant}/BD-{name}", "fvBD"))
for anp in template.get("anps", []) or []:
if not isinstance(anp, dict):
continue
anp_name = anp.get("name")
if not anp_name:
continue
dns.append((f"uni/tn-{tenant}/ap-{anp_name}", "fvAp"))
for epg in anp.get("epgs", []) or []:
epg_name = epg.get("name") if isinstance(epg, dict) else None
if epg_name:
dns.append((f"uni/tn-{tenant}/ap-{anp_name}/epg-{epg_name}", "fvAEPg"))
return dns
def mirror_template_to_sites(
state: NdoState,
template_name: str,
apic_states: dict,
*,
schema_id: str | None = None,
undeploy: bool = False,
) -> int:
"""Materialize (or, if *undeploy*, tear down) *template_name*'s
VRFs/BDs/ANPs/EPGs into every associated site's APIC MITStore.
*schema_id*, when known (the real `POST /mso/api/v1/task` body always
carries one), scopes template resolution to that ONE schema template
names collide across schemas (e.g. every multi-site tenant's schema has
templates literally named 'LAB1-LAB2'/'LAB1'/'LAB2'), so resolving by
name alone across ALL schemas risks mirroring the WRONG tenant. Pass
None only for legacy callers/tests that don't have a schema id handy.
*apic_states* maps site_id -> an object exposing a `.store` (MITStore)
typically `aci_sim.rest_aci.app.ApicSiteState`. Only sites that
are BOTH associated with this template (schema `sites[].templateName ==
template_name`) AND present in *apic_states* are touched; every other
site's store is left completely untouched.
Returns the number of MOs upserted/deleted (0 if the template or none of
its sites could be resolved a safe no-op).
"""
schema_detail, template = _find_template(state, template_name, schema_id)
if template is None or schema_detail is None:
return 0
tenant = _tenant_name(state, template.get("tenantId", ""))
if not tenant:
return 0
site_entries = _site_entries_for_template(schema_detail, template_name)
if not site_entries:
return 0
written = 0
if undeploy:
object_dns = _template_object_dns(tenant, template)
for site_entry in site_entries:
site_id = site_entry.get("siteId")
apic_state = apic_states.get(str(site_id)) if apic_states else None
if apic_state is None:
continue
store = apic_state.store
for dn, cls in object_dns:
store.upsert(MO(cls, dn=dn, status="deleted"))
written += 1
return written
for site_entry in site_entries:
site_id = site_entry.get("siteId")
apic_state = apic_states.get(str(site_id)) if apic_states else None
if apic_state is None:
continue
store = apic_state.store
# Materialize the tenant shadow on this site FIRST. An NDO deploy
# creates the tenant on each target site's APIC; without the fvTenant
# root MO, a subtree/mo query on `uni/tn-<T>` returns empty even though
# the mirrored children exist (real F1 root cause: the direct-POST path
# only creates fvTenant on the --apic site, so the OTHER site had the
# mirrored VRF/BD/EPG children but no tenant root to query them under).
store.upsert(MO("fvTenant", dn=f"uni/tn-{tenant}", name=tenant))
written += 1
for vrf in template.get("vrfs", []) or []:
if not isinstance(vrf, dict):
continue
vrf_name = vrf.get("name", "")
if not vrf_name:
continue
store.upsert(MO(
"fvCtx",
dn=f"uni/tn-{tenant}/ctx-{vrf_name}",
name=vrf_name,
))
written += 1
for bd in template.get("bds", []) or []:
if not isinstance(bd, dict):
continue
bd_name = bd.get("name", "")
if not bd_name:
continue
site_bd = _find_site_bd(site_entry, bd_name)
_mirror_bd(store, tenant, bd, site_bd)
written += 1
for anp in template.get("anps", []) or []:
if not isinstance(anp, dict):
continue
anp_name = anp.get("name", "")
if not anp_name:
continue
store.upsert(MO(
"fvAp",
dn=f"uni/tn-{tenant}/ap-{anp_name}",
name=anp_name,
))
written += 1
for epg in anp.get("epgs", []) or []:
if not isinstance(epg, dict):
continue
epg_name = epg.get("name", "")
if not epg_name:
continue
site_epg = _find_site_epg(site_entry, anp_name, epg_name)
_mirror_epg(store, tenant, anp_name, epg, site_epg)
written += 1
return written
+530
View File
@@ -0,0 +1,530 @@
"""
NDO state model builder Phase 6.
`build_ndo_model(topo)` derives a complete NdoState from the same Topology
object used for the APIC MIT builders, so every NDO tenant/VRF/BD/EPG/contract
name *exactly* matches the APIC MIT (cross-plane consistency, DESIGN.md rule).
The state is a plain dataclass no FastAPI dependency here.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass, field
from aci_sim.topology.schema import Topology
from .patch import _new_site_bd, _new_site_epg, normalize_site, normalize_template
# ---------------------------------------------------------------------------
# Fixed deterministic artefacts (stable across test runs — no datetime.now())
# ---------------------------------------------------------------------------
#: UUID used as the dhcpRelayPolicy reference in every BD's dhcpLabels.
#: The tenantPolicy template exposes this UUID so ndo_bd_view can resolve it.
DHCP_RELAY_UUID: str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
#: UUID for the DHCP option policy exposed in the tenantPolicy template.
DHCP_OPTION_UUID: str = "f9e8d7c6-b5a4-3210-fedc-ba9876543210"
#: Stable template-id for the single tenantPolicy template.
TENANT_POLICY_TEMPLATE_ID: str = "tenantpolicy000000000001" # 24 chars
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hex_id(seed: str) -> str:
"""Return a 24-char lower-hex deterministic id (Mongo ObjectId style)."""
return hashlib.sha256(seed.encode()).hexdigest()[:24]
# ---------------------------------------------------------------------------
# State container
# ---------------------------------------------------------------------------
@dataclass
class NdoState:
"""All NDO-plane state derived from the topology, plus a mutable POST bucket."""
#: [{id, name, platform, urls}] — from topo.sites
sites: list
#: [{id, name, displayName, siteAssociations}] — one per topo.tenants
tenants: list
#: Summary shape [{id, displayName, name, templates:[{name}]}]
schemas: list
#: schema_id → full schema detail dict (returned by GET /schemas/{id})
schema_details: dict
#: [{templateId, templateType}] — includes one "tenantPolicy" entry
template_summaries: list
#: templateId → full template doc (returned by GET /templates/{id})
tenant_policy_templates: dict
#: {"sites": [{id, siteId, status, connectivityStatus}]}
fabric_connectivity: dict
#: schema_id → [{status, drift}]
policy_states: dict
#: [{id, timestamp, user, action, description, details}] — fixed set
audit_records: list
#: ND-platform local users, ND-shaped `{"items":[{"spec":{...}}]}` — PR-11.
#: `cisco.mso.mso_tenant`'s `lookup_users()`/`lookup_remote_users()` query
#: this (and the sibling `remote_users`) as part of every tenant create.
local_users: dict = field(default_factory=dict)
#: ND-platform remote (AAA/TACACS/LDAP) users, same ND aggregate shape.
#: Seeded with the sandbox's own `admin` login so `mso_tenant`'s implicit
#: `users: [ansible_user]` (defaulted to admin if unset) resolves.
remote_users: dict = field(default_factory=dict)
#: Accumulates schemas POSTed at runtime (mutable — shared with app closures)
extra_schemas: list = field(default_factory=list)
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
def build_ndo_model(topo: Topology, sandbox: bool = False) -> NdoState: # noqa: C901
"""Derive an NdoState from *topo* with names matching the APIC MIT.
In sandbox mode each APIC is served on its own ``mgmt_ip`` at :443, so NDO
advertises the portless ``https://<mgmt_ip>`` URL exactly how real NDO
reports controller URLs, and what autoACI's discovery expects. Otherwise it
advertises ``https://<apic_host>`` (the 127.0.0.1:<port> loopback form).
"""
# ------------------------------------------------------------------
# Sites
# ------------------------------------------------------------------
# PR-10: real NDO registers sites under their fabric name (e.g.
# "LAB1-IT-ACI"), not the short topology-internal site name ("LAB1") —
# verified against a real-gear cisco.mso.mso_tenant run (see
# docs/CONTRACT.md §7 / CHANGELOG 0.2.1): the tenant's `sites` list only
# accepted the fabric-name form, and `mso_tenant` failed with
# "Site 'LAB1-IT-ACI' is not a valid site name" when only the short name
# was exposed here. `Site.fabric_name` (topSystem.fabricDomain) already
# carries this value; fall back to the short `site.name` only if a
# topology omits fabric_name (keeps this builder tolerant of minimal
# topologies used by unit tests that don't set it).
sites: list = []
site_id_map: dict[str, str] = {} # site.name (short) → site.id — internal join key
for site in topo.sites:
apic_url = f"https://{site.mgmt_ip}" if (sandbox and site.mgmt_ip) else f"https://{site.apic_host}"
ndo_site_name = site.fabric_name or site.name
sites.append(
{
"id": site.id,
"name": ndo_site_name,
"platform": "APIC",
"urls": [apic_url],
}
)
site_id_map[site.name] = site.id
# ------------------------------------------------------------------
# Tenants
# ------------------------------------------------------------------
tenants: list = []
for tenant in topo.tenants:
t_id = _hex_id(f"tenant-{tenant.name}")
site_assocs = [
{"siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
tenants.append(
{
"id": t_id,
"name": tenant.name,
"displayName": tenant.name,
"siteAssociations": site_assocs,
}
)
# ------------------------------------------------------------------
# Schemas — one per tenant
# ------------------------------------------------------------------
schemas: list = []
schema_details: dict = {}
policy_states: dict = {}
for tenant in topo.tenants:
schema_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
tmpl_name = f"{tenant.name}-template"
# VRFs — name/displayName/vrfRef match APIC fvCtx names
vrfs = [
{
"name": vrf.name,
"displayName": vrf.name,
"vrfRef": f"/vrfs/{vrf.name}",
"vzAnyEnabled": False,
"ipDataPlaneLearning": "enabled",
}
for vrf in tenant.vrfs
]
# BDs — name/vrfRef match APIC fvBD names; every BD carries a DHCP
# relay label so ndo_bd_view can exercise uuid→name resolution.
bds = []
for bd in tenant.bds:
bds.append(
{
"name": bd.name,
"vrfRef": f"/vrfs/{bd.vrf}",
"subnets": [{"ip": s} for s in bd.subnets],
"l2Stretch": bd.l2stretch,
"intersiteBumTraffic": bd.l2stretch,
"l2UnknownUnicast": "flood",
"arpFlood": True,
"unicastRouting": True,
"epMoveDetectMode": "garp",
"dhcpLabels": [{"ref": DHCP_RELAY_UUID}],
}
)
# ANPs / EPGs — names match APIC fvAp / fvAEPg names
anps = []
for ap in tenant.aps:
epgs = []
for epg in ap.epgs:
contract_rels = [
{
"relationshipType": "provider",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.provide
] + [
{
"relationshipType": "consumer",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.consume
]
epgs.append(
{
"name": epg.name,
"bdRef": f"/bds/{epg.bd}",
"preferredGroup": False,
"proxyArp": False,
"uSegEpg": False,
"intraEpg": "unenforced",
"contractRelationships": contract_rels,
}
)
anps.append({"name": ap.name, "epgs": epgs})
# Contracts — names match APIC vzBrCP names
contracts = []
for contract in tenant.contracts:
filter_rels = [
{"filterRef": f"/filters/{flt.name}"}
for flt in contract.filters
]
contracts.append(
{
"name": contract.name,
"scope": contract.scope,
"filterRelationships": filter_rels,
}
)
# Filters — deduplicated; entries use CONTRACT §7 field names
# (ipProtocol / dFromPort / dToPort / etherType)
seen_filters: set[str] = set()
filters: list = []
for contract in tenant.contracts:
for flt in contract.filters:
if flt.name in seen_filters:
continue
seen_filters.add(flt.name)
entries = [
{
"name": f"{e.proto}-{e.from_port}",
"ipProtocol": e.proto,
"dFromPort": e.from_port,
"dToPort": e.to_port,
"etherType": e.ether_type,
}
for e in flt.entries
]
filters.append({"name": flt.name, "entries": entries})
# External EPGs from L3Out ext_epgs — vrfRef / l3outRef match APIC names
external_epgs: list = []
for l3out in tenant.l3outs:
for ext_epg in l3out.ext_epgs:
external_epgs.append(
{
"name": ext_epg.name,
"vrfRef": f"/vrfs/{l3out.vrf}",
"l3outRef": f"/l3outs/{l3out.name}",
"type": "on-premise",
"subnets": [{"ip": s} for s in ext_epg.subnets],
}
)
# Schema-level site associations (template ↔ site mappings)
schema_sites = [
{"templateName": tmpl_name, "siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
template = {
"name": tmpl_name,
"templateType": tmpl_type,
# PR-13: `custom_mso_schema_service_graph.py`'s "service graph
# already exists, add a node to it" branch (invoked the SECOND
# time the module PATCHes the same service-graph name — e.g.
# once per site in a multi-site service-graph bind) reads
# `schema_obj.get('templates')[template_idx]['tenantId']` with
# a bare subscript to resolve the L4-L7 device's owning tenant.
# Same deterministic id `build_ndo_model`'s tenants[] list
# already assigns this tenant (`_hex_id(f"tenant-{name}")`) —
# recomputed here rather than threaded through because it's
# derived purely from the tenant name.
"tenantId": _hex_id(f"tenant-{tenant.name}"),
"vrfs": vrfs,
"bds": bds,
"anps": anps,
"contracts": contracts,
"filters": filters,
"externalEpgs": external_epgs,
}
# PR-12: backfill self-referencing anpRef/epgRef on every template
# anp/epg (real NDO carries these — see aci_sim/ndo/patch.py
# normalize_template's docstring), then mirror each template ANP/EPG
# into every site already associated with this template, matching
# real NDO 4.x's auto-population of sites[].anps[].epgs[] the moment
# a template with sites attached gains an ANP/EPG — otherwise a
# topology.yaml tenant that ships with ANPs/EPGs already configured
# would boot into the same `set_site_anp()`-returns-None crash this
# PR fixes for the PATCH-built case.
normalize_template(template, schema_id)
for site in schema_sites:
if site.get("templateName") != tmpl_name:
continue
# PR-15: mirror each template BD into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring below (and `_mirror_template_bd_to_sites` in
# patch.py, which does the equivalent for a runtime PATCH `add`):
# real NDO 4.x auto-creates the site BDDelta shadow the moment a
# template BD is added to a template with sites attached, and
# `mso_schema_site_bd.py` always PATCHes that shadow with `op:
# replace` (never `add`) — a topology.yaml tenant that boots with
# BDs already configured must start with the shadow already
# present, or the FIRST site-BD PATCH 400s with "'bd-X' not found
# at '/sites/{siteId}-{t}/bds/bd-X'" (confirmed on a real hardware
# aci-py create_tenant/create_bd run against a freshly-booted sim).
site.setdefault("bds", [])
for bd in template["bds"]:
bd_ref = "/schemas/{}/templates/{}/bds/{}".format(schema_id, tmpl_name, bd["name"])
site["bds"].append(_new_site_bd(bd_ref))
site.setdefault("anps", [])
for anp in template["anps"]:
site["anps"].append(
{"anpRef": anp["anpRef"], "epgs": [_new_site_epg(epg["epgRef"]) for epg in anp["epgs"]]}
)
# PR-13: mirror each template contract into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring above, for the site-local `contracts[]`
# array a raw mso_rest-driven PATCH (e.g. the mso-model role's
# per-fabric service-graph redirect bind) addresses by bare
# contract name (`/sites/{siteId}-{t}/contracts/{c}/
# serviceGraphRelationship`).
site.setdefault("contracts", [])
for contract in template["contracts"]:
contract_ref = "/schemas/{}/templates/{}/contracts/{}".format(schema_id, tmpl_name, contract["name"])
site["contracts"].append({"contractRef": contract_ref})
normalize_site(site)
schema_name = f"{tenant.name}-schema"
schemas.append(
{
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
# Summary shape: only template names (no inner lists)
"templates": [{"name": tmpl_name}],
}
)
schema_details[schema_id] = {
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
"templates": [template],
"sites": schema_sites,
}
policy_states[schema_id] = [{"status": "synced", "drift": False}]
# ------------------------------------------------------------------
# Template summaries
# ------------------------------------------------------------------
# One tenantPolicy template (carries DHCP policies), plus one summary
# entry per schema template so ndo_deployment_status / stretch-analysis
# can enumerate them.
#
# PR-13: `MSOTemplate.__init__`'s name-based lookup
# (`ansible-mso`'s `plugins/module_utils/template.py`) filters
# `templates/summaries` entries by `templateName`+`templateType` (and
# tolerates absent `schemaName`/`schemaId` kwargs — `query_objs()` skips
# `None`-valued filter kwargs). Every summary entry must therefore carry
# a `templateName` field, not just `templateId`/`templateType`, or a
# by-name lookup (`ndo_template`/`ndo_dhcp_relay_policy`'s `template:
# "TenantPol-MS-TN1"` param) never matches anything.
template_summaries: list = [
{
"templateId": TENANT_POLICY_TEMPLATE_ID,
"templateName": "tenantpolicy-default",
"templateType": "tenantPolicy",
}
]
for tenant in topo.tenants:
s_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
template_summaries.append(
{
"templateId": f"{s_id}-{tenant.name}",
"templateName": f"{tenant.name}-template",
"templateType": tmpl_type,
}
)
# ------------------------------------------------------------------
# Tenant policy template detail (DHCP relay + option policies)
# ------------------------------------------------------------------
# PR-13: keyed by templateId, one entry per `GET /templates/{id}` a
# caller might ask for. Every entry mirrors real NDO's generic template
# envelope shape: `{templateId, displayName, templateType,
# tenantPolicyTemplate:{template:{...}, sites:[{siteId}]}}` — the
# `sites` array (not just the inner `template` dict) is required because
# `ndo_template`'s "template already exists" PATCH branch
# (`append_site_config_to_ops`) reads
# `config.get(template_type_container, {}).get("sites", [])` directly.
tenant_policy_templates: dict = {
TENANT_POLICY_TEMPLATE_ID: {
"templateId": TENANT_POLICY_TEMPLATE_ID,
"displayName": "tenantpolicy-default",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {
"dhcpRelayPolicies": [
{"uuid": DHCP_RELAY_UUID, "name": "dhcp-relay-1"},
],
"dhcpOptionPolicies": [
{"uuid": DHCP_OPTION_UUID, "name": "dhcp-option-1"},
],
},
"sites": [],
},
}
}
# ------------------------------------------------------------------
# Fabric connectivity
# ------------------------------------------------------------------
fabric_connectivity: dict = {
"sites": [
{
"id": site.id,
"siteId": site.id,
"status": "up",
"connectivityStatus": "up",
}
for site in topo.sites
]
}
# ------------------------------------------------------------------
# Audit records (fixed timestamps — no Date.now() / datetime.now())
# ------------------------------------------------------------------
audit_records: list = [
{
"id": "audit-001",
"timestamp": "2026-06-01T10:00:00Z",
"user": "admin",
"action": "create",
"description": "Schema Corp-schema created",
"details": "Initial schema creation via NDO",
},
{
"id": "audit-002",
"timestamp": "2026-06-01T10:05:00Z",
"user": "admin",
"action": "deploy",
"description": "Template Corp-template deployed to SiteA, SiteB",
"details": "Full deployment: 1 VRF, 2 BDs, 2 EPGs, 1 contract",
},
{
"id": "audit-003",
"timestamp": "2026-06-02T09:00:00Z",
"user": "operator",
"action": "update",
"description": "BD app-bd updated",
"details": "Changed ARP flood setting to enabled",
},
]
# ------------------------------------------------------------------
# ND local/remote users — PR-11
# ------------------------------------------------------------------
# `cisco.mso.mso_tenant`'s state=present flow always resolves the tenant's
# `users` list (defaulted to ["admin"] when unset) via `lookup_users()`,
# which on ND platforms queries `/nexus/infra/api/aaa/v4/localusers` +
# `/remoteusers` first, falling back to the legacy `/api/config/class/*`
# form when both come back empty (see ansible-mso module_utils/mso.py).
# The sim only serves the legacy `/api/config/class/remoteusers` route
# (see ndo/app.py), so seed a single "admin" local user in ND's
# `{"items":[{"spec":{...}}]}` aggregate shape — `get_user_from_list_of_
# users()` reads `item["spec"]["loginID"]` and needs `id`/`userID` on
# that same spec dict to build the `userAssociations` id list.
local_users: dict = {
"items": [
{
"spec": {
"loginID": "admin",
"loginDomain": None,
"id": "admin-local-0000000000000001",
"userID": "admin-local-0000000000000001",
}
}
]
}
# Remote (AAA/TACACS/LDAP) users start empty: `nd_request()` returns the
# raw JSON body untouched (see module_utils/mso.py `nd_request()`), and
# `get_user_from_list_of_users()` only iterates `body["items"]` when body
# is a dict — so any 200 response with an `"items"` key (even empty)
# short-circuits the "ND Error: Unknown error" 404 failure the real
# playbook run hit. No remote users are configured in the tenants under
# test, only the local "admin" ships above.
remote_users: dict = {"items": []}
return NdoState(
sites=sites,
tenants=tenants,
schemas=schemas,
schema_details=schema_details,
template_summaries=template_summaries,
tenant_policy_templates=tenant_policy_templates,
fabric_connectivity=fabric_connectivity,
policy_states=policy_states,
audit_records=audit_records,
local_users=local_users,
remote_users=remote_users,
)
+785
View File
@@ -0,0 +1,785 @@
"""Minimal JSON-Patch (RFC 6902) applier for the NDO schema PATCH surface — PR-11.
`cisco.mso`'s schema-object modules (mso_schema_template, mso_schema_template_bd,
mso_schema_template_anp, mso_schema_template_anp_epg, mso_schema_site, ...) all
mutate a schema the same way: look the schema up by displayName/id, then send
`PATCH /mso/api/v1/schemas/{id}` with a JSON body that is a *list* of ops:
[{"op": "add", "path": "/templates/LAB1/bds/-", "value": {...}}]
[{"op": "replace", "path": "/templates/LAB1/anps/0/epgs/2", "value": {...}}]
[{"op": "remove", "path": "/templates/LAB1/bds/bd-App1_LAB1"}]
Two path shapes appear in ansible-mso source:
- numeric array index / "-" (append) standard RFC 6902.
- a *named* segment used as a dict-list lookup key ("bds/bd-App1_LAB1",
"anps/AP1"): the module resolves those to a numeric index in Python and
sends the resolved path in the common case (see mso_schema_template_bd.py
`bd_path = "/templates/{0}/bds/{1}".format(template, bd)` used directly in
ops when `mso.existing` was truthy), but every module also computes
`template` as a *name*, not an index "/templates/LAB1/..." so the verb
is really "the templates list, resolved by matching name field", not
"index 0". We replicate that: any path segment that isn't a valid array
index and isn't "-" is resolved by scanning the current list for a dict
whose `name` (or `displayName`) attribute equals that segment.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
#: Every collection key a schema template can carry. Real NDO always serves
#: these back as (at minimum) empty lists even when a caller's create/add
#: payload omitted them — e.g. `mso_schema_template.py`'s "schema does not
#: exist yet" POST payload is only `{name, displayName, tenantId}`, no
#: `vrfs`/`bds`/... . `MSOSchema.set_template_vrf()` (ansible-mso
#: module_utils/schema.py) then does
#: `enumerate(self.schema_objects["template"].details.get("vrfs"))`
#: unconditionally — a missing key there is `None`, not `[]`, and blows up
#: with "'NoneType' object is not iterable" (PR-11, observed on a real
#: ACI-hardware create_tenant → create VRF run). Normalize on every
#: template create so every reader sees a real list.
TEMPLATE_COLLECTION_KEYS: tuple[str, ...] = (
"vrfs",
"bds",
"anps",
"contracts",
"filters",
"externalEpgs",
# PR-11: mso_schema_template_l3out.py / _service_graph.py subscript
# these directly (`schema_obj.get("templates")[idx]["intersiteL3outs"]`,
# no `.get()` fallback) — a missing key is a hard KeyError, confirmed on
# a real hardware create_tenant run's "Create L3Out" task.
"intersiteL3outs",
"serviceGraphs",
)
#: Child-object collection defaults, one level down from a template's own
#: arrays. Every cisco.mso "add a child object" module writes a payload with
#: only the fields *it* cares about (e.g. mso_schema_template_external_epg.py
#: never sets `subnets`), then a *sibling* module
#: (mso_schema_template_external_epg_subnet.py) reads
#: `externalEpgs[idx]["subnets"]` with a bare subscript — a real-hardware run
#: hit `KeyError: 'subnets'` on exactly this path. Real NDO must default
#: these server-side; replicate that here so every object born via PATCH
#: carries the collection keys its sibling modules expect.
#: Maps: template-array-key -> {default-key: default-value}.
CHILD_OBJECT_DEFAULTS: dict[str, dict[str, Any]] = {
"bds": {"subnets": [], "dhcpLabels": []},
"anps": {"epgs": []},
"epgs": {"subnets": [], "contractRelationships": [], "staticPorts": [], "staticLeafs": [], "domains": []},
"externalEpgs": {"subnets": [], "contractRelationships": [], "selectors": []},
"contracts": {"filterRelationships": []},
"filters": {"entries": []},
"intersiteL3outs": {},
"serviceGraphs": {},
"vrfs": {"rpConfigs": []},
}
class PatchError(Exception):
"""Raised when a JSON-Patch op cannot be applied to the stored document."""
def _normalize_object(obj: dict, array_key: str, schema_id: str, template_name: str, anp_name: str = "") -> None:
"""Backfill *obj*'s missing collection-default keys for its container
array (e.g. array_key="bds" -> obj gets subnets/dhcpLabels defaults),
then recurse into any nested arrays this object type carries (anps.epgs).
PR-12: template-level `anps[]`/`epgs[]` entries also get a
self-referencing `anpRef`/`epgRef` string field backfilled real NDO
stores one on every template ANP/EPG object itself (confirmed by
`ansible-mso`'s `mso_schema_template_anp.py`/`_anp_epg.py` explicitly
stripping it client-side before an idempotency comparison: `if "anpRef"
in mso.previous: del mso.previous["anpRef"]` dead code unless the
server actually sends one back). `MSOSchema.set_site_anp()`/
`set_site_anp_epg()` (`module_utils/schema.py`) key their site-local
lookup off exactly this field (`template_anp.details.get("anpRef")`)
without it those lookups compare against `None` and never match,
crashing `mso_schema_site_anp_epg_staticport.py` with `'NoneType'
object has no attribute 'details'` (confirmed on a real MS-TN1
bind_epg_to_static_port run on ACI hardware before this fix).
PR-13: `epgs`/`externalEpgs` entries also get a stable `uuid` field.
Real NDO assigns one to every schema-template EPG/external-EPG; the
NDO-plane `cisco.mso.ndo_dhcp_relay_policy` module reads it as
`schema_objects["template_anp_epg"].details.get("uuid")` (via
`MSOSchema.set_template_anp_epg()`) to build a DHCP relay policy
provider's `epgRef` — without it the sim always hands back `None`,
and the relay-policy-add PATCH round-trips a provider that can never
resolve back to a real EPG (confirmed against `ansible-mso`'s
`plugins/modules/ndo_dhcp_relay_policy.py::get_providers_payload()`).
"""
for key, default in CHILD_OBJECT_DEFAULTS.get(array_key, {}).items():
if obj.get(key) is None:
obj[key] = [] if isinstance(default, list) else default
if array_key == "anps":
anp_name = obj.get("name", anp_name)
if not obj.get("anpRef"):
obj["anpRef"] = "/schemas/{}/templates/{}/anps/{}".format(schema_id, template_name, anp_name)
for epg in obj.get("epgs", []):
if isinstance(epg, dict):
_normalize_object(epg, "epgs", schema_id, template_name, anp_name)
elif array_key == "epgs":
epg_name = obj.get("name", "")
if not obj.get("epgRef"):
obj["epgRef"] = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(
schema_id, template_name, anp_name, epg_name
)
if not obj.get("uuid"):
seed = "epg-uuid-{}-{}-{}-{}".format(schema_id, template_name, anp_name, epg_name)
obj["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
elif array_key == "externalEpgs":
if not obj.get("uuid"):
ext_epg_name = obj.get("name", "")
seed = "extepg-uuid-{}-{}-{}".format(schema_id, template_name, ext_epg_name)
obj["uuid"] = hashlib.sha256(seed.encode()).hexdigest()[:32]
def normalize_template(template: dict, schema_id: str = "") -> dict:
"""Backfill missing collection keys + templateID on a template dict, and
recursively normalize every child object already present in its arrays.
Mutates and returns *template* in place. Safe to call repeatedly
(idempotent only fills keys that are absent or None). *schema_id* is
used to build the self-referencing anpRef/epgRef strings PR-12 backfills
on every anps[]/epgs[] entry see _normalize_object's docstring.
"""
template_name = template.get("name", "")
for key in TEMPLATE_COLLECTION_KEYS:
if template.get(key) is None:
template[key] = []
for obj in template[key]:
if isinstance(obj, dict):
_normalize_object(obj, key, schema_id, template_name)
# `MSOSchema.set_template()` reads `match.details.get("templateID")` —
# a capital-ID sibling of the lowercase `tenantId`/`id` keys the create
# payload sends. Real NDO assigns this server-side; derive a stable one
# from (schema-scoped) template name so repeated GETs are consistent.
if not template.get("templateID"):
seed = f"template-{template.get('name', '')}"
template["templateID"] = hashlib.sha256(seed.encode()).hexdigest()[:24]
return template
#: Site-array (top-level schema `sites[]`) collection defaults — a distinct
#: namespace from TEMPLATE_COLLECTION_KEYS/CHILD_OBJECT_DEFAULTS because
#: `mso_schema_site_bd.py`'s add payload is only `{bdRef, hostBasedRouting}`
#: (no `subnets`), yet `mso_schema_site_bd_subnet.py` reads
#: `site_bd.details.get("subnets")` and iterates it — a missing key there is
#: `None`, not `[]` (PR-11, same "iterate a None" family of bug as the
#: template-level fix above).
#:
#: The `epgs` entry is the canonical *full* site-local EPG collection shape.
#: A site-local EPG is created by `mso_schema_site_anp_epg.py` (or the
#: staticport/domain modules' own inline fallback) with a payload of only
#: `{epgRef}` — real NDO backfills every child collection array server-side,
#: and every sibling `mso_schema_site_anp_epg_*` module then reads its own
#: array with a **bare subscript** (not `.get()`), so a missing key is a hard
#: `KeyError`, not a 4xx:
#: - `staticPorts` — `mso_schema_site_anp_epg_staticport.py`
#: (`set_existing_static_ports`: `...["epgs"][idx].get("staticPorts")` /
#: bulk module iterates it).
#: - `staticLeafs` — `mso_schema_site_anp_epg_staticleaf.py`
#: (`...["epgs"][epg_idx]["staticLeafs"]`).
#: - `domainAssociations` — `mso_schema_site_anp_epg_domain.py`
#: (`domains = [dom.get("dn") for dom in ...["epgs"][epg_idx]
#: ["domainAssociations"]]`) — a real MS-TN1 hardware
#: `bind_epg_to_physical_domain`/`_vmm_domain` run hit
#: `KeyError: 'domainAssociations'` on exactly this line once PR-12
#: started auto-creating the site-EPG (pre-PR-12 no site-EPG existed at
#: all, so the module took a non-crashing branch).
#: - `subnets` — `mso_schema_site_anp_epg_subnet.py`.
SITE_OBJECT_DEFAULTS: dict[str, dict[str, Any]] = {
"bds": {"subnets": []},
"anps": {"epgs": []},
"epgs": {"subnets": [], "staticPorts": [], "staticLeafs": [], "domainAssociations": []},
}
#: Top-level collection keys on a schema `sites[]` ENTRY ITSELF (a sibling
#: of `bds`/`anps`, not a nested child-object default like
#: SITE_OBJECT_DEFAULTS above) — PR-13. `custom_mso_schema_service_graph.py`
#: (the "Create service graph" task's module, `mso-model/library/`) reads
#: `schema_obj.get('sites')[site_idx]['serviceGraphs']` with a **bare
#: subscript** to find/append the site-local service-graph-to-device
#: binding; a schema born via `POST /schemas` or PATCHed via
#: `mso_schema_site.py`'s "add site" op never carries this key, so a real
#: hardware MS-TN2 `create_tenant` pass-2 run (`-e automate_contract_graph=true`)
#: hit `KeyError: 'serviceGraphs'` on exactly this line — the same class of
#: bug as the template-level `serviceGraphs`/`intersiteL3outs` gap PR-11
#: fixed and the site-local `domainAssociations` gap PR-12 fixed, just one
#: level up (the site entry itself, not one of its child arrays).
SITE_TOP_LEVEL_DEFAULTS: dict[str, Any] = {
"bds": [],
"anps": [],
"serviceGraphs": [],
"contracts": [],
}
def _new_site_epg(epg_ref: str) -> dict:
"""Build a full-shaped site-local EPG dict from its canonical `epgRef`
string every child-collection array real NDO backfills server-side,
so every sibling `mso_schema_site_anp_epg_*` module's bare subscript
(`["domainAssociations"]`/`["staticLeafs"]`/...) finds a real list.
Single source of truth: keys come from SITE_OBJECT_DEFAULTS["epgs"]."""
epg = {"epgRef": epg_ref}
for dkey, default in SITE_OBJECT_DEFAULTS["epgs"].items():
epg[dkey] = [] if isinstance(default, list) else default
return epg
def normalize_site(site: dict) -> dict:
"""Backfill missing collection-default keys on a schema `sites[]` entry
and its child bds/anps.epgs objects. Mutates and returns *site*."""
# PR-13: backfill the site's OWN top-level collection keys (bds/anps/
# serviceGraphs) before descending into their per-object defaults below
# — see SITE_TOP_LEVEL_DEFAULTS docstring for the serviceGraphs
# bare-subscript crash this closes.
for key, default in SITE_TOP_LEVEL_DEFAULTS.items():
if site.get(key) is None:
site[key] = [] if isinstance(default, list) else default
for key, defaults in SITE_OBJECT_DEFAULTS.items():
if key not in ("bds", "anps"):
continue
for obj in site.get(key, []):
if not isinstance(obj, dict):
continue
for dkey, default in defaults.items():
if obj.get(dkey) is None:
obj[dkey] = [] if isinstance(default, list) else default
if key == "anps":
for epg in obj.get("epgs", []):
if isinstance(epg, dict):
for dkey, default in SITE_OBJECT_DEFAULTS["epgs"].items():
if epg.get(dkey) is None:
epg[dkey] = [] if isinstance(default, list) else default
return site
def _is_list_index(seg: str) -> bool:
return seg.isdigit()
#: `*Ref`-keyed objects — site-local bds/anps/epgs carry no `name` field of
#: their own (their whole identity is the `*Ref` string pointing back at the
#: template-level object), yet sibling modules still address them by that
#: template-level object's bare name — e.g.
#: `/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-` (mso_schema_site_bd_subnet.py
#: /custom_mso_schema_site_bd_subnet.py: `bds_path = "/sites/{0}/bds".format(
#: site_template)`, then indexes by matching `bd_ref in bds`). Match by the
#: trailing path segment of the ref string.
_REF_LOOKUP_KEYS: tuple[str, ...] = ("bdRef", "vrfRef", "l3outRef", "anpRef", "epgRef", "contractRef")
#: The subset of `*Ref` keys that constitute a *nameless site-local shadow's*
#: whole IDENTITY — i.e. the one ref that IS the object (`bds[]` entry → its
#: `bdRef`, `anps[]` → `anpRef`, `epgs[]` → `epgRef`, `contracts[]` →
#: `contractRef`). A shadow object carries no `name`/`displayName` of its
#: own; its identity is exactly this single ref pointing back at the
#: template-level object it mirrors.
#:
#: PR-16 (corrected): the other `*Ref` keys in `_REF_LOOKUP_KEYS`
#: (`vrfRef`, `l3outRef`) are *properties* an object may carry, NOT its
#: identity — every template BD under an L3-attached VRF shares the same
#: `vrfRef` (e.g. `vrf-L3_LAB0`). Matching on those would collapse many
#: distinct BDs into one. So ref-based identity matching is restricted to
#: this map, keyed by the *list's* collection name so a `bds[]` list only
#: ever matches on `bdRef` (never a sibling's shared `vrfRef`).
_IDENTITY_REF_BY_COLLECTION: dict[str, str] = {
"bds": "bdRef",
"anps": "anpRef",
"epgs": "epgRef",
"contracts": "contractRef",
}
_IDENTITY_REF_KEYS: frozenset[str] = frozenset(_IDENTITY_REF_BY_COLLECTION.values())
def _bare_ref_name(ref_val: Any) -> str | None:
"""Extract the bare object name from a `*Ref` field value, regardless
of whether it's already NDO's canonical string form
(`/schemas/.../bds/bd-X`) or one of the dict forms cisco.mso's write
payloads send (`{schemaId, templateName, bdName}` see
`_REF_NAME_FIELDS`/`_stringify_refs`). Returns None if no name can be
extracted (e.g. an empty dict).
PR-16: the single bare-name extractor for a shadow object's identity
ref. Handles both ref shapes so a dict-form `bdRef` (a site-module
payload's own shape, possibly a bare `{bdName: ...}`) and a string-form
`bdRef` (the mirror's canonical shape) resolve to the same bare name —
the property that lets mirror + site-module add/replace converge on one
entry. Deliberately used ONLY on identity refs (see
`_IDENTITY_REF_BY_COLLECTION`), never on property refs like `vrfRef`."""
if isinstance(ref_val, str) and ref_val:
return ref_val.rsplit("/", 1)[-1]
if isinstance(ref_val, dict):
for name_field, _category in _REF_NAME_FIELDS.values():
name = ref_val.get(name_field)
if name:
return name
return None
def _find_by_name(items: list, seg: str) -> int | None:
"""Return the index of the dict in *items* matching path segment *seg*.
Three addressing conventions appear across cisco.mso's schema-object
modules:
- most template-level objects (bds/anps/epgs/contracts/filters/
externalEpgs/vrfs) are addressed by their own `name` (or
`displayName`) field e.g. `/templates/LAB1/bds/bd-App1_LAB1`. A
NAMED object is matched ONLY by name/displayName, never by any
`*Ref` field.
- the top-level schema `sites` array is instead addressed by a
synthetic composite key `"{siteId}-{templateName}"` that isn't a
field the object itself carries every `mso_schema_site_*` module
builds this literally: `site_template = "{0}-{1}".format(site_id,
template)` then `"/sites/{0}/bds".format(site_template)` (see
mso_schema_site_bd.py / _anp.py / _anp_epg.py). Detect that shape by
checking for `siteId`+`templateName` keys on the candidate items and
matching the composite key instead of name/displayName.
- site-local child objects (sites[idx].bds / .anps / .contracts /
.anps[].epgs) carry no `name` of their own at all only an IDENTITY
`*Ref` field (string OR dict form, PR-16) pointing at the template
object yet are still addressed by that referenced object's bare
name (confirmed on a real hardware run: `/sites/1-LAB1/bds/
bd-App1_LAB1/subnets/-`). Only for such a NAMELESS item, match by its
identity ref's resolved bare name — and only via the identity refs
(`bdRef`/`anpRef`/`epgRef`/`contractRef`), NEVER a property ref like
`vrfRef` (which many distinct BDs share, PR-16 regression fix). This
is what lets a dict-form bdRef (a site-module payload's own shape)
and a string-form bdRef (the mirror's canonical shape) resolve to the
SAME entry without ever cross-matching two distinct objects.
"""
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
if item.get("name") == seg or item.get("displayName") == seg:
return i
if "siteId" in item and "templateName" in item:
composite = f"{item.get('siteId')}-{item.get('templateName')}"
if composite == seg:
return i
# Ref-matching is ONLY for nameless shadow entries, and ONLY via an
# identity ref — a named object above already returned; a property
# ref (vrfRef/l3outRef) must never be used to establish identity.
if item.get("name") is None and item.get("displayName") is None:
for ref_key in _IDENTITY_REF_KEYS:
if ref_key in item and _bare_ref_name(item.get(ref_key)) == seg:
return i
return None
def _find_shadow_by_identity(items: list, value: Any, collection: str) -> int | None:
"""Append-path dedup for a NAMELESS site-local shadow being added to the
*collection*-named list (`bds`/`anps`/`epgs`/`contracts`).
A JSON-Patch `add` whose path ends in the literal `"-"` (append)
bypasses `_find_by_name` entirely (there's no path segment to resolve),
so a site-module `add` for a BD/ANP/contract that the template-add
mirror (`_mirror_template_bd_to_sites` et al.) already created as a
site-local shadow would otherwise append a SECOND entry instead of
updating the existing one confirmed against a reference NDO
schema (`MS-TN1-LAB0`) where `sites[0].bds` carried two entries for the
same BD after `create_tenant` (the empty full-path mirror) then
`create_bd`'s site-module subnet PATCH (a dict-bdRef entry carrying the
subnet).
Matches STRICTLY by the value's own identity ref for THIS collection
(`bds``bdRef`, etc.), resolved to a bare name, against existing entries'
same identity ref. Never matches:
- a NAMED value (a template-level object add those append normally),
- via a non-identity/property ref (`vrfRef`/`l3outRef` many distinct
BDs share one; the PR-16 regression that collapsed every template's
BDs to a single entry),
- across different bd_names.
"""
if not isinstance(value, dict):
return None
# A value carrying its own name is a template-level object, not a
# nameless shadow — it appends normally (distinct objects stay distinct).
if value.get("name") is not None or value.get("displayName") is not None:
return None
ref_key = _IDENTITY_REF_BY_COLLECTION.get(collection)
if ref_key is None or ref_key not in value:
return None
seg = _bare_ref_name(value.get(ref_key))
if seg is None:
return None
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
if item.get("name") is not None or item.get("displayName") is not None:
continue
if ref_key in item and _bare_ref_name(item.get(ref_key)) == seg:
return i
return None
def _resolve_container(doc: dict, tokens: list[str]) -> tuple[Any, str]:
"""Walk *tokens[:-1]* from *doc*, returning (container, last_token).
The container is either a dict (last_token is a key) or a list
(last_token is "-", a numeric index, or a name to resolve).
"""
node: Any = doc
middle = tokens[:-1]
for i, tok in enumerate(middle):
if isinstance(node, list):
if _is_list_index(tok):
idx = int(tok)
else:
idx = _find_by_name(node, tok)
if idx is None:
raise PatchError(f"path segment '{tok}' not found in list")
if idx >= len(node):
raise PatchError(f"index {idx} out of range")
node = node[idx]
elif isinstance(node, dict):
if tok not in node:
# Auto-vivify a missing intermediate container. Every
# cisco.mso schema-object module PATCHes into a collection
# the schema-create/normalize_* pass already seeded as a
# list (vrfs/bds/anps/.../subnets/epgs/...), so this should
# not normally trigger — but if it does, look at the NEXT
# token to pick the right shape instead of guessing a dict:
# a numeric index or "-" (append) means the caller expects
# a list; anything else means a nested dict key.
next_tok = tokens[i + 1] if i + 1 < len(tokens) else None
if next_tok is not None and (next_tok == "-" or _is_list_index(next_tok)):
node[tok] = []
else:
node[tok] = {}
node = node[tok]
else:
raise PatchError(f"cannot descend into non-container at '{tok}'")
return node, tokens[-1]
#: `*Ref` field name -> (name-field-in-payload, url-category-segment).
#: Real NDO always serves these back as canonical STRING refs
#: (`/schemas/{schemaId}/templates/{templateName}/{category}/{name}`) even
#: though several cisco.mso write payloads send a *dict* form instead — see
#: `mso_schema_site_bd.py`'s add payload (`bdRef=dict(schemaId=...,
#: templateName=..., bdName=...)`) versus its OWN query-side comparison
#: (`mso.bd_ref(...)` builds and compares against the string form, and
#: `mso.dict_from_ref()` exists specifically to convert the server's string
#: back to a dict client-side). A real hardware run proved the server must
#: store the string form: a sibling module
#: (`custom_mso_schema_site_bd_subnet.py`) reads `[v.get('bdRef') for v in
#: ...]` and does `bd_ref_string in bds` / `', '.join(bds)` — if the sim
#: stored the dict form verbatim, that `join()` crashes with "sequence item
#: 0: expected str instance, dict found" (confirmed on a real ACI-hardware "Add
#: site-local subnet to BD" run).
_REF_NAME_FIELDS: dict[str, tuple[str, str]] = {
"bdRef": ("bdName", "bds"),
"vrfRef": ("vrfName", "vrfs"),
"l3outRef": ("l3outName", "l3outs"),
"filterRef": ("filterName", "filters"),
"contractRef": ("contractName", "contracts"),
"anpRef": ("anpName", "anps"),
"serviceGraphRef": ("serviceGraphName", "serviceGraphs"),
}
#: `epgRef` is the one `*Ref` field whose canonical string form nests TWO
#: name segments under the template, not one — confirmed against
#: `ansible-mso`'s `plugins/module_utils/mso.py` `epg_ref()`:
#: `"/schemas/{schema_id}/templates/{template}/anps/{anp}/epgs/{epg}"`
#: (`anp_ref()` alone is the single-segment `.../anps/{anp}` form used by
#: `_REF_NAME_FIELDS["anpRef"]` above). `mso_schema_site_anp_epg_staticport.py`
#: sends the dict form `epgRef=dict(schemaId=..., templateName=..., anpName=...,
#: epgName=...)` when auto-creating a site-epg (PR-12) — handled separately
#: from the generic single-category table since it needs both name fields.
_EPG_REF_NAME_FIELDS: tuple[str, str] = ("anpName", "epgName")
def _stringify_refs(value: Any) -> Any:
"""Recursively convert any `*Ref` dict field to NDO's canonical string
ref form, leaving already-string refs and everything else untouched."""
if isinstance(value, dict):
for key, (name_field, category) in _REF_NAME_FIELDS.items():
ref_val = value.get(key)
if isinstance(ref_val, dict) and "schemaId" in ref_val and "templateName" in ref_val:
name = ref_val.get(name_field, ref_val.get("name", ""))
value[key] = "/schemas/{}/templates/{}/{}/{}".format(
ref_val["schemaId"], ref_val["templateName"], category, name
)
epg_ref_val = value.get("epgRef")
if isinstance(epg_ref_val, dict) and "schemaId" in epg_ref_val and "templateName" in epg_ref_val:
anp_name = epg_ref_val.get(_EPG_REF_NAME_FIELDS[0], "")
epg_name = epg_ref_val.get(_EPG_REF_NAME_FIELDS[1], epg_ref_val.get("name", ""))
value["epgRef"] = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(
epg_ref_val["schemaId"], epg_ref_val["templateName"], anp_name, epg_name
)
# `serviceNodeRef` is the other TWO-segment ref (serviceGraphs/{sg}/serviceNodes/{node})
# — `custom_mso_schema_service_graph.py` writes the dict form when it creates the graph
# node; the STOCK `mso_schema_template_contract_service_graph`'s idempotency re-read then
# does a string op on it and dies with "expected string ... got 'dict'" (F3). Real NDO
# returns the resolved string ref, so resolve it here like serviceGraphRef/epgRef.
sgn_ref_val = value.get("serviceNodeRef")
if isinstance(sgn_ref_val, dict) and "schemaId" in sgn_ref_val and "templateName" in sgn_ref_val:
value["serviceNodeRef"] = "/schemas/{}/templates/{}/serviceGraphs/{}/serviceNodes/{}".format(
sgn_ref_val["schemaId"], sgn_ref_val["templateName"],
sgn_ref_val.get("serviceGraphName", ""),
sgn_ref_val.get("serviceNodeName", sgn_ref_val.get("name", "")),
)
for v in value.values():
_stringify_refs(v)
elif isinstance(value, list):
for item in value:
_stringify_refs(item)
return value
#: Matches `add /templates/{template}/bds/-` — a brand-new template-level BD
#: (`mso_schema_template_bd.py`'s "BD does not exist" branch). PR-15: mirrored
#: into every associated site's `bds[]` the same way template ANPs/EPGs/
#: contracts are mirrored (PR-11/PR-12/PR-13), because
#: `mso_schema_site_bd.py` always PATCHes its site-local shadow with
#: `op: replace` (never `add`) — real NDO 4.x auto-creates the site BDDelta
#: entry as soon as the template BD is added to a template that already has
#: sites attached, so by the time the site-BD module runs the shadow already
#: exists and a fresh `add` would 409 with "Multiple BDDelta entries" (see
#: aci-py's `shims/mso_mso_bd_vrf.py::mso_schema_site_bd` docstring, which
#: documents this exact real-hardware behavior). Without this mirror, the
#: `replace` 400s with "'bd-X' not found at '/sites/{siteId}-{tpl}/bds/bd-X'"
#: — confirmed on a real-hardware aci-py `create_tenant`/`create_bd` run
#: (MS-TN2), the same class of gap PR-11/12/13 closed for anps/epgs/contracts.
_TEMPLATE_BD_ADD_RE = re.compile(r"^templates/([^/]+)/bds/-$")
#: Matches `add /templates/{template}/anps/-` — a brand-new template-level
#: ANP (`mso_schema_template_anp.py`'s "ANP does not exist" branch).
_TEMPLATE_ANP_ADD_RE = re.compile(r"^templates/([^/]+)/anps/-$")
#: Matches `add /templates/{template}/anps/{anp}/epgs/-` — a brand-new
#: template-level EPG under an existing ANP (`mso_schema_template_anp_epg.py`).
_TEMPLATE_EPG_ADD_RE = re.compile(r"^templates/([^/]+)/anps/([^/]+)/epgs/-$")
#: Matches `add /templates/{template}/contracts/-` — a brand-new
#: template-level contract (`mso_schema_template_contract_filter.py`'s
#: "contract does not exist" branch). PR-13: mirrored into every associated
#: site's `contracts[]` the same way template ANPs are mirrored, because
#: `mso_rest`-driven raw-PATCH tasks (e.g. the mso-model role's
#: "Atomic PATCH — bind service-graph redirect on ALL fabrics" task) address
#: a site-local contract by bare name at
#: `/sites/{siteId}-{template}/contracts/{contract}/serviceGraphRelationship`
#: — a real hardware MS-TN2 `create_tenant` pass-2 run (with
#: `automate_contract_graph=true automate_site_redirect=true`) hit `"path
#: segment 'con-Firewall_LAB0' not found in list"` on exactly this path
#: because the site never carried a mirrored `contracts[]` array at all.
_TEMPLATE_CONTRACT_ADD_RE = re.compile(r"^templates/([^/]+)/contracts/-$")
def _new_site_bd(bd_ref: str) -> dict:
"""Build a full-shaped site-local BD dict from its canonical `bdRef`
string mirrors `_new_site_epg` for BDs. `hostBasedRouting` defaults to
`False` (the value `mso_schema_site_bd.py`'s own "BD does not exist yet"
`add` payload would send), so a subsequent `replace` (the module's normal
path once the shadow exists) reads a real bool, not a missing key."""
bd = {"bdRef": bd_ref, "hostBasedRouting": False}
for dkey, default in SITE_OBJECT_DEFAULTS["bds"].items():
bd[dkey] = [] if isinstance(default, list) else default
return bd
def _mirror_template_bd_to_sites(doc: dict, template_name: str, bd_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local BD shadow entry in every site associated
with *template_name*, mirroring what real NDO 4.x does automatically
when a template-level BD is added to a template that already has sites
attached see `_TEMPLATE_BD_ADD_RE`'s comment for the full rationale
and the aci-py shim docstring it cites. Idempotent: skips sites that
already carry this bdRef."""
bd_ref = "/schemas/{}/templates/{}/bds/{}".format(schema_id or doc.get("id", ""), template_name, bd_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("bds", [])
if _find_by_name(site["bds"], bd_name) is not None:
continue
site["bds"].append(_new_site_bd(bd_ref))
def _mirror_template_anp_to_sites(doc: dict, template_name: str, anp_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local ANP entry in every site associated with
*template_name*, mirroring what real NDO 4.x does automatically when a
template-level ANP is added to a template that already has sites
attached (`mso_schema_site.py` having already run in create_tenant's
flow PR-11). Idempotent: skips sites that already carry this anpRef.
Without this, `mso_schema_site_anp_epg_staticport.py`'s own fallback
"create site anp/epg if missing" branch is what fires instead and that
fallback crashes with `'NoneType' object has no attribute 'details'` on
a subsequent step (see module docstring / CONTRACT.md §7a), matching a
real hardware "coverage misses this on 4.x and above" code comment in the
module itself: on real hardware this mirroring already happened, so the
fallback path is never exercised.
"""
anp_ref = "/schemas/{}/templates/{}/anps/{}".format(schema_id or doc.get("id", ""), template_name, anp_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("anps", [])
if _find_by_name(site["anps"], anp_name) is not None:
continue
site["anps"].append({"anpRef": anp_ref, "epgs": []})
def _mirror_template_epg_to_sites(
doc: dict, template_name: str, anp_name: str, epg_name: str, schema_id: str | None
) -> None:
"""Auto-create a site-local EPG entry (under its mirrored site-anp) in
every site associated with *template_name*, mirroring real NDO 4.x's
automatic site-epg creation on template EPG add. See
`_mirror_template_anp_to_sites` for the full rationale. Idempotent."""
sid = schema_id or doc.get("id", "")
epg_ref = "/schemas/{}/templates/{}/anps/{}/epgs/{}".format(sid, template_name, anp_name, epg_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("anps", [])
anp_idx = _find_by_name(site["anps"], anp_name)
if anp_idx is None:
# Template-level ANP add should have mirrored the site-anp
# already; auto-vivify defensively so EPG add never crashes on
# an out-of-order/partial patch batch.
anp_ref = "/schemas/{}/templates/{}/anps/{}".format(sid, template_name, anp_name)
site["anps"].append({"anpRef": anp_ref, "epgs": []})
anp_idx = len(site["anps"]) - 1
site_anp = site["anps"][anp_idx]
site_anp.setdefault("epgs", [])
if _find_by_name(site_anp["epgs"], epg_name) is not None:
continue
site_anp["epgs"].append(_new_site_epg(epg_ref))
def _mirror_template_contract_to_sites(doc: dict, template_name: str, contract_name: str, schema_id: str | None) -> None:
"""Auto-create a site-local contract entry (`{contractRef}`) in every
site associated with *template_name*, mirroring real NDO 4.x's
automatic site-local mirroring for a template-level contract add the
same family of behavior PR-12 implemented for template ANPs/EPGs (see
`_mirror_template_anp_to_sites`'s docstring for the general rationale).
Without this, a raw-PATCH task addressing a site-local contract by its
bare name (e.g. `/sites/{siteId}-{template}/contracts/{contract}/
serviceGraphRelationship`, the mso-model role's redirect-policy bind)
hits `_find_by_name`'s "not found in list" `PatchError` — confirmed on
a real hardware MS-TN2 `create_tenant` pass-2 run (PR-13).
"""
contract_ref = "/schemas/{}/templates/{}/contracts/{}".format(schema_id or doc.get("id", ""), template_name, contract_name)
for site in doc.get("sites", []):
if not isinstance(site, dict) or site.get("templateName") != template_name:
continue
site.setdefault("contracts", [])
if _find_by_name(site["contracts"], contract_name) is not None:
continue
site["contracts"].append({"contractRef": contract_ref})
def apply_json_patch(doc: dict, ops: list[dict]) -> dict:
"""Apply a list of RFC-6902-ish ops to *doc* in place and return it.
Supports op in {"add", "replace", "remove"}. Unknown ops are ignored
(forward-compatible with cisco.mso module versions we haven't seen).
"""
for entry in ops:
op = entry.get("op")
path = entry.get("path", "")
value = entry.get("value")
if op in ("add", "replace"):
value = _stringify_refs(value)
tokens = [t for t in path.split("/") if t != ""]
if not tokens:
continue
container, last = _resolve_container(doc, tokens)
if op == "add":
if isinstance(container, list):
if last == "-":
# A bare "-" (append) skips _find_by_name entirely (no
# path segment to resolve against). For a NAMELESS
# site-local shadow (`sites[].bds`/`anps`/`epgs`/
# `contracts` entries, whose whole identity is a single
# `*Ref`), this previously let a site-module `add` create
# a SECOND entry for an object the template-add mirror
# already shadowed — see _find_shadow_by_identity for the
# real-NDO duplicate-BD symptom this closes. Dedup ONLY a
# nameless shadow, ONLY via its identity ref for THIS
# collection (tokens[-2]); a named object (template-level
# BD add) always appends so distinct objects stay
# distinct (PR-16 regression fix).
collection = tokens[-2] if len(tokens) >= 2 else ""
existing_idx = _find_shadow_by_identity(container, value, collection)
if existing_idx is None:
container.append(value)
else:
container[existing_idx] = value
elif _is_list_index(last):
idx = int(last)
container.insert(idx, value)
else:
idx = _find_by_name(container, last)
if idx is None:
container.append(value)
else:
container[idx] = value
elif isinstance(container, dict):
container[last] = value
else:
raise PatchError(f"add: unsupported container type at '{path}'")
# PR-12: mirror a brand-new template-level ANP/EPG into every
# site already associated with this template — see
# _mirror_template_anp_to_sites for the real-NDO-4.x rationale.
normalized_path = "/".join(tokens)
bd_match = _TEMPLATE_BD_ADD_RE.match(normalized_path)
if bd_match and isinstance(value, dict):
bd_name = value.get("name")
if bd_name:
_mirror_template_bd_to_sites(doc, bd_match.group(1), bd_name, doc.get("id"))
anp_match = _TEMPLATE_ANP_ADD_RE.match(normalized_path)
if anp_match and isinstance(value, dict):
anp_name = value.get("name")
if anp_name:
_mirror_template_anp_to_sites(doc, anp_match.group(1), anp_name, doc.get("id"))
epg_match = _TEMPLATE_EPG_ADD_RE.match(normalized_path)
if epg_match and isinstance(value, dict):
epg_name = value.get("name")
if epg_name:
_mirror_template_epg_to_sites(doc, epg_match.group(1), epg_match.group(2), epg_name, doc.get("id"))
contract_match = _TEMPLATE_CONTRACT_ADD_RE.match(normalized_path)
if contract_match and isinstance(value, dict):
contract_name = value.get("name")
if contract_name:
_mirror_template_contract_to_sites(doc, contract_match.group(1), contract_name, doc.get("id"))
elif op == "replace":
if isinstance(container, list):
if _is_list_index(last):
idx = int(last)
else:
idx = _find_by_name(container, last)
if idx is None or idx >= len(container):
raise PatchError(f"replace: '{last}' not found at '{path}'")
container[idx] = value
elif isinstance(container, dict):
container[last] = value
else:
raise PatchError(f"replace: unsupported container type at '{path}'")
elif op == "remove":
if isinstance(container, list):
if _is_list_index(last):
idx = int(last)
else:
idx = _find_by_name(container, last)
if idx is not None and idx < len(container):
container.pop(idx)
elif isinstance(container, dict):
container.pop(last, None)
# else: unknown op — ignore rather than fail the whole batch.
return doc
View File
+251
View File
@@ -0,0 +1,251 @@
"""ACI query engine — maps APIC query patterns to MITStore lookups.
Implements three query shapes (CONTRACT §2):
1. ``run_class_query`` GET /api/class/{cls}.json
2. ``run_mo_query`` GET /api/mo/{dn}.json
3. ``run_node_scoped`` GET /api/node/class/{node_dn}/{cls}.json
All three return ``(imdata: list[dict], total: int)`` where *total* is the
full pre-pagination matched count and *imdata* is the current page slice.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.filters import FilterParseError, Predicate, parse_filter
# ---------------------------------------------------------------------------
# QueryParams
# ---------------------------------------------------------------------------
@dataclass
class QueryParams:
"""Carries all APIC query-string parameters for a single request."""
filter_expr: str | None = None
rsp_subtree: str | None = None # "children" | "full"
rsp_subtree_class: list[str] | None = None
query_target: str | None = None # "subtree" (legacy)
target_subtree_class: list[str] | None = None
page: int = 0
page_size: int = 500
def params_from_dict(raw: dict[str, str]) -> QueryParams:
"""Build a :class:`QueryParams` from raw APIC query-string key names.
APIC key names:
``query-target-filter``, ``rsp-subtree``, ``rsp-subtree-class``,
``query-target``, ``target-subtree-class``, ``page``, ``page-size``.
Class-list params are comma-split.
``page-size`` is clamped to ``[1, 5000]``; ``page`` must be ``>= 0``.
Non-numeric or out-of-range ``page``/``page-size`` raise
:class:`~aci_sim.query.filters.FilterParseError` (mapped by the REST
layer to an APIC 400 error envelope never a bare 500 or silent
truncation).
"""
def _classes(key: str) -> list[str] | None:
val = raw.get(key, "").strip()
if not val:
return None
return [c.strip() for c in val.split(",") if c.strip()]
def _int_param(key: str, default: int) -> int:
raw_val = raw.get(key)
if raw_val is None or raw_val == "":
return default
try:
return int(raw_val)
except ValueError:
raise FilterParseError(
f"invalid {key} value {raw_val!r}: must be an integer"
) from None
page = _int_param("page", 0)
if page < 0:
raise FilterParseError(f"invalid page value {page!r}: must be >= 0")
page_size = _int_param("page-size", 500)
if page_size < 1:
raise FilterParseError(f"invalid page-size value {page_size!r}: must be >= 1")
page_size = min(page_size, 5000)
return QueryParams(
filter_expr=raw.get("query-target-filter") or None,
rsp_subtree=raw.get("rsp-subtree") or None,
rsp_subtree_class=_classes("rsp-subtree-class"),
query_target=raw.get("query-target") or None,
target_subtree_class=_classes("target-subtree-class"),
page=page,
page_size=page_size,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _predicate(params: QueryParams) -> Predicate:
return parse_filter(params.filter_expr)
def _wants_subtree(params: QueryParams) -> bool:
return (
params.rsp_subtree in ("children", "full")
or params.query_target == "subtree"
)
def _subtree_classes(params: QueryParams) -> set[str] | None:
classes = params.rsp_subtree_class or params.target_subtree_class
return set(classes) if classes else None
def _nested_children(
store: MITStore, dn: str, cls_filter: set[str] | None
) -> list[dict[str, Any]]:
"""Build imdata child entries for *dn*, nested recursively by DN parentage.
Mirrors APIC ``rsp-subtree=full``: every descendant stays nested under its
real parent (grandchildren under children, not hoisted to the root). When
*cls_filter* is set, a node is emitted iff its own class is in the filter OR
it has an emitted descendant so structural containers on the path to a
matching grandchild are preserved (matching ``rsp-subtree-class`` semantics).
MO-less structural intermediates are traversed through: their emitted
descendants hoist up to this level (there is no MO to emit for them).
"""
out: list[dict[str, Any]] = []
for cdn in store.child_dns(dn):
child_mo = store.get(cdn)
nested = _nested_children(store, cdn, cls_filter)
if child_mo is None:
out.extend(nested) # structural intermediate → hoist its matches up
continue
if cls_filter is None or child_mo.class_name in cls_filter or nested:
body: dict[str, Any] = {"attributes": dict(child_mo.attrs)}
if nested:
body["children"] = nested
out.append({child_mo.class_name: body})
return out
def _attach_subtree(store: MITStore, mo: MO, params: QueryParams) -> dict[str, Any]:
"""Produce the imdata entry for *mo* with its descendants nested as children."""
cls_filter = _subtree_classes(params)
if params.rsp_subtree == "children":
# rsp-subtree=children → one level only (direct children)
children = [d.to_imdata() for d in store.children(mo.dn, classes=cls_filter)]
else:
# rsp-subtree=full → full recursive tree, NESTED (hierarchy preserved).
# Real APIC keeps grandchildren under their parent; flattening them into
# direct children of the root silently empties multi-level consumers like
# autoACI contract_map (vzBrCP→vzSubj→vzRsSubjFiltAtt).
children = _nested_children(store, mo.dn, cls_filter)
body: dict[str, Any] = {"attributes": dict(mo.attrs)}
if children:
body["children"] = children
return {mo.class_name: body}
def _paginate(items: list[Any], page: int, page_size: int) -> list[Any]:
start = page * page_size
return items[start: start + page_size]
def _build_result(
store: MITStore,
top_level: list[MO],
params: QueryParams,
) -> tuple[list[dict], int]:
"""Filter, optionally attach subtrees, then paginate.
Returns ``(imdata_page, total_matched)``. *total* is always the full
pre-pagination count (the caller converts it to a string for the wire
``totalCount`` field).
"""
pred = _predicate(params)
# Legacy subtree (query-target=subtree, used by autoACI's query_dn(subtree=True)):
# real APIC returns the matched descendants as FLAT top-level imdata elements
# (filtered by target-subtree-class; root included only if it matches). autoACI
# consumers read the target class at the TOP LEVEL of imdata and never recurse into
# `children` (topology.py ISN/node-detail, fabric_bgp_evpn, l3out_detail, ~60 sites).
#
# ``query-target=subtree`` first sets the SCOPE to root+descendants (unfiltered),
# THEN both the class filter and ``query-target-filter`` predicate are evaluated
# per scoped MO — never pre-filtering the roots with `pred`. Otherwise a filter on
# a descendant-only attribute (e.g. faultInst.severity under a topSystem root)
# would exclude the root before subtree expansion ever runs, returning empty
# imdata even though matching descendants exist (attr-missing→False on the root
# is a legitimate non-match for the root itself, but must not gate expansion).
if params.query_target == "subtree" and params.rsp_subtree is None:
cls_filter = _subtree_classes(params)
flat: list[MO] = []
for root in top_level:
for mo in [root, *store.subtree(root.dn)]:
if cls_filter is None or mo.class_name in cls_filter:
flat.append(mo)
flat = [mo for mo in flat if pred(mo)]
total = len(flat)
page_items = _paginate(flat, params.page, params.page_size)
return [mo.to_imdata() for mo in page_items], total
# Modern rsp-subtree=children|full: NESTED children under each matched object.
filtered = [mo for mo in top_level if pred(mo)]
total = len(filtered)
page_items = _paginate(filtered, params.page, params.page_size)
if params.rsp_subtree in ("children", "full"):
imdata = [_attach_subtree(store, mo, params) for mo in page_items]
else:
imdata = [mo.to_imdata() for mo in page_items]
return imdata, total
# ---------------------------------------------------------------------------
# Public query functions
# ---------------------------------------------------------------------------
def run_class_query(
store: MITStore,
cls: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Query all MOs of class *cls* in the store."""
return _build_result(store, store.by_class(cls), params)
def run_mo_query(
store: MITStore,
dn: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Query the single MO at *dn* (plus subtree if requested)."""
mo = store.get(dn)
if mo is None:
return [], 0
return _build_result(store, [mo], params)
def run_node_scoped(
store: MITStore,
node_dn: str,
cls: str,
params: QueryParams,
) -> tuple[list[dict], int]:
"""Return instances of *cls* whose DN is under *node_dn*.
Mirrors ``GET /api/node/class/{node_dn}/{cls}.json`` used for
node-local MOs such as ``faultInst``.
"""
prefix = node_dn.rstrip("/") + "/"
top_level = [mo for mo in store.by_class(cls) if mo.dn.startswith(prefix)]
return _build_result(store, top_level, params)
+267
View File
@@ -0,0 +1,267 @@
"""Parse APIC filter expressions into a callable predicate ``fn(MO) -> bool``.
Supported grammar (CONTRACT §4):
eq(<class>.<attr>, "<val>") equal
ne(<class>.<attr>, "<val>") not equal
wcard(<class>.<attr>, "<substr>") substring containment
gt|lt|ge|le(<class>.<attr>, "<val>") numeric compare (string fallback)
bw(<class>.<attr>, "<lo>", "<hi>") inclusive range
and(<expr>, <expr>, ...)
or(<expr>, <expr>, ...)
Rules:
- Nesting is allowed: ``and(or(...), eq(...))``
- The ``<class>.`` prefix is advisory; we use the attr name after the last dot.
- ``wcard`` performs a substring containment check (not a glob).
- ``gt/lt/ge/le/bw`` compare numerically when both sides parse as numbers,
else fall back to string comparison.
- An unknown attr evaluates to False for eq/wcard/compares (attr not present).
- An empty / None expression is treated as "match everything".
- A malformed expression raises :class:`FilterParseError` (the REST layer maps
it to an APIC 400 error envelope never a 500).
"""
from __future__ import annotations
from typing import Callable
from aci_sim.mit.mo import MO
Predicate = Callable[[MO], bool]
class FilterParseError(ValueError):
"""Raised when a ``query-target-filter`` expression cannot be parsed.
The REST layer catches this and returns an APIC error envelope with HTTP
400, matching real APIC (which rejects a malformed filter with a 400 +
imdata error) rather than surfacing a 500.
"""
# ---------------------------------------------------------------------------
# Tokenizer (quote- and bracket-aware)
# ---------------------------------------------------------------------------
def _tokenize(s: str) -> list[str]:
"""Produce a flat list of tokens from a filter expression string.
Token types:
- Identifiers / dotted names: ``eq``, ``wcard``, ``and``, ``or``,
``fabricNode.role``,
- Quoted strings (double-quote delimited, returned WITH the quotes).
- Punctuation: ``(``, ``)`, ``,``.
Whitespace is skipped.
"""
tokens: list[str] = []
i = 0
n = len(s)
while i < n:
ch = s[i]
if ch in " \t\r\n":
i += 1
elif ch == '"':
j = i + 1
while j < n and s[j] != '"':
j += 1
tokens.append(s[i: j + 1])
i = j + 1
elif ch in "(,)":
tokens.append(ch)
i += 1
else:
# Identifier / dotted name — stop at delimiter chars
j = i
while j < n and s[j] not in '(,) \t\r\n"':
j += 1
tokens.append(s[i:j])
i = j
return tokens
# ---------------------------------------------------------------------------
# Recursive-descent parser
# ---------------------------------------------------------------------------
def _expect(tokens: list[str], pos: int, want: str) -> int:
"""Assert ``tokens[pos] == want`` and return ``pos + 1`` (else raise)."""
if pos >= len(tokens) or tokens[pos] != want:
got = tokens[pos] if pos < len(tokens) else "<end-of-expression>"
raise FilterParseError(f"expected {want!r} but got {got!r}")
return pos + 1
# Operators taking (class.attr, value): eq/ne substring-agnostic + comparisons.
_BINARY_OPS = ("eq", "ne", "wcard", "gt", "lt", "ge", "le")
def _parse_expr(tokens: list[str], pos: int) -> tuple[Predicate, int]:
"""Parse one expression starting at *tokens[pos]*.
Returns ``(predicate, new_pos)``. Raises :class:`FilterParseError` on any
structural problem.
"""
if pos >= len(tokens):
raise FilterParseError("unexpected end of filter expression")
tok = tokens[pos]
if tok in ("and", "or"):
pos = _expect(tokens, pos + 1, "(")
parts: list[Predicate] = []
while pos < len(tokens) and tokens[pos] != ")":
if tokens[pos] == ",":
pos += 1
continue
pred, pos = _parse_expr(tokens, pos)
parts.append(pred)
pos = _expect(tokens, pos, ")")
if not parts:
raise FilterParseError(f"{tok}() requires at least one argument")
combiner = _and_pred if tok == "and" else _or_pred
return combiner(parts), pos
if tok in _BINARY_OPS:
# <op>(<class>.<attr>, "<val>")
pos = _expect(tokens, pos + 1, "(")
dotted, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
val_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ")")
return _make_binary_pred(tok, dotted.split(".")[-1], val_token.strip('"')), pos
if tok == "bw":
# bw(<class>.<attr>, "<low>", "<high>")
pos = _expect(tokens, pos + 1, "(")
dotted, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
low_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ",")
high_token, pos = _take(tokens, pos)
pos = _expect(tokens, pos, ")")
return _bw_pred(dotted.split(".")[-1], low_token.strip('"'), high_token.strip('"')), pos
raise FilterParseError(f"unknown filter operator {tok!r} at position {pos}")
def _take(tokens: list[str], pos: int) -> tuple[str, int]:
"""Return ``(tokens[pos], pos + 1)`` or raise on end-of-input."""
if pos >= len(tokens):
raise FilterParseError("unexpected end of filter expression")
return tokens[pos], pos + 1
# ---------------------------------------------------------------------------
# Predicate factories
# ---------------------------------------------------------------------------
def _eq_pred(attr: str, val: str) -> Predicate:
def pred(mo: MO) -> bool:
return mo.attrs.get(attr) == val
return pred
def _wcard_pred(attr: str, substr: str) -> Predicate:
def pred(mo: MO) -> bool:
return substr in mo.attrs.get(attr, "")
return pred
def _and_pred(preds: list[Predicate]) -> Predicate:
def pred(mo: MO) -> bool:
return all(p(mo) for p in preds)
return pred
def _or_pred(preds: list[Predicate]) -> Predicate:
def pred(mo: MO) -> bool:
return any(p(mo) for p in preds)
return pred
def _ne_pred(attr: str, val: str) -> Predicate:
# ne = ¬eq: an object lacking the attr does not equal *val*, so it matches.
def pred(mo: MO) -> bool:
return mo.attrs.get(attr) != val
return pred
def _to_number(s: object) -> float | None:
try:
return float(s) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
_CMP_OPS: dict[str, Callable[[object, object], bool]] = {
"gt": lambda a, b: a > b,
"lt": lambda a, b: a < b,
"ge": lambda a, b: a >= b,
"le": lambda a, b: a <= b,
}
def _cmp_pred(op: str, attr: str, val: str) -> Predicate:
fn = _CMP_OPS[op]
def pred(mo: MO) -> bool:
raw = mo.attrs.get(attr)
if raw is None:
return False
rn, vn = _to_number(raw), _to_number(val)
if rn is not None and vn is not None:
return fn(rn, vn) # numeric compare when both parse
return fn(str(raw), val) # else lexical
return pred
def _bw_pred(attr: str, low: str, high: str) -> Predicate:
def pred(mo: MO) -> bool:
raw = mo.attrs.get(attr)
if raw is None:
return False
rn, ln, hn = _to_number(raw), _to_number(low), _to_number(high)
if None not in (rn, ln, hn):
return ln <= rn <= hn # type: ignore[operator]
return str(low) <= str(raw) <= str(high)
return pred
def _make_binary_pred(op: str, attr: str, val: str) -> Predicate:
if op == "eq":
return _eq_pred(attr, val)
if op == "ne":
return _ne_pred(attr, val)
if op == "wcard":
return _wcard_pred(attr, val)
return _cmp_pred(op, attr, val) # gt/lt/ge/le
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_filter(expr: str | None) -> Predicate:
"""Parse *expr* into a callable predicate.
Returns a predicate that always returns ``True`` for empty/``None`` input.
"""
if not expr:
return lambda mo: True
try:
tokens = _tokenize(expr)
pred, pos = _parse_expr(tokens, 0)
if pos != len(tokens):
trailing = tokens[pos]
raise FilterParseError(
f"unexpected trailing token {trailing!r} after complete expression "
f"in filter {expr!r}"
)
except FilterParseError:
raise
except (IndexError, ValueError) as exc: # defensive: any tokenizer/parse slip
raise FilterParseError(f"invalid filter expression {expr!r}: {exc}") from exc
return pred
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
View File
+19
View File
@@ -0,0 +1,19 @@
import os
APIC_A_PORT: int = int(os.environ.get("APIC_A_PORT", "8443"))
APIC_B_PORT: int = int(os.environ.get("APIC_B_PORT", "8444"))
NDO_PORT: int = int(os.environ.get("NDO_PORT", "8445"))
CERT_FILE: str = os.environ.get("CERT_FILE", "certs/sim.crt")
KEY_FILE: str = os.environ.get("KEY_FILE", "certs/sim.key")
TOPOLOGY_PATH: str = os.environ.get("TOPOLOGY_PATH", "topology.yaml")
# Default bind address for the non-sandbox servers (#23). Defaults to loopback
# so the unauthenticated /_sim control plane is never LAN-exposed by accident.
# Set SIM_BIND=0.0.0.0 explicitly for LAN deployments (e.g. the Raspberry Pi
# standing deployment, which is accessed from other hosts on the LAN).
SIM_BIND: str = os.environ.get("SIM_BIND", "127.0.0.1")
# Sandbox mode: bind each controller to its own mgmt_ip on :443 (via lo0 aliases),
# so autoACI connects by IP with no port — like real gear. Set by scripts/sandbox-up.sh.
SANDBOX: bool = os.environ.get("SIM_SANDBOX", "").strip().lower() in ("1", "true", "yes", "on")
SANDBOX_PORT: int = int(os.environ.get("SIM_SANDBOX_PORT", "443"))
+105
View File
@@ -0,0 +1,105 @@
"""Entry point: python -m aci_sim.runtime.supervisor
Loads topology build_all + build_ndo_model two ApicSiteStates + NdoState
make_apic_app×2 + make_ndo_app three uvicorn servers with TLS, gathered
with asyncio.
"""
from __future__ import annotations
import asyncio
import copy
import uvicorn
from aci_sim.build.orchestrator import build_all
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.runtime.config import (
APIC_A_PORT,
APIC_B_PORT,
CERT_FILE,
KEY_FILE,
NDO_PORT,
SANDBOX,
SANDBOX_PORT,
SIM_BIND,
TOPOLOGY_PATH,
)
from aci_sim.topology.loader import load_topology
def apic_port_for_site(site_index: int) -> int:
"""Effective APIC port for a site, by index (#25).
- site 0 APIC_A_PORT (the primary/first fabric's port env var)
- site 1 APIC_B_PORT (so APIC_B_PORT, defined in config.py, is actually
honored instead of being silently shadowed by an APIC_A_PORT+i formula)
- site >1 APIC_A_PORT + site_index, a documented fallback for topologies
with more than two sites (there is no APIC_C_PORT etc. env var; this
keeps ports contiguous and collision-free for the common 2-site case
while still supporting N sites).
"""
if site_index == 0:
return APIC_A_PORT
if site_index == 1:
return APIC_B_PORT
return APIC_A_PORT + site_index
async def run_servers() -> None:
topo = load_topology(TOPOLOGY_PATH)
stores = build_all(topo)
ndo_state = build_ndo_model(topo, sandbox=SANDBOX)
def _cfg(app, host, port):
return uvicorn.Config(
app, host=host, port=port,
ssl_certfile=CERT_FILE, ssl_keyfile=KEY_FILE, log_level="info",
)
# One APIC app per site (supports single-fabric or N sites).
# default → SIM_BIND (default 127.0.0.1, see #23) on distinct high ports (8443, 8444, …)
# sandbox → each site's own mgmt_ip on :443 (real-gear addressing, no ports)
configs = []
# site.id -> ApicSiteState, so the NDO app's deploy handler can mirror a
# deployed multi-site template's VRFs/BDs/ANPs/EPGs straight into the
# target sites' APIC MITStores (see ndo/deploy_mirror.py) — closing the
# confirmed SIM GAP where POST /mso/api/v1/task was a pure ack no-op and
# multi-site EPGs never appeared on the APIC side of the sim.
apic_states: dict = {}
for i, site in enumerate(topo.sites):
store = stores[site.name]
state = ApicSiteState(
name=site.name, site=site, topo=topo,
store=store, baseline=copy.deepcopy(store),
)
apic_states[site.id] = state
if SANDBOX:
host, port = (site.mgmt_ip or "127.0.0.1"), SANDBOX_PORT
else:
host, port = SIM_BIND, apic_port_for_site(i)
configs.append(_cfg(make_apic_app(state), host, port))
print(f"[sim] APIC {site.name} (asn {site.asn}) → https://{host if SANDBOX else SIM_BIND}:{port}")
if SANDBOX:
ndo_host, ndo_port = (topo.fabric.ndo_mgmt_ip or "127.0.0.1"), SANDBOX_PORT
else:
# NDO on NDO_PORT, shifted past the APIC ports if they would collide (>=3 sites).
ndo_host = SIM_BIND
last_apic = apic_port_for_site(len(topo.sites) - 1)
ndo_port = NDO_PORT if NDO_PORT > last_apic else last_apic + 1
configs.append(_cfg(make_ndo_app(ndo_state, apic_states), ndo_host, ndo_port))
print(f"[sim] NDO → https://{topo.fabric.ndo_mgmt_ip if SANDBOX else SIM_BIND}:{ndo_port}")
servers = [uvicorn.Server(cfg) for cfg in configs]
await asyncio.gather(*[s.serve() for s in servers])
def main() -> None:
asyncio.run(run_servers())
if __name__ == "__main__":
main()
View File
+47
View File
@@ -0,0 +1,47 @@
"""Topology YAML loader.
Usage::
from aci_sim.topology.loader import load_topology
topo = load_topology("topology.yaml")
"""
from __future__ import annotations
from pathlib import Path
from typing import Union
import yaml
from .schema import Topology
def load_topology(path: Union[str, Path]) -> Topology:
"""Read *path*, parse YAML, validate, and return a :class:`~schema.Topology`.
Raises
------
FileNotFoundError
If *path* does not exist.
ValueError
If the YAML is syntactically invalid.
pydantic.ValidationError
If the topology fails schema or cross-reference validation; the error
message lists every violation found.
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Topology file not found: {path}")
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise ValueError(f"Failed to parse YAML from {path}: {exc}") from exc
if not isinstance(raw, dict):
raise ValueError(
f"Topology YAML must be a mapping at the top level, got {type(raw).__name__}"
)
# Let ValidationError propagate naturally — callers inspect it directly.
return Topology.model_validate(raw)
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
# Deploying aci-sim as a background service
The simulator is a plain long-running process (`python -m
aci_sim.runtime.supervisor`, also reachable via the `aci-sim run`
CLI subcommand — see `README.md` §5 CLI), so it can run under any process
supervisor. This directory ships a systemd **user** unit for Linux (the
standing Raspberry Pi deployment) and a launchd plist sketch for macOS.
Both approaches run the simulator as an unprivileged user process bound to
`SIM_BIND` (default `127.0.0.1`, see `README.md` §Configuration / finding
#23). Sandbox mode (`scripts/sandbox-up.sh`, binding `:443` on real IPs) needs
root for the loopback alias + privileged port and is intentionally NOT
service-managed — run it interactively when you need NDO auto-discovery.
## Linux — systemd `--user` unit
1. Clone the repo and create the venv per `README.md` §2 Quick start:
```bash
git clone <repo-url> ~/aci-sim
cd ~/aci-sim
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .
```
2. Install the unit:
```bash
mkdir -p ~/.config/systemd/user
cp deploy/aci-sim.service ~/.config/systemd/user/
```
3. If the repo lives somewhere other than `~/aci-sim`, edit
`WorkingDirectory`/`ExecStart` in the copied unit (the shipped unit uses
`%h` — the systemd specifier for the invoking user's home directory).
4. If this host is a shared/LAN target for other machines (e.g. autoACI
running elsewhere), uncomment `Environment=SIM_BIND=0.0.0.0` in the unit.
Otherwise leave it commented — the sim will only be reachable from
`localhost` on this host.
5. Enable + start:
```bash
systemctl --user daemon-reload
systemctl --user enable --now aci-sim.service
```
6. Allow the user service to run even when the user isn't logged in
(needed on a headless Pi):
```bash
sudo loginctl enable-linger "$USER"
```
7. Verify:
```bash
systemctl --user status aci-sim.service
journalctl --user -u aci-sim.service -f
curl -sk https://127.0.0.1:8443/api/class/fabricNode.json # or the SIM_BIND host
```
To pick up an edited unit (e.g. after changing `Environment=`):
```bash
systemctl --user daemon-reload
systemctl --user restart aci-sim.service
```
## macOS — launchd plist sketch
macOS has no systemd; the equivalent is a launchd **LaunchAgent** (per-user,
runs at login — parallel to how OCP-class services on this fleet are
managed). Adjust paths for your clone location, then save as
`~/Library/LaunchAgents/com.aci-sim.supervisor.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.aci-sim.supervisor</string>
<key>WorkingDirectory</key>
<string>/Users/YOURUSER/aci-sim</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOURUSER/aci-sim/.venv/bin/python</string>
<string>-m</string>
<string>aci_sim.runtime.supervisor</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<!-- Uncomment/add to expose on the LAN (#23); omit for loopback-only -->
<!-- <key>SIM_BIND</key><string>0.0.0.0</string> -->
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>/tmp/aci-sim.log</string>
<key>StandardErrorPath</key>
<string>/tmp/aci-sim.log</string>
</dict>
</plist>
```
Load/manage it:
```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.aci-sim.supervisor.plist
launchctl kickstart -k gui/$(id -u)/com.aci-sim.supervisor # restart
# after editing the plist's EnvironmentVariables, kickstart -k reuses launchd's
# cached env — you must bootout + bootstrap again for changes to take effect:
launchctl bootout gui/$(id -u)/com.aci-sim.supervisor
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.aci-sim.supervisor.plist
```
## Notes
- Both approaches leave `SIM_BIND` at its loopback-only default unless
explicitly overridden — see `README.md` §Configuration and finding #23.
- Neither unit manages sandbox mode (`scripts/sandbox-up.sh`/`sandbox-down.sh`);
those remain manual, root-invoked, interactive operations.
- This directory ships unit **files** only — nothing here installs or starts
a service automatically.
+31
View File
@@ -0,0 +1,31 @@
[Unit]
Description=aci-sim — ACI/NDO REST fabric simulator
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Adjust to the actual clone path (e.g. /home/YOURUSER/aci-sim). This unit
# assumes a venv already created at <repo>/.venv (see README.md §2 Quick start).
WorkingDirectory=%h/aci-sim
ExecStart=%h/aci-sim/.venv/bin/python -m aci_sim.runtime.supervisor
Restart=on-failure
RestartSec=2
# Uncomment to expose the sim on the LAN instead of the loopback-only default
# (#23 — SIM_BIND defaults to 127.0.0.1). Only do this on a trusted network:
# the /_sim control plane is always unauthenticated, and the APIC/NDO planes
# use lab-grade credentials, never real ones.
#Environment=SIM_BIND=0.0.0.0
# Other overridable knobs (see aci_sim/runtime/config.py); uncomment
# and adjust as needed:
#Environment=APIC_A_PORT=8443
#Environment=APIC_B_PORT=8444
#Environment=NDO_PORT=8445
#Environment=TOPOLOGY_PATH=%h/aci-sim/topology.yaml
#Environment=SIM_USERNAME=admin
#Environment=SIM_PASSWORD=cisco
[Install]
WantedBy=default.target
+579
View File
@@ -0,0 +1,579 @@
# autoACI ⇄ APIC/NDO REST CONTRACT (what the simulator must satisfy)
> Distilled from a read-only audit of `~/autoACI/backend` (services/aci_connector.py,
> ndo_connector.py, routers/topology.py, routers/ndo.py, routers/fabric_build.py,
> services/acipy_exec.py, plugins/*). This is the authoritative contract the sim
> emulates. Do NOT re-explore autoACI; trust this file. If something here is
> ambiguous, prefer the behavior that makes autoACI render.
autoACI connects to **three host identities**, each `https://{host}` with `verify=False`:
- **Site-A APIC**, **Site-B APIC** (each logged in separately; autoACI tracks `sites[].{host,asn}` and prefixes node IDs by site when >1 site), and
- **ND / NDO** (separate auth + endpoints).
---
## 1. APIC — authentication
- `POST /api/aaaLogin.json` body `{"aaaUser":{"attributes":{"name":<user>,"pwd":<pwd>}}}`.
`name` may be a bare username (`admin`) or **domain-qualified**
(`apic:<domain>\<user>` or `<domain>\<user>`, e.g. `apic:local\admin`) — PR-15:
aci-py's APIC connector sends the domain-qualified form by default, matching
real APIC's local-login-domain behavior. `auth.py::_bare_username()` strips an
optional `apic:<domain>\`/`<domain>\` prefix before the credential compare, so
both forms authenticate identically; the domain segment itself is not
validated (this sim has no concept of configured login domains — any domain
string is accepted). Credentials (post-strip) are checked against
`SIM_USERNAME`/`SIM_PASSWORD` (default `admin`/`cisco`; see README §Configuration) —
a mismatch (either form) returns a **401 APIC error envelope**, never a bare token.
**Admin-account wizard (topology `auth:` section):** `aci-sim init`'s Step 0
writes an `auth: {username, password}` section into `topology.yaml`
(`topology/schema.py`'s `Auth` model); `aci-sim run` resolves
`SIM_USERNAME`/`SIM_PASSWORD` with this precedence (highest first): (1) the
caller's shell already has `SIM_USERNAME` set — wins outright, `auth:` is
ignored; (2) `topology.yaml` has an `auth:` section — its `username`/
`password` are injected; (3) neither — nothing injected, this sim falls back
to the hardcoded `admin`/`cisco` default documented above. `cli.py`'s
`resolve_admin_credentials()` implements this precedence and is unit-tested
directly (`tests/test_admin_account.py`). **NDO is NOT affected**
`ndo/app.py`'s `POST /api/v1/auth/login`/`POST /login` (§7 below) accept
any credential by design and never validate anything against
`auth.ndo_username`/`ndo_password`; those two fields (set only when the
wizard's `ndo_same_account` answer is "no") are stored for informational
parity only. Response on
success: `{"imdata":[{"aaaLogin":{"attributes":{"token":"<t>","refreshTimeoutSeconds":"600"}}}]}`.
Token also returned as `APIC-cookie` cookie; subsequent requests send that cookie.
Sessions expire after `refreshTimeoutSeconds` (600s); every query/write route
requires a live, unexpired `APIC-cookie` or returns a **403 APIC error envelope**
(`text="Token was invalid (Error: Token timeout)"`, `code="403"`). A malformed
login body (wrong shape / non-JSON) returns a **400 APIC error envelope**, never
FastAPI's raw `{"detail": [...]}` validation-error shape.
- `GET /api/aaaRefresh.json` → requires a valid existing `APIC-cookie`; renews its
expiry to a full `refreshTimeoutSeconds` and returns the same aaaLogin envelope
(autoACI refreshes every ~240s). Without/with an invalid cookie → 403 envelope.
- `POST /api/aaaLogout.json` body `{"aaaUser":{"attributes":{"name":<user>}}}`
invalidates the server-side session in addition to clearing the cookie.
- During/after login autoACI probes: `GET /api/class/topSystem.json?query-target-filter=eq(topSystem.role,"controller")&page-size=1`
and `GET /api/mo/uni/userprofile-<user>.json?query-target=subtree&target-subtree-class=aaaUserDomain`.
## 2. APIC — the three query shapes + full param grammar
1. **Class query**`GET /api/class/{class}.json` with any of:
- `page-size` (always; default 500, clamped to `[1, 5000]`), `page` (always; 0-indexed, must be `>= 0`).
A non-numeric or out-of-range `page`/`page-size` yields a **400 APIC error envelope**
(`code="107"`), never a bare 500 or silent truncation.
- `query-target-filter={expr}` (filter grammar §4)
- `rsp-subtree=children|full` + `rsp-subtree-class={c1,c2,...}` (modern subtree)
- `query-target=subtree` + `target-subtree-class={c1,c2,...}` (legacy subtree)
2. **MO/DN query**`GET /api/mo/{dn}.json` with optional `query-target=subtree` + `target-subtree-class={...}`.
DNs contain slashes inside brackets: `subnet-[10.0.0.1/24]`, `pathep-[eth1/9]`, `rsdomAtt-[uni/phys-X]` — bracket-aware DN parsing required.
`GET/POST/DELETE /api/node/mo/{dn}.json` is a full alias of `/api/mo/{dn}.json`
(PR-10) — real APIC serves both; the sim's API Inspector / `aci_rest`-module
callers may use either interchangeably with identical behavior.
3. **Node-scoped class query**`GET /api/node/class/{dn}/{class}.json` with optional `query-target-filter`.
Used for node-local `faultInst` (e.g. `.../topology/pod-1/node-101/faultInst.json`).
**Fabric-wide form:** `GET /api/node/class/{class}.json` (no `topology/pod-X/node-Y`
DN prefix at all — just the bare class name) is also supported and is equivalent to
`GET /api/class/{class}.json` (same result set, real APIC behavior). The sim
distinguishes the two by whether the path segment after `/api/node/class/` contains
a `/`: a node-scoped DN always does, the fabric-wide form never does.
**Response envelope (always):**
```json
{"imdata":[ {"<class>":{"attributes":{...}, "children":[ {"<child>":{"attributes":{...}}} ]}} ], "totalCount":"<N>"}
```
- `children` present only when a subtree mode was requested (children/full nest children;
`rsp-subtree-class`/`target-subtree-class` filter which descendant classes appear).
- `totalCount` is a STRING; used for pagination (client loops `page` until a short page).
- Subtree responses may return children before parents → consumers do **two-pass parsing**; the
sim may emit any order but MUST include all requested descendants.
## 2a. APIC — query subscriptions + websocket push
All three query shapes in §2 accept `?subscription=yes`. The response is
the normal §2 envelope PLUS a top-level `subscriptionId` string sitting
alongside `imdata`/`totalCount` (NOT nested inside them):
```json
{"imdata":[...], "totalCount":"<N>", "subscriptionId":"<id>"}
```
Without `?subscription=yes`, the response has no `subscriptionId` key —
byte-identical to a pre-subscription-feature query. This is a strict
backward-compat requirement, not a default choice.
**Scope**: a class-query subscription (`/api/class/{cls}.json`) watches
every MO of that class fabric-wide. A DN-scoped subscription
(`/api/mo/{dn}.json`, `/api/node/class/{dn}/{cls}.json`) watches that DN
and its entire subtree.
**Websocket**: `GET /socket<token>``<token>` is the same `APIC-cookie`
token from `aaaLogin` (no separator, literal path concatenation, matching
real APIC). Unknown/expired token → connection rejected before accept. One
live connection per token.
**Push event** (sent on the matching token's websocket after a write commits):
```json
{"subscriptionId":["<id>",...], "imdata":[{"<class>":{"attributes":{...,"status":"created|modified|deleted","dn":"<dn>"}}}]}
```
- `subscriptionId` is a list — every subscription on that token's session
that matched the change (a client can hold multiple overlapping subs).
- `status` is `created` (DN didn't previously exist), `modified` (DN
already existed), or `deleted` (DELETE, or POST body `status="deleted"`).
- A DELETE on a DN with children emits one event per removed MO in the
subtree (root + all descendants), each carrying its own class/dn/status,
so both class-scoped and DN-scoped subscribers see everything actually
removed.
**Refresh**: `GET /api/subscriptionRefresh.json?id=<id>` — always 200
(`{"imdata":[],"totalCount":"0","subscriptionId":"<id>"}`), including for
an unknown/expired id (real APIC does not error a stale refresh). Default
subscription TTL is 90s, matching real APIC, renewed by this call.
**Lifecycle**: disconnecting the websocket immediately drops every
subscription owned by that connection's token — a subsequent matching
change is silently skipped (no error), same as a DN/class nobody
subscribed to.
Implementation: `aci_sim/rest_aci/subscriptions.py` (in-memory
registry + notify) and `aci_sim/rest_aci/app.py` (route wiring). See
README §10a for the user-facing walkthrough.
## 3. APIC — write path (Fabric Build apply)
- `POST /api/mo/{dn}.json` body `{"<class>":{"attributes":{"name":...,"dn":...,"status":"created|modified|deleted"?}, "children":[...]}}`.
Response `{"imdata":[{"<class>":{"attributes":{"dn":...,"status":"created"}}}],"totalCount":"1"}` (HTTP 200).
Nested `children` in the body must be upserted too (recursively). `status:"deleted"` removes.
- Writes must **upsert into the live MIT** so a later GET reflects them (read-back verification).
- **Atomic (all-or-nothing):** the entire body (root + every nested child, recursively) is shape-validated
*before* any store mutation happens. A malformed node anywhere in the tree (e.g. a `children` entry that
isn't a single-key `{className: {...}}` object) rejects the whole POST with a 400 APIC error envelope and
leaves the store completely untouched — including MOs earlier in traversal order that would otherwise
have been written first. Mirrors real APIC POST semantics.
- **Canonical RN synthesis:** a child posted without an explicit `dn` gets its RN derived from the class's
real RN prefix (e.g. `fvBD``BD-{name}`, `fvAEPg``epg-{name}`, `fvSubnet``subnet-[{ip}]`), not a
generic `{class}-{name}` guess — so it lands on the same DN a canonical GET or a later explicit-`dn` POST
would use (no orphan duplicates). Classes without a known template fall back to `{class}-{name}`.
- **Node registration reaction:** `initial_fabric_discovery`/`add_leaf_switch_pair`/`add_spine_switch`
POST `fabricNodeIdentP` at `uni/controller/nodeidentpol/nodep-{id}`. The sim reacts by
materializing a full node-registration MO set, not just a bare `fabricNode`: `fabricNode`,
`topSystem` (loopback + OOB addresses via the same scheme every boot-seeded node gets),
`healthInst`, `fabricLink` (+enriched `lldpAdjEp`/`cdpAdjEp` and their `lldpInst`/`lldpIf`/
`cdpInst`/`cdpIf` containers, v0.12.0 — same `build/neighbors.py` helper the boot-time
builders use, so a dynamically-registered node's neighbors are queryable the identical way)
cabling the new node to every spine in its site, and `l1PhysIf`/`ethpmPhysIf` port inventory
(fabric uplinks + host-access ports) — so a dynamically-registered node renders identically
to one seeded from `topology.yaml`. The
reaction fires for a `fabricNodeIdentP` found **anywhere** in the POST body's validated write
plan, not only when it is the top-level class (a `fabricNodeIdentP` nested under a parent, e.g.
`uni/controller`, also triggers it). Pod is derived from the target site (`site.pod`), never
hardcoded — `fabricNodeIdentP`/nodeidentpol DNs carry no pod of their own. `POST /_sim/add-leaf`
shares this same enrichment.
- Multi-site playbooks push NDO schemas/templates/deploy (see §7) then per-site APIC MOs.
## 4. APIC — filter grammar (query-target-filter values)
- `eq(<class>.<attr>,"<val>")` exact match (numbers unquoted).
- `ne(<class>.<attr>,"<val>")` not-equal (a missing attr is treated as ≠, i.e. matches).
- `wcard(<class>.<attr>,"<substr>")` substring/wildcard (commonly on `.dn`).
- `gt|lt|ge|le(<class>.<attr>,"<val>")` compare — numeric when both sides parse as
numbers (e.g. `gt(fabricNode.id,"200")`), else lexical.
- `bw(<class>.<attr>,"<lo>","<hi>")` inclusive range.
- `and(<c1>,<c2>,...)`, `or(<c1>,<c2>,...)` compose. Nesting allowed. Zero-argument
`and()`/`or()` are rejected (not "match everything" / "match nothing").
- `query-target=subtree` scopes the match set to root+descendants FIRST, then applies
`query-target-filter` to every scoped MO (root and descendants alike) — a filter on
an attribute only descendants carry (e.g. `faultInst.severity` under a `fabricNode`
root) still reaches matching descendants; it does not pre-filter the root out of the
scope before expansion.
- A malformed expression (unknown operator, truncated call, trailing tokens after a
complete expression, e.g. two comma-joined filters or an unbalanced trailing `)`)
yields a **400 APIC error envelope** (`code="107"`), never a 500.
- Examples seen verbatim:
`eq(fabricNode.role,"spine")`, `wcard(fvCtx.dn,"tn-Tenant1/")`,
`and(eq(fvBD.name,"X"),wcard(fvBD.dn,"tn-T/"))`,
`or(eq(faultInst.severity,"critical"),eq(faultInst.severity,"major"))`,
`ne(fvTenant.name,"common")`, `gt(fabricNode.id,"200")`, `bw(fabricNode.id,"100","200")`.
## 5. APIC — error shapes / HTTP codes
- 200 success. 400 → ACIQueryError (also: malformed aaaLogin body, bad page params,
strict-filter-parse errors). 401 → bad aaaLogin credentials
("Authentication failed: invalid username or password"). 403 → ACIAuthError —
missing/unknown/expired `APIC-cookie` on any query/write route or on
aaaRefresh ("Token was invalid (Error: Token timeout)").
Error body: `{"imdata":[{"error":{"attributes":{"text":"<msg>","code":"<c>"}}}]}`.
The `/_sim/*` control-plane router and `/api/aaaLogin.json`/`aaaLogout.json`
are intentionally exempt from the 403 auth gate (test tooling + the login
flow itself depend on them being reachable pre-auth).
- **`GET /api/mo/{dn}.json` on a nonexistent DN → 200 + empty envelope
(`{"imdata":[],"totalCount":"0"}`), NOT 404** (PR-9 FEATURE 6, corrects a
pre-PR-9 design choice that returned 404/ACINotFoundError here). Verified
against upstream cisco.aci `plugins/module_utils/aci.py`'s `api_call()`:
any GET response with `status != 200` is unconditionally fatal
(`fail_json`) — there is no "404 means not-found, that's fine" special
case. Every `state=present` module does a `get_existing()` GET *before*
building its diff, so on a brand-new object that GET's DN does not exist
yet; a 404 there fails the very first task of any create playbook
(`fatal: APIC Error 103: Unable to find the object specified`). 404 is
reserved for genuinely malformed requests, not absent-but-well-formed
objects — matching real APIC. `/api/class/*` and `/api/node/class/*`
already returned 200-empty for a query matching nothing; this brings
`/api/mo/*` in line for the "DN itself absent" case too.
- **Any unmatched `/api/*` path → 400 APIC error envelope, never FastAPI's
`{"detail":...}` shape** (PR-10). Real APIC never emits FastAPI's default
404 body; `cisco.aci` cannot parse it and surfaces it as
`"APIC Error None: None"`, `status=-1`. Body:
`{"imdata":[{"error":{"attributes":{"text":"Invalid request path: <path>","code":"400"}}}],"totalCount":"1"}`.
Implemented as a 404 exception-handler fallback scoped to `/api/*` — it
only fires once every real route (including the `/api/node/mo` alias)
has already failed to match, so it can never shadow one.
## 6. APIC — class catalog (102) + attributes actually read
Group by concern; every listed attribute is read by some plugin/router — include them.
(Header count corrected from a stale "63" to the actual number of distinct
classes catalogued below, verified by direct count — see PR-3b-batch1.
✅ marks classes seeded by PR-3b-batch1 (previously catalogued here but never
built, so their class queries returned empty and the depending autoACI
plugins rendered nothing); 🆕 marks classes seeded by PR-3b-batch2/3 (same
gap, closed in this batch); everything else was already built pre-batch-1.
**Batch-2/3 changelog note (this revision):**
- `vpcRsMbrIfs`**corrected to `pcRsMbrIfs`**. The catalogued name was
wrong: autoACI's `vpc_status.py` actually queries `pcRsMbrIfs` (the real
ACI class for a port-channel member-port relation, child of `pcAggrIf`,
RN `rsmbrIfs-[{ifDn}]`), never `vpcRsMbrIfs` — verified read-only against
the plugin source before building. Built as `pcRsMbrIfs` in
`build/fabric.py`; see docs/DESIGN.md "Batch-2" section.
- `infraNodeIdentP`**removed from this catalog, NOT built.** No autoACI
plugin/router references it at all (verified: a fabric-wide grep across
`~/autoACI/backend/plugins/*.py` for `infraNodeIdentP` returns zero
matches — unlike `fabricNodeIdentP`, which the Fabric Build write-path
reaction genuinely needs). Real ACI's `infraNodeIdentP` is a *node
provisioning-policy* object (`uni/infra/nodep-{id}`, part of interface
selector/AEP scoping for a specific node), a materially different concept
from `fabricNodeIdentP`'s Fabric Membership node registration
(`uni/controller/nodeidentpol/nodep-{id}`) — the two are easy to confuse
by name alone. Building an `infraNodeIdentP` that mirrors
`fabricNodeIdentP`'s semantics (as an earlier draft of this catalog
implied) would be a faithfulness regression: a wrong object with a
plausible-looking name, satisfying no real consumer. Decision: leave it
out of scope entirely rather than build a wrong object.
**Fabric/topology:** `fabricNode`(id,name,role∈{spine,leaf,controller},model,serial,version,adSt,fabricSt,dn) ·
`fabricLink`(dn contains `/lnk-{n1port}-to-{n2port}/`, n1,n2,operSt,operSpeed,linkType) ·
`topSystem`(dn,fabricDomain,version,role,state,address/oobMgmtAddr) ·
`fabricHealthTotal`(cur) · `healthInst`(cur,min,max,prev) ·
`vpcDom`(id,dn,peerSt) · `vpcIf`(dn,name,operSt) · `pcAggrIf`🆕(dn,name,id,operSt) · `pcRsMbrIfs`🆕(tDn,parentSKey) · `infraWiNode`🆕(health,state) ·
`firmwareCtrlrRunning`🆕(version,node) — PR-9, one per controller at `{ctrl_dn}/ctrlrfwstatuscont/ctrlrrunning`.
**Mgmt tenant — OOB management (OOB-gateway PR):** real APIC models each
node's OOB management address + default gateway in the special `mgmt`
tenant, never on `topSystem` itself (`topSystem.oobMgmtAddr` carries only the
address — see above). Prior to this PR, `aci-sim init`'s wizard collected an
OOB gateway per site but discarded it entirely (no schema field, no MO).
`build/mgmt.py` now builds the minimal faithful scaffolding, one tree per
site: `fvTenant`(name="mgmt", dn=`uni/tn-mgmt`) →
`mgmtMgmtP`(name="default", dn=`uni/tn-mgmt/mgmtp-default`) →
`mgmtOoB`(name="default", dn=`uni/tn-mgmt/mgmtp-default/oob-default`) → one
`mgmtRsOoBStNode`(tDn,addr,gw) per node — switches AND controllers — at
`{oob-default dn}/rsooBStNode-[topology/pod-{p}/node-{id}]`, where
`tDn=topology/pod-{p}/node-{id}`, `addr=<node's OOB IP>/<mask>` (same
`oob_ip(pod,node_id)` address `topSystem.oobMgmtAddr` uses; mask from
`fabric.oob_subnet` if set, else `/24` — see build/mgmt.py docstring for why
not `fabric.oob_pool`'s `/16`), and `gw=site.oob_gateway` (empty string if
unset — never invented/derived). No other mgmt-tenant children (`mgmtInB`,
`mgmtRsOoBCtx`, `mgmtInstP`, ...) are built — no autoACI/aci-py chain
audited for this sim reads them; same scope judgment as
`fabric.inb_subnet`'s store-only status (README §6c).
**Faults:** `faultInst`(dn,severity∈{critical,major,minor,warning},code,lc,type,subject,descr,created,lastTransition).
Fault DNs live under node-local shards; node-scoped query via `/api/node/class/{nodeDn}/faultInst.json`.
**Interfaces:** `l1PhysIf`(id,adminSt,descr,mtu,mode) · `ethpmPhysIf`(dn has `phys-[eth{s}/{p}]`,operSt,operSpeed,usage,lastLinkStChg,resetCtr) · `ethpmFcot`🆕(typeName,guiName,vendorName,vendorSn,actualType).
PR-19: the ISN/IPN spine uplink `l1PhysIf.mtu` is now `isn.mtu`-driven (default 9150) instead of the general fabric `9216` hardcode every other port still uses.
**Neighbors/underlay:** `lldpAdjEp`(dn,sysName,mgmtIp,portIdV,portIdT,chassisIdT,chassisIdV,sysDesc,capability,id,ttl) ·
`cdpAdjEp`(dn,sysName,mgmtIp,devId,portId,platId,ver,cap) ·
`lldpInst`🆕(dn,adminSt,name,holdTime,initDelayTime,txFreq,ctrl,optTlvSel) · `lldpIf`🆕(dn has `if-[eth{s}/{p}]`,id,adminRxSt,adminTxSt,operRxSt,operTxSt,mac,portDesc,portVlan,sysDesc,wiring) ·
`cdpInst`🆕(dn,adminSt,name,holdIntvl,txFreq,ver,ctrl) · `cdpIf`🆕(dn has `if-[eth{s}/{p}]`,id,adminSt,operSt) ·
`isisAdjEp`(dn,operSt,sysId,lastTrans,numAdjTrans) · `isisDom` · `ospfAdjEp`(dn,operSt,id,peerIp/addr,nbrId,area) · `ospfIf`✅(name,area,state,helloIntvl,deadIntvl) · `arpAdjEp`✅(ip,mac,ifId,physIfId,operSt).
LLDP/CDP fidelity (v0.12.0): `lldpAdjEp`/`cdpAdjEp` are now enriched with real-APIC-shaped fields
(`portIdV`/`portId` carry the NEIGHBOR's own port; `chassisIdV` is a deterministic per-node MAC,
see `build/neighbors.py:node_mac`; `sysDesc`/`platId`/`ver` describe the neighbor's model+version, or
"Cisco APIC"/`APIC-SERVER-M3` for a controller neighbor) — additive only, pre-existing `sysName`/
`mgmtIp` are unchanged. `lldpInst`/`cdpInst`/`lldpIf`/`cdpIf` are newly materialized containers (one
inst per switch node with any adjacency, one `*If` per adjacency-bearing local port) — this also fixes
a bug where a deep-root subtree query (`GET .../sys/lldp/inst.json?query-target=subtree&
target-subtree-class=lldpAdjEp`) returned `totalCount=0` because the root DN had no MO of its own.
`aci-sim lldp` (CLI) prints a `show lldp neighbors`-style table from these fields (`--cdp` for CDP,
`--json` for machine-readable output).
PR-19: `ospfIf.area`/`ospfAdjEp.area` are now `isn.ospf_area`-driven (default `"0.0.0.0"`) instead of a hardcoded literal.
PR-20: `ospfIf.helloIntvl`/`deadIntvl` are new, driven by `isn.ospf_hello`/`isn.ospf_dead` (defaults 10/40, real ACI defaults) — previously not carried on this MO at all.
**Overlay/BGP:** `bgpInst`(dn,asn) · `bgpPeer`(dn,asn,addr,type,peerRole) ·
`bgpPeerEntry`(dn,addr,operSt∈{established,...},rtrId,lastFlapTs,connEst,connDrop,type) ·
`bgpPeerAfEntry`(dn,type/afId,acceptedPaths,pfxSent,tblVer) · `bgpVpnRoute`🆕(pfx,rd) · `bgpPath`🆕(nh,asPath,localPref,metric,origin,type).
For ISN: each spine `/sys/bgp/inst` subtree must contain `bgpPeer` entries whose addr is a **/32** and whose ASN ≠ the local fabric ASN (autoACI infers the inter-site EVPN cloud from this).
**COOP/EPM:** `coopPol`,`coopInst`(operSt) · `epmMacEp`(addr,ifId,flags,createTs,encap) · `epmIpEp`(addr,ifId,flags,createTs).
**Routing tables/control:** `uribv4Route`✅,`uribv4Nexthop`✅ · `rtctrlProfile`✅,`rtctrlCtxP`✅,`rtctrlSubjP`✅,`rtctrlMatchRtDest`✅,`rtctrlSetComm`✅,`rtctrlSetPref`✅.
**Tenant (control):** `fvTenant`(name,descr,dn) · `fvCtx`(name,pcEnfPref,bdEnforcedEnable,ipDataPlaneLearning,pcEnfDir,pcTag,scope) ·
`fvBD`(name,arpFlood,unicastRoute,ipLearning,unkMacUcastAct,unkMcastAct,multiDstPktAct,epMoveDetectMode,limitIpLearnToSubnets,mtu,mac,intersiteBumTrafficAllow,type) ·
PR-20: `fvBD.mac` now reflects `bd.mac` (per-BD) falling back to `fabric.default_bd_mac` (fabric-wide, default `"00:22:BD:F8:19:FF"` — the exact literal already hardcoded here pre-PR-20, so unset topology.yaml fields produce byte-identical output).
`fvSubnet`(ip,scope) · `fvAp`(name) · `fvAEPg`(name,prefGrMemb,floodOnEncap,pcEnfPref,isAttrBasedEPg,pcTag) ·
`fvCEp`(mac,ip,encap,fabricPathDn) · `fvIp`(addr) · `fvIfConn`(dn has tn-/ap-/epg-, encap) · `fvEpP`🆕(epgPKey,pcTag,scopeId).
**Rels:** `fvRsBd`(tnFvBDName) · `fvRsCtx`(tnFvCtxName) · `fvRsBDToOut`(tnL3extOutName) ·
`fvRsDomAtt`(tDn,instrImedcy) · `fvRsPathAtt`✅(tDn,encap,mode,instrImedcy) · `fvRsCons`(tnVzBrCPName) · `fvRsProv`(tnVzBrCPName).
**Contracts:** `vzBrCP`(name,scope,prio,targetDscp) · `vzSubj`(name,revFltPorts) · `vzFilter`(name) ·
`vzEntry`(name,etherT,prot,dFromPort,dToPort,sFromPort,sToPort,stateful) · `vzRsSubjFiltAtt`(tnVzFilterName) · `vzRsSubjGraphAtt`✅ · `actrlRule`🆕(scopeId,sPcTag,dPcTag,fltId,action,direction,prio,ctrctName,operSt).
**L3Out (control):** `l3extOut`(name,enforceRtctrl) · `l3extLNodeP`(name) · `l3extRsNodeL3OutAtt`(tDn,rtrId) ·
`l3extLIfP`(name) · `l3extRsPathL3OutAtt`(tDn,encap,addr,ifInstT) · `l3extInstP`(name,prefGrMemb) ·
`l3extSubnet`(ip,scope) · `l3extRsL3DomAtt`(tDn) · `l3extRsEctx`(tnFvCtxName,tDn) · `l3extMember`✅(side,addr) ·
`bgpPeerP`(addr,peerCtrl,keepAliveIntvl,holdIntvl) · `bgpAsP`(asn) · `bgpExtP` · `ospfExtP`✅ · `ipRouteP`✅(ip,pref) · `ipNexthopP`✅(nhAddr).
PR-20: `bgpPeerP.keepAliveIntvl`/`holdIntvl` are new, driven by `l3out.csw_peer.keepalive`/`.hold` (defaults 60/180, real ACI defaults) — previously not carried on this MO at all.
**Access policy:** `infraAttEntityP`(AAEP,name) · `infraAccPortGrp`,`infraAccBndlGrp`✅(name,lagT) · `infraAccPortP`✅(name) ·
`infraHPortS`✅(name,type) · `infraPortBlk`✅(fromPort,toPort,fromCard) · `infraRsAttEntP`(tDn) · `infraRsAccBaseGrp`✅(tDn) ·
`infraRsDomP`(tDn) · `infraRsVlanNs`(tDn) · `physDomP`(name) · `l3extDomP`(name) · `fvnsVlanInstP`(name,allocMode) · `fvnsEncapBlk`(from,to) ·
`vmmDomP`🆕(name) · `vmmCtrlrP`🆕(name,hostOrIp,rootContName,dvsName,dvsVersion) · `vmmUsrAccP`🆕(name) — PR-19, Tier-2
`access.vmm_domains[]`; `vmmCtrlrP`/`vmmUsrAccP` only emitted when `vcenter_ip` is set. Pure addition — no
existing plugin/production-chain reads these (see docs/DESIGN.md's PR-19 section for the verified
`bind_epg_to_vmm_domain` DN-string-only finding).
**Infra write targets (Fabric Build):** `fabricNodeIdentP`🆕(nodep-{id},serial,nodeId,name,role) · `infraInfra`,`infraAttEntityP`, VLAN pools/AAEPs.
(`infraNodeIdentP` intentionally removed from this catalog — see the batch-2/3 changelog note above.)
**Capacity/backup:** `eqptcapacityPolUsage5min`(polUsage,polUsageCap,polUsageCum,polUsageCapCum) · `configExportP`(name) · `configJob`(operSt,executeTime).
## 7. NDO / MSO contract
- `POST /api/v1/auth/login` `{"userName","userPasswd","domain":"local"}``{"token":...}` (Bearer header after).
**Deliberately lenient**: accepts ANY credential and never validates the returned
token on subsequent requests (client-compat for `cisco.mso`/aci-py, which expect a
login to just work). This is unchanged by the admin-account wizard (§1) — NDO
shares the APIC admin account by default but is never actually checked against it.
- `GET /api/v1/platform/version``{"version":"4.2.2"}`.
- `GET /mso/api/v1/sites``{"sites":[{id,name,platform,urls[]}]}`. **`name` is
the topology's `fabric_name`** (e.g. `"LAB1-IT-ACI"`), NOT the short
internal site name (`"LAB1"`) — PR-10, verified against a real-gear
`cisco.mso.mso_tenant` run that rejected the short form
(`"Site 'LAB1-IT-ACI' is not a valid site name"` when only `"LAB1"` was
exposed). Internal site-id joins (tenant/schema site associations) key off
the topology's short name regardless of what `name` reports here.
- `GET /mso/api/v1/tenants` (also `GET /api/v1/tenants` — PR-10, backs
`cisco.mso.ndo_template`'s `lookup_tenant()` prereq lookup, same data) →
`{"tenants":[{id,name,displayName,siteAssociations:[{siteId}]}]}`.
- `GET /mso/api/v1/schemas``{"schemas":[{id,displayName,name,templates:[{name}]}]}`.
- `GET /mso/api/v1/schemas/list-identity` (PR-10) → `{"schemas":[{id,displayName}]}`
— lightweight enumeration every `cisco.mso` schema module calls first to
resolve a schema `displayName``id` (verified against `ansible-mso`'s
`plugins/module_utils/mso.py` `lookup_schema()`, which calls
`query_objs("schemas/list-identity", key="schemas", displayName=schema)`).
**Must be declared before** the parameterized `/schemas/{id}` route below,
or `"list-identity"` gets matched as a schema id instead.
- `GET /mso/api/v1/schemas/{id}` → full schema; template shape:
`templates[].{name,templateType,vrfs[],bds[],anps[].epgs[],contracts[],filters[],externalEpgs[]}` and top-level `sites[].{templateName,siteId}`.
- vrf: `{name,displayName,vrfRef:"/vrfs/NAME",vzAnyEnabled,ipDataPlaneLearning}`
- bd: `{name,vrfRef:"/vrfs/NAME",subnets:[{ip}],l2Stretch,intersiteBumTraffic,l2UnknownUnicast,arpFlood,unicastRouting,epMoveDetectMode,dhcpLabels:[{ref:UUID}]}`
- epg: `{name,bdRef:"/bds/NAME",preferredGroup,proxyArp,uSegEpg,intraEpg,contractRelationships:[{relationshipType,contractRef:"/contracts/NAME"}]}`
- contract: `{name,scope,filterRelationships:[{filterRef:"/filters/NAME"}]}` filter: `{name,entries:[{name,ipProtocol,dFromPort,dToPort,etherType}]}`
- externalEpg: `{name,vrfRef,l3outRef:"/l3outs/NAME",type,subnets:[{ip}]}`
- `GET /mso/api/v1/sites/fabric-connectivity``{"sites":[{id/siteId,status,connectivityStatus}]}` (or list).
- `GET /mso/api/v1/schemas/{id}/policy-states``[{status,drift}]` or `{policyStates:[...]}`.
- `GET /mso/api/v1/templates/summaries``[{templateId,templateName,templateType}]` (tenantPolicy carries DHCP;
`templateName` required — PR-13 — since `ansible-mso`'s `MSOTemplate` resolves templates by name+type).
- `GET /mso/api/v1/templates/{id}``{tenantPolicyTemplate:{template:{dhcpRelayPolicies:[{uuid,name}],dhcpOptionPolicies:[{uuid,name}]}}}`.
- **Template writes (PR-13):** `POST /templates` (create — payload
`{displayName,templateType,<typeContainer>:{template:{...},sites:[{siteId}]}}`,
e.g. `tenantPolicyTemplate`), `PATCH /templates/{id}` (JSON-Patch ops,
e.g. `add /tenantPolicyTemplate/template/dhcpRelayPolicies/-`), `DELETE
/templates/{id}`. **Both bare (`/api/v1/...`) and `/mso`-prefixed
(`/mso/api/v1/...`) forms are backed by the same store** — see §7c.
- `GET /templates/objects?type={dhcpRelay|dhcpOption|epg|externalEpg}&{uuid=...|name=...}`
(both prefixes) — cross-template object lookup; `dhcpRelay`/`dhcpOption`
search every tenant-policy template's policy lists, `epg`/`externalEpg`
search every schema template's `anps[].epgs[]`/`externalEpgs[]`. Returns
a list when only `type` (or `type`+`name`) is given, a single object (or
`{}`) when `uuid` is given.
- `GET /mso/api/v1/schemas/service-node-types` (also bare) →
`{"serviceNodeTypes":[{id,displayName}]}` (Firewall/Load Balancer/Other).
- `GET /api/v1/audit-records?count=N` (fallback `/mso/api/v1/audit-records`) → `{auditRecords:[{timestamp,user,action,description,details}]}` (or records/imdata/list).
- **Writes:** `POST /mso/api/v1/schemas`, `POST /mso/api/v1/deploy` → accept + store; return an id/status. Cross-plane rule: NDO tenants/VRFs/BDs must match APIC MIT names.
- Errors: 401 → NDOAuthError; else propagate. Router returns `{"detail":...}`.
### 7a. NDO schema write round-trip (PR-11)
The multi-site (`MS`) aci-ansible playbooks create a per-tenant-region schema (`<tenant>-<region>`, e.g. `"MS-TN1-LAB0"`) during `create_tenant`'s MSO play, then PATCH it from every subsequent playbook (`create_bd`, `create_application`, ...). Verified against `ansible-mso`'s `plugins/module_utils/{mso,schema}.py` and a real multi-site E2E run against ACI hardware.
- **Sequence:** `GET schemas/list-identity` (displayName→id) → `GET schemas/{id}` (full doc) → `PATCH schemas/{id}` with a JSON-Patch-shaped op list `[{"op":"add"/"replace"/"remove","path":...,"value":...}]`. `POST /mso/api/v1/schemas` creates a schema (optionally already carrying its first template — `mso_schema_template.py`'s "schema doesn't exist yet" branch); `PATCH` applies ops against the stored doc and returns the updated doc. Implemented in `aci_sim/ndo/patch.py` (`apply_json_patch`), wired into `aci_sim/ndo/app.py`'s `create_schema`/`patch_schema` routes.
- **Path addressing — three conventions cisco.mso modules use, all supported:**
1. Standard RFC 6902: numeric index, or `"-"` to append.
2. Named-segment lookup against a `name`/`displayName` field — most template-level objects (`bds`/`anps`/`epgs`/`contracts`/`filters`/`externalEpgs`/`vrfs`), e.g. `/templates/LAB1/bds/bd-App1_LAB1`.
3. A synthetic **composite key** `"{siteId}-{templateName}"` unique to the top-level `sites[]` array — every `mso_schema_site_*` module builds this literally (`site_template = "{0}-{1}".format(site_id, template)`), e.g. `/sites/1-LAB1/bds/-`.
4. A **`*Ref`-name** lookup for site-local child objects, which carry no `name` field of their own — only a `bdRef`/`vrfRef`/etc string pointing back at the template object — yet are still addressed by that object's bare name: `/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-`.
- **Collection-default normalization:** real NDO always serves back every collection key (`vrfs`/`bds`/`anps`/`contracts`/`filters`/`externalEpgs`/`intersiteL3outs`/`serviceGraphs`, and nested `subnets`/`epgs`/`contractRelationships`/...) as `[]` even when a client's create/add payload omitted them. `normalize_template()`/`normalize_site()` backfill these on every create and every PATCH — a missing key is a hard client-side crash (`'NoneType' object is not iterable`, bare `KeyError`), not a 4xx, so this has to be done proactively rather than reactively.
- **`*Ref` string normalization:** several write payloads (e.g. `mso_schema_site_bd.py`'s `bdRef=dict(schemaId=...,templateName=...,bdName=...)`) send a *dict* form of a `*Ref` field, but real NDO stores/serves it as the canonical **string** `/schemas/{schemaId}/templates/{templateName}/{category}/{name}` — confirmed because a sibling module (`custom_mso_schema_site_bd_subnet.py`) does `bd_ref_string in [v.get('bdRef') for v in ...]` and `', '.join(...)` on the stored values; a dict there crashes with `TypeError: sequence item 0: expected str instance, dict found`. `apply_json_patch` stringifies `bdRef`/`vrfRef`/`l3outRef`/`filterRef`/`contractRef`/`anpRef`/`serviceGraphRef` dicts on every `add`/`replace` op.
- **ND user-class fallback:** `GET /api/config/class/remoteusers` / `/localusers``{"items":[{"spec":{"loginID",...,"id"|"userID"}}]}`. `cisco.mso.mso_tenant`'s `lookup_users()`/`lookup_remote_users()` query the modern `/nexus/infra/api/aaa/v4/*` routes first and fall back to these legacy routes when both come back empty; a 404 here (no route at all) aborts with `"ND Error: Unknown error no error code in decoded payload"` since the error body has neither `code` nor `messages`.
- **Bare `GET /api/v1/sites`** (PR-11) — `cisco.mso.ndo_template`'s TenantPol prereq lookup hits this un-prefixed path; same data as `/mso/api/v1/sites`.
- **`PUT`/`POST /mso/api/v1/tenants{,/{id}}`** — `mso_tenant`'s create-vs-update branch: every tenant this sim seeds already exists in `GET /mso/api/v1/tenants`, so a real run always takes the `PUT .../tenants/{id}` (update) path.
- **Schema-template deploy:** `GET schemas/{id}/validate` (legacy + `ndo_schema_template_deploy`, discarded return value, only status matters), `GET execute|status/schema/{id}/template/{name}` (legacy `mso_schema_template_deploy`), `POST /mso/api/v1/task` `{schemaId,templateName,isRedeploy}` (`ndo_schema_template_deploy` — the module aci-ansible's `mso-model` role actually invokes; confirmed against the collection installed on real ACI hardware, which differs from `ansible-mso`'s `master`-branch module source).
- **Site-local ANP/EPG auto-mirroring + self-referencing anpRef/epgRef (PR-12).** Two compounding gaps, both required to close `bind_epg_to_static_port`:
1. **Template-level self-refs.** Real NDO stores a self-referencing `anpRef` string on every template-level ANP object itself (and `epgRef` on every EPG) — confirmed because `ansible-mso`'s `mso_schema_template_anp.py`/`_anp_epg.py` explicitly strip it client-side before an idempotency comparison (`if "anpRef" in mso.previous: del mso.previous["anpRef"]`) — dead code unless the server actually sends one back. `MSOSchema.set_site_anp()`/`set_site_anp_epg()` (`ansible-mso`'s `module_utils/schema.py`) key their site-local lookup off exactly this field (`template_anp.details.get("anpRef")`); without it, the lookup compares every site-anp's `anpRef` against `None` and never matches. `normalize_template()` (`aci_sim/ndo/patch.py`) now backfills `anpRef`/`epgRef` on every `anps[]`/`epgs[]` entry it normalizes (`/schemas/{id}/templates/{t}/anps/{a}` and `/schemas/{id}/templates/{t}/anps/{a}/epgs/{e}` respectively), and takes an optional `schema_id` param to build them; `build_ndo_model()` (`aci_sim/ndo/model.py`) calls it at boot too, so a topology tenant that ships with ANPs/EPGs already configured doesn't skip this.
2. **Site-local mirroring.** Real NDO 4.x auto-populates a schema's site-local `sites[].anps[].epgs[]` the moment a template-level ANP/EPG is added to a template that already has a site attached (`mso_schema_site.py` already ran in `create_tenant`'s flow, per PR-11). `apply_json_patch` replicates this: an `add /templates/{t}/anps/-` op auto-creates a matching `{anpRef, epgs:[]}` entry in every `sites[]` row whose `templateName == t`; an `add /templates/{t}/anps/{a}/epgs/-` op auto-creates a matching **full-shaped** site-EPG `{epgRef, subnets:[], staticPorts:[], staticLeafs:[], domainAssociations:[]}` entry under that mirrored site-anp. `build_ndo_model()` performs the same mirroring at boot for topology-seeded schemas. Both are idempotent (safe on playbook retries). The full child-collection shape is mandatory: every sibling `mso_schema_site_anp_epg_*` module reads its own array with a **bare subscript** (not `.get()`), so any missing key is a hard `KeyError` client-side — `mso_schema_site_anp_epg_domain.py` bare-subscripts `domainAssociations` (`domains = [dom.get("dn") for dom in ...["epgs"][epg_idx]["domainAssociations"]]`), `_staticleaf` reads `staticLeafs`, `_staticport` reads `staticPorts`, `_subnet` reads `subnets`. A real hardware MS-TN1 run regressed on `bind_epg_to_physical_domain`/`_vmm_domain` with `KeyError: 'domainAssociations'` when the mirrored site-EPG shipped without it (pre-PR-12 those binds passed only because no site-EPG existed at all, so the module took a non-crashing branch). Single source of truth for the shape is `SITE_OBJECT_DEFAULTS["epgs"]`, consumed by both `_new_site_epg()` (mirror path) and `normalize_site()` (backfill path).
Both `*Ref` strings use NDO's canonical form — `anpRef`: `/schemas/{id}/templates/{t}/anps/{a}` (`_REF_NAME_FIELDS`); `epgRef` uniquely nests **two** name segments, `/schemas/{id}/templates/{t}/anps/{a}/epgs/{e}` (confirmed against `ansible-mso`'s `module_utils/mso.py` `epg_ref()` — handled by a dedicated `_stringify_refs` branch since the generic single-category table only fits one name field). `_REF_LOOKUP_KEYS` also gained `epgRef` so a site-epg (ref-only, no `name` of its own) resolves by its bare EPG name the same way site-local bds/anps already did (PR-11).
Without either fix, `mso_schema_site_anp_epg_staticport.py`'s own "create site anp/epg if missing" fallback fires instead (its own code comment: *"Coverage misses this two conditionals when testing on 4.x and above"* — i.e. it's dead code on real hardware) and ultimately crashes with `'NoneType' object has no attribute 'details'` — confirmed on a real MS-TN1 `bind_epg_to_static_port` run on ACI hardware before this fix (schema=MS-TN1-LAB0, anp=app-Web_LAB1, epg=epg-web, site=LAB1-IT-ACI, static vpc port `ipg-vpc-LegacySW01` vlan-100), now resolved end-to-end.
### 7c. Tenant-policy-template DHCP surface + schema serviceGraphs (PR-13)
Closes the last two NDO write-surface gaps blocking the MS suite's DHCP and service-graph tasks. Verified against `ansible-mso`'s `plugins/modules/{ndo_template,ndo_dhcp_relay_policy,ndo_schema_template_bd_dhcp_policy}.py`, `plugins/module_utils/{mso,template}.py`, the `mso-model` role's `library/custom_mso_schema_service_graph.py`, and real multi-site E2E runs against ACI hardware.
- **Bare vs `/mso`-prefixed split, and why it matters for templates.** `ansible-mso`'s `MSOModule.request()` builds its URL differently depending on connection mode: tasks using the persistent `ansible.netcommon.httpapi` connection (`ansible_network_os: cisco.nd.nd`) route through `NDO_API_VERSION_PATH_FORMAT = "/mso/api/{v}/{path}"`; tasks carrying `delegate_to: localhost` (no persistent httpapi socket) fall through `MSOModule`'s direct-HTTP branch, which never adds the `/mso` prefix at all — `self.url = "{0}api/{1}/{2}".format(base_uri, api_version, path)`. `prereq_tenantpol.yml`'s `cisco.mso.ndo_template` task uses `delegate_to: localhost`, so it hits `GET/POST https://host/api/v1/templates*` (BARE) while `create_dhcp_relay`'s `cisco.mso.ndo_dhcp_relay_policy` and `bind_dhcp_relay_to_bd`'s `cisco.mso.ndo_schema_template_bd_dhcp_policy` (no `delegate_to`) hit the `/mso`-prefixed form. Both forms are registered against the SAME mutable template store (`aci_sim/ndo/app.py`'s `_get_template_summaries`/`_create_template`/`_patch_template`/`_get_template_objects`, looped over `("/mso/api/v1", "/api/v1")`), matching the existing `/api/v1/tenants``/mso/api/v1/tenants` / `/api/v1/sites``/mso/api/v1/sites` pattern PR-10/PR-11 established for the identical `delegate_to` split.
- **Template create/lookup sequence.** `ndo_template`'s `MSOTemplate.__init__` first queries `templates/summaries?templateName=...&templateType=...` (name+type filter; `schemaName`/`schemaId` kwargs are `None` and skipped by `query_objs()`) — every summary entry therefore needs a `templateName` field, not just `templateId`/`templateType` (the boot-seeded tenantPolicy template summary was retrofitted with one). If nothing matches, it `POST`s `{displayName, templateType, tenantPolicyTemplate:{template:{tenantId}, sites:[{siteId}]}}` to `templates` (no `/summaries` suffix) — the sim assigns a `templateId`, stores the full doc, and mirrors a summary entry so the very next `templates/summaries` lookup (by any caller, either path prefix) resolves it.
- **DHCP relay policy add + uuid backfill.** `ndo_dhcp_relay_policy`'s add PATCHes `add /tenantPolicyTemplate/template/dhcpRelayPolicies/-` with `{name, providers, description?}` — no `uuid` (real NDO assigns one server-side). A provider's `epgRef` is the target schema-template EPG's `uuid` (`MSOSchema.set_template_anp_epg().details.get("uuid")`) — schema-template EPGs/externalEpgs never carried a `uuid` field before this PR; `normalize_template()` now backfills a stable one (`_normalize_object`'s `epgs`/`externalEpgs` branches). `ndo_schema_template_bd_dhcp_policy` (`bind_dhcp_relay_to_bd`) resolves a relay/option policy's uuid via `GET templates/objects?type={dhcpRelay|dhcpOption}&name=...`, requiring both a `tenantId` (matched client-side against the policy-owning template) and a non-empty `uuid` on the returned entry, or it `fail_json`s — the sim's `_patch_template` backfills a stable uuid on every dhcpRelayPolicies/dhcpOptionPolicies entry that lacks one. The same `templates/objects` route also serves `type=epg`/`type=externalEpg` queries (searching every schema template's `anps[].epgs[]`/`externalEpgs[]`), used by `ndo_dhcp_relay_policy`'s own uuid→name resolution on query/present.
- **Site-level `serviceGraphs` + full-detail `GET /schemas`.** The mso-model role's "Create service graph" task uses `custom_mso_schema_service_graph.py`, a pre-`MSOTemplate`/`MSOSchema` community module (`version_added: '2.8'`) that bare-subscripts straight into the RAW `GET /schemas` list response: `mso.get_obj('schemas', displayName=schema)` then `schema_obj.get('templates')[idx]['serviceGraphs']` and, once a site-local device binding step runs, `schema_obj.get('sites')[site_idx]['serviceGraphs']`. The template-level bare-subscript was already covered (PR-11's `TEMPLATE_COLLECTION_KEYS`), but (a) the sim's `GET /mso/api/v1/schemas` previously returned only a trimmed `{id,displayName,name,templates:[{name}]}` summary — insufficient for this module's bare-subscript pattern, which needs the full nested doc (`bds`/`anps`/`serviceGraphs`/`sites`) — and (b) the SITE-level `serviceGraphs` key was never backfilled at all. Both fixed: `GET /schemas` now returns full schema detail per entry (real NDO's plain list endpoint already does this — it's precisely why the lighter `schemas/list-identity` endpoint exists as a separate optimization); `SITE_TOP_LEVEL_DEFAULTS` (`aci_sim/ndo/patch.py`) backfills `serviceGraphs` (and `contracts`, see below) as top-level keys on every schema `sites[]` entry via `normalize_site()`. The module also needs `GET schemas/service-node-types` (Firewall/Load Balancer/Other → stable id) and a `tenantId` field on the schema template itself (used on its "service graph already exists, add a node" branch) — both added.
- **Site-local contracts mirroring (deeper layer, found chasing GAP 2 to green on real ACI hardware).** Once the `serviceGraphs` KeyError was fixed, a real hardware MS-TN2 `create_tenant` pass-2 run (`-e automate_contract_graph=true -e automate_site_redirect=true`) progressed to the mso-model role's raw `cisco.mso.mso_rest`-driven "Atomic PATCH — bind service-graph redirect on ALL fabrics in one request" task, which PATCHes `add /sites/{siteId}-{template}/contracts/{contract}/serviceGraphRelationship` — addressing a SITE-LOCAL contract by bare name, mirroring the same addressing convention site-local bds/anps already use. The sim never mirrored template-level contract adds into `sites[].contracts[]` at all (unlike anps/epgs, mirrored since PR-12), so `apply_json_patch`'s `_find_by_name` raised `PatchError: path segment 'con-Firewall_LAB0' not found in list` — confirmed verbatim on the real run. `_mirror_template_contract_to_sites()` (PATCH-time, parallel to `_mirror_template_anp_to_sites`) and an equivalent boot-time mirror in `build_ndo_model()` close this: an `add /templates/{t}/contracts/-` op (or a topology-seeded template that already has contracts) now auto-creates a matching `{contractRef}` entry in every `sites[]` row whose `templateName == t`.
- **Verified on real ACI hardware end-to-end:** MS-TN1 `create_dhcp_relay` (08) and `bind_dhcp_relay_to_bd` (09) both reach `failed=0`; MS-TN2's identical DHCP chain (08/09) also `failed=0`; MS-TN2 `create_tenant` pass-2 (01b, `-e automate_contract_graph=true -e automate_site_redirect=true`) reaches `failed=0` — including the atomic per-fabric service-graph redirect PATCH. No regression observed on MS-TN1/MS-TN2's already-green tasks (01-07, 10, 01a) or a SF-suite spot-check (create_tenant, create_dhcp_relay) re-run on the same sandbox.
### 7d. Site-local BD auto-mirroring for aci-py (PR-15)
Closes a schema-PATCH 400 mid `create_tenant`/`create_bd`, found running `aci-py` (a pure-Python Ansible-to-ACI compiler, distinct from the `ansible-mso`/`cisco.mso` collection §7a-7c document) against the sandbox on real ACI hardware. Same family of gap as PR-12's ANP/EPG mirroring and PR-13's contract mirroring — this closes the missing BD sibling.
- **Why `mso_schema_site_bd` always sends `replace`, never `add`.** aci-py's `mso_schema_site_bd` shim (mirroring `ansible-mso`'s `mso_schema_site_bd.py`) PATCHes a site-local BD shadow with `op: replace` unconditionally — its own docstring/comment documents why: real NDO 4.x auto-creates the site BDDelta shadow the instant a template-level BD is added to a template that already has a site attached, so by the time the site-BD module runs the shadow already exists and a fresh `add` would 409 ("Multiple BDDelta entries") on real hardware; the module does a GET-then-replace instead.
- **The gap.** The sim mirrored template-level ANP/EPG adds into `sites[].anps[].epgs[]` (PR-12) and contract adds into `sites[].contracts[]` (PR-13) but never mirrored template-level BD adds into `sites[].bds[]` at all. So the very first `replace /sites/{siteId}-{t}/bds/{bd}` against a schema that had only ever seen the template-level `add /templates/{t}/bds/-` hit `_find_by_name`'s "not found" branch: `PatchError: replace: 'bd-FW_LAB0' not found at '/sites/1-LAB1-LAB2/bds/bd-FW_LAB0'` — confirmed verbatim against a real hardware aci-py `create_tenant`/`create_bd` run (MS-TN2) before this fix.
- **Fix.** `_mirror_template_bd_to_sites()` (`aci_sim/ndo/patch.py`, PATCH-time, parallel to `_mirror_template_anp_to_sites`/`_mirror_template_contract_to_sites`): an `add /templates/{t}/bds/-` op now auto-creates a matching site-local BD shadow (`_new_site_bd()`: `{bdRef, hostBasedRouting: False, subnets: []}` — the full child-collection shape `mso_schema_site_bd_subnet.py` bare-subscripts) in every `sites[]` row whose `templateName == t`. `build_ndo_model()` (`aci_sim/ndo/model.py`) performs the equivalent mirroring at boot, so a topology.yaml tenant that ships with BDs already configured also starts with the site shadow pre-mirrored (matching the same boot-time treatment PR-12/PR-13 gave ANPs/EPGs/contracts). Idempotent (safe on playbook/driver retries) — `_find_by_name` skips a site that already carries the bdRef.
- **Verified on real ACI hardware end-to-end:** aci-py's `scripts/live_chain.py` driver against `environments/lab-multisite-sim` — MS-TN2 now reaches **13/13 steps rc=0** (was 9 ok / 4 fail before this fix: `create_tenant` pass1, `create_bd`, `create_tenant` pass2, and `migrate_existing_vlan_gateway` all 400'd on exactly this `replace: 'bd-*' not found` error); MS-TN1 stays **11/11 rc=0** (1 skip — no `set_primary_fw_state` var file for TN1's combo — no regression).
### 7e. Site-local BD mirror-vs-site-module dedup (PR-16)
Closes a site-local BD *duplication* left behind by PR-15's mirror, found running the Acme App1-Net tenant's `create_tenant`/`create_bd` playbooks against a real NDO schema on ACI hardware (schema `App1-Net-LAB0`).
- **The symptom.** After `create_tenant` (whose template-BD `add` fires `_mirror_template_bd_to_sites`, PR-15) and `create_bd` (whose `cisco.mso.mso_schema_site_bd`/`custom_mso_schema_site_bd_subnet` tasks PATCH the site-local shadow), `sites[0].bds` on the live schema carried **two** entries for the same template BD `App1-Net_VLAN10`: one empty mirror (`bdRef=".../templates/LAB1/bds/App1-Net_VLAN10"`, `subnets=[]`) and one carrying the real subnet (`10.10.10.1/24`). Real NDO has exactly one site-BD row per template BD.
- **Root cause #1 — the append path skipped dedup entirely.** `apply_json_patch`'s `op: add` handling has three sub-branches keyed on the last path token: `"-"` (append), a numeric index (`insert`), or a name (resolved via `_find_by_name`, which already deduped named-segment adds). Only the named-segment branch deduped. `mso_schema_site_bd.py`'s "BD does not exist yet" branch (confirmed against the pre-existing `test_patch_add_site_bd_by_composite_key`, PR-11) PATCHes `add /sites/{siteId}-{template}/bds/-` — the **append** form — so it always appended unconditionally, regardless of whether the template-add mirror already created a site-local shadow for the same BD.
- **Root cause #2 — `_find_by_name`'s own ref-lookup only recognized the STRING ref form.** Even where dedup *was* attempted (the mirror's own idempotency check, `_find_by_name(site["bds"], bd_name)`), it iterated each item's `bdRef` and matched only `isinstance(ref_val, str)`. Several real cisco.mso write payloads send `bdRef` as a **dict** (`{schemaId, templateName, bdName}`, or a bare `{bdName: ...}` with no schema/template context) — an existing site-local entry left in that shape was invisible to any subsequent name-based lookup.
- **Fix — dedup a NAMELESS shadow strictly by its single IDENTITY ref.** A site-local shadow (`sites[].bds`/`anps`/`epgs`/`contracts` entry) carries no `name`/`displayName` of its own; its identity is exactly one `*Ref` (`bds``bdRef`, `anps``anpRef`, `epgs``epgRef`, `contracts``contractRef`, in `_IDENTITY_REF_BY_COLLECTION`). `_bare_ref_name(ref_val)` extracts the bare object name from that identity ref regardless of shape (string trailing segment, or the dict form's own name field via `_REF_NAME_FIELDS`), so a dict-form `bdRef` (a site-module payload's shape, possibly a bare `{bdName: ...}`) and a string-form `bdRef` (the mirror's canonical shape) resolve to the same bare name. `_find_by_name`'s ref-lookup now fires **only for nameless items** and **only over the identity-ref keys** (never a property ref like `vrfRef`). `_find_shadow_by_identity(items, value, collection)` is the append-path counterpart: it dedups **only** a nameless value, **only** by the identity ref for that collection (`tokens[-2]`), and `apply_json_patch`'s `add`+`"-"` branch calls it before appending — a match **replaces** in place (cisco.mso's GET-then-write idiom, real NDO's one-row-per-object invariant), a genuinely new shadow appends. Because the machinery is keyed by collection→identity-ref, it covers site-local ANPs/contracts too with no per-type change.
- **Why identity scoping is load-bearing (regression the first cut of this PR shipped and an independent full-chain run caught).** An earlier cut resolved a value's identity by iterating **all** `_REF_LOOKUP_KEYS` — including `vrfRef`/`l3outRef`. But every template BD under one L3-attached VRF shares the same `vrfRef` (e.g. `vrf-L3_LAB0`), and a template-level BD `add` (a NAMED object, not a shadow) was also run through the same dedup. Result: two distinct template BDs both "matched" via their shared `vrfRef`, so each `add /templates/{T}/bds/-` overwrote the previous one and every template's BDs collapsed to a **single** entry — the real Acme `create_bd` failed with `"Provided BD 'bd-App2-Net_VLAN11_LAB0' does not exist. Existing BDs: bd-App3-Hst_VLAN12_LAB0"` (only the last-added BD survived per template). The invariant the corrected code enforces: **a named object is matched ONLY by name/displayName, never by any `*Ref`; ref-matching is ONLY for nameless shadows and ONLY via the ONE identity ref for that list** — a property ref is a property, not an identity. Guarded by `test_distinct_template_bds_sharing_vrfref_do_not_collapse` and three sibling tests.
- **Verified on real ACI hardware end-to-end:** Acme App1-Net tenant's `create_tenant.yml` + `create_bd.yml` both `failed=0` against the sim; the live NDO schema `App1-Net-LAB0` shows template BD counts `LAB1-LAB2=6`, `LAB1=14`, `LAB2=10` (30 total, no template collapse) and exactly one site-local BD entry per template BD in each site's `bds[]` (no duplicate).
## 8. Topology view (`GET /api/topology`) — exact needs
Per site autoACI issues: `fabricNode`, `fabricLink`, `vpcDom`, `fabricHealthTotal` (paged);
per-node `query_dn("topology/pod-{pod}/node-{id}/sys/health")` → healthInst.cur;
fabric-wide `faultInst` (severity filter) — counts per node parsed from `/node-{id}/` in DN;
session tables `ospfAdjEp`, `bgpPeer`, `bgpPeerEntry`, `bgpPeerAfEntry`;
multi-site ISN: per spine `query_dn("topology/pod-1/node-{spineId}/sys/bgp/inst", subtree=True, subtree_class="bgpPeer")`
→ detects /32 peers with asn≠local → synthesizes an ISN cloud node + spine→ISN links.
Node detail `GET /api/topology/node/{id}`: `ethpmPhysIf`,`l1PhysIf` (via `/sys` subtree), node-scoped `faultInst`,
`fvIfConn` (EPGs on ports), health. `pod` is parsed from `fabricNode.dn` (`pod-{N}`) — do NOT assume pod-1 everywhere.
Link adjacency from `fabricLink.n1/n2` + DN `/lnk-{p1}-to-{p2}/`. Roles from `fabricNode.role`.
## 9. Plugins — 46 files, all must get believable data
Every class in §6 is read by some plugin. Control-plane plugins (tenant/bd/epg/vrf/contract/l3out/access/
capacity) read config MOs. Data-plane plugins (bgp_health, routing_state, fabric_bgp_evpn, adjacency_health,
interface_status/flaps, ep_tracker, fault_summary, health_score) read operational MOs. Many do two-pass
parent→child parsing and use `eq/wcard` filters + subtree. The generic query engine + a consistent MIT
covers all of them without per-plugin code.
## 10. Fabric Build (mostly local; write path is dry-run-first)
Catalog/prereq-gate/common-vars/generate-vars are LOCAL to autoACI (no APIC). Real apply goes through a
hidden `/api/_internal/run-playbook` that compiles templates via aci-py → `POST /api/mo/{dn}.json` (single-fabric)
and/or NDO `schemas`+`deploy` (multi-site), `--check` (dry-run) by default. To test a playbook end-to-end:
run in apply mode → sim upserts the MOs → verify via read-back. ~22 playbooks are pure ACI/NDO REST
(create_*/bind_*/initial_fabric_*/add_leaf/add_spine/provision_*); the N5K/N7K SSH migration family is OUT OF SCOPE.
## 11. Ansible-compatibility (PR-9 — cisco.aci fidelity)
The owner's primary public use case is running **Ansible `cisco.aci` playbooks**
against this simulator directly (not just autoACI). Everything below was
verified read-only against upstream `plugins/module_utils/aci.py`
(https://github.com/CiscoDevNet/ansible-aci) — the shared HTTPAPI/module
base every `cisco.aci.aci_*` module calls into.
**GET-before-write existence check (FEATURE 6, live blocker).** Every
`state=present`/`state=absent` module calls `get_existing()` (a GET) before
building its diff or delete. `api_call()`'s response handling treats ANY
`status != 200` as fatal via `fail_json` — GET has no "404 = not found,
that's fine" carve-out. So `GET /api/mo/{dn}.json` on a nonexistent-but-
well-formed DN **must** be `200 {"imdata":[],"totalCount":"0"}`, never 404
(§5 above) — otherwise the first task of any `state=present` playbook
against a not-yet-created object fails immediately.
**`DELETE /api/mo/{dn}.json` (`state=absent`).** `delete_config()`:
```python
if not self.existing and not self.suppress_previous:
return
elif not self.module.check_mode:
self.api_call("DELETE", self.url, None, return_response=False)
```
The module itself is idempotent at the CLIENT level — it only ever issues
DELETE after its own GET confirmed the object exists, so it never actually
sends a DELETE for an already-absent DN. This sim implements the route as
server-side idempotent too (200 + empty envelope whether the DN existed or
not) rather than 404-on-missing: (a) it matches this codebase's existing
MIT delete semantics (`MITStore.upsert`'s `status="deleted"` branch is
unconditional, no existence check) and (b) it's the safer default for any
OTHER client that DOES call DELETE without a prior GET. An existing DN's
entire subtree is removed (`MITStore.delete(dn)`, public wrapper added in
PR-9 around the pre-existing `_remove_subtree` helper `upsert` already used).
**`status` attribute.** cisco.aci's `payload()` never sets a `status` key —
delete goes through the dedicated DELETE verb above, not a POST body with
`status:"deleted"`. That POST-body form is a Fabric Build / aci-py
convention this sim already supported (§3) and continues to: `status:
"deleted"` on any planned `(class, attrs)` node removes that DN's entire
subtree (verified: `MITStore.upsert` already special-cased this pre-PR-9;
PR-9 added regression coverage). Any other `status` value (including the
literal string `"created,modified"`, which real APIC/aci-py tooling can
emit) is ignored and the write proceeds as a normal upsert — no special
handling, no error.
**`annotation`/`ownerKey`/`ownerTag`/`nameAlias` passthrough.** cisco.aci's
`aci_annotation_spec()` defaults `annotation` to the literal string
`"orchestrator:ansible"` and `payload()` injects it (plus `ownerKey`/
`ownerTag` when set) into `proposed["attributes"]` for every managed
object, unconditionally. `name_alias` is a separate per-module spec, also
folded into the same attributes dict when set. None of these are special
sim attributes — they round-trip because `MO.attrs` is a generic
`dict[str,str]` and the write path never allowlists known attribute names;
this section just documents and regression-tests that this passthrough
survives POST → `mo` GET → class query unchanged.
**Certificate signature auth (accept-mode).** cisco.aci supports
password-less auth via `private_key`; `cert_auth()` sets a single `Cookie`
header per request (never touches `APIC-cookie` / never calls aaaLogin):
```python
sig_dn = "uni/userext/user-{username}/usercert-{certificate_name}"
self.headers["Cookie"] = (
"APIC-Certificate-Algorithm=v1.0; "
f"APIC-Certificate-DN={sig_dn}; "
"APIC-Certificate-Fingerprint=fingerprint; "
f"APIC-Request-Signature={base64(RSA-SHA256(method+path+body))}"
)
```
This sim implements **accept-mode**: `require_session()` treats a request
carrying all four `APIC-Certificate-*`/`APIC-Request-Signature` cookies as
authenticated, for the user named in `APIC-Certificate-DN`, PROVIDED that
user matches `SIM_USERNAME` — WITHOUT verifying the RSA-SHA256 signature
bytes at all. A missing cookie, an unparsable `APIC-Certificate-DN`, or a
DN naming a different user all fall through to the normal cookie-session
check (which also fails for a request that never called aaaLogin),
yielding the standard 403 envelope. This is a deliberate simulator trust
model — see docs/DESIGN.md's "Certificate accept-mode trust model" note.
`SIM_CERT_STRICT` env var is a documented placeholder for a future
signature-verifying mode; it currently has no effect.
**firmwareCtrlrRunning.** Seeded per controller (`build/fabric.py`,
alongside the existing controller `topSystem`) at
`{ctrl_dn}/ctrlrfwstatuscont/ctrlrrunning`, `version` = `site.apic_version`
— the same value `topSystem.version` already carried, so the two can never
drift apart. Small, trivially-derived addition; not previously built.
+829
View File
@@ -0,0 +1,829 @@
# aci-sim — DESIGN
A software simulator that faithfully impersonates the REST APIs of a **2-site Cisco ACI
fabric** (two per-site APICs + one ND/NDO), driven by an editable `topology.yaml`, so that
pointing local **autoACI** at it makes Topology, NDO views, all query plugins, and Fabric
Build render internally-consistent, believable control- + data-plane data.
The contract we emulate is in `docs/CONTRACT.md` (authoritative — trust it, don't re-explore autoACI).
## Locked decisions (owner-approved)
1. **Generic MIT engine** — model each APIC as a real Managed Information Tree (DN-keyed
objects, parent/child) with a query resolver implementing APIC's class/mo/node-scoped +
`rsp-subtree`/`target-subtree-class` + `eq/wcard/and/or` + pagination semantics. One YAML →
full object graph; every current AND future autoACI query works with no per-class code.
2. **Accept-reflect writes + reset/snapshot**`POST /api/mo` and NDO schema/deploy upsert
into the live MIT so read-back + Topology show the change (enables push→verify playbook
testing). In-memory, with `reset` (to baseline) and `snapshot`/restore.
3. **Static deterministic snapshot** — stable, internally-consistent, reproducible data (no
liveness engine in v1). Deterministic so views are easy to verify.
4. **Configurable topology** — YAML parametrizes spine/leaf counts; leaves can be added/removed
(statically via YAML+reload, dynamically via the `add_leaf_switch_pair` playbook's
`fabricNodeIdentP` write reaction). Border-leaf vPC → CSW, 2 sites + ISN, full L3Out subtree.
5. **Scope** — ACI + NDO REST surface (~22 REST playbooks fully testable + the ACI half of
migration). **NX-OS SSH is OUT of v1** (extension seam left, no auth stub).
6. **Stack** — Python 3.11+, FastAPI + uvicorn + Pydantic v2, PyYAML, httpx (tests). Self-signed
TLS on each host (autoACI uses `verify=False`). Matches autoACI's stack.
## Process / host layout
One process, three HTTPS apps on three localhost ports (self-signed certs in `certs/`):
`APIC Site-A :8443`, `APIC Site-B :8444`, `NDO :8445`. Each site APIC serves its own MIT;
both sites are built from one global topology so stretched tenants + ISN peering are consistent.
The NDO app derives its schema model from the same topology (same tenant/VRF/BD names).
Point autoACI: Site-A→`127.0.0.1:8443`, Site-B→`127.0.0.1:8444`, NDO→`127.0.0.1:8445`.
## Module map + interfaces (the contracts subagents implement against)
### `mit/` — object store (foundation; no deps)
- `mo.py`: `MO` = `{class_name:str, attrs:dict[str,str], children:list[MO]}`; `attrs["dn"]` is the key.
Helpers: `MO(cls, **attrs)`, `.dn`, `.add_child(mo)`, `.flat()` (self+descendants).
- `dn.py`: bracket-aware DN utils — `dn_split(dn)->list[str]` (never split inside `[]`),
`parent_dn(dn)`, `rn_of(dn)`, `dn_class_hint`, `pod_from_dn`, `name_from_dn`.
- `store.py`: `MITStore``add(mo)`, `upsert(mo)` (merge attrs + recurse children; honor
`status=deleted`), `get(dn)->MO|None`, `children(dn, classes=None)`, `subtree(dn, classes=None)`,
`by_class(cls)->list[MO]`, `all()`; maintains a `dn→MO` map and a `parent→[child dn]` index.
Deterministic iteration order (insertion order).
### `query/` — ACI query engine (deps: mit)
- `filters.py`: parse `eq/wcard/and/or(...)` strings → predicate `fn(MO)->bool`. Bracket/quote aware.
- `engine.py`: `run_class_query(store, cls, params)`, `run_mo_query(store, dn, params)`,
`run_node_scoped(store, node_dn, cls, params)`. Params object carries filter, subtree mode
(`rsp-subtree` children|full or legacy `query-target=subtree`), subtree classes, page, page_size.
Returns `(imdata:list[dict], total:int)` in the exact `{cls:{attributes,children}}` shape.
**Two subtree shapes (must match real APIC + autoACI):** (a) legacy `query-target=subtree` (autoACI's
`query_dn(subtree=True)`) → descendants FLAT as top-level imdata, filtered by `target-subtree-class` (root
included only if it matches); consumers read the target class at the top level, never recurse into `children`.
(b) modern `rsp-subtree=children|full` (autoACI's `query_class(subtree=…)`) → matched objects with NESTED
`children` (children=one level, full=recursive). Pagination slices the result set; `total` = pre-pagination count.
### `topology/` — the YAML contract (deps: none)
- `schema.py`: Pydantic v2 models for the whole topology (see `topology.yaml` + §Topology schema below).
Validates counts, references (bd→vrf, epg→bd, l3out→vrf), site/ISN config. Fail loudly on bad refs.
Also validates (PR-8, findings #27/#28):
- `fabric.loopback_pool`/`fabric.oob_pool` fall within the fixed `10.0.0.0/8` /
`192.168.0.0/16` ranges that `build/fabric.py`'s `loopback_ip()`/`oob_ip()`
actually generate (those functions are not parameterized by an arbitrary CIDR
— 6+ call sites across `build/*.py` call them with just `(pod, node_id)` — so
the pool fields describe, and are checked against, the builder's real output
rather than being purely cosmetic).
- CSW peering params duplicated between `site.border_leaves[].csw` (authoritative
— carries `local_ip`/`vlan` too, used by `l3out.py` for the physical
sub-interface/`l3extMember`) and `tenant.l3outs[].csw_peer` (used for the
L3Out's `bgpPeerP`/static-route nexthop) must agree on `name`/`asn`/`peer_ip`
for any border-leaf the l3out references, or loading fails with a clear error.
- `loader.py`: `load_topology(path)->Topology`.
### `build/` — generators (deps: topology, mit)
Each sub-builder is `build(topo, site, store)` and only ADDS MOs. `orchestrator.build_site(topo, site)
-> MITStore` runs them in dependency order and returns a populated store; `build_all(topo)->{site:store}`.
Sub-builders (one concern each, <~200 lines):
- `fabric.py` — fabricNode (roles/ids/model/serial/version from YAML), topSystem, fabricHealthTotal, vpcDom/vpcIf (border-leaf pair), infraWiNode.
- `cabling.py` — fabricLink (with `/lnk-…/` DN), lldpAdjEp + cdpAdjEp derived STRICTLY from the cabling graph, isisAdjEp.
- `underlay.py` — bgpInst (local ASN), ospfAdjEp/**ospfIf**✅ (spine↔leaf), IS-IS dom.
- `overlay.py` — intra-site BGP-EVPN (spines as RR, leaves as clients): bgpPeer/bgpPeerEntry(established)/bgpPeerAfEntry;
**ISN**: on each spine's `sys/bgp/inst`, add inter-site bgpPeer with peer addr /32 + remote (other-site) ASN.
- `endpoints.py` — fvCEp/fvIp distributed across leaves+EPGs (encap/fabricPathDn consistent), epmMacEp/epmIpEp, fvIfConn.
Runs BEFORE `tenants.py` (PR-3b-batch1 reorder) so `tenants.py` can read back real endpoint paths for `fvRsPathAtt`.
- `tenants.py` — fvTenant/fvCtx/fvBD/fvSubnet/fvAp/fvAEPg + fvRs* rels + vzBrCP/vzSubj/vzFilter/vzEntry from YAML,
plus **fvRsPathAtt**✅ (static binding, reusing the EPG's own endpoint path) and **vzRsSubjGraphAtt**✅.
- `access.py` — infra AAEP/IPG/port-profiles/selectors/blocks + physDomP/l3extDomP + fvnsVlanInstP/fvnsEncapBlk + rels,
plus the **leaf-interface-policy cluster**✅ (infraAccPortP/infraHPortS/infraPortBlk/infraRsAccBaseGrp/infraAccBndlGrp)
and an `infraInfra` root MO at `uni/infra`.
- `l3out.py` — full l3extOut subtree (LNodeP/LIfP/InstP/subnets/RsEctx/RsL3DomAtt/RsPathAtt) + bgpPeerP/bgpAsP to CSW,
plus the operational bgpPeer/bgpPeerEntry(established) representing the CSW eBGP session; actrlRule for contracts;
plus the **route-control cluster**✅ (rtctrlProfile/rtctrlCtxP/rtctrlSubjP/rtctrlMatchRtDest/rtctrlSetComm/rtctrlSetPref),
**static routes**✅ (ipRouteP/ipNexthopP), **l3extMember**✅, and **ospfExtP**✅.
- `routing.py` (new, PR-3b-batch1) — per-(tenant,vrf) **uribv4Route/uribv4Nexthop**✅ RIB entries on every leaf/border-leaf
(BD subnets as connected routes + a 0.0.0.0/0 default via the L3Out CSW peer on border leaves), and **arpAdjEp**✅ —
one per locally-learned endpoint, reusing that endpoint's real MAC/IP. Runs after `endpoints`.
- `interfaces.py` — l1PhysIf + ethpmPhysIf (per node, ports incl. fabric uplinks + host ports), ethpmFcot, eqptcapacityPolUsage5min.
- `health_faults.py` — healthInst per node/tenant (`…/sys/health`, `/tn-X/health`), a seeded believable faultInst set
(node-local DNs so node-scoped queries work), configExportP/configJob, coopPol/coopInst.
- `userprofile.py` — static `uni/userprofile-admin` (aaaUserEp) + `aaaUserDomain` child (name="all"), so the
CONTRACT §1 login probe (`query-target=subtree&target-subtree-class=aaaUserDomain`) resolves instead of 404ing.
### `rest_aci/` — APIC FastAPI app (deps: query, mit, build)
- `auth.py` — aaaLogin/aaaRefresh/aaaLogout, token+cookie, the topSystem/userprofile login probes.
- `app.py``make_apic_app(site_state)`: routes `/api/aaaLogin.json`, `/api/aaaRefresh.json`,
`/api/aaaLogout.json`, `/api/class/{cls}.json`, `/api/mo/{dn:path}.json` (GET+POST),
`/api/node/class/{dn:path}/{cls}.json` (node-scoped) and `/api/node/class/{cls}.json`
(fabric-wide — equivalent to `/api/class/{cls}.json`; PR-6 FIX 3). Parses query params →
query engine → envelope. Error mapping (§5 of CONTRACT).
- `writes.py` — POST body → upsert into MIT (recurse children, honor status); `fabricNodeIdentP`
reaction materializes a full node-registration set (fabricNode + topSystem + healthInst +
fabricLink/lldpAdjEp/cdpAdjEp cabling to every spine + l1PhysIf inventory), fires for a
`fabricNodeIdentP` anywhere in the planned write (not just top-level), and derives pod from the
target site instead of hardcoding it (PR-6 FIX 2; shared with `/_sim/add-leaf`). Returns
created/modified imdata.
### `ndo/` — NDO (deps: topology, mit optional)
- `model.py``build_ndo_model(topo)`: sites, tenants, schemas/templates (vrfs/bds/anps/epgs/contracts/filters/externalEpgs
with `/vrfs/…` `/bds/…` refs), template summaries + tenantPolicy DHCP, fabric-connectivity, policy-states, audit records.
Names MUST match the APIC MIT (cross-plane consistency).
- `app.py``make_ndo_app(ndo_state)`: login/version + all §7 GETs + schema/deploy POST (store + return id/status).
### `runtime/` — supervisor (deps: all)
- `config.py` — ports, cert paths, topology path, host→site map (env-overridable).
- `supervisor.py` — load topology → `build_all` + `build_ndo_model` → start 3 uvicorn servers (asyncio) with TLS.
Keeps a `baseline` deep-copy of each store for `reset`.
### `control/` — admin API (mounted on each app under `/_sim/…`, deps: runtime state)
- `reset` (restore baseline), `snapshot`/`restore` (named), `reload` (re-read YAML + rebuild against
the FRESH matching `Site` object from the just-loaded topology — matched by `Site.id`, falling back
to `Site.name` — not the stale pre-reload one, so edits to a site's nodes/leaves actually take
effect; returns a 400 APIC error envelope if the site no longer exists in the reloaded topology
instead of crashing or silently keeping stale state; PR-6 FIX 1), `add-leaf`/`remove-leaf` (runtime
mutate + rebuild affected derived data — `add-leaf` shares `writes.py`'s enriched node-registration
reaction, not a bare `fabricNode`). Not part of the APIC/NDO contract — a side control plane for
testing.
## Internal-consistency rules (the "尽可能真实" core — enforce in build/)
- **Nothing free-floating.** Every operational MO derives from a topology fact.
- Cabling graph is the single source for `fabricLink`, `lldpAdjEp`, `cdpAdjEp` (neighbors ↔ actual links).
- IS-IS/OSPF underlay adjacencies only between physically-cabled spine↔leaf pairs.
- BGP-EVPN overlay: spines = route-reflectors, leaves = clients (sessions to spine loopbacks), operSt=established.
- ISN: for each spine, an inter-site eBGP-EVPN peer to the remote site's spines — addr /32, remote ASN =
the OTHER site's fabric ASN (this is what makes autoACI draw the ISN cloud). EVPN AF up.
- L3Out: border leaves peer eBGP to CSW (asn from YAML); the l3extOut config subtree AND the operational
bgpPeer(Entry) to the CSW must both exist and agree (addr/asn).
- Endpoints: each fvCEp lives on a real leaf port, in a real EPG/BD, with a plausible IP in the BD subnet;
epmMacEp/epmIpEp mirror them on the owning node. Endpoint host ports never overlap the APIC-controller
port range on the same leaf (see PR-5 FIX 3 below). A stretched-tenant endpoint is local (front-panel port)
on exactly ONE site and tunnel/vxlan-learned on the other — never front-panel-local on both (PR-5 FIX 4).
- Health/faults: healthInst per node/tenant/controller; a small seeded faultInst set with node-local DNs so
counts render and node-scoped fault queries return.
- Two sites share stretched tenants (per YAML `stretch:true`) → same tenant/VRF/BD names present in both MITs
+ NDO schema references them; site-local tenants present only in their site.
## PR-5 topology-consistency fixes (findings #13-#18)
Six internal-consistency findings, all fixed in `build/`. Every port an operational MO references
(`l3extRsPathL3OutAtt.tDn`, `ospfAdjEp` interface, `fvCEp.fabricPathDn`) must resolve against a real
`l1PhysIf` built by `interfaces.py` for that exact node — this is now a checked invariant
(`tests/test_pr5_topology_consistency.py`).
**Full port inventory per node role** (see `interfaces.py` module docstring for the authoritative version):
- Spines: `eth1/1..4` fabric uplinks (one per downlink leaf/BL) + `eth1/49[,50,...]` dedicated ISN/IPN
uplink ports, **multi-site topologies only**, one per spine (`eth1/{49+spine_index}` — matches the port
`underlay.py`'s `ospfAdjEp` already referenced; previously these ports didn't exist at all → FIX 2).
- Leaves + border-leaves: `eth1/1..8` host-facing access ports, `eth1/49[,50,...]` fabric uplinks (one per
spine). Border-leaves ONLY also get a dedicated `eth1/48` routed L3Out sub-interface port (previously
`l3out.py` referenced this port but nothing ever built it → FIX 1).
**FIX 1 (#13) — L3Out port.** Chose to extend the border-leaf port inventory with a dedicated `eth1/48`
routed port (`mode="routed"`, `usage="l3out"`) rather than reassign the L3Out onto an existing host port
(e.g. `eth1/8`), because real ACI border-leaf L3Out sub-interfaces conventionally live on a dedicated
front-panel port, not one shared with regular EPG/host traffic. `l3out.py` was unchanged — it already
referenced `eth1/48`; the fix makes that port real.
**FIX 2 (#14) — spine OSPF/ISN port.** Chose to extend the spine port inventory with a dedicated
`eth1/{49+i}` ISN uplink (one per spine, multi-site only) rather than reuse an existing fabric-uplink port,
because every spine port in `eth1/1-4` is already consumed by the intra-fabric spine↔leaf mesh (full mesh
= 2 leaves + 2 border-leaves = 4 downlinks per spine in the default topology) — there is no free fabric port
to reuse. This also matches real ACI: multi-site spines use a dedicated ISN-facing port, distinct from the
leaf-facing fabric ports. `underlay.py` was unchanged — its `ospfAdjEp` already used `eth1/{49+si}`; the fix
makes that port real via the same index arithmetic.
**FIX 3 (#15) — endpoint/APIC port separation.** `fabric.py` wires each `APIC-{cid}` (cid 1..
`site.controllers`, default 3) to `eth1/{cid}` on the first two leaves (`site.leaf_nodes()[:2]`) — the same
leaves `endpoints.py` places host endpoints on. `endpoints.py` now reserves `eth1/1..{controllers}` on those
leaves and starts endpoint port allocation at `eth1/{controllers+1}`, wrapping within the remaining
`_HOST_PORTS - controllers` budget. Non-APIC-attached leaves are unaffected (still start at `eth1/1`).
**FIX 4 (#16) — multi-site endpoint semantics.** See below.
**FIX 5 (#17) — BD unicastRoute.** `unicastRoute` now reflects whether the BD actually has `fvSubnet`
children (routed) rather than `l2stretch` — a stretched BD can still carry routed subnets, and this
topology's stretched BDs (`bd-BD3_LAB0`, `bd-BD4_LAB0`) do. This agrees with `ndo/model.py`, which
unconditionally reports `unicastRouting: True` for every BD (all BDs in `topology.yaml` have subnets).
**FIX 6 (#18) — controller health.** `health_faults.py` now emits a `healthInst` at
`topology/pod-{pod}/node-{cid}/sys/health` for every controller node id (1..`site.controllers`), matching
the `topSystem` `fabric.py` already builds there. Real APIC exposes controller health the same way as
switch health.
### FIX 4 — multi-site endpoint representation (design)
Real ACI never shows the identical endpoint as locally front-panel-attached at both sites of a stretched
tenant simultaneously. One site has it physically attached; the other only knows about it via the
multi-site overlay (ISN) tunnel. `endpoints.py` now models this:
- **HOME site** (physically attached): `fvCEp.lcC="learned"`, `fabricPathDn` = the real front-panel leaf
path (`topology/pod-{pod}/paths-{leaf_id}/pathep-[eth1/{port}]`), epm mirrors use `flags="local"`, and a
`fvIfConn` is emitted (real front-panel port).
- **REMOTE/peer site** (learned via the ISN): same MAC/IP/DN, but `fvCEp.lcC="learned,vxlan"` and
`fabricPathDn` points at a `tunnelIf` on one of the local spines
(`topology/pod-{pod}/paths-{spine_id}/pathep-[tunnel{remote_spine_id}]`), epm mirrors use
`flags="learned,vxlan"`, and no `fvIfConn` is emitted (there is no front-panel port to attach one to).
- A minimal `tunnelIf` MO is created per (local spine, remote spine) pair used
(`topology/pod-{pod}/node-{spine_id}/sys/tunnel-[tunnel{remote_spine_id}]`), `dest` = the remote spine's
loopback (representing its spine-proxy anycast TEP), `src` = the local spine's own loopback. `overlay.py`
does not yet build tunnel/TEP objects, so this is a new minimal object, not a reuse.
- **HOME-site assignment is deterministic and stateless across the two independent per-site `build()`
calls**: for the i-th endpoint in a stretched tenant's sequence, home site =
`tenant.sites[i % len(tenant.sites)]`. Since both sites iterate the exact same tenant/AP/EPG/per-EPG
sequence in the same order, they agree on which site is home for a given MAC without sharing any state —
each site's independent build() computes the same `i` for the same endpoint and reaches the same
conclusion.
- Non-stretched (site-local) tenants are unaffected — every endpoint is simply local, exactly as before.
## Topology schema (topology.yaml) — shape
```
fabric: { name, evpn_rr: spine }
sites:
- name, id, apic_host, asn, pod
spines: N # count OR explicit list [{id,name,model,serial}]
leaves: M # count OR explicit list
border_leaves: [{id, name, vpc_domain, csw:{name,asn,peer_ip,local_ip,vlan}}]
cabling: auto|explicit # auto = full spine×leaf mesh + border-leaf uplinks
isn: { enabled: true, transit_asn?, peer_mesh: full } # drives inter-site EVPN
tenants:
- name, stretch: bool, sites: [..]
vrfs: [{name}]
bds: [{name, vrf, subnets:[cidr], l2stretch?}]
aps: [{name, epgs:[{name, bd, contracts:{provide:[],consume:[]}}]}]
contracts: [{name, scope, filters:[{name, entries:[{proto,from,to,ether}]}]}]
l3outs: [{name, vrf, border_leaves:[..], csw_peer:{...}, ext_epgs:[{name, subnets:[cidr]}]}]
endpoints: { per_epg: K, ip_pool_from_subnet: true } # generation knobs
faults: { seed: [{severity,code,node,descr}] , health_defaults:{node:95,tenant:98} }
access: { vlan_pools:[{name,from,to}], phys_domains:[..], aaeps:[..] } # sensible defaults if omitted
```
Default `topology.yaml` ships the spec baseline: 2 sites × (2 spine + 2 leaf + 1 border-leaf vPC pair to CSW),
ISN between them, a couple stretched + site-local tenants, one L3Out per site to its CSW, seeded endpoints/faults.
## Phase plan (each phase: implementer subagent(s) → independent reviewer per 第十律)
- **P1 foundation**`mit/` (mo, dn, store) + `query/` (filters, engine). Unit-tested in isolation.
- **P2 topology**`topology/schema.py` + `loader.py` + default `topology.yaml`.
- **P3 fabric builders** — fabric, cabling, underlay, overlay(+ISN), health_faults, interfaces → Topology view renders.
- **P4 tenant builders** — tenants, access, l3out, endpoints → config + data-plane plugins render.
- **P5 ACI REST app** — auth + GET routes + POST writes + reactions + runtime supervisor + TLS + control API.
- **P6 NDO** — model + app.
- **P7 verify** — verification harness that mimics autoACI's queries for every view/plugin + write→readback +
add_leaf; README + run scripts.
## Verification (第二律 evidence-first)
`tests/verify_autoaci.py` replays the exact query patterns from `docs/CONTRACT.md` §89 against the running
sim and asserts non-empty, shape-correct, internally-consistent results for: Topology (2 sites + ISN cloud +
vpc pairs + faults/health), every plugin's class set, L3Out BGP-to-CSW, NDO sites/tenants/schemas, and a
write→read-back cycle (POST a tenant → GET returns it) + an add_leaf reaction. Real command output is the
acceptance evidence.
## PR-3b-batch1 — 21 previously-catalogued-but-unbuilt classes seeded
CONTRACT.md §6 catalogued 21 classes across 6 concern groups that had no builder ever emitting them, so
their class queries returned `totalCount: 0` and the depending autoACI plugins (access_policy.py,
epg_detail.py, contract_detail/contract_map/contract_debug.py, l3out_detail.py, l3out_status.py,
route_control.py, routing_state.py, route_table.py, fabric_ospf.py) rendered nothing. All 21 are now seeded,
derived from the existing topology/tenant objects rather than invented data.
**Access-policy cluster (`build/access.py`)** — one leaf-interface-policy tree per border-leaf vPC domain
(`infraAccBndlGrp` lagT="node", consistent with the vpcDom/vpcIf pair `fabric.py` already builds for the same
border leaves) plus one for the regular-leaf pair (lagT="link", a PC bundle). Each `infraAccPortP` gets one
`infraHPortS` (type="range") selector spanning the real host-port inventory (`eth1/1..eth1/_HOST_PORTS`) via
one `infraPortBlk` + one `infraRsAccBaseGrp` pointing at the bundle group. Also seeds an `infraInfra` MO at
`uni/infra` itself — previously a structural-only DN with no MO, which 404'd `GET /api/mo/uni/infra.json`
even under `rsp-subtree=full` (fixed as part of this batch since the TESTS requirement needed it; the generic
subtree engine already worked correctly once a root MO existed).
**Route-control cluster (`build/l3out.py`)** — one `rtctrlProfile` (export) per L3Out, with a nested
`rtctrlCtxP` (+ `rtctrlSetComm`/`rtctrlSetPref` children using real ACI's fixed short-form RNs `scomm`/`spref`).
One tenant-level `rtctrlSubjP` match rule per L3Out (not nested under the L3Out — autoACI's route_control.py
queries `rtctrlSubjP` with `wcard(dn,"tn-{tenant}")`, tenant-scoped, verified read-only against the plugin
source) with an `rtctrlMatchRtDest` child reusing a real tenant BD subnet. A static default route (`ipRouteP`
ip="0.0.0.0/0") nested under each L3Out's `l3extRsNodeL3OutAtt`, with an `ipNexthopP` child pointing at the
same CSW peer IP the eBGP session builder (`_upsert_ebgp_sessions`) already peers with. `l3extMember`
(side="A") on the existing `l3extRsPathL3OutAtt` — this topology's L3Out path is a single port per
border-leaf (not vPC-bundled), so only one member is emitted, per the batch-1 placement note. `ospfExtP`
added to the SAME L3Out that already carries `bgpExtP` (real ACI supports OSPF+BGP coexistence on one
L3Out) rather than fabricating a second OSPF-only L3Out in topology.yaml — the simpler faithful choice since
this topology has no existing OSPF-only L3Out to attach to.
**EPG/contract classes (`build/tenants.py`)** — `fvRsPathAtt` (static binding) per EPG, pointing at the SAME
front-panel path that EPG's own endpoints already use. This required reordering the orchestrator's builder
list (`endpoints` now runs BEFORE `tenants`, moved up from after `l3out`) so `tenants.py` can read back the
real `fvCEp.fabricPathDn`/`encap` endpoints.py already wrote for that EPG, instead of independently
re-deriving the port-allocation algorithm (which risked silently drifting out of sync). Every other builder
remains pure add-only; `endpoints.py` itself never reads the store, so the reorder is safe. A stretched EPG
whose only endpoints at this site are REMOTE (tunnel-learned) gets no `fvRsPathAtt` — a static binding must
reference a real local port. `vzRsSubjGraphAtt` attached to exactly one contract's subject per tenant (the
tenant's first contract), with a plausible `tnVnsAbsGraphName`; `vnsAbsGraph` itself stays out of scope.
**Operational/routing classes (new `build/routing.py` + `build/underlay.py`)** — `uribv4Route`/`uribv4Nexthop`
seeded per (tenant, vrf) on every leaf/border-leaf: BD subnets as directly-connected routes (nexthop = the
node's own loopback) plus a 0.0.0.0/0 default via the L3Out's CSW peer on border leaves, matching the
`ipRouteP` static route already seeded on the same node. VRF "dom" name reuses the exact `{tenant}:{vrf}`
convention `l3out.py`'s BGP dom already established. `uribv4Nexthop`'s RN uses the 4-bracket
`nh-[proto]-[addr]-[ifname]-[vrf]` shape autoACI's `_RE_NH_PARENT` regex (`route_table.py`/`routing_state.py`,
identical in both files) requires — verified read-only against the plugin source, not re-derived from
guesswork. `arpAdjEp` — one per locally-learned (front-panel) endpoint, reusing that endpoint's real MAC/IP
from the store `endpoints.py` already populated (routing.py runs after `endpoints` in the orchestrator
order); REMOTE/tunnel-learned endpoints get no ARP entry (real ACI resolves those via the overlay, not local
ARP). `ospfIf` nested between the existing `ospfDom` and `ospfAdjEp` in `underlay.py`, on the same
`eth1/{49+i}` ISN uplink port `interfaces.py` already builds a dedicated `l1PhysIf` for — so the pre-existing
`ospfAdjEp` now sits on a real, matching `ospfIf`.
**Writes** — `rest_aci/writes.py` `_RN_TEMPLATES` gained entries for all POST-able batch-1 classes so a
child posted without an explicit `dn` lands on the same canonical DN a GET or builder-created MO would use.
## PR-3b-batch2/3 — remaining previously-catalogued-but-unbuilt classes seeded
Batches 2+3 of the seeding effort closed the rest of CONTRACT.md §6's gap: 9 more classes across
`build/fabric.py`, `build/interfaces.py`, `build/tenants.py`, a new `build/zoning.py`, and `build/overlay.py`,
plus a deliberate decision NOT to build `infraNodeIdentP`. DN/attribute shapes were verified read-only
against the specific autoACI consumer plugins (a targeted grep of autoACI's `backend/plugins/*.py`)
before writing any builder code, per the same discipline batch-1 established.
**Port-channel/vPC (`build/fabric.py`)** — `pcAggrIf` + `pcRsMbrIfs`, one pair per vPC leg (border-leaf),
consistent with the `vpcDom`/`vpcIf` this module already builds for the same pairs. `vpc_status.py` matches
`pcAggrIf` to `vpcIf` by `(node, name)` — so `pcAggrIf.name` intentionally reuses the exact same `"po{bl.id}"`
string `vpcIf.name` already carries (not `access.py`'s differently-named `infraAccBndlGrp`, a different IPG
identity autoACI's plugin does not join on). **Naming correction**: CONTRACT.md catalogued this relation
class as `vpcRsMbrIfs`; a read-only grep of `vpc_status.py` showed it actually queries `pcRsMbrIfs` (the real
ACI class, child of `pcAggrIf`, RN `rsmbrIfs-[{ifDn}]`) — built as `pcRsMbrIfs`, with a CONTRACT.md §6
changelog note recording the correction. Member ports are two real host-facing `l1PhysIf` per leg
(`eth1/1`, `eth1/2` — always present on every leaf/border-leaf) so `pcRsMbrIfs.tDn` always resolves.
**Optics (`build/interfaces.py`)** — `ethpmFcot`, sibling of `ethpmPhysIf` under the same port DN (own
`fcot` RN). Emitted only for fabric-uplink, ISN-uplink, and L3Out routed ports — real optics-bearing
front-panel ports — not plain host-access ports, mirroring real labs where copper/DAC host ports often
report no transceiver inventory while fabric/WAN-facing optical ports do (a deliberate realism choice,
documented in the module docstring, not an omission).
**Cluster health (`build/fabric.py`)** — `infraWiNode`, the APIC appliance-vector: every controller carries
its own view of every cluster member (an N×N matrix for N=`site.controllers`), all seeded `health="fully-fit"`
to match this sim's other all-healthy defaults (`fabricHealthTotal.cur=100`).
**Zoning-rule / pcTag plumbing (`build/tenants.py` + new `build/zoning.py`)** — real ACI zoning rules key off
per-EPG/VRF `pcTag` ("sclass") + `scopeId`, neither of which existed anywhere in this sim before batch-2
(verified: zero prior `pcTag` references). `tenants.py` gained `pctag_for`/`scope_id_for` — small
CRC32-derived helpers (same technique `endpoints.py`'s `_vnid` already uses) offset clear of autoACI's 5
hardcoded system pcTags (1/13/14/15/16384) — assigning a stable `pcTag` to every `fvAEPg` and `fvCtx`
(+ `scope` on `fvCtx`), and seeding one `fvEpP` (`uni/epp/fv-[{epg_dn}]`) per EPG carrying the matching
`pcTag`, the EPG's VRF `scopeId`, and `epgPKey`=the EPG's own dn (`zoning_rules.py` extracts the friendly
EPG name back out of `epgPKey`'s trailing `epg-` segment). New `build/zoning.py` derives `actrlRule` — one
permit rule per (contract subject filter, provider EPG, consumer EPG) triple, emitted on every leaf where
either side has a locally-learned endpoint (read back from `endpoints.py`'s `fvCEp`, same technique
`tenants.py`'s `fvRsPathAtt` already established), with `sPcTag`/`dPcTag`/`scopeId` reusing the exact
`pctag_for`/`scope_id_for` values the owning EPGs/VRF already carry. Also seeds real MOs at the
`.../sys/actrl` and `.../sys/actrl/scope-[...]` structural containers — the same class of gap batch-1's
`infraInfra` fix addressed for `uni/infra`: without a real MO at the exact DN a subtree query targets,
`GET /api/mo/{dn}.json` 404s even though the generic subtree engine itself works fine once a root MO exists.
`zoning` runs right after `tenants` in the orchestrator (both already after `endpoints`).
**EVPN routes (`build/overlay.py`)** — `bgpVpnRoute` + `bgpPath`, the per-spine EVPN VPN route family
`fabric_bgp_evpn.py`'s "vpn_routes" view two-pass parses (pass 1 collects routes by dn; pass 2 recovers
the parent route dn from a path dn via `path_dn.rsplit("/path-", 1)[0]` — so `bgpPath`'s RN must start with
`path-` directly under the route's own dn, verified read-only against the plugin). Seeded per spine
(the EVPN route-reflector role) for each BD subnet deployed at the site, with one `bgpPath` per leaf that
actually hosts a locally-learned endpoint for that BD (read back from `fvRsBd`+`fvCEp`). This needs the
same post-`tenants`/`endpoints` data `zoning.py` needs, but `overlay.build()` itself must stay in its
original EARLY orchestrator slot (BGP peer setup only needs fabric/topology data) — so the new logic lives
in a second function, `overlay.build_vpn_routes()`, called by the orchestrator as an explicit extra step
after the main `_BUILDERS` loop completes, rather than moving/splitting `overlay.build()`'s existing peer
logic.
**Node registration (`build/fabric.py` + `rest_aci/writes.py`)** — `fabricNodeIdentP`, boot-seeded one per
real switch `fabricNode` (spines/leaves/border-leaves — NOT controllers; real ACI's Fabric Membership
registers switches joining the fabric, not the APICs themselves) at `uni/controller/nodeidentpol/nodep-{id}`,
so a GET returns already-registered nodes without requiring a prior POST. Keys on the node **id**, not
serial, to stay consistent with `writes.py`'s pre-existing `_materialize_fabric_node` reaction (which parses
the id back out of the same `nodep-(\d+)$` pattern) — a boot-seeded and a later-POSTed registration must
agree on the identifier scheme, or they'd silently diverge into two different DNs for the same node.
`_RN_TEMPLATES` gained a `fabricNodeIdentP -> nodep-{nodeId}` entry (the one batch-2/3 class an actual
Fabric Build playbook POSTs, per CONTRACT.md §3). Regression-tested: `tests/test_pr3b_batch2.py` re-verifies
`test_rest_aci.py::test_add_leaf_reaction`'s exact scenario (POSTing a NEW `nodep-950` must still
materialize a matching `fabricNode`) to confirm boot-seeding existing nodes doesn't interfere with the
write-path reaction for a genuinely new one.
**`infraNodeIdentP` — deliberately NOT built.** No autoACI plugin references it (verified via a fabric-wide
grep across autoACI's `backend/plugins/*.py`). Real ACI's `infraNodeIdentP` is a node *provisioning-policy*
object, a different concept from `fabricNodeIdentP`'s Fabric Membership registration despite the similar
name — building it with `fabricNodeIdentP`-like semantics would be a wrong object satisfying no real
consumer. Removed from CONTRACT.md's catalog entirely rather than built incorrectly; see that file's
batch-2/3 changelog note for the full reasoning.
## PR-9 — Ansible cisco.aci fidelity
The owner's primary public use case for this simulator is running Ansible `cisco.aci` playbooks against
it directly. PR-9 hardens the write/delete/auth surface to match what `cisco.aci`'s
`plugins/module_utils/aci.py` actually sends, verified read-only against the upstream collection source
(https://github.com/CiscoDevNet/ansible-aci). Full behavioral contract: `docs/CONTRACT.md` §11. Highlights:
- **DELETE `/api/mo/{dn}.json`** added (state=absent), server-side idempotent (200-empty on both
existing-then-removed and already-missing DNs) — see CONTRACT.md §11 for why idempotent-on-missing
was chosen over 404 despite the module never actually exercising that exact case itself.
- **GET-on-missing-DN is 200-empty, not 404** (FEATURE 6, a live blocker found running an actual playbook
against the sim: `fatal: APIC Error 103` on the very first task of a `state=present` create). This
superseded the pre-PR-9 design (404-on-missing), which had been justified as matching a DIFFERENT
consumer's (autoACI's `ACINotFoundError`) expectation — cisco.aci is now this simulator's primary
target per the owner's stated use case, and its `api_call()` treats any non-200 GET as fatal with no
"not found is fine" exception. `/api/class/*` and node-scoped queries already had no such 404 case
(they return `200` + empty on a no-match query); this brings `/api/mo/*` in line for "the DN itself
doesn't exist" too.
- **`status="deleted"` cascade** — already correctly removed the full subtree pre-PR-9
(`MITStore.upsert`'s `status="deleted"` branch predates this PR); PR-9 added regression coverage
proving it (not just "skips planning children", which is a separate, narrower behavior the
write-planning layer also has). `status="created,modified"` (and any status other than `"deleted"`)
is a plain upsert — no special-casing needed since the store only ever special-cases the literal
`"deleted"` string.
- **annotation/nameAlias/descr passthrough** — no fix needed; `MO.attrs` is a generic dict and the
write path never allowlists attribute names. PR-9 added the regression test proving the round-trip
survives POST → mo GET → class query, since this was previously undocumented/unverified rather than
actually broken.
- **firmwareCtrlrRunning** — new, small: one per controller in `build/fabric.py`, `version` reusing the
same `site.apic_version` the controller's `topSystem` already carries.
### Certificate accept-mode trust model (cert-auth, PR-9)
cisco.aci supports password-less certificate-signature auth: instead of calling `aaaLogin`, every request
carries a single `Cookie` header with four fields (`APIC-Certificate-Algorithm`, `APIC-Certificate-DN`,
`APIC-Certificate-Fingerprint`, `APIC-Request-Signature` — an RSA-SHA256 signature over
`METHOD + path + body`, base64-encoded). Real APIC verifies that signature against a public key
previously uploaded for the named user (`aaaUserCert`).
**This simulator does NOT verify the signature.** `require_session()` (`rest_aci/auth.py`) implements
**accept-mode**: if all four 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 for that user — full stop. The `APIC-Request-Signature` bytes are read (cookie must be
non-empty) but never decoded or checked against any key. This is a deliberate, documented trust
shortcut, not an oversight:
- The simulator has no real user-certificate registry (`aaaUserCert` upload flow is out of scope,
same tier as NX-OS SSH being out of v1 per the "Locked decisions" above) — implementing real
verification would require building that entire subsystem for a security property the simulator
doesn't need (there is no real secret being protected; it's a local test fixture). Trusting the
claimed identity once the DN's user matches `SIM_USERNAME` is sufficient to unblock cert-auth
playbooks running unmodified against the sim, but is insufficient as a real security boundary.
- **Do not point this accept-mode design at anything internet-reachable or multi-tenant.** Anyone who
can reach the sim's port can authenticate as `SIM_USERNAME` by sending four cookies with no
cryptographic proof of possession.
- `SIM_CERT_STRICT` env var is reserved (parsed but currently a no-op) for a **future** mode that would
register a real public key per user and verify `APIC-Request-Signature` against it. Not implemented in
PR-9 — out of scope; noted here so a future PR doesn't have to rediscover the gap.
## PR-18 — Tier-1 fabric configuration parameters
Exposes real-ACI-lab-variable Tier-1 fabric knobs that Ansible/`cisco.aci`/aci-py fidelity depends on,
all optional with defaults matching the pre-PR-18 hardcodes so the existing `topology.yaml` (and any
hand-authored one) validates and builds unchanged.
- **`site.controllers: int` (default 3)** — already existed pre-PR-18 (introduced with the APIC-cluster
work) and was already fully wired: `build/fabric.py`'s controller `fabricNode`/`topSystem`/
`firmwareCtrlrRunning` loop, the `infraWiNode` appliance-vector double loop (both viewer and member
ranges), `build/health_faults.py`, and `build/endpoints.py`'s APIC-reserved-port accounting all already
read `site.controllers` — no hardcoded `3` remained to replace. PR-18 only added `--controllers` to
`aci-sim new` and surfaced the value in `aci-sim show`.
- **`fabric.tep_pool: str` (default `"10.0.0.0/16"`)** — the infra TEP address space. Stored + CIDR-
validated + surfaced (`aci-sim show`/`new`). **No builder derives a per-node address from this field**
`build/fabric.py`'s `loopback_ip()`/`oob_ip()` are the actual address source (see the pre-existing
`loopback_pool`/`oob_pool` note in `topology/schema.py`'s `Fabric` docstring, which documents the same
"hardcoded family, pool is descriptive metadata" pattern this field follows). Treat `tep_pool` as
store-only/informational until a future PR threads it into an actual infra-address builder.
- **`fabric.infra_vlan: int` (default 3967, range 1-4094)** — the fabric infrastructure VLAN. Stored +
range-validated + surfaced. No MIT class in this sim currently carries a distinct infra-VLAN attribute
to wire it into (real ACI's `infraSetPol.fabricInfraVlan` classes are not part of the current CONTRACT.md
catalog) — store-only, same caveat as `tep_pool`.
- **`fabric.gipo_pool: str` (default `"225.0.0.0/15"`)** — the multicast GIPo pool. Stored + CIDR-validated
+ surfaced. Store-only, same caveat.
- **Deterministic node serials**`build/fabric.py`'s `default_serial(site_id, node_id)` replaces the
previous inline `f"SIM{node.id:08d}"` fallback used whenever an auto-generated `Node.serial` is empty
(`fabricNode.serial` and `fabricNodeIdentP.serial`). New format: `f"SAL{site_id}{node_id:04d}"` (e.g.
site "1" node 101 → `"SAL10101"`) — encodes the site id so serials stay visibly distinct per site.
Explicit `serial:` values in a YAML `Node` entry are unaffected (still override — this is only the
`serial or default_serial(...)` fallback path). No test asserted the old `"SIM..."` format, so this is
a safe behavior change; `aci-sim show` now prints the resolved serial per node.
- **`site.pod: int` (already existed, default 1)** — verified honored end-to-end: every builder that
emits a `topology/pod-{N}/...` DN reads `site.pod` (grepped all of `build/*.py`, `rest_aci/writes.py`,
`control/admin.py` — zero remaining `pod-1`/`pod=1` literals). A site with `pod: 2` builds all its
`fabricNode`/`topSystem`/etc. DNs under `pod-2` (verified: `topology/pod-2/node-201`, ...).
### N-site limit (verified, not fixed by PR-18)
`aci-sim new --sites N` (N>2) and the builders that only depend on `site.pod`/`site.controllers`/node-ID
offsets work for arbitrary N with zero node-ID collisions (verified with a 3-site scaffold: sites at
offsets 0/200/400, `infraWiNode` count = `controllers²` per site, all DNs correct).
**However, `Topology.other_site(name)` (topology/schema.py) is hard-coded to 2-site semantics**: it
returns "the first site in `self.sites` that is not `name`", not "every other site". Two builders call it
to resolve the ISN/multi-site remote peer:
- `build/overlay.py`'s ISN inter-site eBGP-EVPN peering (`build()`, "ISN: each local spine → each
remote-site spine loopback").
- `build/endpoints.py`'s stretched-tenant HOME/REMOTE endpoint placement (`is_stretched` branch).
Verified with a live 3-site topology (`generate_topology(sites=3, ...)`, ISN `peer_mesh: full`): site C
(3rd site, asn 65003) peers ISN eBGP ONLY with site A (asn 65001) — `other_site("LABC")` picks the first
non-C site in list order (`LABA`) and never reaches `LABB`. This means with 3+ sites:
- ISN is NOT a true full mesh across all sites, despite `isn.peer_mesh: full` — each site peers with
exactly one other (the first site in the YAML's `sites:` list order that isn't itself), not N-1 others.
- A tenant with `stretch: true` and 3+ `sites:` entries passes schema validation (the validator only
requires `len(tenant.sites) >= 2`, not `== 2`) but `endpoints.py`'s remote-peer logic will treat only
one of the non-home sites as the ISN-reachable remote for tunnel/REMOTE-endpoint purposes.
**This is a pre-existing limitation, not introduced by PR-18** (confirmed via `git diff main --
aci_sim/topology/schema.py`: `other_site()` is byte-identical to what PR-18 branched from).
Fixing it to a real N-site full mesh is a bigger architectural change (both call sites would need to
iterate `[s for s in topo.sites if s.name != site.name]` instead of a single `other_site()` call, and
`endpoints.py`'s single `remote_site`/`tunnel_spine`/`home_spine` variables would need to become
per-remote-site collections) — out of scope for a Tier-1-parameters PR. **Practical limit: ISN/stretch
correctness is verified for exactly 2 sites; 3+ site topologies build without collision but do not get
a correct N-site ISN mesh or N-site stretched-endpoint placement.**
## PR-19 — Tier-2 topology-elasticity parameters
Exposes ISN underlay knobs (`ospf_area`, `mtu`) and a VMM domain vCenter-connection model, plus fixes a
real leaf/spine-count-elasticity bug in `aci-sim new`'s node-ID generator. All new fields optional with
defaults matching pre-PR-19 hardcoded behavior, so the existing `topology.yaml` validates and builds
byte-identical output.
### `isn.ospf_area: str` (default `"0.0.0.0"`) and `isn.mtu: int` (default 9150)
Both were previously hardcoded literals in the builders:
- `build/underlay.py`'s `ospfIf`/`ospfAdjEp` `area` attribute was hardcoded to `"0.0.0.0"` — now reads
`topo.isn.ospf_area`. Accepts either dotted-decimal (`"0.0.0.0"`) or the decimal-integer shorthand ACI
also renders (`"0"`) — validated in `Topology.normalize_and_validate` via `IPv4Address` parse or
`str.isdigit()`.
- `build/interfaces.py`'s dedicated ISN/IPN spine uplink port (`eth1/{49+i}`, multi-site only) was built
via the same `_add_port()` helper as every other port, which hardcoded `mtu="9216"` (the intra-fabric
default) for every port including this one. `_add_port()` now takes an `mtu` keyword (default `"9216"`,
unchanged for all ordinary ports); the ISN uplink call site alone passes `str(topo.isn.mtu)`. Verified:
a leaf's `eth1/49` fabric-uplink port and a spine's `eth1/49` **ISN** uplink port are different physical
interfaces on different node roles (interfaces.py's port-numbering docstring), so this change touches
only the true ISN port — ordinary fabric ports (including any leaf port that happens to share the same
port *number*) are unaffected. `isn.mtu` range-validated 576-9216 (real ACI inter-pod/inter-site MTU
range, same ceiling as the fabric's own 9216).
- `isn.peer_mesh: partial` remains a **documented validate-and-reject** (unchanged from PR-17/18's
decision in `build/overlay.py`) — re-examined for PR-19 and kept: a partial mesh has no sensible default
partition without additional per-site config this schema doesn't carry (which spine pairs with which
isn't inferable from spine count alone). Silently building full-mesh (ignoring the setting) or an
arbitrary subset (guessing a partition no author asked for) would both be worse than failing fast with a
clear message, so PR-19 keeps the existing rejection rather than inventing a partition scheme.
### VMM domain vCenter connection model — pure addition, not a rewire
`access.vmm_domains: list[VMMDomain]` (default: **empty list**, unlike `phys_domains`/`l3_domains`/
`aaeps`, which each default to one seeded entry). Each `VMMDomain` carries `name` (required) plus
optional `vcenter_ip`, `vcenter_dvs`, `vlan_pool`, `datacenter`.
**Why this is additive, not a wire-up of an existing gap** (verified read-only against a real
aci-py + cisco.mso production chain on a lab test host):
- `~/aci-py/aci_py/playbooks.py`'s `bind_epg_to_vmm_domain` playbook (MSO section) drives
`mso_schema_site_anp_epg_domain` (shimmed in `~/aci-py/aci_py/shims/mso_mso_epg.py`), which appends a
JSON-Patch op `add /sites/{site}-{tpl}/anps/{anp}/epgs/{epg}/domainAssociations/-` with
`value.dn = "uni/vmmp-VMware/dom-{domain_profile}"` — a **DN string**, not a live object reference. The
sim's NDO layer (`aci_sim/ndo/patch.py`) stores this JSON-Patch document verbatim; nothing
cross-validates the referenced `dn` against a real `vmmDomP` in any APIC-side MIT store.
`~/aci-py/environments/lab-multisite-sim/vars/bind_epg_to_vmm_domain_MS-TN1.yml` confirms the domain
names actually exercised in the live MS-TN1 chain (`vmm-vmw-lab1`, `vmm-vmw-lab2`) are just opaque
strings from this var file's perspective.
- The single-fabric branch of the same playbook template (`bind_epg_to_vmm_domain.j2.yml`'s `APIC`
section) DOES drive `cisco.aci.aci_domain` (shimmed in `~/aci-py/aci_py/shims/access.py`'s
`_domain_dn_class`, which maps `vmmDomain``vmmDomP` at `uni/vmmp-{provider}/dom-{name}`) — but this
branch is gated on `single_fabric|default(false)` and is not what the MS-TN1 multi-site chain
(`scripts/live_chain.py environments/lab-multisite-sim MS-TN1`) exercises.
- Conclusion: **no consumer in either verified production chain (autoACI sandbox e2e, aci-py MS-TN1)
reads a `vmmDomP`/`vmmCtrlrP` object from this sim today.** Adding one is genuinely new surface, not a
fix for an existing silently-broken path — hence the empty-list default (true backward-compat baseline:
omitting `access.vmm_domains` produces zero `vmmDomP`/`vmmCtrlrP`/`vmmUsrAccP` MOs, byte-identical to
pre-PR-19) rather than seeding a default domain the way `phys_domains`/`l3_domains`/`aaeps` do.
**MO shape** (`build/access.py::_build_vmm_domain`), verified against the real DN scheme in
`~/aci-py/aci_py/shims/access.py`'s `_domain_dn_class` (`"vmmDomP", "uni/vmmp-{provider}/dom-{name}"`) and
`~/aci-py/aci_py/shims/mso_mso_epg.py`'s `dn_map["vmmDomain"] = "uni/vmmp-VMware/dom-{0}"`:
- `vmmDomP` at `uni/vmmp-VMware/dom-{name}` — always built (only "VMware" is modeled; the sole provider
either verified call site actually exercises).
- `vmmCtrlrP` at `{vmmDomP dn}/ctrlr-{name}` — built **only when `vcenter_ip` is set**: `hostOrIp` =
`vcenter_ip`, `rootContName` = `datacenter` (falls back to the domain's own `name` if `datacenter` is
unset). `dvsName` is additionally set when `vcenter_dvs` is provided.
- `vmmUsrAccP` at `{vmmCtrlrP dn}/usracc-{name}` — built alongside `vmmCtrlrP` (placeholder
credential-profile reference; no real credentials in a sim, matching the no-secret pattern phys/l3
domains already use for their own child objects).
- `infraRsVlanNs` at `{vmmDomP dn}/rsvlanNs` — built only when `vlan_pool` is set, pointing at the first
`access.vlan_pools[]` entry's DN (same convention `_build_phys_dom`/`_build_l3_dom` already use).
`vlan_pool` is cross-referenced against `access.vlan_pools[].name` in
`Topology.normalize_and_validate` — an unknown pool name is a validation error, same discipline as
`tenant.bds[].vrf`/`epg.bd`.
`_build_epg()` in `build/tenants.py` (the actual EPG-domain binding builder) is **untouched** — EPGs still
bind only to the first `phys_domains[]` entry via `fvRsDomAtt`, exactly as before PR-19. This is
intentional: the real production chain's `bind_epg_to_vmm_domain` never touches this MIT tree (see
above), so there was nothing to rewire.
### Leaf/spine count elasticity — a real generator bug found and fixed
`aci-sim new`'s node-ID scheme (`topology/schema.py`'s docstring: leaves base 101, spines base 201,
`site_offset=200`) has a documented ceiling — **"max ~40 leaves + ~40 spines per site before the ranges
touch"** — but prior to PR-19, `generate_topology()` (`cli.py`) had **no guard enforcing that ceiling**.
Stress-testing the exact boundary (required by this PR's acceptance bar) found three distinct ways a
large `--leaves-per-site`/`--spines-per-site`/`--border-pairs` value silently produced an invalid
`topology.yaml` dict that only failed deep inside `Topology.normalize_and_validate`, with a generic "node
ids collide" message that didn't name the offending flag:
1. **`--leaves-per-site > 100`** — the leaf block itself (`101..100+N`) runs into the spine block
(`201+`). Reproduced: `--leaves-per-site 150 --spines-per-site 4` → leaf ids run 101-250, directly
overlapping spine ids 201-204.
2. **`--spines-per-site > 100`** — the spine block itself (`201..200+N`) runs into the NEXT site's leaf
block (`301+`). Reproduced: `--leaves-per-site 2 --spines-per-site 101` → site 0's spine ids run
201-301, colliding with site 1's leaf ids starting at 301.
3. **Large `--border-pairs`** — the border-leaf block (already pushed past whichever of leaves/spines
extends further, per the pre-existing PR-17 border-ID fix) can itself run past the next site's leaf
block if `border_pairs` is large enough relative to a small leaf/spine count. Reproduced:
`--leaves-per-site 2 --spines-per-site 2 --border-pairs 50` → border ids run up to 300+, colliding with
the next site's leaf block at 301.
**Fix:** `generate_topology()` now computes the same leaf/spine/border-top arithmetic the ID-assignment
loop uses and raises a `ValueError` naming the specific offending flag *before* building any node dicts,
for all three cases above. Verified boundaries: `--leaves-per-site 100`/`--spines-per-site 100` (exact
max) still validate and build cleanly; `101` of either is rejected with a clear message;
`--border-pairs 49` at the default leaf/spine count is the max safe value, `50` is rejected. The
acceptance-bar case from this PR's scope (`--leaves-per-site 8 --spines-per-site 4`, well within the
safe range) validates clean and builds a full `MITStore` with the expected node count, verified via
`orchestrator.build_site()` (not just schema validation) in `tests/test_pr19_tier2.py`'s
`TestLeafSpineElasticity.test_full_build_succeeds_for_8_leaves_4_spines`.
This is a genuine bug fix (not a new feature) — the ID scheme's stated ceiling was never actually
enforced anywhere before this PR, meaning any topology author who requested more than ~100
leaves/spines-per-site via the CLI would previously get a confusing Pydantic stack trace instead of an
actionable error.
## PR-20 — Tier-3 fine-tuning parameters (final param tier)
The last planned parameter tier. Exposes commonly-tuned mgmt-subnet/BD-MAC/routing-timer/APIC-version
knobs as optional `topology.yaml` fields, wired into a real MO attribute wherever this sim already builds
one — and honestly marked store-only where it doesn't (BFD timers, `inb_subnet`). All new fields optional
with defaults matching pre-existing hardcoded behavior (or the real ACI factory default where there was
no prior hardcoded value at all), so the existing `topology.yaml` validates and builds byte-identical
output — verified via the full `tests/test_pr20_tier3.py::TestBackwardCompat` class plus the unchanged
672-test suite passing alongside 41 new tests (713 total).
### `bd.mac` / `fabric.default_bd_mac` — the critical backward-compat case
`build/tenants.py`'s `_build_bd` hardcoded `fvBD.mac = "00:22:BD:F8:19:FF"` (the real ACI default gateway
MAC) for **every** BD before this PR. `fabric.default_bd_mac` (fabric-wide default) reproduces that exact
literal as its own schema default, and `bd.mac` (per-BD, optional) overrides it when set — resolution
order in `_build_bd` is `bd.mac or topo.fabric.default_bd_mac`. Because the fabric-wide default equals
the pre-existing hardcoded literal exactly, a topology with neither field set produces **byte-identical**
`fvBD.mac` output to pre-PR-20 — this is the single highest-risk field in this PR (any drift from the
existing literal would visibly change every BD's gateway MAC that a consumer like autoACI might read),
so it got its own dedicated backward-compat test (`test_repo_fvBD_mac_is_real_aci_default`) asserting the
literal value directly against the real repo `topology.yaml`, not just "no schema-level default drift."
### BGP keepalive/hold — `l3out.csw_peer.keepalive`/`.hold``bgpPeerP`
Real ACI defaults 60s/180s (the standard 1:3 keepalive:hold ratio). `build/l3out.py`'s
`_build_node_profile` already builds a `bgpPeerP` MO (control-plane BGP peer policy, distinct from the
operational `bgpPeer`/`bgpPeerEntry` session MOs `_upsert_ebgp_sessions` builds) with only `addr`/
`peerCtrl` attrs pre-PR-20; this PR adds `keepAliveIntvl`/`holdIntvl` to the same object. Validated:
`hold` must exceed `keepalive` (a hold at or below the keepalive interval can never survive a single
missed keepalive) — same sanity-check pattern as PR-19's `isn.mtu` range check. The operational
`bgpPeer`/`bgpPeerEntry` MOs are **not** touched — real ACI's operational BGP session table doesn't carry
configured-timer attrs (those live on the policy object), so there's nothing to wire there.
### OSPF hello/dead — `isn.ospf_hello`/`.ospf_dead``ospfIf`
Real ACI defaults 10s/40s. `build/underlay.py`'s `ospfIf` MO (already built for `isn.ospf_area`, PR-19)
gains `helloIntvl`/`deadIntvl` attrs. Validated: `ospf_dead` must exceed `ospf_hello`, matching real ACI's
own OSPF sanity check (a dead timer at or below the hello interval can never detect a live neighbor
before declaring it down).
### BFD timers — `isn.bfd_min_rx`/`.bfd_min_tx`/`.bfd_multiplier` — explicitly STORE-ONLY
Real ACI defaults 50ms/50ms/3. Grepped `build/*.py` for "bfd"/"Bfd"/"BFD" — **zero matches**. This sim
builds no `bfdIfP`/`bfdRsIfPol`-style MO anywhere, and adding a faithful one would additionally require a
believable `bfdIfStats`/oper-state child tree with no verified consumer (autoACI plugin or aci-py
playbook) to anchor its shape against — inventing an MO purely to hold a timer, with no real read path
exercising it, would be worse than being explicit that these three fields are schema-level only. Store +
range-validate (`min_rx`/`min_tx` positive; `multiplier` 1-50, real ACI's UI range) + surface in
`aci-sim show`/`new`.
### OOB/INB mgmt subnets — `fabric.oob_subnet` (validate-only) / `fabric.inb_subnet` (store-only)
`fabric.oob_subnet` sits alongside the existing `fabric.oob_pool` (PR-early/#27) — `build/fabric.py`'s
`oob_ip()` derives every node's OOB address from `(pod, node_id)` only and does not consume an arbitrary
subnet/pool CIDR (documented limitation, unchanged by this PR). `oob_subnet` is stored + validated (must
fall within `192.168.0.0/16`, same family as `oob_pool`) + surfaced, letting a topology author record the
*intended* OOB subnet distinctly from the pool the builder actually draws from.
`fabric.inb_subnet` is genuinely new surface, not a wire-up of an existing gap: grepped `build/*.py` for
"mgmtInB"/"inbMgmt" — zero matches. Real ACI's in-band management is a distinct `mgmtInB` bridge-domain
living in the special `mgmt` tenant, requiring its own tenant/BD/EPG-style builder — a materially bigger
addition than "wire an existing attr," and out of scope for a fine-tuning-parameters PR. Stored +
validated (must fall within a private RFC1918 range: `10.0.0.0/8`, `172.16.0.0/12`, or `192.168.0.0/16`)
+ surfaced, explicitly marked store-only in `aci-sim show`'s own output (`(store-only)` label) so a user
never mistakes it for something the sim actually builds.
### `fabric.default_apic_version` — fabric-wide override, on top of already-independent versioning
**Important finding while implementing this PR:** the task ask ("make apic_version drive the APIC
controller topSystem/firmwareCtrlrRunning version consistently, independent of switch-node version") was
**already fully satisfied** before this PR — `site.apic_version` (added in the PR-9/PR-18 lineage) has
driven `build/fabric.py`'s controller `fabricNode.version`/`topSystem.version`/`firmwareCtrlrRunning.version`
since PR-9, completely independently of the per-node switch `Node.version` field (PR-18). Verified: grepped
`build/*.py` for the literal `"4.2(7f)"` and found it only as `Site.apic_version`'s own schema default in
`topology/schema.py` — no builder hardcodes an APIC version literal that bypasses `site.apic_version`.
What was missing: (1) no fabric-wide override existed for topologies with many sites that all want the
same APIC version, and (2) `aci-sim show`/`new` never surfaced `apic_version` at all (verified: zero
matches for "apic_version" in pre-PR-20 `cli.py`). This PR adds `fabric.default_apic_version` (optional,
default `None` = unchanged behavior — each site keeps using its own `site.apic_version`) with resolution
`topo.fabric.default_apic_version or site.apic_version` in `build/fabric.py`, and surfaces the *effective*
resolved value per-site in `aci-sim show`'s new `apic_version=` column — closing the surfacing gap without
touching the wiring that already worked.
### Backward-compat verification performed vs. NOT performed (be precise)
**Performed in this sandboxed development environment:** `aci-sim validate` on the repo `topology.yaml`
(OK), the full unit suite (713 passed: 672 pre-existing + 41 new, zero regressions), and `aci-sim show`/
`new` manual smoke checks confirming every new field surfaces correctly.
**NOT performed — no access from this environment:** the real production-chain regression gate this
project's acceptance bar calls for (autoACI sandbox e2e 11/11, aci-py MS-TN1 live_chain 0-fail) requires
an SSH-reachable hardware test host, which does not exist in
this sandboxed clone. The `fvBD.mac` default was double-checked against the pre-PR-20 hardcoded literal
in `build/tenants.py` specifically because it is the one Tier-3 change with plausible autoACI-regression
risk (autoACI reads `fvBD` attrs) — but this is static-code verification, not a live e2e run. The main
thread's independent re-run of the real Ansible/aci-py chains is the authoritative backward-compat gate
for this PR, per this project's established process (several prior PRs passed unit tests but broke the
real production chain).
## PR-21 — single-APIC default + per-controller OOB IPs
**Design intent (owner directive):** APIC cluster size is irrelevant to Ansible playbook *deployment*
testing — a playbook connects to and pushes config through exactly **one** APIC endpoint, regardless of
how many controllers back it in real ACI. Cluster size only shows up in cluster-health/appliance-vector
tooling, e.g. autoACI's `health_score.py`, which reads `infraWiNode` across every controller. This PR
therefore flips `Site.controllers`'s default from `3` (pre-PR-21) to **`1`** (single-APIC-per-site), while
keeping the field fully configurable (validated range **1-5**) for anyone who does need to exercise
multi-controller/cluster-health semantics.
### `Site.controllers` default 3 → 1, validated range 1-5
This is a **breaking change to generated node counts**, not merely a config default: every site's
`fabricNode`/`topSystem` count drops by (old `controllers` new `controllers`) = 2 by default (e.g. a
2-spine/2-leaf/2-border-leaf/3-controller site was 9 `fabricNode`s; the same shape at the new
1-controller default is 7). `fabricLink`/`lldpAdjEp`/`cdpAdjEp` counts for the APIC↔leaf attachment drop
proportionally (2 leaves × N controllers, so N=3→6 links becomes N=1→2 links), and `infraWiNode` count
(N²) drops from 9 to 1. Every hardcoded count in the test suite and e2e scripts that assumed the old
default was audited and updated to the new default — see the PR-21 commit message / CHANGELOG for the
full list of touched assertions (the pattern used throughout: prefer a test that derives the expected
count from `site.controllers` dynamically, as `test_pr3b_batch2.py`'s `infraWiNode` test already did
pre-PR-21, over one that hardcodes a literal that will need updating again if the default ever changes).
Range validation (1-5) lives in `Topology.normalize_and_validate` alongside the other per-site checks —
`6` (or `0`, or negative) is rejected with a clear error naming the site.
### `Site.controller_ips` — per-controller OOB mgmt IP (optional)
Pre-PR-21, every controller's `topSystem.oobMgmtAddr` was derived from `(pod, controller_id)` via the
same `oob_ip()` helper switch nodes use — **never** from the site's own `mgmt_ip`, which is the address
the sim's REST server actually binds to (controller-1 / the cluster VIP in sandbox mode). That's fine
for a single controller (there's only one address to assign, and CI/tests never asserted a specific
controller OOB IP), but becomes a believability gap for `controllers > 1`: real multi-controller clusters
assign each APIC its own OOB IP typically adjacent to the cluster's primary address, not derived from an
arbitrary per-node formula.
`controller_ips: list[str]` (optional, default unset) fixes this: when set, `controller_ips[i]` is
controller `i+1`'s OOB IP verbatim (length must equal `controllers`, each entry a valid IP — validated in
`Topology.normalize_and_validate`, same place as the `controllers` range check). When unset, `Site.
controller_oob_ips()` (new helper, `topology/schema.py`) auto-derives sequentially from `mgmt_ip`:
controller 1 = `mgmt_ip`, controller 2 = `mgmt_ip + 1`, controller 3 = `mgmt_ip + 2`, etc. (plain
`ipaddress` integer arithmetic, so it correctly rolls over octets). If `mgmt_ip` is *also* unset (empty
string — some hand-authored topologies never set it), `controller_oob_ips()` returns `[]` and
`build/fabric.py` falls back to the legacy `oob_ip(pod, cid)` scheme, so a topology with neither field
set keeps its pre-PR-21 controller OOB addresses unchanged.
Wired into `build/fabric.py`'s controller loop for both `topSystem.oobMgmtAddr` and the leaf-side
`lldpAdjEp`/`cdpAdjEp.mgmtIp` (the neighbor-reported address must match the controller's own OOB IP, or
autoACI's LLDP-based topology rendering would show a controller "announcing" an IP that doesn't match its
own `topSystem`) — both read the same per-call `controller_oob_ips` list so they can never drift apart.
### Shipped `topology.yaml` — dropped to the new default, not left at 3
Both `LAB1`/`LAB2` already omitted an explicit `controllers:` field pre-PR-21 (it was commented out at
its then-default of 3), so they automatically pick up the new default of 1 with no YAML edit required.
The commented example line was updated to describe the *current* default (1) rather than the old one
(3), and a new commented block was added showing the `controllers: 3` + `controller_ips: [...]` opt-in
shape for anyone who copies this file as a starting point and wants multi-controller semantics.
### Backward-compat verification performed vs. NOT performed (be precise)
**Performed in this sandboxed development environment:** `aci-sim validate` on the repo `topology.yaml`
(OK, now reporting 1 controller/site), the full unit suite (counts below), and manual `aci-sim show`
checks confirming `controller_ips` surfaces correctly for both the auto-derived and explicit cases.
**NOT performed from this sandboxed environment** (no SSH/network access here): the real production-chain
regression gate this project's acceptance bar calls for (autoACI sandbox e2e, aci-py MS-TN1
`live_chain.py`) — this PR explicitly changes generated node counts, which is exactly the kind of change
that gate exists to catch (a hardcoded node-count assertion inside autoACI itself, if one exists, would
only surface there). The main thread's independent re-run of the real Ansible/aci-py/autoACI chains on
ACI hardware is the authoritative backward-compat gate for this PR.
+317
View File
@@ -0,0 +1,317 @@
# aci-sim — Operation Manual / 操作手册
A stateful REST simulator of a Cisco ACI/NDO **management plane** (per-site APIC + Nexus
Dashboard/NDO). It answers `moquery`/class/DN queries computed from an in-memory MIT, applies
`POST`/`DELETE` mutations atomically, and models `aaaLogin` + NDO orchestration — enough fidelity
to test Ansible playbooks, aci-py, autoACI, or any tool that speaks the APIC/NDO REST API, with
**no hardware, no data plane, fully deterministic and resettable.**
> 一个有状态的 Cisco ACI/NDO **管理平面** REST 模拟器(每站点一个 APIC + Nexus Dashboard/NDO)。
> 它从内存中的 MIT 计算 `moquery`/class/DN 查询的响应、原子地应用 `POST`/`DELETE` 变更、模拟
> `aaaLogin` 与 NDO 编排 —— 保真度足以测试 Ansible playbook、aci-py、autoACI,或任何讲 APIC/NDO
> REST API 的工具,**无需硬件、无数据平面、完全确定且可重置。**
---
## 1. Overview / 概述
- **What it does**: serves the APIC REST plane (GET class/mo queries, POST/DELETE mutations,
aaaLogin) and the NDO/ND REST plane (schema/template CRUD + deploy). Both are backed by one
in-memory Management Information Tree (MIT) built from `topology.yaml`.
- **What it does NOT do**: no data plane, no real NX-OS/APIC software stack, no live telemetry.
It models the object model + REST behavior, not packet forwarding.
> **能做什么**:提供 APIC REST 平面(GET class/mo 查询、POST/DELETE 变更、aaaLogin)和 NDO/ND
> REST 平面(schema/template 增删改 + deploy)。两者都由一棵从 `topology.yaml` 构建的内存 MIT 支撑。
> **不做什么**:没有数据平面、没有真实 NX-OS/APIC 软件栈、没有实时遥测。它模拟对象模型 + REST
> 行为,不做报文转发。
**Primary use cases / 主要场景**: Ansible playbook testing · CI gate before real hardware ·
SDK/provider testing · AI-agent sandbox · contract/regression tests · teaching/demo.
---
## 2. Installation / 安装
Requires Python >= 3.11.
```bash
# from a checkout (or pip install "aci-sim @ git+<repo-url>")
python3 -m venv .venv
.venv/bin/pip install -e . # runtime deps: fastapi, uvicorn[standard], pydantic, pyyaml
.venv/bin/pip install -e '.[dev]' # + test deps: pytest, pytest-asyncio, httpx
aci-sim --help
```
> 需要 Python >= 3.11。`pip install -e .` 安装运行依赖(fastapi/uvicorn/pydantic/pyyaml);
> `.[dev]` 额外装测试依赖。装完 `aci-sim --help` 应列出子命令,无 traceback。
---
## 3. Topology & the init wizard / 拓扑与 init 向导
Everything is driven by `topology.yaml` (Pydantic-validated). Author it three ways:
```bash
aci-sim init # interactive wizard (APIC setup-dialog style) — RECOMMENDED
aci-sim new --sites 2 # non-interactive scaffold with flags
# or hand-edit topology.yaml
aci-sim validate topology.yaml # validate before running (CI gate)
aci-sim show topology.yaml # print the derived inventory (IDs/IPs/ports)
```
The **`aci-sim init` wizard** walks: Step 0 Admin account -> Step 1 Deployment type (single
fabric / multi-site) -> Step 2 per-site (name/id/asn/pod/controllers/IPs) -> Step 3 multi-site
(NDO IP + ISN). Press ENTER to accept the `[bracketed]` default.
> 一切由 `topology.yaml` 驱动(Pydantic 校验)。三种方式创建:`aci-sim init`(交互式向导,
> 仿 APIC setup dialog,**推荐**)、`aci-sim new`(带 flag 非交互脚手架)、手改。运行前用
> `aci-sim validate` 校验。向导顺序:Step 0 管理员账号 -> 1 部署类型 -> 2 每站点参数 -> 3 多站点
> (NDO IP + ISN)。回车接受 `[方括号]` 里的默认值。
---
## 4. Admin account / 管理员账号
The wizard's **Step 0** sets the fabric admin credentials, written to a `topology.yaml` `auth:`
section:
```yaml
auth:
username: admin
password: cisco
# ndo_username / ndo_password only if you gave NDO a separate account
```
- **APIC plane ENFORCES it**`aaaLogin` returns 401 on a username/password mismatch.
- **NDO plane is lenient by design** (accepts any credential, for cisco.mso/aci-py compat);
the same account drives it by default.
- **Credential precedence** at `aci-sim run`: an explicit `SIM_USERNAME`/`SIM_PASSWORD` in the
environment wins (atomic — set either and you own both) -> else the topology `auth:` section ->
else the built-in `admin`/`cisco` default. `aci-sim run` prints which source is live (never the
password).
> 向导 Step 0 设置管理员凭据,写进 `topology.yaml``auth:` 段。**APIC 平面强制校验**(密码错
> 返回 401);**NDO 平面故意宽松**(接受任意凭据,为兼容 cisco.mso/aci-py),默认用同一账号。
> `aci-sim run` 的凭据优先级:环境里显式的 `SIM_USERNAME`/`SIM_PASSWORD` 最高(原子:设了任一就
> 两个都归你)-> 其次 topology 的 `auth:` -> 再次内建默认 `admin`/`cisco`。run 会打印当前凭据来源
> (不打印密码)。
---
## 5. Running — two modes / 运行的两种模式
### 5a. Port mode (default) / 端口模式(默认)
Binds `127.0.0.1` on distinct ports. Simplest; good for local dev + CI.
```bash
aci-sim run topology.yaml
# APIC LAB1 -> https://127.0.0.1:8443 APIC LAB2 -> :8444 NDO -> :8445
```
### 5b. Sandbox mode (real per-device IPs on :443) / sandbox 模式(真实 per-device IP,:443)
Each APIC/NDO gets its **own loopback-alias IP on :443** (like real gear). Needed for tools that
assume one IP per controller (e.g. autoACI multi-site discovery). Requires root (adds `lo`
aliases + binds :443).
```bash
sudo bash scripts/sandbox-up.sh # adds lo aliases from topology mgmt IPs, binds :443, detaches
# APIC LAB1 -> https://10.192.0.11:443 APIC LAB2 -> https://10.192.128.11:443
# NDO -> https://10.192.0.10:443
sudo bash scripts/sandbox-down.sh # stop + remove aliases
```
The sandbox IPs come from `topology.yaml`'s `fabric.ndo_mgmt_ip` + each `site.mgmt_ip`. On Linux
they are `lo` aliases -> reachable **only on that host** (run clients on the same box). PID in
`/tmp/aci-sim-sandbox.pid`, log in `/tmp/aci-sim-sandbox.log`.
> **端口模式**(默认):绑 `127.0.0.1` 不同端口,最简单,适合本地开发/CI。
> **sandbox 模式**:每个 APIC/NDO 拿到自己的 loopback 别名 IP、都在 :443(像真机),适合假设"每个
> 控制器一个 IP"的工具(如 autoACI 多站点发现)。需 root(加 `lo` 别名 + 绑 :443)。sandbox IP 来自
> `topology.yaml``ndo_mgmt_ip` 和各 `site.mgmt_ip`;Linux 上是 `lo` 别名,**只在本机可达**(客户端
> 要在同一台跑)。PID/日志见 `/tmp/aci-sim-sandbox.{pid,log}`
---
## 6. Connecting clients / 连接客户端
Credentials = your admin account (default `admin`/`cisco`). TLS is self-signed -> disable cert
validation.
**Ansible — cisco.aci (single fabric) / cisco.mso (multi-site):**
```yaml
# inventory: point apic_host / MSO ansible_host at the sim's IP:port
apic_host: "10.192.0.11:443" # sandbox, or 127.0.0.1:8443 in port mode
apic_username: admin
apic_password: cisco
apic_validate_certs: no
```
**aci-py** (Python pusher): point `--apic` at the APIC IP; for multi-site pass an NDO connector
(`Ndo(host, username, password)`) so tenants become NDO-managed (visible in autoACI).
**autoACI**: log in to the **NDO** IP (`10.192.0.10`) -> Discover -> Connect All Sites. It reads
NDO, so multi-site tenants (created via cisco.mso or aci-py's NDO path) appear there.
> 凭据 = 你的管理员账号(默认 `admin`/`cisco`),TLS 自签 -> 关掉证书校验。Ansible 用 cisco.aci
> (单站点)/cisco.mso(多站点),inventory 里把 `apic_host`/MSO `ansible_host` 指向 sim 的 IP:端口。
> aci-py 把 `--apic` 指向 APIC;多站点要传 NDO 连接器,tenant 才会被 NDO 管理(autoACI 才看得到)。
> autoACI 登录 **NDO** IP(`10.192.0.10`)-> Discover -> Connect All Sites。
> **Multi-site invariant / 多站点不变量**: a tenant is "multi-site" only if it exists in **NDO**.
> An APIC-only push (e.g. aci-py without an NDO connector) creates an APIC-local tenant that
> autoACI's NDO view will NOT show. 名字含多站点语义的 tenant 必须出现在 NDO 上。
---
## 7. Querying the MIT / 查询 MIT
Speaks the plain APIC REST API — works with `curl`, httpx, cisco.aci, or a real APIC.
```bash
# login -> cookie
curl -sk -c j.txt -X POST https://<apic>/api/aaaLogin.json \
-d '{"aaaUser":{"attributes":{"name":"admin","pwd":"cisco"}}}'
# class query (== moquery -c fvTenant)
curl -sk -b j.txt https://<apic>/api/class/fvTenant.json
# mo query + subtree
curl -sk -b j.txt "https://<apic>/api/mo/uni/tn-MS-TN1.json?query-target=subtree&target-subtree-class=fvBD"
# LLDP / CDP neighbors
curl -sk -b j.txt https://<apic>/api/class/lldpAdjEp.json
```
`aci-sim lldp topology.yaml [--site N --node N --cdp --json]` prints a `show lldp neighbors`-style
table without hand-writing URLs. Query subscriptions + websocket push-on-change are supported
(`?subscription=yes` + `/socket<token>`).
> 讲标准 APIC REST API,`curl`/httpx/cisco.aci/真机 APIC 都能用。class 查询等价 `moquery -c`;
> mo 查询支持 `query-target=subtree``aci-sim lldp` 直接打印邻居表。支持查询订阅 + websocket
> 变更推送(`?subscription=yes`)。
---
## 8. CLI reference / CLI 参考
| Command | Purpose / 用途 |
|---|---|
| `aci-sim init` | interactive wizard -> topology.yaml / 交互向导 |
| `aci-sim new --sites N ...` | non-interactive scaffold / 非交互脚手架 |
| `aci-sim validate FILE` | validate topology (CI gate) / 校验拓扑 |
| `aci-sim show FILE [--json]` | print derived inventory / 打印推导出的清单 |
| `aci-sim lldp FILE [--site --node --cdp --json]` | LLDP/CDP neighbor table / 邻居表 |
| `aci-sim graph FILE -o out.svg\|.html` | render topology diagram / 渲染拓扑图 |
| `aci-sim run FILE` | run the supervisor (port mode) / 起 supervisor(端口模式) |
| `scripts/sandbox-up.sh` / `sandbox-down.sh` | sandbox mode (real IPs :443) / sandbox 模式 |
| `scripts/sim-state.sh {save\|restore} NAME` | save/restore whole-fabric state (§10) / 保存恢复整个 fabric 状态(见 §10) |
---
## 9. `/_sim` control API / 控制 API
For AI-agent sandboxes and test isolation: snapshot / restore / reset the MIT, and seed faults —
so each test starts from a known state.
> 面向 AI agent 沙箱和测试隔离:对 MIT 做 snapshot / restore / reset、注入 fault —— 让每个测试从
> 已知状态开始。
---
## 10. State persistence / 状态保存与恢复
In sandbox/port mode the sim's state is **in-memory only** — restarting the process wipes every
tenant/schema/template on every plane. To survive a restart, save state to disk first and restore
it after.
**Per-plane endpoints** (mirrors the `/_sim` control API in §9):
| Plane | Save | Load |
|---|---|---|
| APIC (each site) | `POST /_sim/save/{name}` | `POST /_sim/load/{name}` |
| NDO | `POST /_sim/save/{name}` | `POST /_sim/load/{name}` |
- APIC's save writes `{name}.{site.id}.apic.json` (keyed by site id, so multiple sites in the same
`SIM_STATE_DIR` never collide); NDO's save writes `{name}.ndo.json`.
- A missing `load` name returns 404 — an APIC-envelope error (`imdata[0].error`) on the APIC plane,
a plain `{"detail": ...}` on the NDO plane (matching each plane's existing error shape).
- Files are plain JSON under `SIM_STATE_DIR` (default `~/.aci-sim/state`) — human-readable,
diffable, safe to check into a fixtures directory for a known-good baseline.
**Wrapper script — save/restore the WHOLE fabric (all APIC sites + NDO) in one command:**
```bash
scripts/sim-state.sh save mybaseline # snapshot every plane
scripts/sim-state.sh restore mybaseline # restore every plane (maps to /_sim/load)
```
It derives each plane's address the same way `scripts/sandbox-up.sh` does (straight from
`topology.yaml`'s `fabric.ndo_mgmt_ip` / each `site.mgmt_ip`, sandbox mode's real per-device
`:443` IPs), and falls back to port mode (`127.0.0.1:8443`/`:8444`/`:8445`) if those IPs aren't
reachable — so it works unmodified in either running mode.
**Restart workflow:**
```bash
scripts/sim-state.sh save mybaseline
sudo bash scripts/sandbox-down.sh && sudo bash scripts/sandbox-up.sh # or Ctrl-C + aci-sim run
scripts/sim-state.sh restore mybaseline
```
**`SIM_STATE_DIR`** — override the base directory for all saved state (default
`~/.aci-sim/state`; created automatically). Tests set this to an isolated `tmp_path` so
nothing is ever written under the real home directory.
> sandbox/端口模式下 sim 状态**只在内存里**——重启进程会清空所有平面上的 tenant/schema/template。
> 要跨重启保留状态,重启前先保存、重启后再恢复。
>
> **各平面端点**(与 §9 的 `/_sim` 控制 API 呼应):APIC(每个站点)和 NDO 都提供
> `POST /_sim/save/{name}` / `POST /_sim/load/{name}`。APIC 的保存文件名为
> `{name}.{site.id}.apic.json`(按 site id 区分,同一个 `SIM_STATE_DIR` 里多个站点不会冲突);
> NDO 的保存文件名为 `{name}.ndo.json``load` 遇到不存在的 name 返回 404 ——APIC 平面是
> APIC 信封错误(`imdata[0].error`),NDO 平面是普通 `{"detail": ...}`(各自匹配平面已有的
> 错误格式)。文件是 `SIM_STATE_DIR`(默认 `~/.aci-sim/state`)下的纯 JSON——可读、可
> diff,也可以存进 fixtures 目录当已知良好基线用。
>
> **一键保存/恢复整个 fabric**(所有 APIC 站点 + NDO):`scripts/sim-state.sh save <name>` /
> `scripts/sim-state.sh restore <name>`(`restore` 对应 `/_sim/load`)。地址推导方式与
> `scripts/sandbox-up.sh` 一致(从 `topology.yaml``ndo_mgmt_ip`/各 `site.mgmt_ip`),这些 IP
> 不可达时自动退回端口模式(`127.0.0.1:8443`/`:8444`/`:8445`),两种运行模式下都能直接用。
>
> **重启工作流**:先 `sim-state.sh save` → 重启 sim(`sandbox-down.sh && sandbox-up.sh`,或
> Ctrl-C 后 `aci-sim run`)→ 再 `sim-state.sh restore`
>
> **`SIM_STATE_DIR`**:覆盖所有保存状态的根目录(默认 `~/.aci-sim/state`,自动创建)。
> 测试会把它设成隔离的 `tmp_path`,绝不会写到真实 home 目录下。
---
## 11. Multi-site vs single-fabric / 多站点 vs 单站点
- **Single-fabric**: one APIC. Tenants pushed directly via cisco.aci — APIC-local, no NDO.
- **Multi-site**: NDO + per-site APICs. Tenants are created as NDO schemas/templates and
**deployed** to sites; only then do they appear on the site APICs (and in autoACI).
- **multi-pod ~= single-fabric** for Ansible tenant testing — the IPN lives on external Nexus (NX-OS),
not cisco.aci; the only per-pod nuance is the pod id in static binding paths, covered by `site.pod`.
> 单站点:一个 APIC,tenant 经 cisco.aci 直推,APIC 本地,无 NDO。多站点:NDO + 各站点 APIC,tenant
> 建成 NDO schema/template 再 **deploy** 到站点,之后才落到站点 APIC(和 autoACI)。对 Ansible
> tenant 测试,**multi-pod ~= single-fabric** —— IPN 在外部 Nexus 上(NX-OS),不经 cisco.aci。
---
## 12. Troubleshooting / 排错
| Symptom / 现象 | Cause & fix / 原因与解决 |
|---|---|
| `aaaLogin` 401 | password != the enforced account; check `auth:` / `SIM_USERNAME`+`SIM_PASSWORD` / the `run` banner. |
| sandbox: `Needs root` | `sudo bash scripts/sandbox-up.sh` (needs `lo` alias + :443 bind). |
| sandbox: nothing on :443 after start | old listener still holds the socket; the script waits for it to free — check `/tmp/aci-sim-sandbox.log`. |
| deep-root subtree query returns 0 | query a DN that has a materialized MO (e.g. `.../sys`, not an un-materialized container). |
| client can't reach sandbox IP from another host | sandbox IPs are `lo` aliases -> same-host only; use port mode + the host LAN IP for remote clients, or run the client on the sim host. |
| `pip install` CLI crashes on import | ensure v0.13.1+ (earlier packaging didn't declare deps / omitted subpackages). |
---
*Generated as part of the sim playbook-E2E effort. For the REST contract details see
`docs/CONTRACT.md`; for design rationale see `docs/DESIGN.md`; for the changelog see `CHANGELOG.md`.*
+96
View File
@@ -0,0 +1,96 @@
# Ansible `cisco.aci` examples
This directory contains a small, validated `cisco.aci` playbook set that
exercises aci-sim the same way you'd exercise a real APIC: create a
tenant/VRF/BD/AP/EPG, confirm idempotency (re-apply reports no change), then
tear it all down.
## Prerequisites
```bash
pip install ansible
ansible-galaxy collection install cisco.aci
```
The sim must be running first — see the main `README.md` §4 Usage/Quickstart.
## Files
| File | Purpose |
| --- | --- |
| `inventory.ini` | Example inventory — one host (`sim-apic-a`) pointed at the sim, with connection vars set as group vars. |
| `smoke.yml` | Creates `SMOKE-TN1` (tenant) → `SMOKE-VRF1` (VRF) → `SMOKE-BD1` (BD) → `SMOKE-AP1` (AP) → `SMOKE-EPG1` (EPG), then re-applies every task and asserts `changed=false` on the second pass. |
| `teardown.yml` | Removes everything `smoke.yml` created, in reverse dependency order, then asserts that deleting an already-absent tenant is idempotent (`changed=false`). |
## Running against port mode (default)
Port mode is what `bash scripts/run.sh` starts: one shared bind address
(`127.0.0.1` by default), APIC site A on `:8443`.
```bash
# from the repo root, in one terminal:
bash scripts/run.sh
# in another terminal:
cd examples/ansible
ansible-playbook -i inventory.ini smoke.yml
ansible-playbook -i inventory.ini teardown.yml
```
`inventory.ini` already points `sim-apic-a` at `127.0.0.1:8443` with
`admin`/`cisco` and `validate_certs=false` (the sim's TLS cert is
self-signed — see main `README.md` §5 Configuration). If you changed
`APIC_A_PORT` or `SIM_USERNAME`/`SIM_PASSWORD`, override the matching
`aci_port`/`aci_username`/`aci_password` host/group vars.
## Running against sandbox mode
Sandbox mode (`sudo bash scripts/sandbox-up.sh`) gives each APIC a real IP
on the standard port `443` — no port number, like real gear. Point the
inventory at that IP instead and drop the port override (or set it to 443
explicitly):
```bash
sudo bash scripts/sandbox-up.sh # from the repo root
```
Edit `inventory.ini` (or pass `-e` overrides on the command line) so
`ansible_host`/`aci_hostname` is the site's `mgmt_ip` from `topology.yaml`
(e.g. `10.192.0.11` for LAB1's default topology) and `aci_port` is `443`:
```bash
cd examples/ansible
ansible-playbook -i inventory.ini smoke.yml \
-e aci_hostname=10.192.0.11 -e aci_port=443
ansible-playbook -i inventory.ini teardown.yml \
-e aci_hostname=10.192.0.11 -e aci_port=443
```
Tear the sandbox down when done:
```bash
sudo bash scripts/sandbox-down.sh
```
## What "idempotent" means here
`cisco.aci` modules are idempotent at the **client** level: they GET the
existing object, diff it against the desired state, and only issue a
POST/DELETE if something actually differs. `smoke.yml`'s second pass and
`teardown.yml`'s re-delete both assert `changed: false`, proving the round
trip (create → GET-existing → diff → no-op) behaves the same against
aci-sim as it would against a real APIC — this is exactly the
GET-before-write contract documented in `docs/CONTRACT.md` §11
(`api_call()` treats any non-200 GET as fatal, so a not-yet-created
object's GET must be `200 {"imdata":[],"totalCount":"0"}`, never 404).
## Troubleshooting
- **Connection refused** — the sim isn't running, or you're pointed at the
wrong host/port for the run mode you started (port mode vs. sandbox mode).
- **`fatal: APIC Error 103: Unable to find the object specified`** on the
very first task — this was a real bug class this sim's PR-9 fixed (GET on
a nonexistent DN used to 404); if you see it, you're likely running an
older checkout — update to the latest `main`.
- **SSL errors** — make sure `validate_certs: false` (the sim's certificate
is self-signed; see `scripts/gen_certs.sh`).
+21
View File
@@ -0,0 +1,21 @@
; Example inventory for running the cisco.aci playbooks in this directory
; against aci-sim.
;
; Port mode (default `bash scripts/run.sh`):
; host = 127.0.0.1
; port = 8443 (APIC site A) — see README.md §Configuration for
; APIC_A_PORT/APIC_B_PORT if you changed the defaults.
;
; Sandbox mode (`sudo bash scripts/sandbox-up.sh`):
; host = the site's mgmt_ip from topology.yaml (e.g. 10.192.0.11)
; port = 443 (the default; cisco.aci's aci_port omitted or set to 443)
[aci_sim]
sim-apic-a ansible_host=127.0.0.1 aci_port=8443
[aci_sim:vars]
aci_hostname={{ ansible_host }}
aci_username=admin
aci_password=cisco
aci_validate_certs=false
aci_use_ssl=true
+145
View File
@@ -0,0 +1,145 @@
---
# smoke.yml — create tenant/VRF/BD/AP/EPG against aci-sim and verify
# idempotency (run twice: second run must report changed=false for every task).
#
# ansible-playbook -i inventory.ini smoke.yml
#
# Uses the cisco.aci collection (https://github.com/CiscoDevNet/ansible-aci).
# Install it first if needed:
# ansible-galaxy collection install cisco.aci
#
- name: aci-sim smoke test — tenant/VRF/BD/AP/EPG create + idempotency
hosts: aci_sim
connection: local
gather_facts: false
vars:
aci_connection: &aci_connection
host: "{{ aci_hostname }}"
port: "{{ aci_port | default(8443) }}"
username: "{{ aci_username | default('admin') }}"
password: "{{ aci_password | default('cisco') }}"
validate_certs: "{{ aci_validate_certs | default(false) }}"
use_ssl: "{{ aci_use_ssl | default(true) }}"
smoke_tenant: SMOKE-TN1
smoke_vrf: SMOKE-VRF1
smoke_bd: SMOKE-BD1
smoke_ap: SMOKE-AP1
smoke_epg: SMOKE-EPG1
tasks:
- name: Ensure tenant exists
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
description: Created by aci-sim examples/ansible/smoke.yml
state: present
register: tenant_result
- name: Ensure VRF exists
cisco.aci.aci_vrf:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
vrf: "{{ smoke_vrf }}"
state: present
register: vrf_result
- name: Ensure bridge domain exists
cisco.aci.aci_bd:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
bd: "{{ smoke_bd }}"
vrf: "{{ smoke_vrf }}"
state: present
register: bd_result
- name: Ensure application profile exists
cisco.aci.aci_ap:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
state: present
register: ap_result
- name: Ensure EPG exists
cisco.aci.aci_epg:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
epg: "{{ smoke_epg }}"
bd: "{{ smoke_bd }}"
state: present
register: epg_result
- name: Report first-run results (expect changed=true for a fresh sim)
ansible.builtin.debug:
msg: >-
tenant changed={{ tenant_result.changed }},
vrf changed={{ vrf_result.changed }},
bd changed={{ bd_result.changed }},
ap changed={{ ap_result.changed }},
epg changed={{ epg_result.changed }}
# --- Idempotency re-check -------------------------------------------------
# Re-running every task above against the same state must report
# changed=false. This block re-issues each module and fails the play if
# any of them still reports a change, catching non-idempotent behavior.
- name: Re-apply tenant (idempotency check)
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
description: Created by aci-sim examples/ansible/smoke.yml
state: present
register: tenant_recheck
- name: Re-apply VRF (idempotency check)
cisco.aci.aci_vrf:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
vrf: "{{ smoke_vrf }}"
state: present
register: vrf_recheck
- name: Re-apply bridge domain (idempotency check)
cisco.aci.aci_bd:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
bd: "{{ smoke_bd }}"
vrf: "{{ smoke_vrf }}"
state: present
register: bd_recheck
- name: Re-apply application profile (idempotency check)
cisco.aci.aci_ap:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
state: present
register: ap_recheck
- name: Re-apply EPG (idempotency check)
cisco.aci.aci_epg:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
epg: "{{ smoke_epg }}"
bd: "{{ smoke_bd }}"
state: present
register: epg_recheck
- name: Assert idempotency (all re-applies must be changed=false)
ansible.builtin.assert:
that:
- not tenant_recheck.changed
- not vrf_recheck.changed
- not bd_recheck.changed
- not ap_recheck.changed
- not epg_recheck.changed
fail_msg: >-
Idempotency check failed — at least one module reported a change on
a re-apply against unchanged state. This usually means the sim (or
the playbook's parameters) disagree between GET-existing and the
proposed diff.
success_msg: Idempotency check passed — no module reported a change on re-apply.
+83
View File
@@ -0,0 +1,83 @@
---
# teardown.yml — remove everything smoke.yml created (state=absent), in
# reverse dependency order. Deleting the tenant cascades its whole subtree
# in aci-sim (see docs/CONTRACT.md §11, MITStore.delete), so the
# later tasks are mostly redundant once the tenant is gone — they're kept
# here for clarity and so this playbook also works if you only want to
# tear down a single child object.
#
# ansible-playbook -i inventory.ini teardown.yml
#
- name: aci-sim teardown — remove smoke.yml objects
hosts: aci_sim
connection: local
gather_facts: false
vars:
aci_connection: &aci_connection
host: "{{ aci_hostname }}"
port: "{{ aci_port | default(8443) }}"
username: "{{ aci_username | default('admin') }}"
password: "{{ aci_password | default('cisco') }}"
validate_certs: "{{ aci_validate_certs | default(false) }}"
use_ssl: "{{ aci_use_ssl | default(true) }}"
smoke_tenant: SMOKE-TN1
smoke_vrf: SMOKE-VRF1
smoke_bd: SMOKE-BD1
smoke_ap: SMOKE-AP1
smoke_epg: SMOKE-EPG1
tasks:
- name: Remove EPG
cisco.aci.aci_epg:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
epg: "{{ smoke_epg }}"
bd: "{{ smoke_bd }}"
state: absent
- name: Remove application profile
cisco.aci.aci_ap:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
ap: "{{ smoke_ap }}"
state: absent
- name: Remove bridge domain
cisco.aci.aci_bd:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
bd: "{{ smoke_bd }}"
vrf: "{{ smoke_vrf }}"
state: absent
- name: Remove VRF
cisco.aci.aci_vrf:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
vrf: "{{ smoke_vrf }}"
state: absent
- name: Remove tenant (cascades any remaining children)
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
state: absent
- name: Re-run tenant delete (idempotency check — must be changed=false)
cisco.aci.aci_tenant:
<<: *aci_connection
tenant: "{{ smoke_tenant }}"
state: absent
register: tenant_redelete
- name: Assert delete idempotency
ansible.builtin.assert:
that:
- not tenant_redelete.changed
fail_msg: >-
Deleting an already-absent tenant reported changed=true — DELETE
on a missing DN should be idempotent (see docs/CONTRACT.md §11).
success_msg: Delete idempotency check passed.
+52
View File
@@ -0,0 +1,52 @@
# CI gate example — fail a PR before it reaches real hardware
Pattern: run your network automation (Ansible / Terraform / Python / aci-py)
against a running `aci-sim`, then **assert the MIT reached the expected
end state**. A failed assertion fails the build. No hardware, deterministic,
resettable.
## Files
- **`assert_mit.py`** — the gate. Logs into the sim (or a real APIC) and checks
that the objects your change was supposed to create/modify are present.
Exit code = number of failed checks, so CI can gate on it.
- **`github-actions-aci-gate.yml`** — a copy-pasteable GitHub Actions workflow
wiring the three steps (start sim → run automation → assert).
## `assert_mit.py` check syntax
Each `--expect` (repeatable):
| Check | Meaning |
|---|---|
| `class:<cls>[>=N]` | the class query returns ≥ N objects (default N=1) |
| `mo:<dn>` | `GET /api/mo/<dn>.json` returns the object (exists) |
| `absent:<dn>` | the MO does NOT exist (e.g. after a `state=absent` / delete) |
| `attr:<dn>:<key>=<val>` | the MO at `<dn>` has attribute `<key>` == `<val>` |
## Try it locally
```bash
# 1. start the sim (ships MS-TN1 / SF-TN1-* tenants from topology.yaml)
aci-sim run &
# 2. assert the seeded end state
python examples/ci/assert_mit.py --host 127.0.0.1:8443 \
--expect "class:fvTenant>=1" \
--expect "mo:uni/tn-MS-TN1" \
--expect "attr:uni/tn-MS-TN1:name=MS-TN1" \
--expect "absent:uni/tn-DoesNotExist"
# → 4/4 checks passed, exit 0
```
Because `assert_mit.py` speaks the plain APIC REST API, you can point it at a
**real APIC** too (`--host <apic-ip> --user ... --password ...`) to diff
sim-vs-real, or to validate the same expectations against staging gear.
## Real-world shape
In practice step 2 is your existing playbook (see `../ansible/smoke.yml` for a
full cisco.aci create→idempotency example), and step 3's `--expect` list is the
objects that playbook is contracted to produce. When someone edits the playbook
in a way that stops creating those objects, the PR goes red — before anything
touches production.
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""assert_mit.py — a tiny CI gate: assert the simulator's MIT reached an
expected end state after your automation ran.
This is the "gate" half of the CI pattern: run your playbook / Terraform /
Python against a running aci-sim, then run this to fail the build if
the objects your change was supposed to create/modify are not present.
It talks the plain APIC REST API (aaaLogin + class/mo queries), so it works
against a real APIC too point it at real gear to diff sim-vs-real.
Usage
-----
python assert_mit.py --host 127.0.0.1:8443 --user admin --password cisco \
--expect "class:fvTenant>=1" \
--expect "mo:uni/tn-MS-TN1" \
--expect "attr:uni/tn-MS-TN1:name=MS-TN1"
Check syntax (each --expect, repeatable):
class:<cls>[>=N] the class query returns at least N objects (default N=1)
mo:<dn> GET /api/mo/<dn>.json returns the object (exists)
absent:<dn> GET /api/mo/<dn>.json returns empty (does NOT exist)
attr:<dn>:<key>=<val> the MO at <dn> has attribute <key> equal to <val>
Exit code = number of failed checks (0 = all passed), so CI can gate on it.
"""
from __future__ import annotations
import argparse
import sys
import httpx
def _login(client: httpx.Client, user: str, password: str) -> None:
r = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": user, "pwd": password}}},
)
if r.status_code != 200:
print(f" FATAL aaaLogin failed: HTTP {r.status_code} {r.text[:120]}")
sys.exit(99)
tok = r.json()["imdata"][0]["aaaLogin"]["attributes"]["token"]
client.cookies.set("APIC-cookie", tok)
def _mo(client: httpx.Client, dn: str) -> dict | None:
r = client.get(f"/api/mo/{dn}.json")
if r.status_code == 200 and int(r.json().get("totalCount", 0)) > 0:
return list(r.json()["imdata"][0].values())[0]["attributes"]
return None
def _check(client: httpx.Client, expect: str) -> bool:
"""Run one --expect check; return True on pass, print the result."""
if expect.startswith("class:"):
body = expect[len("class:"):]
minimum = 1
cls = body
if ">=" in body:
cls, n = body.split(">=", 1)
minimum = int(n)
r = client.get(f"/api/class/{cls}.json")
total = int(r.json().get("totalCount", 0)) if r.status_code == 200 else -1
ok = total >= minimum
print(f" {'PASS' if ok else 'FAIL'} class {cls} count={total} (need >={minimum})")
return ok
if expect.startswith("mo:"):
dn = expect[len("mo:"):]
attrs = _mo(client, dn)
ok = attrs is not None
print(f" {'PASS' if ok else 'FAIL'} mo {dn} {'exists' if ok else 'MISSING'}")
return ok
if expect.startswith("absent:"):
dn = expect[len("absent:"):]
attrs = _mo(client, dn)
ok = attrs is None
print(f" {'PASS' if ok else 'FAIL'} absent {dn} {'gone' if ok else 'STILL PRESENT'}")
return ok
if expect.startswith("attr:"):
rest = expect[len("attr:"):]
dn, kv = rest.rsplit(":", 1)
key, val = kv.split("=", 1)
attrs = _mo(client, dn) or {}
got = attrs.get(key)
ok = got == val
print(f" {'PASS' if ok else 'FAIL'} attr {dn} {key}={got!r} (want {val!r})")
return ok
print(f" FAIL unrecognized check: {expect!r}")
return False
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Assert the aci-sim MIT end state (CI gate).")
p.add_argument("--host", required=True, help="APIC host:port, e.g. 127.0.0.1:8443")
p.add_argument("--user", default="admin")
p.add_argument("--password", default="cisco")
p.add_argument("--scheme", default="https", choices=["https", "http"])
p.add_argument("--expect", action="append", default=[], help="a check (repeatable) — see module docstring")
a = p.parse_args(argv)
if not a.expect:
p.error("at least one --expect is required")
base = f"{a.scheme}://{a.host}"
with httpx.Client(base_url=base, verify=False, timeout=30.0) as client:
_login(client, a.user, a.password)
failed = sum(0 if _check(client, e) else 1 for e in a.expect)
print(f"\n=== {len(a.expect) - failed}/{len(a.expect)} checks passed ===")
return failed
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
# Example GitHub Actions workflow: gate a network-automation change against
# aci-sim before it can reach real hardware.
#
# The pattern is three steps:
# 1. Start the simulator (in the background, port mode).
# 2. Run YOUR automation against it (Ansible / Terraform / Python / aci-py).
# 3. Assert the MIT reached the expected end state (examples/ci/assert_mit.py).
# A non-zero exit fails the build.
#
# Copy this into .github/workflows/ in the repo that holds your automation,
# adjust the "Run your automation" and "Assert" steps to your playbook + the
# objects it is supposed to create, and you have a hardware-free CI gate.
name: ACI automation gate
on:
pull_request:
push:
branches: [main]
jobs:
aci-gate:
runs-on: ubuntu-latest
steps:
- name: Check out your automation repo
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# ---- 1. Bring up the simulator -------------------------------------
- name: Install and start aci-sim
run: |
pip install "aci-sim @ git+https://github.com/dtzp555-max/aci-sim@v0.8.0"
# Or: git clone the repo and `pip install -e .`
# Port mode: APIC LAB1 on :8443, LAB2 :8444, NDO :8445 (127.0.0.1).
aci-sim run & # backgrounds the supervisor
# wait for the APIC REST endpoint to answer
for i in $(seq 1 30); do
curl -sk https://127.0.0.1:8443/api/class/fvTenant.json >/dev/null && break
sleep 1
done
# ---- 2. Run YOUR automation against the sim ------------------------
# Replace this with your real playbook / terraform / script.
# It targets the sim exactly like a real APIC (admin/cisco by default).
- name: "Run automation (example: cisco.aci playbook)"
run: |
pip install ansible-core
ansible-galaxy collection install cisco.aci
ansible-playbook -i inventory.ini create_tenant.yml \
-e apic_host=127.0.0.1:8443 -e apic_username=admin -e apic_password=cisco \
-e apic_validate_certs=false
# ---- 3. Gate: assert the MIT end state -----------------------------
- name: Assert MIT end state
run: |
# assert_mit.py ships in the aci-sim repo under examples/ci/.
# Vendor it, or curl it, or add the sim repo as a submodule.
python assert_mit.py --host 127.0.0.1:8443 \
--expect "class:fvTenant>=1" \
--expect "mo:uni/tn-YOUR-TENANT" \
--expect "attr:uni/tn-YOUR-TENANT:name=YOUR-TENANT"
# non-zero exit here fails the PR — your change did not produce the
# objects it was supposed to. No hardware was touched.
+68
View File
@@ -0,0 +1,68 @@
[project]
name = "aci-sim"
version = "0.16.0"
description = "Faithful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO)"
readme = "README.md"
license = "MIT"
authors = [
{ name = "aci-sim contributors" },
]
requires-python = ">=3.11"
classifiers = [
# No "License :: OSI Approved :: MIT License" classifier: the SPDX
# `license = "MIT"` field above supersedes license classifiers (PEP 639)
# and modern setuptools rejects having both.
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Networking",
"Topic :: Software Development :: Testing",
"Typing :: Typed",
]
# Runtime dependencies are declared statically here (NOT `dynamic`) so that a
# plain `pip install aci-sim` / `pip install .` pulls them in. requirements.txt
# is kept as a dev convenience (it also lists the test-only extras below).
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"pydantic>=2.6",
"pyyaml>=6.0",
]
[project.optional-dependencies]
# Test/dev-only — not needed to run the simulator. `pip install -e '.[dev]'`.
dev = [
"httpx>=0.27",
"pytest>=8.0",
"pytest-asyncio>=0.23",
]
[project.urls]
Homepage = "https://github.com/dtzp555-max/aci-sim"
Repository = "https://github.com/dtzp555-max/aci-sim"
Changelog = "https://github.com/dtzp555-max/aci-sim/blob/main/CHANGELOG.md"
[project.scripts]
aci-sim = "aci_sim.cli:main"
[tool.setuptools.packages.find]
# Auto-discover ALL subpackages (aci_sim.topology/build/rest_aci/ndo/
# mit/runtime/query/control/...) — a bare `packages = ["aci_sim"]` would
# ship only the top-level module and omit every subpackage.
include = ["aci_sim*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501"]
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.110
uvicorn[standard]>=0.29
pydantic>=2.6
pyyaml>=6.0
httpx>=0.27
pytest>=8.0
pytest-asyncio>=0.23
+126
View File
@@ -0,0 +1,126 @@
"""Full-stack E2E: drive the REAL autoACI backend against the sim.
Proves the acceptance criterion point local autoACI at the sim and every view
renders internally-consistent data. Unlike tests/verify_autoaci.py (which uses
TestClient in-process), this hits the running HTTP stack end to end.
Prerequisites (two servers running):
1. the sim: cd ~/aci-sim && bash scripts/run.sh
2. autoACI: cd ~/autoACI/backend && ./.venv/bin/uvicorn main:app --port 8000
Run: cd ~/autoACI/backend && ./.venv/bin/python ~/aci-sim/scripts/e2e_live.py
It logs autoACI into the sim's two APICs (:8443/:8444) + NDO (:8445), then
exercises /api/topology, ~14 real plugins, and the NDO views. Exit 0 = all pass.
"""
import json
import sys
import httpx
BASE = "http://127.0.0.1:8000"
SITE_A, SITE_B, NDO = "127.0.0.1:8443", "127.0.0.1:8444", "127.0.0.1:8445"
c = httpx.Client(base_url=BASE, timeout=90.0)
results = [] # (name, ok, detail)
def rec(name, ok, detail=""):
results.append((name, ok, detail))
print(f" {'PASS' if ok else 'FAIL'} {name} {detail}")
def csrf():
return c.cookies.get("csrf_token", "")
def post(path, body, site=None):
h = {"X-CSRF-Token": csrf(), "Content-Type": "application/json"}
if site:
h["X-Active-Site"] = site
return c.post(path, json=body, headers=h)
def get(path, site=None):
h = {}
if site:
h["X-Active-Site"] = site
return c.get(path, headers=h)
print("=== bootstrap: obtain CSRF cookie ===")
c.get("/api/health")
print(" csrf_token:", (csrf()[:12] or "<none>"))
print("\n=== APIC logins (point autoACI at the sim) ===")
for host, label in [(SITE_A, "LAB1"), (SITE_B, "LAB2")]:
r = post("/api/auth/login", {"host": host, "username": "admin", "password": "cisco", "verify_ssl": False})
ok = r.status_code == 200 and r.json().get("success")
d = r.json()
rec(f"APIC login {label} ({host})", ok,
f"fabric={d.get('fabric_name')!r} ver={d.get('apic_version')!r} role={d.get('role')!r}")
print("\n=== NDO login ===")
r = post("/api/ndo/login", {"host": NDO, "username": "admin", "password": "cisco", "verify_ssl": False})
rec("NDO login", r.status_code == 200, f"HTTP {r.status_code} {str(r.json())[:80]}")
print("\n=== /api/auth/status (multi-site) ===")
r = get("/api/auth/status")
st = r.json()
rec("auth status authenticated", bool(st.get("authenticated")),
f"sites={[s.get('host') for s in st.get('sites', [])]}")
print("\n=== /api/topology (the headline view) ===")
r = get("/api/topology", site=SITE_A)
if r.status_code != 200:
rec("GET /api/topology", False, f"HTTP {r.status_code} {r.text[:200]}")
topo = {}
else:
topo = r.json()
nodes = topo.get("nodes", [])
links = topo.get("links", [])
roles = sorted({n.get("role", "?") for n in nodes})
sites_seen = sorted({n.get("site", n.get("pod", "?")) for n in nodes})
isn = topo.get("isn") or topo.get("inter_site") or [l for l in links if str(l.get("type", "")).lower().find("isn") >= 0]
rec("GET /api/topology", len(nodes) > 0, f"top-level keys={list(topo.keys())}")
rec(" topology nodes", len(nodes) >= 6, f"{len(nodes)} nodes, roles={roles}, sites={sites_seen}")
rec(" topology links", len(links) > 0, f"{len(links)} links")
print(f" ISN/inter-site signal: {json.dumps(isn)[:160] if isn else '(inspect keys)'}")
print("\n=== prebuilt plugins (real autoACI plugin code vs the sim) ===")
# mode "rows" = expect >0 rows; mode "ran" = health/anomaly plugin, 0 rows can mean healthy → pass on HTTP 200
PLUGINS = [
("fabric_nodes", {}, "rows"), ("fabric_links", {}, "rows"), ("fabric_bgp_evpn", {}, "rows"),
("bgp_health", {}, "ran"), ("adjacency_health", {}, "ran"),
("fault_summary", {}, "rows"), ("health_score", {}, "rows"), ("fabric_node_summary", {}, "rows"),
("tenant_list", {}, "rows"), ("bd_detail", {"tenant_name": "MS-TN1"}, "rows"),
("epg_detail", {"tenant_name": "MS-TN1"}, "rows"), ("l3out_detail", {"tenant_name": "MS-TN1"}, "rows"),
("endpoint_search", {}, "rows"), ("interface_status", {"node_id": "101"}, "rows"),
]
for name, params, mode in PLUGINS:
try:
r = post(f"/api/query/prebuilt/{name}", {"plugin_name": name, "params": params}, site=SITE_A)
if r.status_code != 200:
rec(f"plugin {name}", False, f"HTTP {r.status_code} {r.text[:120]}")
continue
qr = r.json()
rows = qr.get("rows", [])
ok = (r.status_code == 200) if mode == "ran" else (len(rows) > 0)
rec(f"plugin {name}", ok, f"{len(rows)} rows · {str(qr.get('summary',''))[:60]}")
except Exception as e:
rec(f"plugin {name}", False, str(e)[:120])
print("\n=== NDO views ===")
r = get("/api/ndo/sites")
sites = r.json() if r.status_code == 200 else {}
site_names = [s.get("name") for s in (sites.get("sites", sites) if isinstance(sites, (dict, list)) else [])] if r.status_code == 200 else []
rec("NDO /sites", r.status_code == 200 and len(site_names) >= 2, f"{site_names}")
r = get("/api/query/options/schemas", site=SITE_A)
rec("NDO schemas (options)", r.status_code == 200, f"HTTP {r.status_code} {str(r.json())[:100]}")
print("\n" + "=" * 60)
passed = sum(1 for _, ok, _ in results if ok)
total = len(results)
print(f" E2E RESULT: {passed}/{total} checks passed")
fails = [n for n, ok, _ in results if not ok]
if fails:
print(" FAILED:", ", ".join(fails))
print("=" * 60)
sys.exit(0 if not fails else 1)
+93
View File
@@ -0,0 +1,93 @@
"""Sandbox E2E — replicate autoACI's exact NDO-discovery → Connect-All flow."""
import sys
from urllib.parse import urlparse
import httpx
BASE = "http://127.0.0.1:8000"
NDO_IP = "10.192.0.10"
c = httpx.Client(base_url=BASE, timeout=90.0)
results = []
def rec(n, ok, d=""):
results.append((n, ok, d))
print(f" {'PASS' if ok else 'FAIL'} {n} {d}")
def csrf():
return c.cookies.get("csrf_token", "")
def post(p, b, site=None):
h = {"X-CSRF-Token": csrf(), "Content-Type": "application/json"}
if site:
h["X-Active-Site"] = site
return c.post(p, json=b, headers=h)
def get(p, site=None):
h = {"X-Active-Site": site} if site else {}
return c.get(p, headers=h)
c.get("/api/health")
# 1. NDO login — the only thing the user types
r = post("/api/ndo/login", {"host": NDO_IP, "username": "admin", "password": "cisco", "verify_ssl": False})
rec(f"NDO login ({NDO_IP})", r.status_code == 200 and r.json().get("success"), f"HTTP {r.status_code}")
# 2. Discover sites — replicate the frontend's host extraction (new URL(url).hostname)
r = get("/api/ndo/sites")
sites = r.json().get("sites", [])
discovered = []
for s in sites:
urls = s.get("urls") or s.get("apicUrls") or []
host = ""
if urls:
try:
host = urlparse(urls[0]).hostname # == JS new URL(urls[0]).hostname
except Exception:
host = urls[0]
discovered.append({"name": s.get("name"), "apicHost": host})
rec("NDO discovered sites", len(discovered) >= 2, str([(d["name"], d["apicHost"]) for d in discovered]))
# 3. Connect All Sites — log in to each discovered APIC by IP, NO port
for d in discovered:
r = post("/api/auth/login", {"host": d["apicHost"], "username": "admin", "password": "cisco", "verify_ssl": False})
j = r.json()
rec(f"Connect {d['name']} ({d['apicHost']})", r.status_code == 200 and j.get("success"),
f"fabric={j.get('fabric_name')!r} ver={j.get('apic_version')!r}")
site_a = discovered[0]["apicHost"]
# 4. Topology (aggregates all connectors → both sites + ISN)
r = get("/api/topology", site=site_a)
topo = r.json() if r.status_code == 200 else {}
nodes, links, isn = topo.get("nodes", []), topo.get("links", []), topo.get("isnConnections", [])
rec("Topology", len(nodes) > 0,
f"{len(nodes)} nodes, {len(links)} links, {len(isn)} ISN links, roles={sorted({n.get('role') for n in nodes})}")
# 5. plugins
for name, params, mode in [
("fabric_nodes", {}, "rows"), ("fabric_bgp_evpn", {}, "rows"),
("adjacency_health", {}, "ran"), ("l3out_detail", {"tenant_name": "MS-TN1"}, "rows"),
("endpoint_search", {}, "rows"),
]:
r = post(f"/api/query/prebuilt/{name}", {"plugin_name": name, "params": params}, site=site_a)
qr = r.json() if r.status_code == 200 else {}
rows = qr.get("rows", [])
ok = (r.status_code == 200) if mode == "ran" else len(rows) > 0
rec(f"plugin {name}", ok, f"{len(rows)} rows · {str(qr.get('summary', ''))[:50]}")
# 6. NDO views
r = get("/api/ndo/sites")
rec("NDO /sites view", r.status_code == 200, str([s.get("name") for s in r.json().get("sites", [])]))
passed = sum(1 for _, ok, _ in results if ok)
print("=" * 60)
print(f" SANDBOX E2E: {passed}/{len(results)} passed")
fails = [n for n, ok, _ in results if not ok]
if fails:
print(" FAILED:", ", ".join(fails))
print("=" * 60)
sys.exit(0 if not fails else 1)
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p certs
openssl req -x509 -newkey rsa:4096 -keyout certs/sim.key -out certs/sim.crt \
-days 3650 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
echo "Certificates generated in certs/"
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Equivalent to `aci-sim run` (see README.md §5 CLI) after `pip install -e .`;
# this script remains the dependency-free entrypoint and keeps working
# unchanged.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f certs/sim.crt ] || [ ! -f certs/sim.key ]; then
bash scripts/gen_certs.sh
fi
python -m aci_sim.runtime.supervisor
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Tear down the sandbox virtual network: stop the sim and remove the loopback
# aliases (macOS: lo0 alias; Linux: ip addr del ... dev lo).
#
# sudo bash scripts/sandbox-down.sh
#
set -euo pipefail
cd "$(dirname "$0")/.."
PY="./.venv/bin/python"
OS="$(uname -s)"
IPS=$(PYTHONPATH="$PWD" "$PY" -c "from aci_sim.topology.loader import load_topology; t=load_topology('topology.yaml'); print(' '.join([t.fabric.ndo_mgmt_ip]+[s.mgmt_ip for s in t.sites if s.mgmt_ip]))")
if [ "$(id -u)" -ne 0 ]; then
echo "Needs root: sudo bash scripts/sandbox-down.sh" >&2
exit 1
fi
# Verify a recorded pid really is our supervisor process before killing it —
# a stale /tmp pid file could otherwise reference an unrelated process that
# has since reused that pid (#24).
pid_is_supervisor() {
local pid="$1"
if [ "$OS" = "Linux" ]; then
[ -r "/proc/$pid/cmdline" ] || return 1
tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
else
ps -p "$pid" -o command= 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
fi
}
if [ -f /tmp/aci-sim-sandbox.pid ]; then
old_pid="$(cat /tmp/aci-sim-sandbox.pid)"
if pid_is_supervisor "$old_pid"; then
kill "$old_pid" 2>/dev/null || true
else
echo "[sandbox] WARNING: /tmp/aci-sim-sandbox.pid ($old_pid) is not an" >&2
echo "[sandbox] aci_sim.runtime.supervisor process — refusing to kill it." >&2
fi
fi
pkill -f "aci_sim.runtime.supervisor" 2>/dev/null || true
# Wait for the supervisor to actually exit before dropping aliases, so an
# immediately-following `up` sees a clean (process-gone, :443-free) state.
for i in $(seq 1 16); do # up to ~8s
pgrep -f "aci_sim.runtime.supervisor" >/dev/null 2>&1 || break
sleep 0.5
done
rm -f /tmp/aci-sim-sandbox.pid
for ip in $IPS; do
if [ "$OS" = "Linux" ]; then
ip addr del "${ip}/32" dev lo 2>/dev/null || true
else
ifconfig lo0 -alias "$ip" 2>/dev/null || true
fi
echo "[sandbox] removed alias $ip"
done
echo "[sandbox] down."
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Sandbox virtual network — give each ACI controller a real IP on :443 via a
# loopback alias (macOS: lo0 alias; Linux: ip addr add ... dev lo), so autoACI
# connects to it by IP with NO port, exactly like real gear. autoACI is NOT
# modified in any way. Requires root (loopback alias + privileged :443).
#
# sudo bash scripts/sandbox-up.sh
#
# Safe to re-run: it stops any previous sim (verifying the recorded pid is
# actually a supervisor process before killing it — #24), waits for :443 to
# actually free up on each sandbox IP (avoids the bind race that silently
# killed old restarts), then verifies the servers really came up — failing
# loudly with the log if not.
set -euo pipefail
cd "$(dirname "$0")/.."
PY="./.venv/bin/python"
OS="$(uname -s)"
# Sandbox IPs come straight from topology.yaml (single source of truth).
IPS=$(PYTHONPATH="$PWD" "$PY" -c "from aci_sim.topology.loader import load_topology; t=load_topology('topology.yaml'); print(' '.join([t.fabric.ndo_mgmt_ip]+[s.mgmt_ip for s in t.sites if s.mgmt_ip]))")
if [ "$(id -u)" -ne 0 ]; then
echo "Needs root (loopback alias + bind :443). Re-run: sudo bash scripts/sandbox-up.sh" >&2
exit 1
fi
# Return 0 if (ip, 443) is bindable RIGHT NOW — same options uvicorn will use
# (SO_REUSEADDR), so this gate matches exactly what the supervisor is about to do.
port_free() {
"$PY" -c 'import socket,sys
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
try:
s.bind((sys.argv[1],443)); s.close()
except OSError:
sys.exit(1)' "$1"
}
# Verify a recorded pid really is our supervisor process before we kill it —
# a stale /tmp pid file could otherwise reference an unrelated (possibly
# security-sensitive) process that has since reused that pid (#24).
pid_is_supervisor() {
local pid="$1"
if [ "$OS" = "Linux" ]; then
[ -r "/proc/$pid/cmdline" ] || return 1
tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
else
ps -p "$pid" -o command= 2>/dev/null | grep -q "aci_sim.runtime.supervisor"
fi
}
echo "[sandbox] loopback aliases ($OS): $IPS"
for ip in $IPS; do
if [ "$OS" = "Linux" ]; then
ip addr add "${ip}/32" dev lo 2>/dev/null || true
else
ifconfig lo0 alias "$ip" 255.255.255.255 up 2>/dev/null || true
fi
done
[ -f certs/sim.crt ] || bash scripts/gen_certs.sh
# Stop any previous sim, then WAIT for it to die and for :443 to actually free up
# on every sandbox IP before starting — starting while the old listener is still
# holding the socket is what made uvicorn sys.exit(1) silently.
if [ -f /tmp/aci-sim-sandbox.pid ]; then
old_pid="$(cat /tmp/aci-sim-sandbox.pid)"
if pid_is_supervisor "$old_pid"; then
kill "$old_pid" 2>/dev/null || true
else
echo "[sandbox] WARNING: /tmp/aci-sim-sandbox.pid ($old_pid) is not an" >&2
echo "[sandbox] aci_sim.runtime.supervisor process — refusing to kill it." >&2
fi
fi
pkill -f "aci_sim.runtime.supervisor" 2>/dev/null || true
for i in $(seq 1 40); do # up to ~20s
pgrep -f "aci_sim.runtime.supervisor" >/dev/null 2>&1 && { sleep 0.5; continue; }
free=1
for ip in $IPS; do port_free "$ip" || { free=0; break; }; done
[ "$free" = 1 ] && break
sleep 0.5
done
LOG=/tmp/aci-sim-sandbox.log
export SIM_SANDBOX=1 PYTHONPATH="$PWD"
nohup "$PY" -m aci_sim.runtime.supervisor >"$LOG" 2>&1 &
PID=$!
echo "$PID" >/tmp/aci-sim-sandbox.pid
ndo_ip=${IPS%% *}
# Verify the servers actually came up on :443 (loud, not silent). Any HTTP code
# (200/401/404) means the server answered; 000 means nothing is listening yet.
up=0
for i in $(seq 1 24); do # up to ~12s
kill -0 "$PID" 2>/dev/null || break # process already died → stop, dump log below
all=1
for ip in $IPS; do
code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 2 "https://${ip}:443/" 2>/dev/null || echo 000)
[ "$code" = 000 ] && { all=0; break; }
done
[ "$all" = 1 ] && { up=1; break; }
sleep 0.5
done
if [ "$up" -ne 1 ]; then
echo "[sandbox] ERROR: sim did not come up on :443 for all of: $IPS" >&2
echo "----- last 25 log lines ($LOG) -----------------------------------" >&2
tail -n 25 "$LOG" >&2 || true
echo "------------------------------------------------------------------" >&2
exit 1
fi
echo "[sandbox] up — aliases: $IPS"
echo "[sandbox] sim PID $PID serving :443 (log: $LOG)"
echo "[sandbox] autoACI → log in to NDO at $ndo_ip → Discover → Connect All Sites"
echo "[sandbox] stop with: sudo bash scripts/sandbox-down.sh"
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Save or restore the WHOLE fabric's state (every APIC site + NDO) in one
# command, by hitting each plane's /_sim/{save,load}/<name> endpoint.
#
# scripts/sim-state.sh save <name> # snapshot every plane to disk
# scripts/sim-state.sh restore <name> # restore every plane from disk
#
# Files land under SIM_STATE_DIR (default ~/.aci-sim/state) — see
# aci_sim/control/persist.py::state_dir(). Restarting the sim wipes its
# in-memory state (sandbox/port mode has no other durability), so the
# intended workflow is:
#
# scripts/sim-state.sh save mybaseline
# <restart the sim: sandbox-down.sh && sandbox-up.sh, or Ctrl-C + aci-sim run>
# scripts/sim-state.sh restore mybaseline
#
# Addressing — this script must reach ALL planes, so it derives the same
# per-device IPs scripts/sandbox-up.sh uses (straight from topology.yaml, via
# the identical `load_topology` one-liner) and falls back to port mode if
# those IPs aren't reachable:
#
# sandbox mode (real per-device IPs, :443) — from topology.yaml:
# NDO -> https://<fabric.ndo_mgmt_ip>:443
# APIC site -> https://<site.mgmt_ip>:443 (one per site)
#
# port mode (fallback, 127.0.0.1 + distinct ports — see runtime/config.py):
# APIC LAB1 -> https://127.0.0.1:8443 (APIC_A_PORT)
# APIC LAB2 -> https://127.0.0.1:8444 (APIC_B_PORT)
# NDO -> https://127.0.0.1:8445 (NDO_PORT)
#
# `restore` maps to each plane's POST /_sim/load/<name> endpoint (the sim's
# route is named "load"; this script's user-facing verb is "restore" to match
# the existing snapshot/restore vocabulary in /_sim).
set -euo pipefail
cd "$(dirname "$0")/.."
PY="./.venv/bin/python"
usage() {
echo "usage: $0 {save|restore} <name>" >&2
exit 1
}
[ $# -eq 2 ] || usage
ACTION="$1"
NAME="$2"
case "$ACTION" in
save) SIM_ACTION="save" ;;
restore) SIM_ACTION="load" ;;
*) usage ;;
esac
# Derive sandbox IPs the exact same way scripts/sandbox-up.sh does (single
# source of truth: topology.yaml). Prints "<ndo_ip> <site_ip> [<site_ip> ...]"
# or nothing if topology.yaml can't be loaded (e.g. no venv yet).
IPS=$(PYTHONPATH="$PWD" "$PY" -c "from aci_sim.topology.loader import load_topology; t=load_topology('topology.yaml'); print(' '.join([t.fabric.ndo_mgmt_ip]+[s.mgmt_ip for s in t.sites if s.mgmt_ip]))" 2>/dev/null || true)
NDO_IP=""
SITE_IPS=()
if [ -n "$IPS" ]; then
read -r -a _all <<<"$IPS"
NDO_IP="${_all[0]}"
SITE_IPS=("${_all[@]:1}")
fi
# Reachability probe: does curl get ANY response (even 4xx) from this host:443?
# 000 means nothing is listening / unreachable.
reachable() {
local ip="$1"
local code
# curl's -w '%{http_code}' already prints "000" on connect failure/timeout,
# so don't ALSO append a fallback echo on nonzero exit — that would double
# up the output (e.g. "000000") and always compare unequal to "000".
code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 2 "https://${ip}:443/" 2>/dev/null)
[ "$code" = "200" ] || [ "$code" = "401" ] || [ "$code" = "404" ]
}
USE_SANDBOX=0
if [ -n "$NDO_IP" ] && reachable "$NDO_IP"; then
USE_SANDBOX=1
fi
hit() {
local label="$1" host_port="$2"
local resp
resp=$(curl -sk -X POST "https://${host_port}/_sim/${SIM_ACTION}/${NAME}" || echo '{"status":"error","detail":"curl failed"}')
echo "[$label] $resp"
}
if [ "$USE_SANDBOX" -eq 1 ]; then
echo "[sim-state] sandbox mode — using topology.yaml mgmt IPs" >&2
hit "NDO" "${NDO_IP}:443"
i=0
for ip in "${SITE_IPS[@]}"; do
i=$((i + 1))
hit "APIC-site${i}" "${ip}:443"
done
else
echo "[sim-state] port mode fallback — 127.0.0.1:8443/8444/8445" >&2
APIC_A_PORT="${APIC_A_PORT:-8443}"
APIC_B_PORT="${APIC_B_PORT:-8444}"
NDO_PORT="${NDO_PORT:-8445}"
hit "APIC-LAB1" "127.0.0.1:${APIC_A_PORT}"
hit "APIC-LAB2" "127.0.0.1:${APIC_B_PORT}"
hit "NDO" "127.0.0.1:${NDO_PORT}"
fi
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# verify.sh — run the aci-sim verification harness
# Usage: scripts/verify.sh [pytest-options]
# Example: scripts/verify.sh -v --tb=short
set -euo pipefail
cd "$(dirname "$0")/.."
exec .venv/bin/python -m pytest tests/verify_autoaci.py -v "$@"
View File
+372
View File
@@ -0,0 +1,372 @@
"""Tests for the Admin account wizard step + `aci-sim run` credential enforcement.
Covers:
1. `run_wizard`/`cmd_init` write a topology `auth:` section (custom
username/password, and the `ndo_same_account=no` split-account path),
round-tripping through `Topology.model_validate`/`load_topology`.
2. Defaults (no answers / non-tty `--defaults`) still produce a valid
topology with `auth: {username: admin, password: cisco}`.
3. `resolve_admin_credentials` the pure credential-resolution function
`cmd_run` uses is unit-tested directly for all three precedence cases:
topology-only, env-override-wins, neither-present.
4. `build_run_env_argv` injects SIM_USERNAME/SIM_PASSWORD only when both
admin_username/admin_password are given, and never touches the
filesystem (pure function, per its own docstring contract).
5. End-to-end enforcement: a FastAPI APIC app backed by `auth.py`'s real
`aaaLogin` route 401s on the wrong password and 200s on the right one,
for a NON-default (custom) SIM_USERNAME/SIM_PASSWORD pair proving the
enforcement is real, not just resolved-and-ignored. Since auth.py binds
SIM_USERNAME/SIM_PASSWORD as module-level constants at import time, this
test monkeypatches the module's attributes directly (the aaaLogin route
closure reads them as module globals at request time, not at def time
verified by this file's own tests) rather than fighting import-time
binding with `importlib.reload`.
6. Backward compat: an existing topology.yaml with NO `auth:` section still
loads (`topo.auth is None`) and `resolve_admin_credentials`/
`build_run_env_argv` inject nothing `rest_aci/auth.py` keeps its own
env-or-admin/cisco default, unchanged.
Does NOT weaken any pre-existing auth test this file only adds new
coverage; `tests/test_pr9_ansible.py`'s cert-auth/aaaLogin tests are
untouched.
"""
from __future__ import annotations
import copy
import io
import os
from pathlib import Path
import pytest
import yaml
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.cli import build_run_env_argv, resolve_admin_credentials, run_wizard
from aci_sim.rest_aci import auth as auth_module
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Auth, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
# ---------------------------------------------------------------------------
# 1 + 2. Wizard writes an `auth:` section (defaults + custom + split NDO).
# ---------------------------------------------------------------------------
class TestWizardAuthSection:
def test_defaults_produce_admin_cisco_auth(self) -> None:
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=True)
assert topo["auth"] == {"username": "admin", "password": "cisco"}
validated = Topology.model_validate(topo)
assert validated.auth == Auth(username="admin", password="cisco")
def test_custom_admin_credentials_via_answers(self) -> None:
answers = {
"admin_username": "netops",
"admin_password": "s3cr3t!",
"deployment_type": "1",
}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
assert topo["auth"] == {"username": "netops", "password": "s3cr3t!"}
validated = Topology.model_validate(topo)
assert validated.auth.username == "netops"
assert validated.auth.password == "s3cr3t!"
assert validated.auth.ndo_username is None
assert validated.auth.ndo_password is None
def test_custom_admin_credentials_round_trip_through_yaml(self, tmp_path: Path) -> None:
answers = {"admin_username": "netops", "admin_password": "s3cr3t!", "deployment_type": "1"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
out_file = tmp_path / "auth.topology.yaml"
out_file.write_text(yaml.dump(topo, sort_keys=False), encoding="utf-8")
loaded = load_topology(out_file)
assert loaded.auth.username == "netops"
assert loaded.auth.password == "s3cr3t!"
def test_ndo_same_account_no_stores_split_ndo_credentials(self) -> None:
answers = {
"deployment_type": "1",
"admin_username": "apicadmin",
"admin_password": "apicpass",
"ndo_same_account": "no",
"ndo_username": "ndoadmin",
"ndo_password": "ndopass",
}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
assert topo["auth"] == {
"username": "apicadmin",
"password": "apicpass",
"ndo_username": "ndoadmin",
"ndo_password": "ndopass",
}
validated = Topology.model_validate(topo)
assert validated.auth.ndo_username == "ndoadmin"
assert validated.auth.ndo_password == "ndopass"
def test_ndo_same_account_no_defaults_ndo_fields_to_admin_account(self) -> None:
"""ndo_same_account=no but ndo_username/ndo_password not separately
answered -> the prompt defaults (admin_username/admin_password) are used."""
answers = {"deployment_type": "1", "ndo_same_account": "no"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
assert topo["auth"]["ndo_username"] == "admin"
assert topo["auth"]["ndo_password"] == "cisco"
def test_scripted_stdin_admin_account_step(self) -> None:
stdin_script = (
"svcaccount\n" # admin username
"hunter2\n" # admin password
"no\n" # ndo_same_account -> no
"ndosvc\n" # ndo username
"ndopass\n" # ndo password
"1\n" # deployment type -> single fabric
)
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False)
assert topo["auth"] == {
"username": "svcaccount",
"password": "hunter2",
"ndo_username": "ndosvc",
"ndo_password": "ndopass",
}
def test_summary_shows_username_but_masks_password(self) -> None:
answers = {"deployment_type": "1", "admin_username": "netops", "admin_password": "supersecret"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
from aci_sim.cli import _print_summary
summary_out = io.StringIO()
_print_summary(topo, _gw, summary_out)
text = summary_out.getvalue()
assert "netops" in text
assert "supersecret" not in text
assert "Admin account" in text
# ---------------------------------------------------------------------------
# 3. resolve_admin_credentials — the pure precedence function cmd_run uses.
# ---------------------------------------------------------------------------
class TestResolveAdminCredentialsPrecedence:
def test_topology_auth_present_no_env_override(self) -> None:
auth = Auth(username="netops", password="s3cr3t!")
username, password, source = resolve_admin_credentials({}, auth)
assert (username, password, source) == ("netops", "s3cr3t!", "topology.yaml")
def test_env_override_wins_even_with_topology_auth_present(self) -> None:
auth = Auth(username="netops", password="s3cr3t!")
base_env = {"SIM_USERNAME": "envuser", "SIM_PASSWORD": "envpass"}
username, password, source = resolve_admin_credentials(base_env, auth)
assert (username, password, source) == (None, None, "env override")
def test_neither_present_injects_nothing(self) -> None:
username, password, source = resolve_admin_credentials({}, None)
assert (username, password, source) == (None, None, "built-in default")
def test_env_override_wins_even_without_topology_auth(self) -> None:
base_env = {"SIM_USERNAME": "envuser"}
username, password, source = resolve_admin_credentials(base_env, None)
assert (username, password, source) == (None, None, "env override")
def test_empty_string_sim_username_in_env_does_not_count_as_override(self) -> None:
"""An empty-string SIM_USERNAME (e.g. `SIM_USERNAME= aci-sim run`) is
falsy falls through to topology.yaml/default, matching os.environ's
own treatment of unset-vs-empty as not meaningfully "set"."""
auth = Auth(username="netops", password="s3cr3t!")
username, password, source = resolve_admin_credentials({"SIM_USERNAME": ""}, auth)
assert (username, password, source) == ("netops", "s3cr3t!", "topology.yaml")
def test_sim_password_only_in_env_is_an_atomic_override(self) -> None:
"""Regression (reviewer Finding 1): a caller who sets ONLY SIM_PASSWORD
(rotating just the password, keeping the default username) must NOT have
it silently discarded in favor of the topology password. Setting EITHER
credential var counts as an env override, so topology.auth is ignored
entirely the override is atomic, the caller owns both vars."""
auth = Auth(username="topo_user", password="topo_pass")
username, password, source = resolve_admin_credentials({"SIM_PASSWORD": "caller_pw"}, auth)
assert (username, password, source) == (None, None, "env override")
def test_sim_password_only_no_topology_is_env_override(self) -> None:
username, password, source = resolve_admin_credentials({"SIM_PASSWORD": "caller_pw"}, None)
assert (username, password, source) == (None, None, "env override")
def test_caller_sim_password_survives_into_exec_env(self) -> None:
"""End of the Finding-1 chain: with the atomic override, the caller's
SIM_PASSWORD passes through to the exec'd env untouched (never clobbered
by the topology password); the unset SIM_USERNAME is left for auth.py to
fill with its own admin default."""
base = {"SIM_PASSWORD": "caller_pw"}
user, pwd, _src = resolve_admin_credentials(base, Auth(username="topo_user", password="topo_pass"))
env, _argv = build_run_env_argv("topology.yaml", base_env=base, admin_username=user, admin_password=pwd)
assert env["SIM_PASSWORD"] == "caller_pw" # survived — not clobbered
assert "SIM_USERNAME" not in env # left unset -> auth.py fills with admin default
# ---------------------------------------------------------------------------
# 4. build_run_env_argv — env injection + filesystem purity.
# ---------------------------------------------------------------------------
class TestBuildRunEnvArgvCredentialInjection:
def test_injects_both_vars_when_both_given(self) -> None:
env, argv = build_run_env_argv(
"topology.yaml", base_env={"PATH": "/usr/bin"}, admin_username="netops", admin_password="s3cr3t!"
)
assert env["SIM_USERNAME"] == "netops"
assert env["SIM_PASSWORD"] == "s3cr3t!"
assert env["TOPOLOGY_PATH"] == "topology.yaml"
def test_injects_nothing_when_neither_given(self) -> None:
env, argv = build_run_env_argv("topology.yaml", base_env={"PATH": "/usr/bin"})
assert "SIM_USERNAME" not in env
assert "SIM_PASSWORD" not in env
def test_base_env_untouched_by_reference(self) -> None:
"""build_run_env_argv deepcopies base_env — the caller's dict must
not be mutated (pure-function contract in its docstring)."""
base_env = {"PATH": "/usr/bin"}
build_run_env_argv("topology.yaml", base_env=base_env, admin_username="netops", admin_password="s3cr3t!")
assert "SIM_USERNAME" not in base_env
assert "TOPOLOGY_PATH" not in base_env
def test_does_not_touch_filesystem(self, tmp_path: Path) -> None:
"""Passing a nonexistent topology path must not raise / must not stat
the file build_run_env_argv is filesystem-pure per its docstring."""
nonexistent = tmp_path / "does-not-exist.yaml"
env, argv = build_run_env_argv(str(nonexistent))
assert env["TOPOLOGY_PATH"] == str(nonexistent)
# ---------------------------------------------------------------------------
# 5. End-to-end: aaaLogin actually enforces a custom password.
# ---------------------------------------------------------------------------
def _new_client(topo) -> TestClient:
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(name=site.name, site=site, topo=topo, store=store, baseline=copy.deepcopy(store))
return TestClient(make_apic_app(state))
class TestEndToEndEnforcement:
@pytest.fixture(autouse=True)
def _restore_auth_module_globals(self):
"""auth.py binds SIM_USERNAME/SIM_PASSWORD as module-level constants
at import time; this fixture monkeypatches the module's OWN attributes
(which aaaLogin's closure reads as module globals at request time, not
at def time) and restores the originals after each test, so these
tests never leak a custom credential into any other test in the
suite."""
orig_username = auth_module.SIM_USERNAME
orig_password = auth_module.SIM_PASSWORD
yield
auth_module.SIM_USERNAME = orig_username
auth_module.SIM_PASSWORD = orig_password
def test_aaalogin_succeeds_with_custom_password(self) -> None:
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
client = _new_client(topo)
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "customadmin", "pwd": "custompass123"}}},
)
assert resp.status_code == 200
def test_aaalogin_401s_with_wrong_password(self) -> None:
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
client = _new_client(topo)
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "customadmin", "pwd": "totally-wrong"}}},
)
assert resp.status_code == 401
def test_aaalogin_401s_with_stale_default_password_after_rotation(self) -> None:
"""The previous default (admin/cisco) must stop working once a
topology-driven credential rotation has taken effect proves this
isn't just "any password works", it's a real replacement."""
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
client = _new_client(topo)
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 401
def test_ndo_remains_lenient_regardless_of_apic_credential_rotation(self) -> None:
"""NDO must keep accepting any credential even while the APIC plane
enforces a rotated (non-default) password proves this feature does
NOT change NDO's deliberate leniency."""
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
ndo_state = build_ndo_model(topo)
ndo_client = TestClient(make_ndo_app(ndo_state))
resp = ndo_client.post(
"/api/v1/auth/login",
json={"userName": "anyone", "userPasswd": "anything-at-all"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# 6. Backward compat: no `auth:` section -> no injection, no regression.
# ---------------------------------------------------------------------------
class TestBackwardCompatNoAuthSection:
def test_repo_topology_has_no_auth_section(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
assert topo.auth is None
def test_resolve_admin_credentials_injects_nothing_for_repo_topology(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
username, password, source = resolve_admin_credentials({}, topo.auth)
assert (username, password, source) == (None, None, "built-in default")
def test_build_run_env_argv_injects_nothing_for_repo_topology(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
username, password, _source = resolve_admin_credentials(dict(os.environ), topo.auth)
env, _argv = build_run_env_argv(
str(TOPOLOGY_YAML), base_env={"PATH": "/usr/bin"}, admin_username=username, admin_password=password
)
assert "SIM_USERNAME" not in env
assert "SIM_PASSWORD" not in env
def test_topology_dict_without_auth_key_still_validates(self) -> None:
"""A hand-authored topology dict that never mentions `auth` at all
(pre-dating this feature) must still validate Topology.auth is
Optional[Auth] = None."""
from aci_sim.cli import generate_topology
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=1)
assert "auth" not in topo_dict
validated = Topology.model_validate(topo_dict)
assert validated.auth is None
+273
View File
@@ -0,0 +1,273 @@
"""Regression tests for PR-4 auth fidelity.
Covers: unauthenticated/garbage/expired-cookie query rejection, credential
checking on aaaLogin, session expiry + aaaRefresh renewal, aaaLogout
invalidation, and the 422-leak fix for malformed aaaLogin bodies. See
docs/CONTRACT.md §1 (login) and §5 (error shapes) for the contract this
enforces.
"""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
import aci_sim.rest_aci.auth as auth_module
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
def _new_client() -> TestClient:
"""Fresh, unauthenticated TestClient backed by its own store/state."""
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
return TestClient(make_apic_app(state))
def _login(client: TestClient, *, user: str = "admin", pwd: str = "cisco"):
return client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": user, "pwd": pwd}}},
)
def _assert_apic_error_envelope(resp, code: str) -> None:
data = resp.json()
assert "detail" not in data, f"FastAPI detail leak: {data}"
assert "imdata" in data
assert data["imdata"][0]["error"]["attributes"]["code"] == code
# ---------------------------------------------------------------------------
# No-cookie / garbage-cookie / expired-cookie query rejection
# ---------------------------------------------------------------------------
def test_no_cookie_query_returns_403():
c = _new_client()
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_garbage_cookie_query_returns_403():
c = _new_client()
c.cookies.set("APIC-cookie", "not-a-real-token")
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_expired_token_query_returns_403():
c = _new_client()
login = _login(c)
assert login.status_code == 200
token = c.cookies.get("APIC-cookie")
assert token in auth_module._sessions
# Force expiry without sleeping — patch the session's expires_at directly.
auth_module._sessions[token].expires_at = auth_module._now() - 1
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_no_cookie_mo_query_returns_403():
c = _new_client()
resp = c.get("/api/mo/uni/tn-MS-TN1.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_no_cookie_node_scoped_query_returns_403():
c = _new_client()
resp = c.get("/api/node/class/topology/pod-1/node-101/faultInst.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_no_cookie_write_returns_403():
c = _new_client()
resp = c.post(
"/api/mo/uni/tn-Blocked.json",
json={"fvTenant": {"attributes": {"name": "Blocked", "dn": "uni/tn-Blocked"}}},
)
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
# And the write must not have landed.
c2 = _new_client()
_login(c2)
names = [
i["fvTenant"]["attributes"]["name"]
for i in c2.get("/api/class/fvTenant.json").json()["imdata"]
]
assert "Blocked" not in names
# ---------------------------------------------------------------------------
# Credential checking
# ---------------------------------------------------------------------------
def test_wrong_password_returns_401_envelope():
c = _new_client()
resp = _login(c, pwd="wrong-password")
assert resp.status_code == 401
_assert_apic_error_envelope(resp, "401")
assert "APIC-cookie" not in resp.cookies
def test_wrong_username_returns_401_envelope():
c = _new_client()
resp = _login(c, user="notadmin")
assert resp.status_code == 401
_assert_apic_error_envelope(resp, "401")
def test_correct_credentials_return_200_with_token():
c = _new_client()
resp = _login(c)
assert resp.status_code == 200
data = resp.json()
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
assert token
assert "APIC-cookie" in resp.cookies
# ---------------------------------------------------------------------------
# Malformed aaaLogin body -> 400 imdata envelope (never FastAPI {"detail":...})
# ---------------------------------------------------------------------------
def test_malformed_login_body_non_dict_top_level():
c = _new_client()
resp = c.post("/api/aaaLogin.json", content=b'"just a string"',
headers={"Content-Type": "application/json"})
assert resp.status_code == 400
data = resp.json()
assert "imdata" in data
assert "detail" not in data
def test_malformed_login_body_missing_aaauser():
c = _new_client()
resp = c.post("/api/aaaLogin.json", json={"wrongKey": {}})
assert resp.status_code == 400
data = resp.json()
assert "imdata" in data
assert "detail" not in data
def test_malformed_login_body_non_json():
c = _new_client()
resp = c.post(
"/api/aaaLogin.json",
content=b"not json at all {{{",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
data = resp.json()
assert "imdata" in data
assert "detail" not in data
# ---------------------------------------------------------------------------
# aaaRefresh
# ---------------------------------------------------------------------------
def test_refresh_without_login_returns_403():
c = _new_client()
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_refresh_with_garbage_cookie_returns_403():
c = _new_client()
c.cookies.set("APIC-cookie", "garbage")
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_refresh_extends_expiry():
c = _new_client()
_login(c)
token = c.cookies.get("APIC-cookie")
sess = auth_module._sessions[token]
# Move expiry to the near future (still valid) and confirm refresh pushes
# it back out to a full lifetime.
sess.expires_at = auth_module._now() + 1
old_expiry = sess.expires_at
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 200
data = resp.json()
assert "aaaLogin" in data["imdata"][0]
new_token = c.cookies.get("APIC-cookie")
assert new_token in auth_module._sessions
assert auth_module._sessions[new_token].expires_at > old_expiry
# And a subsequent query with the refreshed cookie still works.
q = c.get("/api/class/fabricNode.json")
assert q.status_code == 200
def test_refresh_on_expired_token_returns_403():
c = _new_client()
_login(c)
token = c.cookies.get("APIC-cookie")
auth_module._sessions[token].expires_at = auth_module._now() - 1
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
# ---------------------------------------------------------------------------
# aaaLogout
# ---------------------------------------------------------------------------
def test_logout_then_query_returns_403():
c = _new_client()
_login(c)
token = c.cookies.get("APIC-cookie")
assert token in auth_module._sessions
resp = c.post("/api/aaaLogout.json")
assert resp.status_code == 200
assert token not in auth_module._sessions
q = c.get("/api/class/fabricNode.json")
assert q.status_code == 403
_assert_apic_error_envelope(q, "403")
# ---------------------------------------------------------------------------
# /_sim control plane must remain unauthenticated
# ---------------------------------------------------------------------------
def test_sim_reset_works_without_auth():
c = _new_client()
resp = c.post("/_sim/reset")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
+288
View File
@@ -0,0 +1,288 @@
"""Phase 3 — fabric builder tests.
Loads the default topology, builds SiteA into a fresh MITStore, and asserts:
- 2 spines + 2 leaves + 2 border-leaves present as fabricNode
- Every fabricLink n1/n2 references a real fabricNode
- lldpAdjEp + cdpAdjEp neighbor set matches the cabling graph
- Each spine's sys/bgp/inst subtree has ISN bgpPeer with /32 addr + remote ASN
- healthInst per node present with correct DN
- Seeded faultInst under node-local DNs (contain /node-{id}/)
- topSystem has the correct loopback address formula
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
from aci_sim.mit.store import MITStore
from aci_sim.build import fabric, cabling, underlay, overlay, interfaces, health_faults
from aci_sim.build.cabling import cabling_links
from aci_sim.build.fabric import loopback_ip, oob_ip
TOPO_YAML = Path(__file__).parent.parent / "topology.yaml"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo() -> Topology:
return load_topology(TOPO_YAML)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def store(topo, site_a) -> MITStore:
"""Build SiteA into a fresh store using all Phase-3 builders."""
s = MITStore()
fabric.build(topo, site_a, s)
cabling.build(topo, site_a, s)
underlay.build(topo, site_a, s)
overlay.build(topo, site_a, s)
interfaces.build(topo, site_a, s)
health_faults.build(topo, site_a, s)
return s
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_attrs(store: MITStore, cls: str) -> list[dict]:
return [mo.attrs for mo in store.by_class(cls)]
# ---------------------------------------------------------------------------
# Test: node counts
# ---------------------------------------------------------------------------
def test_node_counts(store, site_a):
"""SiteA default: 2 spines + 2 leaves + 2 border-leaves = 6 switches, plus a
1-node APIC cluster (role=controller, PR-21 single-APIC default) 7 fabricNodes."""
nodes = get_attrs(store, "fabricNode")
spines = [n for n in nodes if n["role"] == "spine"]
leaves = [n for n in nodes if n["role"] == "leaf"]
controllers = [n for n in nodes if n["role"] == "controller"]
assert len(spines) == 2, f"Expected 2 spines, got {len(spines)}"
# Leaves include regular leaves (2) + border-leaves (2) — all role="leaf"
assert len(leaves) == 4, f"Expected 4 leaf-role nodes, got {len(leaves)}"
assert len(controllers) == 1, f"Expected 1 controller, got {len(controllers)}"
assert len(nodes) == 7, f"Expected 7 fabricNodes (6 switch + 1 APIC), got {len(nodes)}: {[n['name'] for n in nodes]}"
# ---------------------------------------------------------------------------
# Test: fabricLink n1/n2 reference real nodes
# ---------------------------------------------------------------------------
def test_fabric_links_reference_real_nodes(store, site_a):
"""Every fabricLink n1 and n2 must be a real fabricNode id in this site."""
node_ids = {mo.attrs["id"] for mo in store.by_class("fabricNode")}
links = store.by_class("fabricLink")
assert len(links) > 0, "No fabricLinks found"
for link in links:
n1 = link.attrs.get("n1", "")
n2 = link.attrs.get("n2", "")
assert n1 in node_ids, f"fabricLink n1={n1!r} not in fabricNodes {node_ids}"
assert n2 in node_ids, f"fabricLink n2={n2!r} not in fabricNodes {node_ids}"
# Expected link count: (2 spines) × (2 leaves + 2 border-leaves) = 8
# 8 spine↔leaf (2 spines × 4 downlinks) + 2 APIC↔leaf (1 controller × 2 leaves, PR-21 default)
assert len(links) == 10, f"Expected 10 fabricLinks (8 fabric + 2 APIC), got {len(links)}"
# ---------------------------------------------------------------------------
# Test: LLDP/CDP neighbor set matches cabling graph
# ---------------------------------------------------------------------------
def test_lldp_cdp_matches_cabling(store, site_a):
"""lldpAdjEp sysName values on each node match what the cabling graph predicts."""
pod = site_a.pod
node_map = {n.id: n for n in site_a.all_nodes()}
# Build expected neighbor pairs from cabling_links
# For each link (n1, s1, p1, n2, s2, p2):
# node n1 expects neighbor n2.name on port eth{s1}/{p1}
# node n2 expects neighbor n1.name on port eth{s2}/{p2}
expected_lldp: set[tuple[int, str]] = set() # (node_id, neighbor_name)
for n1, s1, p1, n2, s2, p2 in cabling_links(site_a):
expected_lldp.add((n1, node_map[n2].name))
expected_lldp.add((n2, node_map[n1].name))
# APIC↔leaf: the first two leaves each see every APIC (leaf-side lldpAdjEp)
for cid in range(1, site_a.controllers + 1):
for leaf in site_a.leaf_nodes()[:2]:
expected_lldp.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
actual_lldp: set[tuple[int, str]] = set()
for mo in store.by_class("lldpAdjEp"):
dn = mo.attrs["dn"]
m = re.search(r"/node-(\d+)/", dn)
if m:
actual_lldp.add((int(m.group(1)), mo.attrs.get("sysName", "")))
assert expected_lldp == actual_lldp, (
f"LLDP mismatch\n missing: {expected_lldp - actual_lldp}\n"
f" extra: {actual_lldp - expected_lldp}"
)
# CDP must be symmetric
cdp_pairs: set[tuple[int, str]] = set()
for mo in store.by_class("cdpAdjEp"):
dn = mo.attrs["dn"]
m = re.search(r"/node-(\d+)/", dn)
if m:
cdp_pairs.add((int(m.group(1)), mo.attrs.get("sysName", "")))
assert expected_lldp == cdp_pairs, "CDP neighbor set does not match cabling"
# ---------------------------------------------------------------------------
# Test: ISN bgpPeer — /32 addr + remote ASN on each spine
# ---------------------------------------------------------------------------
def test_isn_bgp_peers(store, topo, site_a):
"""Each SiteA spine's bgp/inst subtree must contain ISN bgpPeers:
- addr ends in /32
- asn == remote site's ASN (not the local site's ASN)
"""
pod = site_a.pod
local_asn = str(site_a.asn)
remote_site = topo.other_site(site_a.name)
remote_asn = str(remote_site.asn)
remote_spines = remote_site.spine_nodes()
remote_pod = remote_site.pod
for spine in site_a.spine_nodes():
inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
# Collect all bgpPeer descendants under this inst
bgp_peers = store.subtree(inst_dn, {"bgpPeer"})
assert bgp_peers, f"No bgpPeer found under {inst_dn}"
isn_peers = [
p for p in bgp_peers
if "/32" in p.attrs.get("addr", "")
and p.attrs.get("asn", "") != local_asn
]
assert isn_peers, (
f"No ISN bgpPeer (/32 + remote ASN) found on spine {spine.id}. "
f"Peers found: {[(p.attrs.get('addr'), p.attrs.get('asn')) for p in bgp_peers]}"
)
# Verify one ISN peer per remote spine
isn_addrs = {p.attrs["addr"] for p in isn_peers}
for rs in remote_spines:
expected_addr = f"{loopback_ip(remote_pod, rs.id)}/32"
assert expected_addr in isn_addrs, (
f"Missing ISN peer to remote spine {rs.id} ({expected_addr}) "
f"on spine {spine.id}. Found: {isn_addrs}"
)
# Verify the remote ASN
for p in isn_peers:
if p.attrs["addr"] == expected_addr:
assert p.attrs["asn"] == remote_asn, (
f"ISN peer {expected_addr} has asn={p.attrs['asn']!r}, "
f"expected {remote_asn!r}"
)
# ---------------------------------------------------------------------------
# Test: healthInst per node
# ---------------------------------------------------------------------------
def test_health_inst_per_node(store, site_a, topo):
"""Every node in SiteA must have a healthInst at topology/.../sys/health."""
pod = site_a.pod
hd = topo.faults.health_defaults
for node in site_a.all_nodes():
dn = f"topology/pod-{pod}/node-{node.id}/sys/health"
mo = store.get(dn)
assert mo is not None, f"healthInst missing for node {node.id} at {dn}"
assert mo.class_name == "healthInst"
assert mo.attrs["cur"] == str(hd.node), (
f"healthInst cur={mo.attrs['cur']!r} != {hd.node!r} for node {node.id}"
)
# ---------------------------------------------------------------------------
# Test: seeded faultInst under node-local DNs
# ---------------------------------------------------------------------------
def test_seeded_fault_insts(store, site_a, topo):
"""Seeded faultInst MOs must exist under node-local DNs (contain /node-{id}/)."""
site_node_ids = {n.id for n in site_a.all_nodes()}
for seed in topo.faults.seed:
if seed.node not in site_node_ids:
continue
# Find a faultInst with the right code under the right node
fault_dn_pattern = re.compile(rf"/node-{seed.node}/")
found = [
mo for mo in store.by_class("faultInst")
if fault_dn_pattern.search(mo.attrs.get("dn", ""))
and seed.severity == mo.attrs.get("severity", "")
]
assert found, (
f"No faultInst found for seed code={seed.code} node={seed.node} "
f"severity={seed.severity}"
)
# Verify the DN contains /node-{id}/ (required for topology.py grouping)
for mo in found:
assert f"/node-{seed.node}/" in mo.attrs["dn"], (
f"faultInst DN {mo.attrs['dn']!r} does not contain /node-{seed.node}/"
)
# ---------------------------------------------------------------------------
# Test: topSystem loopback address
# ---------------------------------------------------------------------------
def test_top_system_loopback(store, site_a):
"""topSystem.address must follow the formula 10.<pod>.<node_id>.1"""
pod = site_a.pod
for node in site_a.all_nodes():
ts_dn = f"topology/pod-{pod}/node-{node.id}/sys"
mo = store.get(ts_dn)
assert mo is not None, f"topSystem missing for node {node.id} at {ts_dn}"
expected_lb = loopback_ip(pod, node.id)
assert mo.attrs["address"] == expected_lb, (
f"topSystem.address={mo.attrs['address']!r} != {expected_lb!r} "
f"for node {node.id}"
)
# ---------------------------------------------------------------------------
# Test: fabricLink DN format parseable by topology.py
# ---------------------------------------------------------------------------
def test_fabric_link_dn_format(store, site_a):
"""fabricLink DNs must be parseable by topology.py's port-extraction logic."""
pod = site_a.pod
for mo in store.by_class("fabricLink"):
dn = mo.attrs["dn"]
source_port = target_port = ""
for seg in dn.split("/"):
if seg.startswith("lnk-"):
lnk_part = seg[4:]
if "-to-" in lnk_part:
sp, dp = lnk_part.split("-to-", 1)
spc = sp.split("-")
dpc = dp.split("-")
if len(spc) >= 3:
source_port = f"eth{spc[1]}/{spc[2]}"
if len(dpc) >= 3:
target_port = f"eth{dpc[1]}/{dpc[2]}"
assert source_port, f"Could not parse source_port from fabricLink DN: {dn}"
assert target_port, f"Could not parse target_port from fabricLink DN: {dn}"
# Ports must follow ethN/M format
assert re.match(r"eth\d+/\d+", source_port), f"Bad source_port {source_port!r}"
assert re.match(r"eth\d+/\d+", target_port), f"Bad target_port {target_port!r}"
+219
View File
@@ -0,0 +1,219 @@
"""Tests for Phase 4 tenant-side builders.
Load topology.yaml, build SiteA into a fresh MITStore, then assert:
1. Tenant / VRF / BD / EPG presence; SiteB-Local absent; SiteA-Local present.
2. Every fvRsBd.tnFvBDName references a real fvBD.
3. Endpoints: parent EPG real; fvIp inside BD subnet; fabricPathDn leaf in SiteA.
4. L3Out SiteA present, SiteB absent; bgpPeerP and bgpPeerEntry established.
5. Contracts/filters cross-refs valid; each vzFilter has 1 vzEntry.
"""
from __future__ import annotations
import ipaddress
import re
from pathlib import Path
import pytest
from aci_sim.build import access, endpoints, l3out, tenants
from aci_sim.mit.dn import parent_dn
from aci_sim.mit.store import MITStore
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
@pytest.fixture(scope="module")
def built():
topo = load_topology(TOPOLOGY_YAML)
site_a = topo.site_by_name("LAB1")
store = MITStore()
tenants.build(topo, site_a, store)
access.build(topo, site_a, store)
l3out.build(topo, site_a, store)
endpoints.build(topo, site_a, store)
return store, topo, site_a
# ---------------------------------------------------------------------------
# 1. Tenant / VRF / BD / EPG presence
# ---------------------------------------------------------------------------
class TestTenantPresence:
def test_corp_tenant_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1") is not None
def test_corp_vrf_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/ctx-vrf-L3_LAB0") is not None
def test_corp_bd_app_bd_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/BD-bd-BD3_LAB0") is not None
def test_corp_bd_db_bd_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/BD-bd-BD4_LAB0") is not None
def test_corp_epg_web_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/ap-app-Stretched_Apps_LAB0/epg-epg-APP3_Stretched_BD") is not None
def test_corp_epg_db_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/ap-app-Stretched_Apps_LAB0/epg-epg-APP4_Stretched_BD") is not None
def test_siteb_local_absent(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-SF-TN1-LAB2") is None
def test_sitea_local_present(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-SF-TN1-LAB1") is not None
# ---------------------------------------------------------------------------
# 2. fvRsBd cross-references
# ---------------------------------------------------------------------------
class TestFvRsBdRefs:
def test_all_rsbds_reference_real_bds(self, built) -> None:
store, _, _ = built
bd_names = {mo.attrs["name"] for mo in store.by_class("fvBD")}
for rsbd in store.by_class("fvRsBd"):
assert rsbd.attrs["tnFvBDName"] in bd_names, (
f"fvRsBd {rsbd.dn!r} references BD "
f"{rsbd.attrs['tnFvBDName']!r} not in store"
)
# ---------------------------------------------------------------------------
# 3. Endpoints
# ---------------------------------------------------------------------------
class TestEndpoints:
def test_at_least_one_fvcep(self, built) -> None:
store, _, _ = built
assert len(store.by_class("fvCEp")) >= 1
def test_cep_parent_is_real_epg(self, built) -> None:
store, _, _ = built
for cep in store.by_class("fvCEp"):
p_dn = parent_dn(cep.dn)
assert store.get(p_dn) is not None, (
f"fvCEp {cep.dn!r} parent {p_dn!r} not in store"
)
def test_cep_has_fvip_inside_bd_subnet(self, built) -> None:
store, _, _ = built
for cep in store.by_class("fvCEp"):
fvips = store.children(cep.dn, {"fvIp"})
assert fvips, f"fvCEp {cep.dn!r} has no fvIp child"
epg_dn = parent_dn(cep.dn)
rsbds = store.children(epg_dn, {"fvRsBd"})
assert rsbds, f"EPG {epg_dn!r} has no fvRsBd"
bd_name = rsbds[0].attrs.get("tnFvBDName", "")
bd_mo = next(
(m for m in store.by_class("fvBD") if m.attrs.get("name") == bd_name),
None,
)
assert bd_mo is not None, f"BD {bd_name!r} not found for {cep.dn!r}"
subnet_mos = store.children(bd_mo.dn, {"fvSubnet"})
assert subnet_mos, f"BD {bd_mo.dn!r} has no fvSubnet"
for fvip in fvips:
ip_str = fvip.attrs.get("addr", "")
in_net = any(
ipaddress.ip_address(ip_str)
in ipaddress.ip_network(sm.attrs.get("ip", "0.0.0.0/0"), strict=False)
for sm in subnet_mos
)
assert in_net, (
f"fvIp {ip_str!r} not inside any BD subnet for {cep.dn!r}"
)
def test_cep_fabricpathdn_uses_sitea_leaf(self, built) -> None:
"""Every fvCEp's fabricPathDn must reference a node that really exists
in SiteA: either a real leaf (HOME endpoint, front-panel port) or a
real spine (REMOTE/stretched endpoint, learned via the multi-site
overlay tunnel that terminates on a local spine see FIX 4 /
docs/DESIGN.md). It must never reference a node id from the OTHER
site (that was the finding #16 bug: identical local-looking fvCEp on
both sites)."""
store, _, site_a = built
leaf_ids = {n.id for n in site_a.leaf_nodes()}
spine_ids = {n.id for n in site_a.spine_nodes()}
for cep in store.by_class("fvCEp"):
path = cep.attrs.get("fabricPathDn", "")
m = re.search(r"paths-(\d+)", path)
assert m is not None, f"Cannot parse leafId from {path!r}"
nid = int(m.group(1))
assert nid in leaf_ids or nid in spine_ids, (
f"fvCEp {cep.dn!r} uses node {nid} not in SiteA "
f"leaves={leaf_ids} spines={spine_ids}"
)
# ---------------------------------------------------------------------------
# 4. L3Out
# ---------------------------------------------------------------------------
class TestL3Out:
def test_l3out_sitea_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/out-l3o-bgp-Core_LAB1") is not None
def test_l3out_siteb_absent(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/out-l3o-bgp-Core_LAB2") is None
def test_bgp_peer_p_with_csw_a_ip(self, built) -> None:
store, _, _ = built
peers = store.by_class("bgpPeerP")
assert any(p.attrs.get("addr") == "10.100.0.1" for p in peers), (
"No bgpPeerP with addr=10.100.0.1 found"
)
def test_bgp_peer_entry_established_on_border_leaf(self, built) -> None:
store, _, _ = built
entries = store.by_class("bgpPeerEntry")
matching = [
e for e in entries
if e.attrs.get("operSt") == "established"
and e.attrs.get("addr") == "10.100.0.1"
and ("node-103" in e.dn or "node-104" in e.dn)
]
assert matching, (
"No bgpPeerEntry with operSt=established, addr=10.100.0.1 on node-103/104"
)
# ---------------------------------------------------------------------------
# 5. Contracts / Filters
# ---------------------------------------------------------------------------
class TestContractsFilters:
def test_vzbrcp_web_to_db_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/brc-con-Web_to_DB_LAB0") is not None
def test_rssubjfiltatt_refs_real_filter(self, built) -> None:
store, _, _ = built
flt_names = {mo.attrs["name"] for mo in store.by_class("vzFilter")}
for rs in store.by_class("vzRsSubjFiltAtt"):
assert rs.attrs["tnVzFilterName"] in flt_names, (
f"vzRsSubjFiltAtt {rs.dn!r} refs filter "
f"{rs.attrs['tnVzFilterName']!r} not in store"
)
def test_each_vzfilter_has_vzentry(self, built) -> None:
store, _, _ = built
filters = store.by_class("vzFilter")
assert filters, "No vzFilter MOs in store"
for flt in filters:
entries = store.children(flt.dn, {"vzEntry"})
assert entries, f"vzFilter {flt.dn!r} has no vzEntry children"
+461
View File
@@ -0,0 +1,461 @@
"""Tests for aci_sim/cli.py — the `aci-sim` topology CLI (PR-17).
Covers `validate` (positive + negative), `show` (text + --json, asserting
derived loopback/OOB IPs for a known node), `new` (scaffold generation +
re-validation of the output), and `run`'s env/argv construction (no live
socket binding build_run_env_argv is a pure helper factored out of
cmd_run for exactly this reason).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
from aci_sim.cli import (
_ensure_certs,
build_parser,
build_run_env_argv,
generate_topology,
main,
)
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------
class TestValidate:
def test_validate_default_topology_ok(self, capsys) -> None:
rc = main(["validate", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
assert "OK:" in out
assert "2 site(s)" in out
assert "3 tenant(s)" in out
assert "LAB1" in out and "LAB2" in out # per-site breakdown lines
assert "leaves" in out and "spines" in out and "border leaves" in out
def test_validate_missing_file(self, capsys) -> None:
rc = main(["validate", "/nonexistent/path/topology.yaml"])
err = capsys.readouterr().err
assert rc != 0
assert "not found" in err.lower() or "ERROR" in err
def test_validate_duplicate_node_id(self, tmp_path, capsys) -> None:
bad = {
"fabric": {"name": "BAD-ACI"},
"sites": [
{
"name": "BADSITE",
"id": "1",
"apic_host": "127.0.0.1:8443",
"asn": 65001,
"spines": 2,
"leaves": [
{"id": 101, "name": "leaf-a"},
{"id": 101, "name": "leaf-b"}, # duplicate id -> validation error
],
}
],
}
bad_path = tmp_path / "bad_topology.yaml"
bad_path.write_text(yaml.dump(bad), encoding="utf-8")
rc = main(["validate", str(bad_path)])
err = capsys.readouterr().err
assert rc != 0
assert "collide" in err.lower() or "error" in err.lower()
def test_validate_bad_ip_pool(self, tmp_path, capsys) -> None:
bad = {
"fabric": {"name": "BAD-ACI", "loopback_pool": "not-a-cidr"},
"isn": {"enabled": False},
"sites": [
{
"name": "BADSITE",
"id": "1",
"apic_host": "127.0.0.1:8443",
"asn": 65001,
"spines": 1,
"leaves": 1,
}
],
}
bad_path = tmp_path / "bad_ip.yaml"
bad_path.write_text(yaml.dump(bad), encoding="utf-8")
rc = main(["validate", str(bad_path)])
err = capsys.readouterr().err
assert rc != 0
assert "loopback_pool" in err or "cidr" in err.lower()
def test_validate_default_arg_is_topology_yaml(self) -> None:
parser = build_parser()
args = parser.parse_args(["validate"])
assert args.topology == "topology.yaml"
# ---------------------------------------------------------------------------
# show
# ---------------------------------------------------------------------------
class TestShow:
def test_show_text_contains_known_node_derived_ips(self, capsys) -> None:
rc = main(["show", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
# Node 101 (LAB1-ACI-LF101, pod 1) -> loopback 10.1.101.1 / oob 192.168.1.101
assert "10.1.101.1" in out
assert "192.168.1.101" in out
assert "LAB1" in out
assert "LAB2" in out
def test_show_json_parses_and_has_known_node(self) -> None:
import io
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
rc = main(["show", str(TOPOLOGY_YAML), "--json"])
assert rc == 0
data = json.loads(buf.getvalue())
assert data["fabric_name"] == "LAB-IT-ACI"
site_names = {s["name"] for s in data["sites"]}
assert {"LAB1", "LAB2"} <= site_names
lab1 = next(s for s in data["sites"] if s["name"] == "LAB1")
node_101 = next(n for n in lab1["nodes"] if n["id"] == 101)
assert node_101["loopback_ip"] == "10.1.101.1"
assert node_101["oob_ip"] == "192.168.1.101"
assert node_101["role"] == "leaf"
# Port bindings: default (non-sandbox) mode -> SIM_BIND host + fixed ports.
roles = {b["role"] for b in data["ports"]}
assert "apic" in roles
assert "ndo" in roles
def test_show_missing_file(self, capsys) -> None:
rc = main(["show", "/nonexistent/path/topology.yaml"])
assert rc != 0
assert capsys.readouterr().err
# -- PR-18: Tier-1 fabric params surfaced in `show` --------------------
def test_show_json_has_tier1_fabric_fields(self) -> None:
import io
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
rc = main(["show", str(TOPOLOGY_YAML), "--json"])
assert rc == 0
data = json.loads(buf.getvalue())
assert data["tep_pool"] == "10.0.0.0/16"
assert data["infra_vlan"] == 3967
assert data["gipo_pool"] == "225.0.0.0/15"
lab1 = next(s for s in data["sites"] if s["name"] == "LAB1")
# PR-21: single-APIC-per-site is now the default (was 3 pre-PR-21).
assert lab1["controllers"] == 1
node_101 = next(n for n in lab1["nodes"] if n["id"] == 101)
assert node_101["serial"] == "SAL10101"
def test_show_text_contains_tier1_fields(self, capsys) -> None:
rc = main(["show", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
assert "tep_pool=10.0.0.0/16" in out
assert "infra_vlan=3967" in out
assert "gipo_pool=225.0.0.0/15" in out
assert "controllers=1" in out # PR-21: single-APIC-per-site default (was 3)
assert "SAL10101" in out
# -- PR-19: Tier-2 ISN params + VMM domains surfaced in `show` ----------
def test_show_json_has_tier2_isn_fields(self) -> None:
import io
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
rc = main(["show", str(TOPOLOGY_YAML), "--json"])
assert rc == 0
data = json.loads(buf.getvalue())
assert data["isn_enabled"] is True
assert data["isn_peer_mesh"] == "full"
assert data["isn_ospf_area"] == "0.0.0.0"
assert data["isn_mtu"] == 9150
assert data["vmm_domains"] == []
def test_show_text_contains_tier2_isn_fields(self, capsys) -> None:
rc = main(["show", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
assert "ospf_area=0.0.0.0" in out
assert "mtu=9150" in out
# ---------------------------------------------------------------------------
# new (scaffold)
# ---------------------------------------------------------------------------
class TestNew:
def test_generate_topology_default_shape(self) -> None:
topo_dict = generate_topology()
# Must validate cleanly via the real Pydantic model.
Topology.model_validate(topo_dict)
assert len(topo_dict["sites"]) == 2
def test_new_sites3_leaves4_stdout(self, capsys) -> None:
rc = main(["new", "--sites", "3", "--leaves-per-site", "4"])
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
assert len(parsed["sites"]) == 3
for site in parsed["sites"]:
assert len(site["leaves"]) == 4
# Re-validate through the full loader-equivalent path.
topo = Topology.model_validate(parsed)
assert len(topo.sites) == 3
for site in topo.sites:
assert len(site.leaf_nodes()) == 4
def test_new_node_id_offsets(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=4, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
expected_offsets = [0, 200, 400]
for site, offset in zip(topo.sites, expected_offsets, strict=True):
leaf_ids = sorted(n.id for n in site.leaf_nodes())
assert leaf_ids == [101 + offset + i for i in range(4)]
spine_ids = sorted(n.id for n in site.spine_nodes())
assert spine_ids == [201 + offset + i for i in range(2)]
# Border leaves must land outside both the leaf (101-104+offset)
# and spine (201-202+offset) ranges -- no fixed 103/104 assumption
# here since that collides once leaves_per_site > 2 (see
# generate_topology's border_base logic).
border_ids = sorted(bl.id for bl in site.border_leaves)
assert len(border_ids) == 2
assert all(bid not in leaf_ids and bid not in spine_ids for bid in border_ids)
assert border_ids[1] == border_ids[0] + 1 # a vPC pair, contiguous ids
def test_new_default_shape_matches_documented_border_ids(self) -> None:
# The default 2-leaf/2-spine shape should reproduce the exact
# documented example from topology/schema.py's module docstring
# (site 0: border 103,104; site 1: border 303,304).
topo_dict = generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
site0_border = sorted(bl.id for bl in topo.sites[0].border_leaves)
site1_border = sorted(bl.id for bl in topo.sites[1].border_leaves)
assert site0_border == [103, 104]
assert site1_border == [303, 304]
def test_new_writes_to_file(self, tmp_path) -> None:
out_path = tmp_path / "scaffold.yaml"
rc = main(["new", "--sites", "1", "--leaves-per-site", "2", "-o", str(out_path)])
assert rc == 0
assert out_path.exists()
# Must pass the same load_topology() path `aci-sim validate` uses.
topo = load_topology(out_path)
assert len(topo.sites) == 1
assert len(topo.sites[0].leaf_nodes()) == 2
# And `aci-sim validate` itself must accept it end-to-end.
rc2 = main(["validate", str(out_path)])
assert rc2 == 0
def test_new_custom_asn_count_mismatch_errors(self, capsys) -> None:
rc = main(["new", "--sites", "2", "--asn", "65001"]) # only 1 value for 2 sites
err = capsys.readouterr().err
assert rc != 0
assert "asn" in err.lower()
def test_new_single_site_disables_full_isn_mesh(self) -> None:
# ISN peer_mesh:full requires >=2 sites (schema.py); scaffolding a
# single-site topology must not trip that validator.
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 1
assert topo.isn.enabled is False
# -- PR-18: Tier-1 fabric params in `new` scaffold ----------------------
def test_new_controllers_flag_stdout(self, capsys) -> None:
rc = main(["new", "--sites", "2", "--controllers", "5"])
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
for site in parsed["sites"]:
assert site["controllers"] == 5
topo = Topology.model_validate(parsed)
for site in topo.sites:
assert site.controllers == 5
def test_new_tier1_fabric_flags_stdout(self, capsys) -> None:
rc = main(
[
"new",
"--sites",
"1",
"--leaves-per-site",
"1",
"--spines-per-site",
"1",
"--border-pairs",
"0",
"--tep-pool",
"172.16.0.0/16",
"--infra-vlan",
"100",
"--gipo-pool",
"226.0.0.0/15",
]
)
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
assert parsed["fabric"]["tep_pool"] == "172.16.0.0/16"
assert parsed["fabric"]["infra_vlan"] == 100
assert parsed["fabric"]["gipo_pool"] == "226.0.0.0/15"
topo = Topology.model_validate(parsed)
assert topo.fabric.tep_pool == "172.16.0.0/16"
assert topo.fabric.infra_vlan == 100
assert topo.fabric.gipo_pool == "226.0.0.0/15"
def test_new_default_shape_has_default_tier1_values(self) -> None:
topo_dict = generate_topology()
assert topo_dict["fabric"]["tep_pool"] == "10.0.0.0/16"
assert topo_dict["fabric"]["infra_vlan"] == 3967
assert topo_dict["fabric"]["gipo_pool"] == "225.0.0.0/15"
for site in topo_dict["sites"]:
# PR-21: single-APIC-per-site is now the default (was 3 pre-PR-21).
assert site["controllers"] == 1
# -- PR-19: Tier-2 ISN flags + leaf/spine count elasticity --------------
def test_new_isn_flags_stdout(self, capsys) -> None:
rc = main(
[
"new",
"--sites", "2",
"--leaves-per-site", "1",
"--spines-per-site", "1",
"--border-pairs", "0",
"--isn-ospf-area", "10.0.0.0",
"--isn-mtu", "9000",
]
)
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
assert parsed["isn"]["ospf_area"] == "10.0.0.0"
assert parsed["isn"]["mtu"] == 9000
topo = Topology.model_validate(parsed)
assert topo.isn.ospf_area == "10.0.0.0"
assert topo.isn.mtu == 9000
def test_new_default_shape_has_default_tier2_isn_values(self) -> None:
topo_dict = generate_topology()
assert topo_dict["isn"]["ospf_area"] == "0.0.0.0"
assert topo_dict["isn"]["mtu"] == 9150
def test_new_8_leaves_4_spines_via_cli(self, capsys) -> None:
rc = main(
["new", "--sites", "2", "--leaves-per-site", "8", "--spines-per-site", "4"]
)
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
topo = Topology.model_validate(parsed)
for site in topo.sites:
assert len(site.leaf_nodes()) == 8
assert len(site.spine_nodes()) == 4
def test_new_leaves_per_site_overrun_errors_cleanly(self, capsys) -> None:
rc = main(
["new", "--sites", "2", "--leaves-per-site", "150", "--spines-per-site", "4"]
)
err = capsys.readouterr().err
assert rc != 0
assert "leaves-per-site" in err
# ---------------------------------------------------------------------------
# run — env/argv construction only, no live boot
# ---------------------------------------------------------------------------
class TestRunEnvArgv:
def test_build_run_env_argv_sets_topology_path(self) -> None:
env, argv = build_run_env_argv("custom/topology.yaml", base_env={"PATH": "/usr/bin"})
assert env["TOPOLOGY_PATH"] == "custom/topology.yaml"
assert env["PATH"] == "/usr/bin" # base env preserved
assert argv == [sys.executable, "-m", "aci_sim.runtime.supervisor"]
def test_build_run_env_argv_passthrough_vars_preserved(self) -> None:
base_env = {"SIM_BIND": "0.0.0.0", "SIM_SANDBOX": "1", "UNRELATED": "x"}
env, _argv = build_run_env_argv("topology.yaml", base_env=base_env)
assert env["SIM_BIND"] == "0.0.0.0"
assert env["SIM_SANDBOX"] == "1"
assert env["UNRELATED"] == "x"
assert env["TOPOLOGY_PATH"] == "topology.yaml"
def test_run_missing_topology_errors_without_exec(self, capsys) -> None:
rc = main(["run", "/nonexistent/path/topology.yaml"])
err = capsys.readouterr().err
assert rc != 0
assert "not found" in err.lower()
def test_run_invalid_topology_errors_without_exec(self, tmp_path, capsys) -> None:
bad_path = tmp_path / "bad.yaml"
bad_path.write_text(
yaml.dump(
{
"fabric": {"name": "BAD"},
"sites": [
{
"name": "S",
"id": "1",
"apic_host": "h",
"asn": 1,
"leaves": [{"id": 1, "name": "a"}, {"id": 1, "name": "b"}],
}
],
}
),
encoding="utf-8",
)
rc = main(["run", str(bad_path)])
err = capsys.readouterr().err
assert rc != 0
assert err
def test_ensure_certs_noop_when_already_present(self, tmp_path) -> None:
cert = tmp_path / "sim.crt"
key = tmp_path / "sim.key"
cert.write_text("existing-cert", encoding="utf-8")
key.write_text("existing-key", encoding="utf-8")
# Must not touch (or attempt to regenerate) files that already exist.
_ensure_certs(str(cert), str(key))
assert cert.read_text(encoding="utf-8") == "existing-cert"
assert key.read_text(encoding="utf-8") == "existing-key"
+74
View File
@@ -0,0 +1,74 @@
"""Tests for the /_sim control-plane admin endpoints (Phase 5)."""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
@pytest.fixture
def client_and_state():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
c = TestClient(app)
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c, state
def test_reset_removes_posted_tenant(client_and_state):
client, state = client_and_state
# POST a new tenant
client.post(
"/api/mo/uni/tn-TEMP.json",
json={"fvTenant": {"attributes": {"name": "TEMP", "dn": "uni/tn-TEMP"}}},
)
# Verify it's there
resp = client.get("/api/class/fvTenant.json")
names = [i["fvTenant"]["attributes"]["name"] for i in resp.json()["imdata"]]
assert "TEMP" in names
# Reset
r = client.post("/_sim/reset")
assert r.status_code == 200
# Verify it's gone
resp2 = client.get("/api/class/fvTenant.json")
names2 = [i["fvTenant"]["attributes"]["name"] for i in resp2.json()["imdata"]]
assert "TEMP" not in names2
def test_snapshot_restore(client_and_state):
client, state = client_and_state
# POST a tenant
client.post(
"/api/mo/uni/tn-SNAP.json",
json={"fvTenant": {"attributes": {"name": "SNAP", "dn": "uni/tn-SNAP"}}},
)
# Snapshot
client.post("/_sim/snapshot/s1")
# Reset to baseline (removes SNAP)
client.post("/_sim/reset")
# Restore
client.post("/_sim/restore/s1")
# Verify SNAP is back
resp = client.get("/api/class/fvTenant.json")
names = [i["fvTenant"]["attributes"]["name"] for i in resp.json()["imdata"]]
assert "SNAP" in names
+496
View File
@@ -0,0 +1,496 @@
"""Tests for the NDO -> APIC deploy mirror (aci_sim/ndo/deploy_mirror.py).
Confirmed SIM GAP: `POST /mso/api/v1/task` used to be a pure ack no-op
multi-site EPGs/BDs/VRFs deployed via NDO never appeared on the target
sites' APIC MITStores. These tests cover the pure `mirror_template_to_sites`
function directly (unit + idempotency + undeploy) and the app-level wiring
in `POST /mso/api/v1/task` (with and without `apic_states`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
import pytest
from fastapi.testclient import TestClient
from aci_sim.mit.store import MITStore
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.deploy_mirror import _basename, mirror_template_to_sites
from aci_sim.ndo.model import NdoState
@dataclass
class _FakeApicState:
"""Minimal stand-in for rest_aci.app.ApicSiteState — the mirror only
ever touches `.store`, so a full ApicSiteState (name/site/topo/baseline)
is unnecessary scaffolding for these unit tests."""
store: MITStore = field(default_factory=MITStore)
TENANT_ID = "tenant-ms-tn1-id"
TENANT_NAME = "ANS-MS_TN1"
SCHEMA_ID = "schema-1"
TEMPLATE_NAME = "MS-TN1-template"
def _build_state(*, site1_host_based_routing: bool = True) -> NdoState:
"""A minimal NdoState: one schema, one template (1 vrf, 1 bd with
subnets, 1 anp with 1 epg providing a contract), and one site entry
(siteId '1') associated with the template."""
template = {
"name": TEMPLATE_NAME,
"tenantId": TENANT_ID,
"vrfs": [{"name": "VRF1"}],
"bds": [
{
"name": "App1",
"vrfRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/vrfs/VRF1",
"epMoveDetectMode": "garp",
"arpFlood": True,
"l2UnknownUnicast": "flood",
"unicastRouting": True,
"l2Stretch": True,
"intersiteBumTrafficAllow": True,
"subnets": [{"ip": "10.10.10.1/24", "scope": "public,shared", "primary": True}],
}
],
"anps": [
{
"name": "AP1",
"epgs": [
{
"name": "Web",
"bdRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/bds/App1",
"preferredGroup": False,
"subnets": [],
"contractRelationships": [
{
"relationshipType": "provider",
"contractRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/contracts/Web-Con",
}
],
}
],
}
],
"contracts": [{"name": "Web-Con"}],
}
schema_detail = {
"id": SCHEMA_ID,
"templates": [template],
"sites": [
{
"siteId": "1",
"templateName": TEMPLATE_NAME,
"bds": [
{
"bdRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/bds/App1",
"hostBasedRouting": site1_host_based_routing,
}
],
"anps": [
{
"anpRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/anps/AP1",
"epgs": [
{
"epgRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/anps/AP1/epgs/Web",
"domainAssociations": [],
"staticPorts": [],
"staticLeafs": [],
"subnets": [],
}
],
}
],
# site '2' deliberately omitted — no association at all.
}
],
}
return NdoState(
sites=[],
tenants=[{"id": TENANT_ID, "name": TENANT_NAME}],
schemas=[],
schema_details={SCHEMA_ID: schema_detail},
template_summaries=[],
tenant_policy_templates={},
fabric_connectivity={},
policy_states={},
audit_records=[],
)
# ---------------------------------------------------------------------------
# Unit tests — pure mirror_template_to_sites()
# ---------------------------------------------------------------------------
def test_mirror_materializes_vrf_bd_anp_epg():
state = _build_state(site1_host_based_routing=True)
apic_states = {"1": _FakeApicState(), "2": _FakeApicState()}
written = mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert written > 0
store1 = apic_states["1"].store
ctx = store1.get(f"uni/tn-{TENANT_NAME}/ctx-VRF1")
assert ctx is not None
assert ctx.class_name == "fvCtx"
bd = store1.get(f"uni/tn-{TENANT_NAME}/BD-App1")
assert bd is not None
assert bd.class_name == "fvBD"
assert bd.attrs["hostBasedRouting"] == "yes"
assert bd.attrs["epMoveDetectMode"] == "garp"
assert bd.attrs["unkMacUcastAct"] == "flood"
assert bd.attrs["unicastRoute"] == "yes"
# _CLASS_DEFAULTS-filled attrs present (not overridden by the template)
assert bd.attrs["mac"] == "00:22:BD:F8:19:FF"
assert bd.attrs["type"] == "regular"
assert bd.attrs["limitIpLearnToSubnets"] == "yes"
rsctx = store1.get(f"uni/tn-{TENANT_NAME}/BD-App1/rsctx")
assert rsctx is not None
assert rsctx.attrs["tnFvCtxName"] == "VRF1"
subnet = store1.get(f"uni/tn-{TENANT_NAME}/BD-App1/subnet-[10.10.10.1/24]")
assert subnet is not None
ap = store1.get(f"uni/tn-{TENANT_NAME}/ap-AP1")
assert ap is not None
assert ap.class_name == "fvAp"
epg_dn = f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web"
epg = store1.get(epg_dn)
assert epg is not None
assert epg.class_name == "fvAEPg"
rsbd = store1.get(f"{epg_dn}/rsbd")
assert rsbd is not None
assert rsbd.attrs["tnFvBDName"] == "App1"
rsprov = store1.get(f"{epg_dn}/rsprov-Web-Con")
assert rsprov is not None
assert rsprov.attrs["tnVzBrCPName"] == "Web-Con"
# class query surface works too
epgs_by_class = store1.by_class("fvAEPg")
assert any(e.dn == epg_dn for e in epgs_by_class)
# The tenant SHADOW MO must exist on the site so a subtree/mo query on
# uni/tn-<T> resolves the mirrored children (F1 root cause: without the
# fvTenant root MO, the subtree root is empty and the query returns 0 even
# though the mirrored VRF/BD/EPG children are present).
tenant_mo = store1.get(f"uni/tn-{TENANT_NAME}")
assert tenant_mo is not None and tenant_mo.class_name == "fvTenant"
# Site '2' was never associated with this template's schema-sites entry,
# and is absent from the schema — its store must stay completely empty.
store2 = apic_states["2"].store
assert store2.all() == []
def test_mirror_skips_sites_not_in_apic_states():
state = _build_state()
# Only site '1' has a backing APIC store; a real deployment might target
# other sites this sim process doesn't happen to be running.
apic_states = {"1": _FakeApicState()}
written = mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert written > 0
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/BD-App1") is not None
def test_mirror_unknown_template_is_safe_noop():
state = _build_state()
apic_states = {"1": _FakeApicState()}
written = mirror_template_to_sites(state, "does-not-exist", apic_states)
assert written == 0
assert apic_states["1"].store.all() == []
# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------
def test_mirror_twice_is_idempotent():
state = _build_state()
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
count_after_first = len(apic_states["1"].store.all())
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
count_after_second = len(apic_states["1"].store.all())
assert count_after_first == count_after_second
epg = apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web")
assert epg is not None
assert epg.attrs["name"] == "Web"
# ---------------------------------------------------------------------------
# Undeploy
# ---------------------------------------------------------------------------
def test_undeploy_removes_template_objects():
state = _build_state()
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is not None
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states, undeploy=True)
store1 = apic_states["1"].store
assert store1.get(f"uni/tn-{TENANT_NAME}/BD-App1") is None
assert store1.get(f"uni/tn-{TENANT_NAME}/ap-AP1") is None
assert store1.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is None
assert store1.get(f"uni/tn-{TENANT_NAME}/ctx-VRF1") is None
# ---------------------------------------------------------------------------
# Schema-collision regression (schema_id threading)
# ---------------------------------------------------------------------------
def test_schema_id_scopes_template_resolution_to_the_right_tenant():
"""Two DIFFERENT schemas each have a template named 'LAB1-LAB2' (a real
collision: every multi-site tenant's schema commonly reuses these
generic template names), but belong to different tenants. Passing
schema_id must resolve ONLY the targeted schema's tenant — the other
tenant's DNs must never appear in any site's store."""
collide_name = "LAB1-LAB2"
schema_a_id = "schema-a"
schema_b_id = "schema-b"
tenant_a_id, tenant_a_name = "tenant-a-id", "Tenant-A"
tenant_b_id, tenant_b_name = "tenant-b-id", "Tenant-B"
def _make_schema(schema_id: str, tenant_id: str) -> dict:
template = {
"name": collide_name,
"tenantId": tenant_id,
"vrfs": [{"name": "VRF1"}],
"bds": [],
"anps": [],
}
return {
"id": schema_id,
"templates": [template],
"sites": [
{
"siteId": "1",
"templateName": collide_name,
"bds": [],
"anps": [],
}
],
}
state = NdoState(
sites=[],
tenants=[
{"id": tenant_a_id, "name": tenant_a_name},
{"id": tenant_b_id, "name": tenant_b_name},
],
schemas=[],
schema_details={
schema_a_id: _make_schema(schema_a_id, tenant_a_id),
schema_b_id: _make_schema(schema_b_id, tenant_b_id),
},
template_summaries=[],
tenant_policy_templates={},
fabric_connectivity={},
policy_states={},
audit_records=[],
)
apic_states = {"1": _FakeApicState()}
written = mirror_template_to_sites(
state, collide_name, apic_states, schema_id=schema_b_id
)
assert written > 0
store1 = apic_states["1"].store
assert store1.get(f"uni/tn-{tenant_b_name}/ctx-VRF1") is not None
# Tenant A's DNs must be completely absent — no cross-schema bleed.
assert store1.get(f"uni/tn-{tenant_a_name}/ctx-VRF1") is None
# ---------------------------------------------------------------------------
# fvRsPathAtt encap key regression
# ---------------------------------------------------------------------------
def test_static_port_encap_uses_encap_key_not_deployment_immediacy():
state = _build_state()
schema_detail = state.schema_details[SCHEMA_ID]
site_epg = schema_detail["sites"][0]["anps"][0]["epgs"][0]
site_epg["staticPorts"] = [
{
"path": "topology/pod-1/paths-101/pathep-[eth1/1]",
"encap": "vlan-101",
"deploymentImmediacy": "immediate",
}
]
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
epg_dn = f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web"
path_dn = "topology/pod-1/paths-101/pathep-[eth1/1]"
rspath = apic_states["1"].store.get(f"{epg_dn}/rspathAtt-[{path_dn}]")
assert rspath is not None
assert rspath.attrs["encap"] == "vlan-101"
assert rspath.attrs["instrImedcy"] == "immediate"
# ---------------------------------------------------------------------------
# Redeploy prune — stale EPG children must not survive a merge-upsert
# ---------------------------------------------------------------------------
def test_redeploy_prunes_removed_contract_binding():
state = _build_state()
template = state.schema_details[SCHEMA_ID]["templates"][0]
epg = template["anps"][0]["epgs"][0]
# Seed a SECOND provider contract relationship (C1) alongside the
# existing Web-Con one, deploy, then remove C1 from the template (as if
# NDO-side config removed it) and redeploy — C1's fvRsProv must be gone.
epg["contractRelationships"].append(
{
"relationshipType": "provider",
"contractRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/contracts/C1",
}
)
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
epg_dn = f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web"
store1 = apic_states["1"].store
assert store1.get(f"{epg_dn}/rsprov-C1") is not None
# Remove C1 from the template (mutate in place) and redeploy.
epg["contractRelationships"] = [
rel
for rel in epg["contractRelationships"]
if _basename_test_helper(rel["contractRef"]) != "C1"
]
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert store1.get(f"{epg_dn}/rsprov-C1") is None
# The surviving provider relationship must still be present.
assert store1.get(f"{epg_dn}/rsprov-Web-Con") is not None
def _basename_test_helper(ref: str) -> str:
return ref.rsplit("/", 1)[-1]
def test_basename_resolves_string_and_dict_refs():
# STRING ref -> last path segment.
assert _basename("/schemas/x/templates/LAB1-LAB2/bds/bd-App1_LAB0") == "bd-App1_LAB0"
# DICT refs -> the leaf object-name key (aci-ansible's cisco.mso store shape).
# Regression: the mirror used to AttributeError ('dict'.rsplit) on a redeploy
# of an aci-ansible multi-site tenant whose vrfRef/bdRef are dicts, 500-ing
# the NDO deploy.
assert _basename({"vrfName": "vrf-L3_LAB0", "schemaId": "s", "templateName": "t"}) == "vrf-L3_LAB0"
assert _basename({"bdName": "bd-App1_LAB0", "schemaId": "s", "templateName": "t"}) == "bd-App1_LAB0"
assert _basename({"contractName": "con-Firewall", "schemaId": "s", "templateName": "t"}) == "con-Firewall"
# epgRef carries BOTH anpName and epgName; the EPG's own name wins.
assert _basename({"epgName": "epg-web", "anpName": "app-App", "schemaId": "s"}) == "epg-web"
assert _basename(None) == ""
assert _basename({}) == ""
# ---------------------------------------------------------------------------
# Empty vrfRef — no fvRsCtx child
# ---------------------------------------------------------------------------
def test_bd_with_no_vrf_ref_emits_no_fvrsctx_child():
state = _build_state()
template = state.schema_details[SCHEMA_ID]["templates"][0]
bd = template["bds"][0]
bd["vrfRef"] = None
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
bd_dn = f"uni/tn-{TENANT_NAME}/BD-App1"
assert apic_states["1"].store.get(bd_dn) is not None
assert apic_states["1"].store.get(f"{bd_dn}/rsctx") is None
# ---------------------------------------------------------------------------
# App-level wiring — POST /mso/api/v1/task
# ---------------------------------------------------------------------------
def test_app_level_deploy_mirrors_into_apic_store():
state = _build_state()
apic_states = {"1": _FakeApicState()}
app = make_ndo_app(state, apic_states)
with TestClient(app) as client:
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": SCHEMA_ID, "templateName": TEMPLATE_NAME},
)
assert resp.status_code == 200
body = resp.json()
assert body["templateName"] == TEMPLATE_NAME
assert body["status"] == "success"
epg = apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web")
assert epg is not None
assert epg.class_name == "fvAEPg"
def test_app_level_without_apic_states_is_still_a_noop():
"""make_ndo_app(state) with NO apic_states (every pre-existing test /
call site) must keep behaving exactly as before ack only, no mirror
attempted (and no crash from a missing apic_states arg)."""
state = _build_state()
app = make_ndo_app(state)
with TestClient(app) as client:
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": SCHEMA_ID, "templateName": TEMPLATE_NAME},
)
assert resp.status_code == 200
body = resp.json()
assert body["templateName"] == TEMPLATE_NAME
assert body["status"] == "success"
def test_app_level_undeploy_via_task_body():
state = _build_state()
apic_states = {"1": _FakeApicState()}
app = make_ndo_app(state, apic_states)
with TestClient(app) as client:
client.post(
"/mso/api/v1/task",
json={"schemaId": SCHEMA_ID, "templateName": TEMPLATE_NAME},
)
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is not None
resp = client.post(
"/mso/api/v1/task",
json={
"schemaId": SCHEMA_ID,
"templateName": TEMPLATE_NAME,
"undeploy": ["1"],
},
)
assert resp.status_code == 200
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is None
+185
View File
@@ -0,0 +1,185 @@
"""Tests for aci_sim/graph.py — self-contained SVG/HTML topology diagram.
Covers `render_topology()` directly (node counts, ISN cloud presence/absence,
role colors, well-formed XML) plus the `aci-sim graph` CLI subcommand. No
browser is required anywhere every assertion works on the raw string
output.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from aci_sim.cli import generate_topology, main
from aci_sim.graph import ROLE_COLORS, build_graph, render_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
def _new_topology(sites: int) -> Topology:
"""Build a fresh N-site Topology via cli.generate_topology() (returns a dict)."""
raw = generate_topology(sites=sites)
return Topology.model_validate(raw)
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
def _total_node_count(topo) -> int:
total = 0
for site in topo.sites:
total += len(site.spine_nodes()) + len(site.leaf_nodes()) + len(site.border_leaves)
total += site.controllers
return total
def _svg_fragment(html: str) -> str:
m = re.search(r"<svg.*</svg>", html, re.S)
assert m, "no <svg>...</svg> fragment found"
return m.group(0)
class TestRenderTopologyRepoDefault:
"""The repo's default topology.yaml is a 2-site, ISN-enabled fabric."""
def test_html_contains_svg(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
html = render_topology(topo, fmt="html")
assert "<svg" in html
assert html.startswith("<html")
def test_svg_format_starts_with_svg_tag(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
assert svg.startswith("<svg")
assert "</svg>" in svg
def test_node_rect_count_matches_topology(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
node_groups = re.findall(r'<g class="node node-[a-z-]+"', svg)
expected = _total_node_count(topo) # ISN cloud is the +1 (checked separately)
assert len(node_groups) == expected + 1 # +1 for the ISN cloud node
def test_isn_cloud_present_for_multisite(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
assert topo.isn.enabled and len(topo.sites) > 1
svg = render_topology(topo, fmt="svg")
assert 'class="node node-isn"' in svg
assert "ISN" in svg
def test_role_colors_present(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
for role in ("spine", "leaf", "border-leaf", "controller", "isn"):
fill, _border = ROLE_COLORS[role]
assert fill in svg, f"expected {role} fill color {fill} in output"
def test_output_is_well_formed_xml(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
ET.fromstring(svg) # raises if malformed
def test_html_output_is_well_formed_xml_fragment(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
html = render_topology(topo, fmt="html")
ET.fromstring(_svg_fragment(html))
def test_legend_present(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
assert 'class="legend"' in svg
assert "Spine" in svg and "Leaf" in svg and "Border Leaf" in svg
def test_title_contains_fabric_name_and_site_count(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
assert topo.fabric.name in svg
assert f"{len(topo.sites)} site(s)" in svg
class TestSingleFabricNoIsn:
def test_single_site_omits_isn_cloud(self) -> None:
single = _new_topology(sites=1)
assert len(single.sites) == 1 # sanity: no ISN possible with 1 site
svg = render_topology(single, fmt="svg")
assert 'class="node node-isn"' not in svg
ET.fromstring(svg)
class TestThreeSiteTopology:
def test_three_sites_all_render(self) -> None:
topo = _new_topology(sites=3)
assert len(topo.sites) == 3
svg = render_topology(topo, fmt="svg")
ET.fromstring(svg)
for site in topo.sites:
assert site.name in svg
node_groups = re.findall(r'<g class="node node-[a-z-]+"', svg)
assert len(node_groups) == _total_node_count(topo) + 1 # +1 ISN cloud
assert 'class="node node-isn"' in svg
class TestBuildGraph:
def test_build_graph_node_and_link_counts(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
graph = build_graph(topo)
assert graph.node_count() == _total_node_count(topo) + 1 # + ISN cloud
assert graph.site_count == len(topo.sites)
# every link references a node that exists in the graph
node_ids = {n.id for n in graph.nodes}
for link in graph.links:
assert link.source in node_ids
assert link.target in node_ids
def test_vpc_pair_links_present(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
graph = build_graph(topo)
vpc_links = [link for link in graph.links if link.kind == "vpc"]
assert len(vpc_links) >= 1 # LAB1 + LAB2 each have one vPC pair
class TestGraphCLI:
def test_cli_graph_writes_html_file(self, tmp_path, capsys) -> None:
out_file = tmp_path / "g.html"
rc = main(["graph", str(TOPOLOGY_YAML), "-o", str(out_file)])
out = capsys.readouterr().out
assert rc == 0
assert out_file.exists()
content = out_file.read_text(encoding="utf-8")
assert content.startswith("<html")
assert "<svg" in content
assert str(out_file) in out
def test_cli_graph_writes_svg_file(self, tmp_path) -> None:
out_file = tmp_path / "g.svg"
rc = main(["graph", str(TOPOLOGY_YAML), "-o", str(out_file)])
assert rc == 0
content = out_file.read_text(encoding="utf-8")
assert content.startswith("<svg")
def test_cli_graph_default_topology_and_output(self, tmp_path, monkeypatch) -> None:
monkeypatch.chdir(REPO_ROOT)
out_file = REPO_ROOT / "topology.html"
try:
rc = main(["graph", "-o", str(tmp_path / "default_out.html")])
assert rc == 0
finally:
if out_file.exists():
out_file.unlink()
def test_cli_graph_unknown_extension_errors(self, tmp_path, capsys) -> None:
out_file = tmp_path / "g.png"
rc = main(["graph", str(TOPOLOGY_YAML), "-o", str(out_file)])
err = capsys.readouterr().err
assert rc != 0
assert "ERROR" in err
def test_cli_graph_missing_topology_errors(self, tmp_path, capsys) -> None:
rc = main(["graph", "/nonexistent/topology.yaml", "-o", str(tmp_path / "g.html")])
err = capsys.readouterr().err
assert rc != 0
assert "ERROR" in err
+456
View File
@@ -0,0 +1,456 @@
"""LLDP/CDP fidelity feature tests (v0.12.0):
1. lldpInst/cdpInst materialized (one per switch node with adjacencies);
lldpIf/cdpIf materialized per adjacency interface.
2. Regression fix: deep-root subtree query
(`GET /api/mo/.../sys/lldp/inst.json?query-target=subtree&
target-subtree-class=lldpAdjEp`) returns totalCount>0 (was 0 before
lldpInst/lldpIf were materialized) plus /api/class/lldpInst.json and
/api/class/lldpIf.json both return >0.
3. lldpAdjEp bidirectional portIdV consistency (spine<->leaf link).
4. cdpAdjEp.devId == sysName, cdpAdjEp.portId non-empty.
5. Backward-compat: sysName + mgmtIp unchanged on both lldpAdjEp/cdpAdjEp.
6. Dynamic path: POSTing a fabricNodeIdentP (writes.py's
materialize_node_registration reaction) gets the same treatment.
7. CLI: `aci-sim lldp` text, --json, --cdp, and a graceful non-existent
node id.
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim import cli
from aci_sim.build import cabling, fabric, health_faults, interfaces, overlay, underlay
from aci_sim.build.orchestrator import build_site
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
TOPO_YAML = Path(__file__).parent.parent / "topology.yaml"
TOPO_PATH_STR = "topology.yaml" # run from project root, matches other test modules
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo() -> Topology:
return load_topology(TOPO_YAML)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def store(topo, site_a) -> MITStore:
s = MITStore()
fabric.build(topo, site_a, s)
cabling.build(topo, site_a, s)
underlay.build(topo, site_a, s)
overlay.build(topo, site_a, s)
interfaces.build(topo, site_a, s)
health_faults.build(topo, site_a, s)
return s
def _login(client: TestClient) -> None:
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200, resp.text
def _fresh_client(site_name: str = "LAB1") -> tuple[ApicSiteState, TestClient]:
topo_obj = load_topology(TOPO_PATH_STR)
site = topo_obj.site_by_name(site_name)
built_store = build_site(topo_obj, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo_obj,
store=built_store,
baseline=copy.deepcopy(built_store),
)
client = TestClient(make_apic_app(state))
_login(client)
return state, client
# ---------------------------------------------------------------------------
# 1. Containers materialized
# ---------------------------------------------------------------------------
def test_lldp_inst_materialized_per_switch_node(store, site_a):
"""One lldpInst per switch node that has at least one lldpAdjEp."""
node_ids_with_adj = set()
for mo in store.by_class("lldpAdjEp"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
if m:
node_ids_with_adj.add(int(m.group(1)))
lldp_inst_node_ids = set()
for mo in store.by_class("lldpInst"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
assert m, f"lldpInst dn missing node id: {mo.attrs['dn']}"
lldp_inst_node_ids.add(int(m.group(1)))
assert node_ids_with_adj, "expected at least one node with an lldpAdjEp"
assert lldp_inst_node_ids == node_ids_with_adj, (
f"lldpInst node set mismatch: adj nodes={node_ids_with_adj} "
f"lldpInst nodes={lldp_inst_node_ids}"
)
inst = store.by_class("lldpInst")[0]
assert inst.attrs["adminSt"] == "enabled"
assert inst.attrs["name"] == "default"
assert inst.attrs["holdTime"] == "120"
assert inst.attrs["initDelayTime"] == "2"
assert inst.attrs["txFreq"] == "30"
assert inst.attrs["ctrl"] == "rx-en,tx-en"
assert "sys-name" in inst.attrs["optTlvSel"]
def test_cdp_inst_materialized_per_switch_node(store, site_a):
node_ids_with_adj = set()
for mo in store.by_class("cdpAdjEp"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
if m:
node_ids_with_adj.add(int(m.group(1)))
cdp_inst_node_ids = set()
for mo in store.by_class("cdpInst"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
assert m
cdp_inst_node_ids.add(int(m.group(1)))
assert cdp_inst_node_ids == node_ids_with_adj
inst = store.by_class("cdpInst")[0]
assert inst.attrs["adminSt"] == "enabled"
assert inst.attrs["name"] == "default"
assert inst.attrs["holdIntvl"] == "180"
assert inst.attrs["txFreq"] == "60"
assert inst.attrs["ver"] == "v2"
def test_lldp_if_cdp_if_materialized_per_adjacency_interface(store, site_a):
"""One lldpIf/cdpIf per (node, local_port) that has an adjacency."""
expected_if_dns = set()
for mo in store.by_class("lldpAdjEp"):
# .../sys/lldp/inst/if-[{port}]/adj-1 -> .../sys/lldp/inst/if-[{port}]
if_dn = mo.attrs["dn"].rsplit("/", 1)[0]
expected_if_dns.add(if_dn)
actual_if_dns = {mo.attrs["dn"] for mo in store.by_class("lldpIf")}
assert actual_if_dns == expected_if_dns
lldp_if = store.by_class("lldpIf")[0]
assert lldp_if.attrs["adminRxSt"] == "enabled"
assert lldp_if.attrs["adminTxSt"] == "enabled"
assert lldp_if.attrs["operRxSt"] == "up"
assert lldp_if.attrs["operTxSt"] == "up"
assert lldp_if.attrs["wiring"] == "valid"
assert re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", lldp_if.attrs["mac"])
expected_cdp_if_dns = set()
for mo in store.by_class("cdpAdjEp"):
expected_cdp_if_dns.add(mo.attrs["dn"].rsplit("/", 1)[0])
actual_cdp_if_dns = {mo.attrs["dn"] for mo in store.by_class("cdpIf")}
assert actual_cdp_if_dns == expected_cdp_if_dns
cdp_if = store.by_class("cdpIf")[0]
assert cdp_if.attrs["adminSt"] == "enabled"
assert cdp_if.attrs["operSt"] == "up"
# ---------------------------------------------------------------------------
# 2. Regression fix — deep-root subtree query + class queries
# ---------------------------------------------------------------------------
def test_deep_root_subtree_query_returns_neighbors():
"""The bug this feature fixes: GET .../sys/lldp/inst.json?query-target=
subtree&target-subtree-class=lldpAdjEp used to return totalCount=0
because the root DN had no materialized MO (run_mo_query short-circuits
to ([], 0) when store.get(dn) is None)."""
_, client = _fresh_client("LAB1")
resp = client.get(
"/api/mo/topology/pod-1/node-101/sys/lldp/inst.json"
"?query-target=subtree&target-subtree-class=lldpAdjEp"
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert int(data["totalCount"]) > 0, f"expected totalCount>0, got {data}"
for item in data["imdata"]:
assert "lldpAdjEp" in item
def test_deep_root_cdp_subtree_query_returns_neighbors():
_, client = _fresh_client("LAB1")
resp = client.get(
"/api/mo/topology/pod-1/node-101/sys/cdp/inst.json"
"?query-target=subtree&target-subtree-class=cdpAdjEp"
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert int(data["totalCount"]) > 0, f"expected totalCount>0, got {data}"
def test_lldp_inst_class_query_nonzero():
_, client = _fresh_client("LAB1")
resp = client.get("/api/class/lldpInst.json")
assert resp.status_code == 200, resp.text
assert int(resp.json()["totalCount"]) > 0
def test_lldp_if_class_query_nonzero():
_, client = _fresh_client("LAB1")
resp = client.get("/api/class/lldpIf.json")
assert resp.status_code == 200, resp.text
assert int(resp.json()["totalCount"]) > 0
def test_cdp_inst_and_if_class_query_nonzero():
_, client = _fresh_client("LAB1")
resp_inst = client.get("/api/class/cdpInst.json")
assert resp_inst.status_code == 200, resp_inst.text
assert int(resp_inst.json()["totalCount"]) > 0
resp_if = client.get("/api/class/cdpIf.json")
assert resp_if.status_code == 200, resp_if.text
assert int(resp_if.json()["totalCount"]) > 0
# ---------------------------------------------------------------------------
# 3. Bidirectional portIdV consistency (spine<->leaf link)
# ---------------------------------------------------------------------------
def test_lldp_adjep_bidirectional_portidv_consistency(store, site_a):
"""For a spine<->leaf link, the leaf-side adj's portIdV must equal the
spine's own local port, and vice-versa."""
from aci_sim.build.cabling import cabling_links
pod = site_a.pod
checked = 0
for n1, s1, p1, n2, s2, p2 in cabling_links(site_a):
port1 = f"eth{s1}/{p1}"
port2 = f"eth{s2}/{p2}"
adj_n1 = store.get(f"topology/pod-{pod}/node-{n1}/sys/lldp/inst/if-[{port1}]/adj-1")
adj_n2 = store.get(f"topology/pod-{pod}/node-{n2}/sys/lldp/inst/if-[{port2}]/adj-1")
assert adj_n1 is not None
assert adj_n2 is not None
# n1 sees n2 on port1; n2's own port is port2 -> n1's portIdV == port2
assert adj_n1.attrs["portIdV"] == port2
# n2 sees n1 on port2; n1's own port is port1 -> n2's portIdV == port1
assert adj_n2.attrs["portIdV"] == port1
checked += 1
assert checked > 0, "no cabling links found to check"
# ---------------------------------------------------------------------------
# 4. cdpAdjEp devId/portId
# ---------------------------------------------------------------------------
def test_cdp_adjep_devid_matches_sysname_and_portid_nonempty(store, site_a):
cdp_mos = store.by_class("cdpAdjEp")
assert cdp_mos, "expected at least one cdpAdjEp"
for mo in cdp_mos:
assert mo.attrs["devId"] == mo.attrs["sysName"]
assert mo.attrs["portId"], f"empty portId on {mo.attrs['dn']}"
# ---------------------------------------------------------------------------
# 5. Backward-compat: sysName + mgmtIp unchanged
# ---------------------------------------------------------------------------
def test_backward_compat_sysname_and_mgmtip_present(store, site_a):
for cls in ("lldpAdjEp", "cdpAdjEp"):
mos = store.by_class(cls)
assert mos, f"expected at least one {cls}"
for mo in mos:
assert mo.attrs.get("sysName"), f"{cls} missing sysName: {mo.attrs['dn']}"
assert mo.attrs.get("mgmtIp"), f"{cls} missing mgmtIp: {mo.attrs['dn']}"
def test_backward_compat_matches_cabling_graph(store, site_a):
"""Same assertion test_build_fabric.py's test_lldp_cdp_matches_cabling
already makes (sysName set matches the cabling graph) re-verified here
to prove the enrichment didn't alter the pre-existing neighbor set."""
from aci_sim.build.cabling import cabling_links
node_map = {n.id: n for n in site_a.all_nodes()}
expected: set[tuple[int, str]] = set()
for n1, _s1, _p1, n2, _s2, _p2 in cabling_links(site_a):
expected.add((n1, node_map[n2].name))
expected.add((n2, node_map[n1].name))
for cid in range(1, site_a.controllers + 1):
for leaf in site_a.leaf_nodes()[:2]:
expected.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
actual: set[tuple[int, str]] = set()
for mo in store.by_class("lldpAdjEp"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
if m:
actual.add((int(m.group(1)), mo.attrs.get("sysName", "")))
assert expected == actual
# ---------------------------------------------------------------------------
# 6. Dynamic path — fabricNodeIdentP registration reaction
# ---------------------------------------------------------------------------
def test_dynamic_node_registration_materializes_lldp_containers():
"""POSTing a fabricNodeIdentP (writes.py's materialize_node_registration
reaction) must produce the same lldpInst/lldpIf materialization the
boot-time builders produce, so its subtree query also works."""
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-920.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-920",
"name": "leaf-920",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200, resp.text
node_dn = "topology/pod-1/node-920"
inst_resp = client.get(f"/api/mo/{node_dn}/sys/lldp/inst.json")
assert inst_resp.status_code == 200, inst_resp.text
assert int(inst_resp.json()["totalCount"]) > 0, "new node's lldpInst missing"
subtree_resp = client.get(
f"/api/mo/{node_dn}/sys/lldp/inst.json"
"?query-target=subtree&target-subtree-class=lldpAdjEp"
)
assert subtree_resp.status_code == 200, subtree_resp.text
subtree_data = subtree_resp.json()
assert int(subtree_data["totalCount"]) > 0, (
f"new node's lldpAdjEp subtree query returned 0: {subtree_data}"
)
# Also verify the enriched fields landed on the dynamic path (not just
# boot-time build) and the spine-side reciprocal adjacency was created too.
adj = subtree_data["imdata"][0]["lldpAdjEp"]["attributes"]
assert adj["sysName"]
assert adj["mgmtIp"]
assert adj["portIdV"]
assert adj["chassisIdV"]
links_resp = client.get("/api/class/fabricLink.json")
node_links = [
item["fabricLink"]["attributes"]
for item in links_resp.json()["imdata"]
if "920" in (item["fabricLink"]["attributes"].get("n1"), item["fabricLink"]["attributes"].get("n2"))
]
assert node_links, "expected a fabricLink for the newly registered node"
def test_dynamic_node_registration_cdp_also_materialized():
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-921.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-921",
"name": "leaf-921",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200, resp.text
node_dn = "topology/pod-1/node-921"
resp2 = client.get(
f"/api/mo/{node_dn}/sys/cdp/inst.json"
"?query-target=subtree&target-subtree-class=cdpAdjEp"
)
assert resp2.status_code == 200, resp2.text
data = resp2.json()
assert int(data["totalCount"]) > 0
adj = data["imdata"][0]["cdpAdjEp"]["attributes"]
assert adj["devId"] == adj["sysName"]
assert adj["portId"]
# ---------------------------------------------------------------------------
# 7. CLI: aci-sim lldp
# ---------------------------------------------------------------------------
def test_cli_lldp_text_shows_neighbor(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101"])
assert rc == 0
out = capsys.readouterr().out
assert "node-101" in out
assert "LAB1-ACI-SP01" in out or "LAB1-ACI-SP02" in out
def test_cli_lldp_json(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101", "--json"])
assert rc == 0
out = capsys.readouterr().out
data = json.loads(out)
assert isinstance(data, list)
assert len(data) > 0
row = data[0]
assert "local_port" in row
assert "neighbor" in row
assert "neighbor_port" in row
assert "mgmt_ip" in row
assert "platform" in row
def test_cli_lldp_cdp_flag(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101", "--cdp", "--json"])
assert rc == 0
out = capsys.readouterr().out
data = json.loads(out)
assert len(data) > 0
# cdp platId values are real model/platform strings (e.g. APIC-SERVER-M3
# or N9K-C9332C), not the lldp sysDesc free-text form.
assert any(row["platform"] for row in data)
def test_cli_lldp_nonexistent_node_is_graceful(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "999999"])
assert rc == 0
out = capsys.readouterr().out
assert "No neighbors found" in out
def test_cli_lldp_nonexistent_node_json_is_empty_list(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "999999", "--json"])
assert rc == 0
out = capsys.readouterr().out
data = json.loads(out)
assert data == []
def test_cli_lldp_missing_topology_file_returns_1(tmp_path, capsys):
rc = cli.main(["lldp", str(tmp_path / "does-not-exist.yaml")])
assert rc == 1
+330
View File
@@ -0,0 +1,330 @@
"""Tests for mit/ — dn, mo, store."""
import copy
import pytest
from aci_sim.mit.dn import (
dn_split, parent_dn, rn_of, name_from_dn, pod_from_dn, dn_class_hint,
)
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
# ---------------------------------------------------------------------------
# dn.py
# ---------------------------------------------------------------------------
class TestDnSplit:
def test_simple_three_parts(self):
assert dn_split("uni/tn-T/BD-b") == ["uni", "tn-T", "BD-b"]
def test_subnet_bracket(self):
# CONTRACT example: slash inside brackets must NOT split
assert dn_split("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == [
"uni", "tn-T", "BD-b", "subnet-[10.0.0.1/24]"
]
def test_pathep_bracket(self):
# CONTRACT example
assert dn_split("uni/tn-T/pathep-[eth1/9]") == [
"uni", "tn-T", "pathep-[eth1/9]"
]
def test_rsdomatt_bracket(self):
# CONTRACT example: bracket value itself contains a slash
assert dn_split("uni/phys-X/rsdomAtt-[uni/phys-X]") == [
"uni", "phys-X", "rsdomAtt-[uni/phys-X]"
]
def test_nested_brackets(self):
# e.g. rslldpIfAtt-[pathep-[eth1/9]] — nested brackets, one slash inside
parts = dn_split("uni/infra/rslldpIfAtt-[pathep-[eth1/9]]")
assert parts == ["uni", "infra", "rslldpIfAtt-[pathep-[eth1/9]]"]
def test_single_component(self):
assert dn_split("uni") == ["uni"]
def test_empty(self):
assert dn_split("") == []
def test_no_brackets(self):
assert dn_split("a/b/c/d") == ["a", "b", "c", "d"]
class TestParentDn:
def test_three_deep(self):
assert parent_dn("uni/tn-T/BD-b") == "uni/tn-T"
def test_top_level(self):
assert parent_dn("uni") == ""
def test_bracket_rn(self):
# parent of a bracket RN is the surrounding path
assert parent_dn("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == "uni/tn-T/BD-b"
class TestRnOf:
def test_basic(self):
assert rn_of("uni/tn-T/BD-b") == "BD-b"
def test_bracket(self):
assert rn_of("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == "subnet-[10.0.0.1/24]"
def test_single(self):
assert rn_of("uni") == "uni"
class TestNameFromDn:
def test_plain(self):
assert name_from_dn("uni/tn-T") == "T"
def test_bd(self):
assert name_from_dn("uni/tn-T/BD-mybd") == "mybd"
def test_subnet_bracket(self):
assert name_from_dn("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == "10.0.0.1/24"
def test_pathep_bracket(self):
# pathep-[eth1/9] → eth1/9
assert name_from_dn("topology/pod-1/node-101/local/svc-x/pathep-[eth1/9]") == "eth1/9"
def test_rsdomatt_bracket(self):
assert name_from_dn("uni/phys-X/rsdomAtt-[uni/phys-X]") == "uni/phys-X"
def test_no_dash(self):
assert name_from_dn("uni") == "uni"
class TestPodFromDn:
def test_pod_1(self):
assert pod_from_dn("topology/pod-1/node-101") == "1"
def test_pod_2(self):
assert pod_from_dn("topology/pod-2/node-201/sys") == "2"
def test_no_pod_default_1(self):
assert pod_from_dn("uni/tn-T") == "1"
def test_pod_at_end(self):
assert pod_from_dn("topology/pod-3") == "3"
def test_pod_prefix_dn(self):
assert pod_from_dn("topology/pod-10/node-1001/sys/health") == "10"
class TestDnClassHint:
def test_tn(self):
assert dn_class_hint("tn-T") == "fvTenant"
def test_uni(self):
assert dn_class_hint("uni") == "polUni"
def test_unknown(self):
assert dn_class_hint("foo-bar") is None
# ---------------------------------------------------------------------------
# mo.py
# ---------------------------------------------------------------------------
class TestMO:
def test_construction(self):
mo = MO("fvTenant", dn="uni/tn-T", name="T")
assert mo.class_name == "fvTenant"
assert mo.dn == "uni/tn-T"
assert mo.attrs["name"] == "T"
def test_add_child(self):
parent = MO("fvTenant", dn="uni/tn-T")
child = MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1")
parent.add_child(child)
assert parent.children == [child]
def test_flat_depth_first(self):
root = MO("fvTenant", dn="uni/tn-T")
c1 = MO("fvCtx", dn="uni/tn-T/ctx-v1")
c2 = MO("fvBD", dn="uni/tn-T/BD-b")
c2a = MO("fvSubnet", dn="uni/tn-T/BD-b/subnet-[10.0.0.1/24]")
root.add_child(c1)
root.add_child(c2)
c2.add_child(c2a)
assert root.flat() == [root, c1, c2, c2a]
def test_to_imdata_no_children(self):
mo = MO("fvTenant", dn="uni/tn-T", name="T")
d = mo.to_imdata()
assert list(d.keys()) == ["fvTenant"]
assert "children" not in d["fvTenant"]
assert d["fvTenant"]["attributes"]["dn"] == "uni/tn-T"
def test_to_imdata_with_children(self):
parent = MO("fvTenant", dn="uni/tn-T")
child = MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1")
parent.add_child(child)
d = parent.to_imdata()
assert "children" in d["fvTenant"]
child_d = d["fvTenant"]["children"][0]
assert "fvCtx" in child_d
assert child_d["fvCtx"]["attributes"]["name"] == "v1"
def test_to_imdata_attrs_are_copy(self):
mo = MO("fvTenant", dn="uni/tn-T", name="T")
d = mo.to_imdata()
d["fvTenant"]["attributes"]["name"] = "mutated"
assert mo.attrs["name"] == "T" # original unchanged
# ---------------------------------------------------------------------------
# store.py
# ---------------------------------------------------------------------------
def _flat_store() -> MITStore:
"""Return a store populated with a small tree (all MOs added individually)."""
s = MITStore()
s.add(MO("fvTenant", dn="uni/tn-T", name="T"))
s.add(MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1"))
s.add(MO("fvBD", dn="uni/tn-T/BD-b", name="b"))
s.add(MO("fvSubnet", dn="uni/tn-T/BD-b/subnet-[10.0.0.1/24]", ip="10.0.0.1/24"))
return s
class TestMITStoreGet:
def test_get_existing(self):
s = _flat_store()
mo = s.get("uni/tn-T/ctx-v1")
assert mo is not None
assert mo.class_name == "fvCtx"
def test_get_missing(self):
assert _flat_store().get("uni/tn-X") is None
def test_get_bracket_dn(self):
s = _flat_store()
mo = s.get("uni/tn-T/BD-b/subnet-[10.0.0.1/24]")
assert mo is not None
assert mo.attrs["ip"] == "10.0.0.1/24"
class TestMITStoreChildren:
def test_children_of_tenant(self):
s = _flat_store()
kids = s.children("uni/tn-T")
dns = {mo.dn for mo in kids}
assert "uni/tn-T/ctx-v1" in dns
assert "uni/tn-T/BD-b" in dns
def test_children_class_filter(self):
s = _flat_store()
ctxs = s.children("uni/tn-T", classes={"fvCtx"})
assert len(ctxs) == 1
assert ctxs[0].class_name == "fvCtx"
def test_children_leaf_node_empty(self):
s = _flat_store()
assert s.children("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == []
class TestMITStoreSubtree:
def test_all_descendants(self):
s = _flat_store()
desc = s.subtree("uni/tn-T")
dns = {mo.dn for mo in desc}
assert "uni/tn-T/ctx-v1" in dns
assert "uni/tn-T/BD-b" in dns
assert "uni/tn-T/BD-b/subnet-[10.0.0.1/24]" in dns
# Root itself must NOT be included
assert "uni/tn-T" not in dns
def test_class_filter(self):
s = _flat_store()
subs = s.subtree("uni/tn-T", classes={"fvSubnet"})
assert len(subs) == 1
assert subs[0].class_name == "fvSubnet"
def test_class_filter_traverses_full_tree(self):
"""A class filter must still find deeply-nested matches."""
s = _flat_store()
# fvSubnet is 3 levels below uni/tn-T; asking for it from the top works
subs = s.subtree("uni/tn-T", classes={"fvSubnet"})
assert len(subs) == 1
def test_empty_subtree(self):
s = _flat_store()
assert s.subtree("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == []
class TestMITStoreByClass:
def test_by_class_present(self):
s = _flat_store()
bds = s.by_class("fvBD")
assert len(bds) == 1
assert bds[0].dn == "uni/tn-T/BD-b"
def test_by_class_absent(self):
assert _flat_store().by_class("fvAp") == []
class TestMITStoreAll:
def test_insertion_order(self):
s = _flat_store()
dns = [mo.dn for mo in s.all()]
assert dns == [
"uni/tn-T",
"uni/tn-T/ctx-v1",
"uni/tn-T/BD-b",
"uni/tn-T/BD-b/subnet-[10.0.0.1/24]",
]
class TestMITStoreUpsert:
def test_upsert_merges_attrs(self):
s = _flat_store()
s.upsert(MO("fvTenant", dn="uni/tn-T", name="T", descr="new-desc"))
mo = s.get("uni/tn-T")
assert mo.attrs["descr"] == "new-desc"
assert mo.attrs["name"] == "T" # original attr preserved
def test_upsert_inserts_new(self):
s = _flat_store()
s.upsert(MO("fvTenant", dn="uni/tn-T2", name="T2"))
assert s.get("uni/tn-T2") is not None
def test_upsert_recurses_children(self):
s = MITStore()
root = MO("fvTenant", dn="uni/tn-T")
child = MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1")
root.add_child(child)
s.upsert(root)
assert s.get("uni/tn-T") is not None
assert s.get("uni/tn-T/ctx-v1") is not None
def test_upsert_deleted_removes_dn(self):
s = _flat_store()
s.upsert(MO("fvBD", dn="uni/tn-T/BD-b", status="deleted"))
assert s.get("uni/tn-T/BD-b") is None
def test_upsert_deleted_removes_subtree(self):
s = _flat_store()
s.upsert(MO("fvBD", dn="uni/tn-T/BD-b", status="deleted"))
assert s.get("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") is None
def test_upsert_deleted_keeps_siblings(self):
s = _flat_store()
s.upsert(MO("fvBD", dn="uni/tn-T/BD-b", status="deleted"))
assert s.get("uni/tn-T") is not None
assert s.get("uni/tn-T/ctx-v1") is not None
class TestMITStoreDeepCopy:
def test_deepcopy_isolates(self):
s = _flat_store()
s2 = copy.deepcopy(s)
s.upsert(MO("fvTenant", dn="uni/tn-T", name="mutated"))
assert s2.get("uni/tn-T").attrs["name"] == "T"
def test_deepcopy_is_complete(self):
s = _flat_store()
s2 = copy.deepcopy(s)
assert {mo.dn for mo in s2.all()} == {mo.dn for mo in s.all()}
+666
View File
@@ -0,0 +1,666 @@
"""Phase 6 NDO tests — tests/test_ndo.py
All tests use FastAPI's synchronous TestClient (no real network).
The module-scoped client/state is shared across the whole module so that the
POST-schema write test can be followed by a GET read-back in the same session.
Key assertions:
- every §7 endpoint responds with the right shape
- VRF/BD/EPG/contract names in the schema exactly match the topology
- DHCP relay UUID resolves via the tenantPolicy template
- POST /schemas stores the schema; subsequent GET /schemas shows it
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import (
DHCP_RELAY_UUID,
TENANT_POLICY_TEMPLATE_ID,
build_ndo_model,
)
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
# ---------------------------------------------------------------------------
# Module-scoped fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPOLOGY_YAML)
@pytest.fixture(scope="module")
def client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def token(client):
resp = client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
data = resp.json()
assert "token" in data, f"Login response missing token: {data}"
return data["token"]
# ---------------------------------------------------------------------------
# Auth + platform version
# ---------------------------------------------------------------------------
def test_login(client):
resp = client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
data = resp.json()
assert "token" in data
assert data["token"], "token must be non-empty"
def test_platform_version(client, token):
resp = client.get(
"/api/v1/platform/version",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["version"] == "4.2.2"
# ---------------------------------------------------------------------------
# Sites
# ---------------------------------------------------------------------------
def test_get_sites_matches_topology(client, topo, token):
"""PR-10: NDO site names must be the fabric name (e.g. "LAB1-IT-ACI"),
not the short topology-internal site name ("LAB1") verified against a
real-gear cisco.mso.mso_tenant run where only the fabric-name form was
accepted (see docs/CONTRACT.md §7).
"""
resp = client.get(
"/mso/api/v1/sites",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
sites = resp.json()["sites"]
assert len(sites) == len(topo.sites)
site_names = {s["name"] for s in sites}
for topo_site in topo.sites:
assert (topo_site.fabric_name or topo_site.name) in site_names
for s in sites:
assert "id" in s
assert s["platform"] == "APIC"
assert isinstance(s["urls"], list) and len(s["urls"]) > 0
# URL must embed the site's apic_host
topo_site = next(
ts for ts in topo.sites if (ts.fabric_name or ts.name) == s["name"]
)
assert topo_site.apic_host in s["urls"][0]
# ---------------------------------------------------------------------------
# Tenants
# ---------------------------------------------------------------------------
def test_get_tenants_matches_topology(client, topo, token):
resp = client.get(
"/mso/api/v1/tenants",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
tenants = resp.json()["tenants"]
assert len(tenants) == len(topo.tenants)
ndo_names = {t["name"] for t in tenants}
for tt in topo.tenants:
assert tt.name in ndo_names
for t in tenants:
assert "id" in t
assert "siteAssociations" in t
assert isinstance(t["siteAssociations"], list)
def test_tenant_site_associations(client, topo, token):
"""Stretched tenant must be associated with the correct site IDs."""
sites_resp = client.get("/mso/api/v1/sites",
headers={"Authorization": f"Bearer {token}"})
# PR-10: /mso/api/v1/sites now reports fabric names, not the topology's
# short site names — join by fabric_name (falling back to the short
# name) to map short-name (as used in tenant.sites) → site id.
fabric_to_short = {
(ts.fabric_name or ts.name): ts.name for ts in topo.sites
}
site_id_map = {
fabric_to_short.get(s["name"], s["name"]): s["id"]
for s in sites_resp.json()["sites"]
}
tenants_resp = client.get("/mso/api/v1/tenants",
headers={"Authorization": f"Bearer {token}"})
ndo_tenants = {t["name"]: t for t in tenants_resp.json()["tenants"]}
for tt in topo.tenants:
ndo_t = ndo_tenants[tt.name]
ndo_site_ids = {sa["siteId"] for sa in ndo_t["siteAssociations"]}
expected_ids = {site_id_map[s] for s in tt.sites if s in site_id_map}
assert ndo_site_ids == expected_ids, (
f"Tenant {tt.name}: site-id mismatch ndo={ndo_site_ids} "
f"expected={expected_ids}"
)
# ---------------------------------------------------------------------------
# Schemas list
# ---------------------------------------------------------------------------
def test_get_schemas_list(client, topo, token):
resp = client.get(
"/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
schemas = resp.json()["schemas"]
# At least one schema per tenant
assert len(schemas) >= len(topo.tenants)
for s in schemas:
assert "id" in s
assert "displayName" in s
assert "name" in s
assert isinstance(s["templates"], list)
# ---------------------------------------------------------------------------
# Schema detail — cross-plane consistency
# ---------------------------------------------------------------------------
def _get_corp_schema_detail(client, token):
"""Helper: fetch the MS-TN1 schema detail (stretched tenant)."""
schemas = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
corp_summary = next(s for s in schemas if "MS-TN1" in s["name"])
resp = client.get(
f"/mso/api/v1/schemas/{corp_summary['id']}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
return resp.json(), corp_summary["id"]
def test_schema_detail_vrf_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
assert len(detail["templates"]) >= 1
tmpl = detail["templates"][0]
topo_vrf_names = {v.name for v in corp_tenant.vrfs}
ndo_vrf_names = {v["name"] for v in tmpl["vrfs"]}
assert topo_vrf_names == ndo_vrf_names, (
f"VRF name mismatch: topo={topo_vrf_names} ndo={ndo_vrf_names}"
)
def test_schema_detail_bd_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_bd_names = {b.name for b in corp_tenant.bds}
ndo_bd_names = {b["name"] for b in tmpl["bds"]}
assert topo_bd_names == ndo_bd_names, (
f"BD name mismatch: topo={topo_bd_names} ndo={ndo_bd_names}"
)
def test_schema_detail_bd_vrf_refs(client, topo, token):
"""Each BD's vrfRef must end with the correct VRF name."""
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_bd_vrf = {b.name: b.vrf for b in corp_tenant.bds}
for bd in tmpl["bds"]:
expected_vrf = topo_bd_vrf[bd["name"]]
assert bd["vrfRef"].endswith(f"/vrfs/{expected_vrf}"), (
f"BD {bd['name']}: vrfRef {bd['vrfRef']!r} "
f"should end /vrfs/{expected_vrf}"
)
assert bd["vrfRef"].startswith("/vrfs/")
def test_schema_detail_epg_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_epg_names: set[str] = {
epg.name for ap in corp_tenant.aps for epg in ap.epgs
}
ndo_epg_names: set[str] = {
epg["name"]
for anp in tmpl["anps"]
for epg in anp["epgs"]
}
assert topo_epg_names == ndo_epg_names, (
f"EPG name mismatch: topo={topo_epg_names} ndo={ndo_epg_names}"
)
def test_schema_detail_contract_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_contract_names = {c.name for c in corp_tenant.contracts}
ndo_contract_names = {c["name"] for c in tmpl["contracts"]}
assert topo_contract_names == ndo_contract_names
def test_schema_detail_site_associations_match_topology(client, topo, token):
"""Stretched schema sites must exactly match the tenant's site IDs."""
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
sites_resp = client.get("/mso/api/v1/sites",
headers={"Authorization": f"Bearer {token}"})
# PR-10: join by fabric_name since /mso/api/v1/sites now reports fabric
# names — see test_tenant_site_associations above for the same pattern.
fabric_to_short = {
(ts.fabric_name or ts.name): ts.name for ts in topo.sites
}
site_id_map = {
fabric_to_short.get(s["name"], s["name"]): s["id"]
for s in sites_resp.json()["sites"]
}
expected_site_ids = {site_id_map[s] for s in corp_tenant.sites}
schema_site_ids = {sa["siteId"] for sa in detail.get("sites", [])}
assert expected_site_ids == schema_site_ids, (
f"Schema site-id mismatch: expected={expected_site_ids} "
f"got={schema_site_ids}"
)
def test_schema_detail_epg_bd_refs(client, topo, token):
"""Each EPG's bdRef must start /bds/ and match a real BD name."""
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_bd_names = {b.name for b in corp_tenant.bds}
for anp in tmpl["anps"]:
for epg in anp["epgs"]:
bd_ref = epg["bdRef"]
assert bd_ref.startswith("/bds/"), (
f"EPG {epg['name']} bdRef {bd_ref!r} must start /bds/"
)
bd_name = bd_ref.split("/")[-1]
assert bd_name in topo_bd_names, (
f"EPG {epg['name']} bdRef references unknown BD {bd_name!r}"
)
def test_schema_detail_contract_filter_refs(client, topo, token):
"""Each contract's filterRelationships must reference real filter names."""
detail, _ = _get_corp_schema_detail(client, token)
tmpl = detail["templates"][0]
filter_names = {f["name"] for f in tmpl["filters"]}
for c in tmpl["contracts"]:
for fr in c.get("filterRelationships", []):
f_ref = fr["filterRef"]
assert f_ref.startswith("/filters/"), (
f"Contract {c['name']} filterRef {f_ref!r} must start /filters/"
)
f_name = f_ref.split("/")[-1]
assert f_name in filter_names, (
f"Contract {c['name']} references unknown filter {f_name!r}"
)
def test_schema_detail_external_epgs(client, topo, token):
"""External EPGs must have l3outRef, vrfRef, and subnets."""
detail, _ = _get_corp_schema_detail(client, token)
tmpl = detail["templates"][0]
# Corp has two l3outs each with one ext_epg → expect 2 externalEpgs
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
expected_count = sum(
len(l3out.ext_epgs) for l3out in corp_tenant.l3outs
)
assert len(tmpl["externalEpgs"]) == expected_count
for xepg in tmpl["externalEpgs"]:
assert xepg["vrfRef"].startswith("/vrfs/")
assert xepg["l3outRef"].startswith("/l3outs/")
assert isinstance(xepg["subnets"], list)
# ---------------------------------------------------------------------------
# Fabric connectivity
# ---------------------------------------------------------------------------
def test_fabric_connectivity(client, topo, token):
resp = client.get(
"/mso/api/v1/sites/fabric-connectivity",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
# Accept either a list or {"sites": [...]}
fc_sites = (
data
if isinstance(data, list)
else data.get("sites", data.get("fabricConnectivity", []))
)
assert len(fc_sites) == len(topo.sites), (
f"Expected {len(topo.sites)} sites in fabric-connectivity, got {len(fc_sites)}"
)
for fc in fc_sites:
# At least one status field must be present
has_status = fc.get("status") or fc.get("connectivityStatus")
assert has_status, f"fabric-connectivity entry missing status: {fc}"
# ---------------------------------------------------------------------------
# Policy states
# ---------------------------------------------------------------------------
def test_policy_states_per_schema(client, token):
schemas = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
for schema_summary in schemas:
schema_id = schema_summary["id"]
resp = client.get(
f"/mso/api/v1/schemas/{schema_id}/policy-states",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
states = (
data
if isinstance(data, list)
else data.get("policyStates", data.get("states", []))
)
assert len(states) > 0, (
f"Schema {schema_id} returned empty policy-states"
)
for state in states:
assert "status" in state, f"policy-state entry missing 'status': {state}"
# ---------------------------------------------------------------------------
# Template summaries + tenantPolicy DHCP
# ---------------------------------------------------------------------------
def test_template_summaries(client, token):
resp = client.get(
"/mso/api/v1/templates/summaries",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
# Normalise: handle both list and {"templates": [...]}
summaries = data if isinstance(data, list) else data.get("templates", [])
assert len(summaries) > 0
types = [s.get("templateType") for s in summaries]
assert "tenantPolicy" in types, (
f"No tenantPolicy entry in template summaries: {types}"
)
for s in summaries:
assert "templateId" in s
assert "templateType" in s
def test_tenant_policy_template_dhcp_relay(client, token):
resp = client.get(
f"/mso/api/v1/templates/{TENANT_POLICY_TEMPLATE_ID}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
inner = (
resp.json()
.get("tenantPolicyTemplate", {})
.get("template", {})
)
relay = inner.get("dhcpRelayPolicies", [])
assert len(relay) > 0, "tenantPolicy template must have dhcpRelayPolicies"
for p in relay:
assert "uuid" in p
assert "name" in p
assert relay[0]["uuid"] == DHCP_RELAY_UUID
def test_bd_dhcp_labels_resolve_via_tenant_policy(client, token):
"""Every BD dhcpLabel ref must resolve in the tenantPolicy DHCP map."""
# Build uuid→name map exactly as ndo_connector.get_dhcp_policy_map() does
summaries_resp = client.get(
"/mso/api/v1/templates/summaries",
headers={"Authorization": f"Bearer {token}"},
)
summaries = summaries_resp.json()
if isinstance(summaries, dict):
summaries = summaries.get("templates", [])
dhcp_map: dict[str, str] = {}
for s in summaries:
if s.get("templateType") != "tenantPolicy":
continue
tmpl_resp = client.get(
f"/mso/api/v1/templates/{s['templateId']}",
headers={"Authorization": f"Bearer {token}"},
)
inner = (
tmpl_resp.json()
.get("tenantPolicyTemplate", {})
.get("template", {})
)
for p in inner.get("dhcpRelayPolicies", []):
if p.get("uuid") and p.get("name"):
dhcp_map[p["uuid"]] = p["name"]
for p in inner.get("dhcpOptionPolicies", []):
if p.get("uuid") and p.get("name"):
dhcp_map[p["uuid"]] = p["name"]
assert dhcp_map, "DHCP policy map must not be empty"
# Verify every BD dhcpLabel ref is resolvable
schemas = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
found_any_label = False
for s in schemas:
detail = client.get(
f"/mso/api/v1/schemas/{s['id']}",
headers={"Authorization": f"Bearer {token}"},
).json()
for tmpl in detail.get("templates", []):
for bd in tmpl.get("bds", []):
for lbl in bd.get("dhcpLabels", []):
ref = lbl.get("ref", "")
assert ref in dhcp_map, (
f"BD {bd['name']} dhcpLabel ref {ref!r} "
f"not in DHCP policy map {list(dhcp_map)}"
)
found_any_label = True
assert found_any_label, "No BD with dhcpLabels found — test is vacuous"
# ---------------------------------------------------------------------------
# Audit records (both endpoints)
# ---------------------------------------------------------------------------
def test_audit_records_nonempty_primary(client, token):
resp = client.get(
"/api/v1/audit-records?count=50",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
records = (
data
if isinstance(data, list)
else data.get("auditRecords", data.get("records", []))
)
assert len(records) > 0, "Primary audit-records endpoint returned empty list"
def test_audit_records_fallback_endpoint(client, token):
resp = client.get(
"/mso/api/v1/audit-records?count=20",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
records = (
data
if isinstance(data, list)
else data.get("auditRecords", data.get("records", []))
)
assert len(records) > 0
def test_audit_record_fields(client, token):
resp = client.get("/api/v1/audit-records",
headers={"Authorization": f"Bearer {token}"})
records = resp.json().get("auditRecords", [])
for rec in records:
# ndo_audit_log reads these fields; all must be present
assert "timestamp" in rec or "date" in rec, f"No timestamp in {rec}"
assert "user" in rec or "userName" in rec, f"No user in {rec}"
assert "action" in rec or "type" in rec, f"No action in {rec}"
# ---------------------------------------------------------------------------
# POST schemas → write + read-back
# ---------------------------------------------------------------------------
def test_post_schema_and_readback(client, token):
"""POST a new schema and verify it appears in the schema list."""
new_schema = {
"displayName": "test-post-schema",
"name": "test-post-schema",
"templates": [
{
"name": "test-tmpl",
"templateType": "application",
"vrfs": [],
"bds": [],
"anps": [],
"contracts": [],
"filters": [],
"externalEpgs": [],
}
],
}
# Create
resp = client.post(
"/mso/api/v1/schemas",
json=new_schema,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
created = resp.json()
assert "id" in created, f"POST /schemas response missing id: {created}"
schema_id = created["id"]
assert created.get("status") == "active"
# Read back from list
list_resp = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"})
ids_in_list = {s["id"] for s in list_resp.json()["schemas"]}
assert schema_id in ids_in_list, (
f"New schema {schema_id!r} not in GET /schemas after POST"
)
# Read back full detail
detail_resp = client.get(
f"/mso/api/v1/schemas/{schema_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert detail_resp.status_code == 200
# ---------------------------------------------------------------------------
# POST deploy
# ---------------------------------------------------------------------------
def test_post_deploy(client, token):
resp = client.post(
"/mso/api/v1/deploy",
json={"schemaId": "schema-123", "templateName": "tmpl"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "id" in data
assert data["status"] == "in-progress"
# ---------------------------------------------------------------------------
# Auth leniency — no bearer token still works
# ---------------------------------------------------------------------------
def test_auth_lenient_no_token(client):
"""Endpoints must respond even without a Bearer token (lenient auth)."""
assert client.get("/mso/api/v1/sites").status_code == 200
assert client.get("/mso/api/v1/tenants").status_code == 200
assert client.get("/mso/api/v1/schemas").status_code == 200
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
def test_unknown_schema_returns_404(client, token):
resp = client.get(
"/mso/api/v1/schemas/does-not-exist-xyzxyz",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
def test_unknown_template_returns_404(client, token):
resp = client.get(
"/mso/api/v1/templates/no-such-template-id",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
+276
View File
@@ -0,0 +1,276 @@
"""Tests for the OOB management gateway PR — a real MO instead of a discarded value.
Today, every node gets `topSystem.oobMgmtAddr` (the OOB IP) but no gateway is
modeled anywhere. Real APIC carries the OOB address + gateway in the special
`mgmt` tenant: `uni/tn-mgmt/mgmtp-default/oob-default` (a `mgmtOoB` EPG) with
one `mgmtRsOoBStNode` child per node, carrying `addr` (node's OOB IP + mask)
and `gw` (the OOB default gateway). Prior to this PR, `aci-sim init`'s wizard
COLLECTED this same gateway value but discarded it (no schema field, no MO,
just an informational summary line) see cli.py's `run_wizard` history.
Covers:
- `Site.oob_gateway`: defaults to `None`; IP-format validation (valid IPs
accepted, garbage rejected).
- `build/mgmt.py`: mgmtRsOoBStNode built per node (switches + controllers),
with `tDn`/`addr`/`gw` matching the real DN/address/gateway; addr mask
derived from `fabric.oob_subnet` if set, else defaults to /24; `gw` empty
when `site.oob_gateway` is unset (no invented default).
- wizard (`run_wizard`): the answered per-site OOB gateway is written into
`topology_dict["sites"][i]["oob_gateway"]` (both `--defaults` and
`--answers`-driven runs), not discarded.
- Backward compatibility: the repo's own topology.yaml (no `oob_gateway`
set anywhere) still builds without error, still produces exactly the
same `fabricNode`/`topSystem` MOs as before, and now ALSO builds the new
mgmt-tenant OOB scaffolding (additive new MOs, no changed existing
ones) with an empty `gw` on every mgmtRsOoBStNode.
"""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import mgmt, orchestrator
from aci_sim.build.fabric import oob_ip
from aci_sim.cli import generate_topology, run_wizard
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
def _base_topo_dict(**kwargs) -> dict:
return generate_topology(sites=1, leaves_per_site=2, spines_per_site=2, border_pairs=1, **kwargs)
# ---------------------------------------------------------------------------
# Schema: Site.oob_gateway
# ---------------------------------------------------------------------------
class TestSchemaField:
def test_default_is_none(self) -> None:
topo = Topology.model_validate(_base_topo_dict())
for site in topo.sites:
assert site.oob_gateway is None
def test_repo_topology_has_no_oob_gateway_set(self) -> None:
"""The shipped topology.yaml predates this PR — no site sets it."""
topo = load_topology(TOPOLOGY_YAML)
for site in topo.sites:
assert site.oob_gateway is None
@pytest.mark.parametrize("gw", ["10.192.0.1", "192.168.1.1", "172.16.5.254", "::1", "2001:db8::1"])
def test_valid_ip_accepted(self, gw: str) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = gw
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].oob_gateway == gw
@pytest.mark.parametrize("gw", ["not-an-ip", "10.192.0.999", "", "10.192.0"])
def test_invalid_ip_rejected(self, gw: str) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = gw
with pytest.raises(ValidationError, match="oob_gateway"):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# Builder: mgmtRsOoBStNode
# ---------------------------------------------------------------------------
class TestMgmtBuilder:
def test_mgmt_tenant_scaffolding_present(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = "10.192.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
tenant_mo = store.get("uni/tn-mgmt")
assert tenant_mo is not None and tenant_mo.class_name == "fvTenant"
mgmtp_mo = store.get("uni/tn-mgmt/mgmtp-default")
assert mgmtp_mo is not None and mgmtp_mo.class_name == "mgmtMgmtP"
oob_mo = store.get("uni/tn-mgmt/mgmtp-default/oob-default")
assert oob_mo is not None and oob_mo.class_name == "mgmtOoB"
def test_one_mgmtrsoobstnode_per_node_including_controllers(self) -> None:
"""2 spines + 2 leaves + 2 border-leaves + 1 controller (default) = 7."""
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = "10.192.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
mos = store.by_class("mgmtRsOoBStNode")
assert len(mos) == 7, f"Expected 7 mgmtRsOoBStNode, got {len(mos)}"
# Every switch/controller node id must have exactly one entry, keyed
# by tDn == topology/pod-<pod>/node-<id>.
all_node_ids = {n.id for n in site.all_nodes()} | {1} # +1 controller
seen_tdns = {mo.attrs["tDn"] for mo in mos}
expected_tdns = {f"topology/pod-{site.pod}/node-{nid}" for nid in all_node_ids}
assert seen_tdns == expected_tdns
def test_addr_and_gw_correct_when_gateway_set(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = "10.192.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
for node in site.all_nodes():
node_dn = f"topology/pod-{site.pod}/node-{node.id}"
mo = store.get(f"uni/tn-mgmt/mgmtp-default/oob-default/rsooBStNode-[{node_dn}]")
assert mo is not None, f"missing mgmtRsOoBStNode for {node_dn}"
assert mo.attrs["tDn"] == node_dn
assert mo.attrs["addr"] == f"{oob_ip(site.pod, node.id)}/24"
assert mo.attrs["gw"] == "10.192.0.1"
def test_gw_empty_when_gateway_unset(self) -> None:
"""No gateway configured -> gw is the empty string, never invented."""
topo = Topology.model_validate(_base_topo_dict())
site = topo.sites[0]
assert site.oob_gateway is None
store = orchestrator.build_site(topo, site)
mos = store.by_class("mgmtRsOoBStNode")
assert mos, "expected mgmtRsOoBStNode entries even with no gateway set"
for mo in mos:
assert mo.attrs["gw"] == ""
def test_mask_derived_from_oob_subnet_when_set(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["oob_subnet"] = "192.168.0.0/20"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
for mo in store.by_class("mgmtRsOoBStNode"):
assert mo.attrs["addr"].endswith("/20")
def test_mask_defaults_to_24_without_oob_subnet(self) -> None:
topo = Topology.model_validate(_base_topo_dict())
assert topo.fabric.oob_subnet is None
store = orchestrator.build_site(topo, topo.sites[0])
for mo in store.by_class("mgmtRsOoBStNode"):
assert mo.attrs["addr"].endswith("/24")
def test_controller_addr_matches_controller_oob_ip(self) -> None:
"""Controller's mgmtRsOoBStNode.addr must match its topSystem.oobMgmtAddr
(same address source: site.controller_oob_ips() / oob_ip fallback)."""
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.11"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrl_dn = f"topology/pod-{site.pod}/node-1"
top_sys = store.get(f"{ctrl_dn}/sys")
assert top_sys is not None
mgmt_mo = store.get(f"uni/tn-mgmt/mgmtp-default/oob-default/rsooBStNode-[{ctrl_dn}]")
assert mgmt_mo is not None
assert mgmt_mo.attrs["addr"].split("/")[0] == top_sys.attrs["oobMgmtAddr"]
# ---------------------------------------------------------------------------
# Wizard: writes oob_gateway instead of discarding it
# ---------------------------------------------------------------------------
class TestWizardWritesGateway:
def test_defaults_run_writes_oob_gateway_per_site(self) -> None:
out = io.StringIO()
topo_dict, gateway_note = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=True)
assert gateway_note # backward-compat return value still populated
for site in topo_dict["sites"]:
assert site.get("oob_gateway"), f"site {site['name']} missing oob_gateway"
# Result must still validate (schema field wired correctly end-to-end).
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
assert site.oob_gateway is not None
def test_custom_answers_gateway_flows_through(self) -> None:
answers = {
"deployment_type": "1",
"site1_gateway": "10.50.0.1",
}
out = io.StringIO()
topo_dict, gateway_note = run_wizard(
stream_in=io.StringIO(""), stream_out=out, accept_defaults=True, answers=answers
)
assert gateway_note == "10.50.0.1"
assert topo_dict["sites"][0]["oob_gateway"] == "10.50.0.1"
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].oob_gateway == "10.50.0.1"
def test_multi_site_each_site_gets_its_own_gateway(self) -> None:
answers = {
"deployment_type": "2",
"num_sites": "2",
"site1_gateway": "10.10.0.1",
"site2_gateway": "10.20.0.1",
}
out = io.StringIO()
topo_dict, _ = run_wizard(
stream_in=io.StringIO(""), stream_out=out, accept_defaults=True, answers=answers
)
assert topo_dict["sites"][0]["oob_gateway"] == "10.10.0.1"
assert topo_dict["sites"][1]["oob_gateway"] == "10.20.0.1"
# And the built store for each site carries its OWN gateway, not the
# other site's.
topo = Topology.model_validate(topo_dict)
store0 = orchestrator.build_site(topo, topo.sites[0])
store1 = orchestrator.build_site(topo, topo.sites[1])
gws0 = {mo.attrs["gw"] for mo in store0.by_class("mgmtRsOoBStNode")}
gws1 = {mo.attrs["gw"] for mo in store1.by_class("mgmtRsOoBStNode")}
assert gws0 == {"10.10.0.1"}
assert gws1 == {"10.20.0.1"}
# ---------------------------------------------------------------------------
# Backward compatibility
# ---------------------------------------------------------------------------
class TestBackwardCompat:
def test_repo_topology_builds_without_error(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
stores = orchestrator.build_all(topo)
assert set(stores.keys()) == {s.name for s in topo.sites}
def test_repo_topology_mgmt_scaffolding_builds_with_empty_gw(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
for site in topo.sites:
store = orchestrator.build_site(topo, site)
mos = store.by_class("mgmtRsOoBStNode")
assert mos, f"expected mgmtRsOoBStNode entries for site {site.name}"
for mo in mos:
assert mo.attrs["gw"] == ""
def test_repo_topology_existing_mos_unchanged(self) -> None:
"""fabricNode/topSystem must be byte-identical to pre-PR shape — this
PR is purely additive (new mgmt-tenant MOs), it must not touch any
existing MO's attributes."""
topo = load_topology(TOPOLOGY_YAML)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
for node in site.all_nodes():
ts = store.get(f"topology/pod-{site.pod}/node-{node.id}/sys")
assert ts is not None
assert ts.attrs["oobMgmtAddr"] == oob_ip(site.pod, node.id)
assert "gw" not in ts.attrs # topSystem never gains a gateway attr
def test_mgmt_module_registered_in_orchestrator(self) -> None:
assert mgmt in orchestrator._BUILDERS
+172
View File
@@ -0,0 +1,172 @@
"""Regression tests for P1 review findings F1 (tree-add children leak/dup) and
F2 (rsp-subtree=children must be one level, full must be recursive).
Builders add MOs as TREES (MO.add_child), the exact pattern the original
flat-only tests never exercised.
"""
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.engine import QueryParams, run_class_query, run_mo_query
def _tenant_tree() -> MO:
"""uni/tn-T ▷ BD-b ▷ subnet-[10.0.0.1/24] (a 3-level tree)."""
tn = MO("fvTenant", dn="uni/tn-T", name="T")
bd = MO("fvBD", dn="uni/tn-T/BD-b", name="b")
sn = MO("fvSubnet", dn="uni/tn-T/BD-b/subnet-[10.0.0.1/24]", ip="10.0.0.1/24")
bd.add_child(sn)
tn.add_child(bd)
return tn
# --- F1: adding a tree must not leak/duplicate children ---------------------
def test_tree_add_no_children_leak_on_nonsubtree_class_query():
s = MITStore()
s.add(_tenant_tree())
imdata, total = run_class_query(s, "fvBD", QueryParams()) # no subtree requested
assert total == 1
assert "children" not in imdata[0]["fvBD"], "children must be absent without a subtree mode (CONTRACT §2)"
def test_tree_add_no_children_leak_on_nonsubtree_mo_query():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_mo_query(s, "uni/tn-T", QueryParams())
assert "children" not in imdata[0]["fvTenant"]
def test_tree_add_subtree_query_lists_each_descendant_once():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_mo_query(s, "uni/tn-T", QueryParams(query_target="subtree"))
# Flatten every dn that appears anywhere in the imdata payload.
seen: list[str] = []
def walk(entry: dict):
for _cls, body in entry.items():
dn = body["attributes"].get("dn")
if dn:
seen.append(dn)
for child in body.get("children", []):
walk(child)
for entry in imdata:
walk(entry)
# root + bd + subnet, each exactly once
assert seen.count("uni/tn-T") == 1
assert seen.count("uni/tn-T/BD-b") == 1
assert seen.count("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == 1
# --- F2: children == one level, full == recursive ---------------------------
def _child_classes(imdata_entry: dict) -> set[str]:
body = next(iter(imdata_entry.values()))
return {next(iter(c)) for c in body.get("children", [])}
def test_rsp_subtree_children_is_one_level():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_class_query(s, "fvTenant", QueryParams(rsp_subtree="children"))
classes = _child_classes(imdata[0])
assert "fvBD" in classes
assert "fvSubnet" not in classes, "children mode is direct children only"
def test_rsp_subtree_full_is_recursive():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_class_query(s, "fvTenant", QueryParams(rsp_subtree="full"))
tn = imdata[0]["fvTenant"]
# full mode returns the WHOLE subtree, and it stays NESTED — the grandchild
# (fvSubnet) hangs under its parent (fvBD), NOT flattened onto the tenant.
# (B1 regression: flattening silently emptied autoACI's two-level readers
# like contract_map vzBrCP→vzSubj→vzRsSubjFiltAtt.)
assert _child_classes(imdata[0]) == {"fvBD"}, "grandchildren must not be hoisted to the root"
bd = tn["children"][0]["fvBD"]
assert {next(iter(c)) for c in bd["children"]} == {"fvSubnet"}, "full mode is recursive"
# --- Legacy query-target=subtree must be FLAT top-level (P3 review Finding 1) ----
def _bgp_inst_tree() -> MO:
"""spine sys/bgp/inst ▷ dom-overlay-1 ▷ peer (ISN /32) — the real ISN shape."""
inst = MO("bgpInst", dn="topology/pod-1/node-201/sys/bgp/inst", asn="65001")
dom = MO("bgpDom", dn="topology/pod-1/node-201/sys/bgp/inst/dom-overlay-1", name="overlay-1")
peer = MO(
"bgpPeer",
dn="topology/pod-1/node-201/sys/bgp/inst/dom-overlay-1/peer-[10.1.401.1]",
addr="10.1.401.1/32",
asn="65002",
)
dom.add_child(peer)
inst.add_child(dom)
return inst
def test_legacy_subtree_returns_target_class_flat_at_top_level():
"""autoACI's query_dn(subtree=True, subtree_class="bgpPeer") reads item.get('bgpPeer')
at the TOP level of imdata the ISN detection depends on this."""
s = MITStore()
s.add(_bgp_inst_tree())
imdata, total = run_mo_query(
s,
"topology/pod-1/node-201/sys/bgp/inst",
QueryParams(query_target="subtree", target_subtree_class=["bgpPeer"]),
)
assert total == 1
assert list(imdata[0].keys()) == ["bgpPeer"], "legacy subtree = class flat at top level, not nested"
assert imdata[0]["bgpPeer"]["attributes"]["addr"] == "10.1.401.1/32"
assert "children" not in imdata[0]["bgpPeer"]
assert all("bgpInst" not in e for e in imdata), "root excluded when it doesn't match the class filter"
def test_legacy_subtree_no_class_filter_is_flat_root_plus_descendants():
s = MITStore()
s.add(_bgp_inst_tree())
imdata, total = run_mo_query(
s,
"topology/pod-1/node-201/sys/bgp/inst",
QueryParams(query_target="subtree"),
)
classes = {next(iter(e)) for e in imdata}
assert classes == {"bgpInst", "bgpDom", "bgpPeer"}
assert total == 3
for e in imdata:
assert "children" not in next(iter(e.values())), "legacy subtree is flat, never nested"
# --- Store hardening: subtree traverses MO-less structural intermediates ------
# (ep_tracker queries epmMacEp via a node/sys subtree; the epm containers
# ctx-[]/bd-[]/db-ep have no MO of their own — reachability must still hold.)
def test_subtree_reaches_deep_mo_without_intermediate_mos():
s = MITStore()
node_sys = "topology/pod-1/node-101/sys"
s.add(MO("topSystem", dn=node_sys, role="leaf"))
deep = f"{node_sys}/ctx-[vxlan-2900001]/bd-[vxlan-15000001]/db-ep/mac-00:50:56:00:00:01"
s.add(MO("epmMacEp", dn=deep, addr="00:50:56:00:00:01"))
found = s.subtree(node_sys, classes={"epmMacEp"})
assert [m.dn for m in found] == [deep], "deep MO reachable via subtree despite MO-less containers"
# the MO-less containers are NOT surfaced as direct children
assert s.children(node_sys) == []
def test_legacy_subtree_reaches_deep_epm_via_engine():
s = MITStore()
node_sys = "topology/pod-1/node-101/sys"
s.add(MO("topSystem", dn=node_sys, role="leaf"))
deep = f"{node_sys}/ctx-[vxlan-2900001]/bd-[vxlan-15000001]/db-ep/mac-00:50:56:00:00:01"
s.add(MO("epmMacEp", dn=deep, addr="00:50:56:00:00:01"))
imdata, total = run_mo_query(
s, node_sys, QueryParams(query_target="subtree", target_subtree_class=["epmMacEp"])
)
assert total == 1
assert imdata[0]["epmMacEp"]["attributes"]["addr"] == "00:50:56:00:00:01"
+79
View File
@@ -0,0 +1,79 @@
"""Regression tests for the 6 Phase-5 cold-review findings.
P1-A empty-subtree-on-existing-MO -> 200; P1-B non-dict body -> 400 (not 500);
P2-A child without dn kept (no dn="" clobber); P2-B delete-with-children removes
subtree; P3 restore-unknown -> 404. (P4 single-site supervisor covered by inspection.)
PR-9 update: GET /api/mo/{dn}.json on a nonexistent DN now returns 200 + empty
imdata (not 404) see tests/test_pr9_ansible.py for the full rationale
(cisco.aci's GET-before-POST existence check treats any non-200 as fatal).
test_p1a_nonexistent_mo_returns_404 renamed/updated accordingly.
"""
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
@pytest.fixture()
def client_and_store():
topo = load_topology("topology.yaml")
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(name=site.name, site=site, topo=topo, store=store, baseline=copy.deepcopy(store))
c = TestClient(make_apic_app(state))
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c, store
def test_p1a_existing_mo_empty_subtree_returns_200(client_and_store):
c, store = client_and_store
node = next(m for m in store.by_class("fabricNode") if m.attrs.get("role") == "leaf")
r = c.get(f"/api/mo/{node.dn}.json", params={"query-target": "subtree", "target-subtree-class": "bgpVpnRoute"})
assert r.status_code == 200
assert r.json()["totalCount"] == "0"
def test_p1a_nonexistent_mo_returns_200_empty(client_and_store):
c, _ = client_and_store
r = c.get("/api/mo/uni/tn-GHOST.json")
assert r.status_code == 200
assert r.json()["totalCount"] == "0"
assert r.json()["imdata"] == []
@pytest.mark.parametrize("bad", ["str", None, 123, [1, 2]])
def test_p1b_non_dict_body_returns_400(client_and_store, bad):
c, _ = client_and_store
assert c.post("/api/mo/uni/tn-X.json", json={"fvTenant": bad}).status_code == 400
def test_p2a_children_without_dn_are_both_kept(client_and_store):
c, store = client_and_store
for nm in ("FIRST", "SECOND"):
c.post("/api/mo/uni/tn-CA.json", json={"fvTenant": {"attributes": {"name": "CA"}, "children": [{"fvBD": {"attributes": {"name": nm}}}]}})
r = c.get("/api/class/fvBD.json", params={"query-target-filter": 'or(eq(fvBD.name,"FIRST"),eq(fvBD.name,"SECOND"))'})
names = {i["fvBD"]["attributes"].get("name") for i in r.json()["imdata"]}
assert {"FIRST", "SECOND"} <= names
assert store.get("") is None
def test_p2b_delete_with_children_removes_subtree(client_and_store):
c, _ = client_and_store
c.post("/api/mo/uni/tn-DP.json", json={"fvTenant": {"attributes": {"name": "DP"}, "children": [{"fvBD": {"attributes": {"name": "b", "dn": "uni/tn-DP/BD-b"}}}]}})
c.post("/api/mo/uni/tn-DP.json", json={"fvTenant": {"attributes": {"name": "DP", "status": "deleted"}, "children": [{"fvBD": {"attributes": {"dn": "uni/tn-DP/BD-b"}}}]}})
r = c.get("/api/mo/uni/tn-DP/BD-b.json")
assert r.status_code == 200
assert r.json()["totalCount"] == "0"
def test_p3_restore_unknown_snapshot_returns_404(client_and_store):
c, _ = client_and_store
assert c.post("/_sim/restore/nope").status_code == 404
+284
View File
@@ -0,0 +1,284 @@
"""Tests for file-backed state persistence (aci_sim/control/persist.py).
Covers both planes:
(a) MITStore round-trip (APIC plane) serialize_store/deserialize_store
(b) NdoState round-trip (NDO plane) serialize_ndo/apply_ndo
(c) APIC app integration /_sim/save + /_sim/load over the real REST API
(d) NDO app integration same, on the NDO app
All tests set SIM_STATE_DIR to an isolated tmp_path so nothing is ever
written under the real ~/.aci-sim/state.
"""
from __future__ import annotations
import copy
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.control.persist import (
apply_ndo,
deserialize_store,
serialize_ndo,
serialize_store,
)
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# (a) MITStore round-trip
# ---------------------------------------------------------------------------
def test_store_round_trip_preserves_all_dns_and_subtree(monkeypatch, tmp_path):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
store = MITStore()
store.add(MO("fvTenant", dn="uni/tn-PERSIST", name="PERSIST"))
store.add(
MO(
"fvAp",
dn="uni/tn-PERSIST/ap-APP1",
name="APP1",
)
)
# Deep nested child MO under the tenant's subtree.
store.add(
MO(
"fvAEPg",
dn="uni/tn-PERSIST/ap-APP1/epg-WEB",
name="WEB",
)
)
before_dns = {mo.dn for mo in store.all()}
data = serialize_store(store)
restored = deserialize_store(data)
after_dns = {mo.dn for mo in restored.all()}
assert after_dns == before_dns
# Subtree query on the tenant must still return the deeply nested child.
subtree_dns = {mo.dn for mo in restored.subtree("uni/tn-PERSIST")}
assert "uni/tn-PERSIST/ap-APP1/epg-WEB" in subtree_dns
assert "uni/tn-PERSIST/ap-APP1" in subtree_dns
def test_store_round_trip_via_real_topology(monkeypatch, tmp_path):
"""Round-trip a store built from the real topology (not just a toy one)."""
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
before_dns = {mo.dn for mo in store.all()}
data = serialize_store(store)
restored = deserialize_store(data)
after_dns = {mo.dn for mo in restored.all()}
assert after_dns == before_dns
assert len(before_dns) > 0
# ---------------------------------------------------------------------------
# (b) NdoState round-trip
# ---------------------------------------------------------------------------
def test_ndo_state_round_trip_onto_different_state(monkeypatch, tmp_path):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
source_state = build_ndo_model(topo)
# Mutate tenants + schemas so the persisted snapshot differs from a fresh build.
source_state.tenants.append(
{"id": "tenant-extra", "name": "EXTRA", "displayName": "EXTRA", "siteAssociations": []}
)
source_state.schemas.append(
{"id": "schema-extra", "displayName": "EXTRA-schema", "name": "EXTRA-schema", "templates": []}
)
data = serialize_ndo(source_state)
# Apply onto a DIFFERENT fresh state object.
target_state = build_ndo_model(topo)
apply_ndo(target_state, data)
for f in (
"tenants",
"schemas",
"schema_details",
"template_summaries",
"tenant_policy_templates",
"policy_states",
"audit_records",
"local_users",
"remote_users",
"extra_schemas",
"sites",
"fabric_connectivity",
):
assert getattr(target_state, f) == getattr(source_state, f), f"field {f} mismatch"
# Confirm mutation actually landed (not just equal-because-untouched).
assert any(t["id"] == "tenant-extra" for t in target_state.tenants)
assert any(s["id"] == "schema-extra" for s in target_state.schemas)
def test_apply_ndo_mutates_in_place_not_a_new_object(monkeypatch, tmp_path):
"""apply_ndo must mutate the SAME object (setattr), since make_ndo_app
closes over the state object identity."""
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
state = build_ndo_model(topo)
state_id = id(state)
data = serialize_ndo(state)
data["tenants"] = data["tenants"] + [{"id": "t2", "name": "T2", "displayName": "T2", "siteAssociations": []}]
apply_ndo(state, data)
assert id(state) == state_id
assert any(t["id"] == "t2" for t in state.tenants)
# ---------------------------------------------------------------------------
# (c) APIC app integration
# ---------------------------------------------------------------------------
@pytest.fixture
def apic_client_and_state():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
c = TestClient(app)
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c, state
def test_apic_save_reset_load_round_trip(monkeypatch, tmp_path, apic_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, state = apic_client_and_state
# Create a new tenant via the REST API.
client.post(
"/api/mo/uni/tn-ITEST.json",
json={"fvTenant": {"attributes": {"name": "ITEST", "dn": "uni/tn-ITEST"}}},
)
resp = client.get("/api/class/fvTenant.json")
names = [i["fvTenant"]["attributes"]["name"] for i in resp.json()["imdata"]]
assert "ITEST" in names
# Save.
r = client.post("/_sim/save/itest")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["count"] > 0
saved_path = Path(body["file"])
assert saved_path.exists()
assert saved_path.parent == tmp_path
# Reset wipes the tenant.
client.post("/_sim/reset")
resp2 = client.get("/api/class/fvTenant.json")
names2 = [i["fvTenant"]["attributes"]["name"] for i in resp2.json()["imdata"]]
assert "ITEST" not in names2
# Load restores it.
r2 = client.post("/_sim/load/itest")
assert r2.status_code == 200
resp3 = client.get("/api/class/fvTenant.json")
names3 = [i["fvTenant"]["attributes"]["name"] for i in resp3.json()["imdata"]]
assert "ITEST" in names3
def test_apic_load_missing_state_returns_404(monkeypatch, tmp_path, apic_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, _state = apic_client_and_state
r = client.post("/_sim/load/does-not-exist")
assert r.status_code == 404
body = r.json()
assert body["imdata"][0]["error"]["attributes"]["text"]
# ---------------------------------------------------------------------------
# (d) NDO app integration
# ---------------------------------------------------------------------------
@pytest.fixture
def ndo_client_and_state():
topo = load_topology(TOPO_PATH)
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c, state
def test_ndo_save_load_round_trip(monkeypatch, tmp_path, ndo_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, state = ndo_client_and_state
# Create a new tenant via the REST API.
resp = client.post(
"/mso/api/v1/tenants",
json={"displayName": "NDO-ITEST", "name": "NDO-ITEST"},
)
assert resp.status_code == 200
new_id = resp.json()["id"]
r = client.post("/_sim/save/nditest")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
saved_path = Path(body["file"])
assert saved_path.exists()
assert saved_path.parent == tmp_path
# Mutate away the tenant to prove load actually restores state (no reset
# endpoint exists on the NDO app, so directly clear the list in place).
state.tenants.clear()
resp2 = client.get("/mso/api/v1/tenants")
ids2 = [t["id"] for t in resp2.json()["tenants"]]
assert new_id not in ids2
r2 = client.post("/_sim/load/nditest")
assert r2.status_code == 200
resp3 = client.get("/mso/api/v1/tenants")
ids3 = [t["id"] for t in resp3.json()["tenants"]]
assert new_id in ids3
def test_ndo_load_missing_state_returns_404(monkeypatch, tmp_path, ndo_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, _state = ndo_client_and_state
r = client.post("/_sim/load/does-not-exist")
assert r.status_code == 404
assert "not found" in r.json()["detail"]
+277
View File
@@ -0,0 +1,277 @@
"""Regression tests for PR-10: cisco.aci/cisco.mso E2E-run gap fixes.
Triaged from a live aci-ansible E2E run whose failures were checked against
real-gear logs (see docs/CONTRACT.md §7/§11 and CHANGELOG 0.2.1). Covers:
1. GET/POST/DELETE via /api/node/mo/{dn} behave identically to the
canonical /api/mo/{dn} real APIC serves /api/node/mo/{dn}.json as a
full alias (API Inspector / aci_rest form); the sim only registered
/api/mo/*, so cisco.aci calls hitting the alias got FastAPI's default
404 {"detail":"Not Found"} (11 E2E failures).
2. An unmatched /api/* path returns an APIC-shaped 400 error envelope
(imdata/error/code), never FastAPI's {"detail":...} shape.
3. NDO /mso/api/v1/sites site names equal the topology's fabric names
(e.g. "LAB1-IT-ACI"), not the short internal site name ("LAB1")
verified against a real-gear cisco.mso.mso_tenant run that only
accepted the fabric-name form.
4. GET /mso/api/v1/schemas/list-identity returns every seeded schema with
id + displayName, and is NOT shadowed by the parameterized
/mso/api/v1/schemas/{schema_id} route.
5. GET /api/v1/tenants (cisco.mso.ndo_template's tenant prereq lookup)
returns the NDO tenants list.
"""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# APIC fixtures
# ---------------------------------------------------------------------------
def _new_apic_client() -> TestClient:
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
return TestClient(make_apic_app(state))
def _logged_in_apic_client() -> TestClient:
c = _new_apic_client()
resp = c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
return c
# ---------------------------------------------------------------------------
# NDO fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture(scope="module")
def ndo_client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def ndo_token(ndo_client):
resp = ndo_client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
return resp.json()["token"]
# ---------------------------------------------------------------------------
# FIX A — /api/node/mo alias
# ---------------------------------------------------------------------------
def test_node_mo_alias_get_post_delete_round_trip():
"""POST/GET/DELETE via /api/node/mo/{dn} must behave identically to the
canonical /api/mo/{dn} route create via the node-alias, read back via
the canonical path, then delete via the alias and confirm removal.
"""
c = _logged_in_apic_client()
# Create via the node-alias.
post_resp = c.post(
"/api/node/mo/uni/tn-PR10AliasTest.json",
json={
"fvTenant": {
"attributes": {"name": "PR10AliasTest", "dn": "uni/tn-PR10AliasTest"}
}
},
)
assert post_resp.status_code == 200, post_resp.text
assert post_resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created"
# Read back via the canonical path.
get_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
assert get_resp.status_code == 200
assert get_resp.json()["totalCount"] == "1"
assert (
get_resp.json()["imdata"][0]["fvTenant"]["attributes"]["name"]
== "PR10AliasTest"
)
# Also readable via the alias itself.
get_alias_resp = c.get("/api/node/mo/uni/tn-PR10AliasTest.json")
assert get_alias_resp.status_code == 200
assert get_alias_resp.json()["totalCount"] == "1"
# Delete via the alias.
del_resp = c.delete("/api/node/mo/uni/tn-PR10AliasTest.json")
assert del_resp.status_code == 200
assert del_resp.json()["totalCount"] == "0"
# Confirm removal via the canonical path.
confirm_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
assert confirm_resp.status_code == 200
assert confirm_resp.json()["totalCount"] == "0"
def test_node_mo_alias_requires_auth():
"""The alias must be gated by the same session check as /api/mo/*."""
c = _new_apic_client()
resp = c.get("/api/node/mo/uni/tn-Whatever.json")
assert resp.status_code == 403
assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403"
def test_node_mo_alias_does_not_shadow_node_class():
"""/api/node/mo/* and /api/node/class/* are distinct literal prefixes —
the fabric-wide node/class form must still resolve correctly.
"""
c = _logged_in_apic_client()
resp = c.get("/api/node/class/fabricNode.json")
assert resp.status_code == 200
assert int(resp.json()["totalCount"]) > 0
# ---------------------------------------------------------------------------
# FIX A — unmatched /api/* fallback
# ---------------------------------------------------------------------------
def test_unmatched_api_path_returns_apic_shaped_400():
c = _logged_in_apic_client()
resp = c.get("/api/bogus/nonexistent/path")
assert resp.status_code == 400
body = resp.json()
assert "detail" not in body
assert body["imdata"][0]["error"]["attributes"]["code"] == "400"
assert "Invalid request path" in body["imdata"][0]["error"]["attributes"]["text"]
def test_unmatched_non_api_path_unaffected():
"""Paths outside /api/* (e.g. control-plane) keep the default 404 shape —
the fallback is scoped to the APIC REST contract only.
"""
c = _logged_in_apic_client()
resp = c.get("/_sim/does-not-exist")
assert resp.status_code == 404
assert "detail" in resp.json()
# ---------------------------------------------------------------------------
# FIX B — NDO site names == fabric names
# ---------------------------------------------------------------------------
def test_ndo_sites_use_fabric_names(ndo_client, ndo_token, topo):
resp = ndo_client.get(
"/mso/api/v1/sites",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
sites = resp.json()["sites"]
reported_names = {s["name"] for s in sites}
expected_fabric_names = {(s.fabric_name or s.name) for s in topo.sites}
assert reported_names == expected_fabric_names, (
f"NDO site names {reported_names} must equal topology fabric names "
f"{expected_fabric_names}, per real-gear cisco.mso.mso_tenant "
f"acceptance (see docs/CONTRACT.md §7)."
)
# And explicitly NOT the short internal names (guards against a
# regression that silently reverts to site.name).
short_names = {s.name for s in topo.sites}
assert reported_names.isdisjoint(short_names - expected_fabric_names)
# ---------------------------------------------------------------------------
# FIX C — schema list-identity
# ---------------------------------------------------------------------------
def test_schema_list_identity_returns_all_seeded_schemas(ndo_client, ndo_token):
resp = ndo_client.get(
"/mso/api/v1/schemas/list-identity",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "schemas" in data
schemas = data["schemas"]
assert len(schemas) >= 3, "expected at least the 3 seeded schemas"
display_names = {s["displayName"] for s in schemas}
for expected in ("MS-TN1-schema", "SF-TN1-LAB1-schema", "SF-TN1-LAB2-schema"):
assert expected in display_names, f"{expected} missing from list-identity"
for s in schemas:
assert "id" in s
assert "displayName" in s
def test_schema_list_identity_not_shadowed_by_id_route(ndo_client, ndo_token):
"""Route-ordering regression guard: list-identity must be declared before
the parameterized /schemas/{schema_id} route, or FastAPI matches
"list-identity" as a schema_id and 404s with
{"detail": "Schema 'list-identity' not found"}.
"""
resp = ndo_client.get(
"/mso/api/v1/schemas/list-identity",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
assert "detail" not in resp.json()
assert "schemas" in resp.json()
# ---------------------------------------------------------------------------
# FIX C — /api/v1/tenants
# ---------------------------------------------------------------------------
def test_bare_api_v1_tenants_returns_tenant_list(ndo_client, ndo_token, topo):
resp = ndo_client.get(
"/api/v1/tenants",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "tenants" in data
tenants = data["tenants"]
assert len(tenants) == len(topo.tenants)
tenant_names = {t["name"] for t in tenants}
for tt in topo.tenants:
assert tt.name in tenant_names
for t in tenants:
assert "id" in t
assert "siteAssociations" in t
+554
View File
@@ -0,0 +1,554 @@
"""PR-11 — NDO write surface: schema POST/PATCH round-trip, ND user-class
fallback endpoints, bare /api/v1/sites.
These close the gap between PR-10 (site names, schema list-identity) and a
real multi-site aci-ansible E2E run: the tenant-region schema
(`<tenant>-<region>`, e.g. "MS-TN1-LAB0") that create_tenant's MSO play
creates via `mso_schema_template` must round-trip through
list-identity GET-by-id PATCH (add template/BD/ANP/EPG ops) GET-by-id
reflecting the mutation, exactly the sequence cisco.mso's schema-object
modules perform (see docs/CONTRACT.md §7 and aci_sim/ndo/patch.py).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.ndo.patch import (
PatchError,
apply_json_patch,
normalize_site,
normalize_template,
)
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
@pytest.fixture()
def topo():
return load_topology(TOPOLOGY_YAML)
@pytest.fixture()
def client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# /api/config/class/remoteusers + localusers — ND fallback shape
# ---------------------------------------------------------------------------
def test_remoteusers_class_query_shape(client):
"""Must be a dict with an "items" list — get_user_from_list_of_users()
(ansible-mso module_utils/mso.py) only iterates users.get("items") when
given a dict; anything else (404, bare list) sends mso_tenant into the
"ND Error: Unknown error" abort observed on the real hardware run.
"""
resp = client.get("/api/config/class/remoteusers")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
assert "items" in body
assert isinstance(body["items"], list)
def test_localusers_class_query_shape_has_admin(client):
resp = client.get("/api/config/class/localusers")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
items = body["items"]
assert len(items) >= 1
admin = next(i for i in items if i["spec"]["loginID"] == "admin")
assert admin["spec"].get("id") or admin["spec"].get("userID")
# ---------------------------------------------------------------------------
# bare /api/v1/sites — ndo_template prereq lookup
# ---------------------------------------------------------------------------
def test_bare_sites_endpoint_matches_mso_prefixed(client):
"""cisco.mso.ndo_template's prereq TenantPol lookup hits the BARE
/api/v1/sites path (no /mso prefix) confirmed 404 on a real hardware run
against the previously mso-only route.
"""
bare = client.get("/api/v1/sites")
prefixed = client.get("/mso/api/v1/sites")
assert bare.status_code == 200
assert bare.json() == prefixed.json()
# ---------------------------------------------------------------------------
# apply_json_patch — unit-level op semantics
# ---------------------------------------------------------------------------
def test_patch_add_append_to_list():
doc = {"templates": [{"name": "T1", "bds": []}]}
ops = [{"op": "add", "path": "/templates/T1/bds/-", "value": {"name": "bd1"}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["bds"] == [{"name": "bd1"}]
def test_patch_add_named_template_slot():
"""/templates/- appends a whole new template dict (mso_schema_template's
'template does not exist yet' branch)."""
doc = {"templates": []}
ops = [{"op": "add", "path": "/templates/-", "value": {"name": "T1", "bds": []}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["name"] == "T1"
def test_patch_replace_by_name_resolution():
doc = {"templates": [{"name": "T1", "displayName": "old"}]}
ops = [{"op": "replace", "path": "/templates/T1/displayName", "value": "new"}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["displayName"] == "new"
def test_patch_remove_by_name_resolution():
doc = {"templates": [{"name": "T1", "bds": [{"name": "bd1"}, {"name": "bd2"}]}]}
ops = [{"op": "remove", "path": "/templates/T1/bds/bd1"}]
apply_json_patch(doc, ops)
assert [b["name"] for b in doc["templates"][0]["bds"]] == ["bd2"]
def test_patch_stringifies_ref_dicts_on_add():
"""A `*Ref` field sent as a dict (schemaId/templateName/xName) must be
persisted as NDO's canonical string ref — real NDO's storage shape,
confirmed by a sibling module (custom_mso_schema_site_bd_subnet.py)
that string-compares/joins bdRef values and crashes on a dict."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1", "bds": []}]}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {"bdRef": {"schemaId": "schema-abc", "templateName": "LAB1", "bdName": "bd1"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"][0]["bdRef"] == "/schemas/schema-abc/templates/LAB1/bds/bd1"
def test_patch_leaves_already_string_refs_untouched():
doc = {"templates": [{"name": "T1", "bds": []}]}
ops = [{"op": "add", "path": "/templates/T1/bds/-", "value": {"name": "bd1", "vrfRef": "/vrfs/vrf1"}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["bds"][0]["vrfRef"] == "/vrfs/vrf1"
def test_patch_add_autovivifies_missing_collection_as_list():
"""Regression guard for a real bug this suite caught: if an
intermediate collection key is missing when a PATCH targets it with
"/-" (append), the resolver must create a LIST, not a dict a dict
turns the "-" append marker into a literal key '-': {'-': value},
silently corrupting the document instead of appending."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1"}]} # no "bds" key at all
ops = [{"op": "add", "path": "/sites/1-LAB1/bds/-", "value": {"name": "bd1"}}]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"] == [{"name": "bd1"}]
def test_patch_unresolvable_path_raises():
doc = {"templates": []}
with pytest.raises(PatchError):
apply_json_patch(doc, [{"op": "replace", "path": "/templates/NoSuchTmpl/name", "value": "x"}])
# ---------------------------------------------------------------------------
# normalize_template — server-side collection-key defaulting
# ---------------------------------------------------------------------------
def test_normalize_template_backfills_bare_create_payload():
"""mso_schema_template.py's create payload is only {name, displayName,
tenantId} every other collection key (vrfs/bds/anps/.../
intersiteL3outs/serviceGraphs) must come back as [] or MSOSchema's
`set_template_vrf()` etc. crash with 'NoneType' object is not iterable
(observed on a real hardware run's Create VRF task)."""
tmpl = {"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "abc"}
normalize_template(tmpl)
for key in ("vrfs", "bds", "anps", "contracts", "filters", "externalEpgs", "intersiteL3outs", "serviceGraphs"):
assert tmpl[key] == [], f"{key} not defaulted to []"
assert tmpl["templateID"], "templateID must be assigned"
def test_normalize_template_backfills_external_epg_subnets():
"""mso_schema_template_external_epg.py's add payload never sets
'subnets' mso_schema_template_external_epg_subnet.py subscripts it
bare (`externalEpgs[idx]["subnets"]`), a real hardware run hit
KeyError: 'subnets' on exactly this path."""
tmpl = {
"name": "LAB1-LAB2",
"externalEpgs": [{"name": "xepg-All", "vrfRef": "/vrfs/v1", "l3outRef": "/l3outs/l1"}],
}
normalize_template(tmpl)
assert tmpl["externalEpgs"][0]["subnets"] == []
assert tmpl["externalEpgs"][0]["contractRelationships"] == []
def test_normalize_template_backfills_nested_epg_under_anp():
tmpl = {"name": "T1", "anps": [{"name": "AP1", "epgs": [{"name": "web"}]}]}
normalize_template(tmpl)
epg = tmpl["anps"][0]["epgs"][0]
assert epg["subnets"] == []
assert epg["contractRelationships"] == []
assert epg["staticPorts"] == []
def test_patch_add_site_bd_by_composite_key():
"""mso_schema_site_bd.py addresses the top-level `sites[]` array by a
synthetic "{siteId}-{templateName}" composite key, not by name/
displayName confirmed on a real hardware create_bd run that hit
"path segment '1-LAB1' not found in list" before this resolution mode
was added (site_template = "{siteId}-{templateName}").
"""
doc = {
"sites": [
{"siteId": "1", "templateName": "LAB1", "bds": []},
{"siteId": "2", "templateName": "LAB1", "bds": []},
]
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {"bdRef": {"bdName": "bd-App1_LAB1"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"] == [{"bdRef": {"bdName": "bd-App1_LAB1"}, "hostBasedRouting": False}]
assert doc["sites"][1]["bds"] == []
def test_patch_add_site_bd_subnet_by_ref_name_resolution():
"""Site-local bds[] entries carry no `name` of their own — only a
bdRef string yet mso_schema_site_bd_subnet.py addresses them by the
referenced BD's bare name: `/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-`.
A real hardware run 400'd with "path segment 'bd-App1_LAB1' not found in
list" before ref-name resolution was added.
"""
doc = {
"sites": [
{
"siteId": "1",
"templateName": "LAB1",
"bds": [
{
"bdRef": "/schemas/schema-abc/templates/LAB1/bds/bd-App1_LAB1",
"hostBasedRouting": False,
"subnets": [],
}
],
}
]
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"][0]["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
def test_normalize_site_backfills_bd_subnets():
"""mso_schema_site_bd.py's add payload is {bdRef, hostBasedRouting} — no
subnets but mso_schema_site_bd_subnet.py reads
site_bd.details.get("subnets") and iterates it."""
site = {"siteId": "1", "templateName": "LAB1", "bds": [{"bdRef": {}, "hostBasedRouting": False}]}
normalize_site(site)
assert site["bds"][0]["subnets"] == []
def test_normalize_template_idempotent():
tmpl = {"name": "T1"}
normalize_template(tmpl)
first_id = tmpl["templateID"]
normalize_template(tmpl)
assert tmpl["templateID"] == first_id
assert tmpl["bds"] == []
# ---------------------------------------------------------------------------
# End-to-end schema write round trip via the FastAPI routes
# ---------------------------------------------------------------------------
def test_schema_create_list_identity_get_roundtrip(client):
"""POST creates a schema whose displayName is resolvable via
list-identity and whose full doc is servable by GET /schemas/{id}
mso_schema_template.py's flow when a tenant-region schema doesn't exist
yet (schema_path = "schemas"; POST {displayName, templates:[...], sites:[]}).
"""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [
{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "abc123", "bds": [], "anps": []}
],
"sites": [],
}
created = client.post("/mso/api/v1/schemas", json=payload)
assert created.status_code == 200
schema_id = created.json()["id"]
identity = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
match = [s for s in identity if s["displayName"] == "MS-TN1-LAB0"]
assert len(match) == 1, f"Expected exactly one list-identity match, got {match}"
assert match[0]["id"] == schema_id
detail = client.get(f"/mso/api/v1/schemas/{schema_id}")
assert detail.status_code == 200
body = detail.json()
assert body["templates"][0]["name"] == "LAB1-LAB2"
def test_schema_patch_add_bd_then_get_reflects_it(client):
"""PATCH adds a BD to an existing template; a subsequent GET must show
it the mso_schema_template_bd.py 'BD does not exist, add it' flow:
ops = [{"op":"add","path":"/templates/{tmpl}/bds/-","value":{...}}].
"""
payload = {
"displayName": "MS-TN2-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "xyz", "bds": [], "anps": []}],
"sites": [],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
bd_ops = [
{
"op": "add",
"path": "/templates/LAB1/bds/-",
"value": {"name": "bd-App1_LAB1", "vrfRef": "/vrfs/vrf-L3_LAB0", "subnets": []},
}
]
patched = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=bd_ops)
assert patched.status_code == 200
assert patched.json()["templates"][0]["bds"][0]["name"] == "bd-App1_LAB1"
# Independent GET after the PATCH must reflect the same mutation.
detail = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
bd_names = {b["name"] for b in detail["templates"][0]["bds"]}
assert "bd-App1_LAB1" in bd_names
def test_schema_patch_add_template_to_existing_schema(client):
"""mso_schema_template.py's 'schema exists, template doesn't' branch:
PATCH ops = [{"op":"add","path":"/templates/-","value":{...}}]."""
payload = {"displayName": "MS-Multi-Schema", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
ops = [{"op": "add", "path": "/templates/-", "value": {"name": "LAB2", "displayName": "LAB2", "tenantId": "t1"}}]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=ops)
assert resp.status_code == 200
assert any(t["name"] == "LAB2" for t in resp.json()["templates"])
# list-identity / GET /schemas summary must reflect the new template too.
schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
summary = next(s for s in schemas if s["id"] == schema_id)
assert {t["name"] for t in summary["templates"]} == {"LAB2"}
def test_schema_patch_external_epg_then_subnet_add(client):
"""End-to-end replica of the real playbook sequence: add an externalEpg
(mso_schema_template_external_epg.py, no subnets key in its payload),
then add a subnet to it (mso_schema_template_external_epg_subnet.py,
which bare-subscripts externalEpgs[idx]["subnets"])."""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "t1"}],
"sites": [],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
add_epg_ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/externalEpgs/-",
"value": {"name": "xepg-All_LAB0", "vrfRef": "/vrfs/vrf-L3_LAB0", "l3outRef": "/l3outs/l3o-bgp-Core_LAB0"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_epg_ops)
assert resp.status_code == 200
assert resp.json()["templates"][0]["externalEpgs"][0]["subnets"] == []
add_subnet_ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/externalEpgs/xepg-All_LAB0/subnets/-",
"value": {"ip": "0.0.0.0/0"},
}
]
resp2 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_subnet_ops)
assert resp2.status_code == 200
subnets = resp2.json()["templates"][0]["externalEpgs"][0]["subnets"]
assert subnets == [{"ip": "0.0.0.0/0"}]
def test_schema_validate_route(client):
"""mso_schema_template_deploy.py calls GET schemas/{id}/validate before
every deploy on ND-platform NDO a real hardware run 404'd here because
the route was entirely missing."""
payload = {"displayName": "MS-Validate-Check", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
resp = client.get(f"/mso/api/v1/schemas/{schema_id}/validate")
assert resp.status_code == 200
def test_schema_validate_unknown_id_404(client):
resp = client.get("/mso/api/v1/schemas/does-not-exist/validate")
assert resp.status_code == 404
def test_execute_and_status_schema_template(client):
"""mso_schema_template_deploy.py's actual deploy/status request:
GET execute|status/schema/{id}/template/{name} response is splatted
into mso.exit_json(**status), so it must be a JSON object."""
payload = {"displayName": "MS-Deploy-Check", "templates": [{"name": "LAB1"}], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
execute_resp = client.get(f"/mso/api/v1/execute/schema/{schema_id}/template/LAB1")
assert execute_resp.status_code == 200
assert isinstance(execute_resp.json(), dict)
status_resp = client.get(f"/mso/api/v1/status/schema/{schema_id}/template/LAB1")
assert status_resp.status_code == 200
assert isinstance(status_resp.json(), dict)
def test_schema_patch_site_local_bd_end_to_end(client):
"""Full replica of the real create_bd MS-TN1 sequence: schema already
has a `sites[]` entry (from mso_schema_site.py's earlier "Add site to
template" run), then mso_schema_site_bd.py PATCHes a site-local BD by
composite key. A real hardware run 400'd with "path segment '1-LAB1' not
found in list" before the composite-key resolver was added.
The write payload's `bdRef` is a DICT (schemaId/templateName/bdName —
what mso_schema_site_bd.py actually sends), but the server must store
and serve it back as the canonical STRING ref
`/schemas/{id}/templates/{tmpl}/bds/{name}` a sibling module
(custom_mso_schema_site_bd_subnet.py) does `bd_ref_string in [v.get(
'bdRef') for v in ...]` and `', '.join(bds)` on a failed match; a dict
stored verbatim crashes that join with "sequence item 0: expected str
instance, dict found" (confirmed on a real hardware "Add site-local subnet
to BD" run).
"""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "t1", "bds": [{"name": "bd-App1_LAB1"}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {
"bdRef": {"schemaId": schema_id, "templateName": "LAB1", "bdName": "bd-App1_LAB1"},
"hostBasedRouting": False,
},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=ops)
assert resp.status_code == 200
site_entry = resp.json()["sites"][0]
assert site_entry["bds"][0]["bdRef"] == f"/schemas/{schema_id}/templates/LAB1/bds/bd-App1_LAB1"
assert site_entry["bds"][0]["subnets"] == []
def test_schema_patch_site_local_bd_subnet_end_to_end(client):
"""create_bd's full MS-TN1 sequence tail: after the site-local BD exists
(bdRef-only, no name), add a subnet to it addressed by the BD's bare
name real NDO/ACI-hardware request shape."""
schema_id = client.post(
"/mso/api/v1/schemas",
json={
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "bds": [{"name": "bd-App1_LAB1"}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
},
).json()["id"]
add_bd_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {
"bdRef": {"schemaId": schema_id, "templateName": "LAB1", "bdName": "bd-App1_LAB1"},
"hostBasedRouting": False,
},
}
]
client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_bd_ops)
add_subnet_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_subnet_ops)
assert resp.status_code == 200
site_bd = resp.json()["sites"][0]["bds"][0]
assert site_bd["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
def test_post_task_deploy(client):
"""cisco.mso.ndo_schema_template_deploy — the module aci-ansible's
mso-model role actually invokes sends deploy/redeploy as
POST /mso/api/v1/task {schemaId, templateName, isRedeploy}. A real
real hardware run 404'd here; confirmed against the collection installed there
(differs from the legacy mso_schema_template_deploy.py's execute/status
GET routes, which are exercised separately above)."""
payload = {"displayName": "MS-Task-Deploy-Check", "templates": [{"name": "LAB1"}], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": schema_id, "templateName": "LAB1", "isRedeploy": False},
)
assert resp.status_code == 200
body = resp.json()
assert body["schemaId"] == schema_id
assert body["templateName"] == "LAB1"
def test_post_task_unknown_schema_404(client):
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": "does-not-exist", "templateName": "LAB1", "isRedeploy": False},
)
assert resp.status_code == 404
def test_schema_patch_unknown_id_404(client):
resp = client.patch("/mso/api/v1/schemas/does-not-exist", json=[{"op": "add", "path": "/x", "value": 1}])
assert resp.status_code == 404
def test_schema_patch_no_double_listing_after_mutation(client):
"""Regression guard: patching a schema must not create a duplicate
summary entry in GET /schemas (seeded-schema id collision check)."""
payload = {"displayName": "MS-Dup-Check", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
client.patch(
f"/mso/api/v1/schemas/{schema_id}",
json=[{"op": "add", "path": "/templates/-", "value": {"name": "T1"}}],
)
schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
matching = [s for s in schemas if s["id"] == schema_id]
assert len(matching) == 1, f"Expected exactly one entry, found {len(matching)}"
+447
View File
@@ -0,0 +1,447 @@
"""PR-12 — NDO site-local ANP/EPG + static-port schema surface.
Closes the last MS-TN1 gap noted in docs/CONTRACT.md §7a: `bind_epg_to_
static_port` (`cisco.mso.mso_schema_site_anp_epg_staticport`) crashed
client-side with `'NoneType' object has no attribute 'details'` because the
sim never mirrored a template-level ANP/EPG add into the schema's site-local
`sites[].anps[].epgs[]` structure so `MSOSchema.set_site_anp()`/
`set_site_anp_epg()` always found nothing (`None`) for a site that already
had the template attached, exactly the site-local surface a real NDO 4.x
controller auto-populates when a template ANP/EPG is added to a template
that already has sites associated (verified against `ansible-mso`'s
`plugins/module_utils/schema.py` `set_site_anp()`/`set_site_anp_epg()` and
`plugins/modules/mso_schema_site_anp_epg_staticport.py`'s literal source,
and confirmed end-to-end on a real-hardware multi-site E2E run).
These tests exercise `apply_json_patch`'s new auto-mirror behavior directly
(unit level) and via the full FastAPI PATCH/GET round trip (integration
level), replicating the exact real-playbook request sequence:
1. `mso_schema_template_anp` PATCHes `add /templates/{t}/anps/-`.
2. `mso_schema_template_anp_epg` PATCHes `add /templates/{t}/anps/{a}/epgs/-`.
3. `mso_schema_site_anp_epg_staticport` (having found a non-None site_anp/
site_anp_epg the crash never triggers) PATCHes
`add /sites/{siteId}-{t}/anps/{a}/epgs/{e}/staticPorts/-`.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.ndo.patch import apply_json_patch, normalize_site, normalize_template
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
@pytest.fixture()
def topo():
return load_topology(TOPOLOGY_YAML)
@pytest.fixture()
def client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# normalize_template — template-level self-referencing anpRef/epgRef
# ---------------------------------------------------------------------------
def test_normalize_template_backfills_self_referencing_anp_ref():
"""Real NDO stores a self-referencing `anpRef` on every template-level
ANP object itself confirmed by `ansible-mso`'s
`mso_schema_template_anp.py` explicitly stripping it client-side before
an idempotency comparison (`if "anpRef" in mso.previous: del
mso.previous["anpRef"]` dead code unless the server sends one back).
`MSOSchema.set_site_anp()` keys its site-local lookup off exactly this
field (`template_anp.details.get("anpRef")`); without it that lookup
always compares against `None` and never matches, crashing
`mso_schema_site_anp_epg_staticport.py` (confirmed on a real
MS-TN1 bind_epg_to_static_port run on ACI hardware before this fix)."""
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": []}]}
normalize_template(tmpl, "schema-abc")
assert tmpl["anps"][0]["anpRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1"
def test_normalize_template_backfills_self_referencing_epg_ref():
"""Same rationale as the ANP case, one level down: `set_site_anp_epg()`
keys off `template_anp_epg.details.get("epgRef")`."""
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}
normalize_template(tmpl, "schema-abc")
epg = tmpl["anps"][0]["epgs"][0]
assert epg["epgRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
def test_normalize_template_self_ref_backfill_is_idempotent():
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}
normalize_template(tmpl, "schema-abc")
first_anp_ref = tmpl["anps"][0]["anpRef"]
first_epg_ref = tmpl["anps"][0]["epgs"][0]["epgRef"]
normalize_template(tmpl, "schema-abc")
assert tmpl["anps"][0]["anpRef"] == first_anp_ref
assert tmpl["anps"][0]["epgs"][0]["epgRef"] == first_epg_ref
# ---------------------------------------------------------------------------
# apply_json_patch — auto-mirror unit tests
# ---------------------------------------------------------------------------
def test_template_anp_add_mirrors_into_associated_site():
"""Adding a template-level ANP to a template that already has a site
attached must auto-create a matching site-local anp entry (anpRef
pointing back at the template ANP, epgs starting empty) real NDO 4.x
behavior; without it `set_site_anp()` always returns None."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": []}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
ops = [
{
"op": "add",
"path": "/templates/LAB1/anps/-",
"value": {"name": "app-Web_LAB1", "displayName": "app-Web_LAB1", "epgs": []},
}
]
apply_json_patch(doc, ops)
site = doc["sites"][0]
assert site["anps"][0]["anpRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1"
assert site["anps"][0]["epgs"] == []
def test_template_anp_add_does_not_mirror_into_unrelated_template_site():
"""A site associated with a DIFFERENT template must be untouched."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": []}, {"name": "LAB2", "anps": []}],
"sites": [
{"siteId": "1", "templateName": "LAB1"},
{"siteId": "2", "templateName": "LAB2"},
],
}
ops = [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}]
apply_json_patch(doc, ops)
assert doc["sites"][0]["anps"][0]["anpRef"].endswith("/anps/app-Web_LAB1")
assert doc["sites"][1].get("anps", []) == []
def test_template_epg_add_mirrors_into_site_anp_epgs():
"""Adding a template-level EPG under an existing ANP must auto-create a
matching site-local epg entry under the already-mirrored site-anp
(epgRef pointing back at the template EPG, staticPorts starting empty)
without it `set_site_anp_epg()` always returns None."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": []}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}],
)
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}],
)
site_anp = doc["sites"][0]["anps"][0]
site_epg = site_anp["epgs"][0]
assert site_epg["epgRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
# A mirrored site-EPG must carry the FULL child-collection shape every
# sibling `mso_schema_site_anp_epg_*` module bare-subscripts — a missing
# key is a hard KeyError, not a 4xx (see SITE_OBJECT_DEFAULTS["epgs"]).
assert site_epg["staticPorts"] == []
assert site_epg["subnets"] == []
assert site_epg["staticLeafs"] == []
assert site_epg["domainAssociations"] == []
def test_mirror_is_idempotent():
"""Re-running the same template ANP/EPG add ops (e.g. a retried
playbook task) must not duplicate the mirrored site-local entries."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": []}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
anp_ops = [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}]
epg_ops = [{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}]
apply_json_patch(doc, anp_ops)
apply_json_patch(doc, epg_ops)
apply_json_patch(doc, anp_ops)
apply_json_patch(doc, epg_ops)
site = doc["sites"][0]
assert len(site["anps"]) == 1
assert len(site["anps"][0]["epgs"]) == 1
def test_site_anp_epg_static_port_add_by_ref_name_resolution():
"""Full replica of the real bind_epg_to_static_port sequence tail: once
the site-anp/site-epg are mirrored (ref-only, no name), a static port is
addressed by the ANP/EPG's bare template names —
`/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-` exactly
what `mso_schema_site_anp_epg_staticport.py` sends once `site_anp`/
`site_anp_epg` are found (the crash path this PR eliminates)."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}])
apply_json_patch(
doc, [{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}]
)
static_port_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-",
"value": {
"path": "topology/pod-1/protpaths-101-102/pathep-[ipg-vpc-LegacySW01]",
"portEncapVlan": 100,
"mode": "regular",
"deploymentImmediacy": "immediate",
"type": "vpc",
},
}
]
apply_json_patch(doc, static_port_ops)
static_ports = doc["sites"][0]["anps"][0]["epgs"][0]["staticPorts"]
assert len(static_ports) == 1
assert static_ports[0]["portEncapVlan"] == 100
def test_site_anp_epg_domain_association_add_by_ref_name_resolution():
"""Full replica of the real bind_epg_to_physical_domain / _vmm_domain
sequence tail (`mso_schema_site_anp_epg_domain.py`): once the site-anp/
site-epg are mirrored, a domain association is added at
`/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/domainAssociations/-`.
The module reads that array with a BARE subscript
(`domains = [dom.get("dn") for dom in ...["epgs"][epg_idx]
["domainAssociations"]]`), so a missing key is a hard `KeyError:
'domainAssociations'` a real MS-TN1 hardware run regressed here after
PR-12 started auto-creating the site-EPG (pre-PR-12 no site-EPG existed
so the module took a non-crashing branch)."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}])
apply_json_patch(
doc, [{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}]
)
# The mirrored site-EPG must already carry an (empty) domainAssociations
# array — the module bare-subscripts it before deciding to append.
assert doc["sites"][0]["anps"][0]["epgs"][0]["domainAssociations"] == []
domain_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/domainAssociations/-",
"value": {
"dn": "uni/phys-phy-general",
"domainType": "physicalDomain",
"deployImmediacy": "immediate",
"resolutionImmediacy": "immediate",
},
}
]
apply_json_patch(doc, domain_ops)
domains = doc["sites"][0]["anps"][0]["epgs"][0]["domainAssociations"]
assert len(domains) == 1
assert domains[0]["dn"] == "uni/phys-phy-general"
assert domains[0]["domainType"] == "physicalDomain"
def test_epg_ref_dict_stringified_to_canonical_two_segment_form():
"""`epgRef` is the one `*Ref` field whose canonical string form nests
TWO name segments under the template (anps/{anp}/epgs/{epg}), not one
confirmed against ansible-mso's module_utils/mso.py `epg_ref()`. A dict
payload embedding it (as `mso_schema_site_anp_epg_staticport.py`'s
create-site-anp fallback sends) must be stored in that exact form."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1", "anps": []}]}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/-",
"value": {
"epgRef": {
"schemaId": "schema-abc",
"templateName": "LAB1",
"anpName": "app-Web_LAB1",
"epgName": "epg-web",
}
},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["anps"][0]["epgRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
def test_normalize_site_backfills_all_epg_child_collections():
"""normalize_site() must backfill the FULL site-EPG child-collection
shape (subnets/staticPorts/staticLeafs/domainAssociations) on a
ref-only site-epg each is bare-subscripted by a sibling
mso_schema_site_anp_epg_* module."""
site = {
"siteId": "1",
"templateName": "LAB1",
"anps": [{"anpRef": "/schemas/s/templates/LAB1/anps/app-Web_LAB1", "epgs": [{"epgRef": "/schemas/s/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"}]}],
}
normalize_site(site)
epg = site["anps"][0]["epgs"][0]
assert epg["subnets"] == []
assert epg["staticPorts"] == []
assert epg["staticLeafs"] == []
assert epg["domainAssociations"] == []
# ---------------------------------------------------------------------------
# End-to-end FastAPI round trip — full real-playbook sequence
# ---------------------------------------------------------------------------
def test_schema_patch_site_local_anp_epg_staticport_end_to_end(client):
"""Full replica of the real MS-TN1 create_application + bind_epg_to_
static_port sequence: schema already has a site attached to the
template (mso_schema_site.py, PR-11's create_tenant flow), then
mso_schema_template_anp / _anp_epg add the template ANP/EPG (already
supported since PR-11), and the site-local mirrors must already exist
by the time mso_schema_site_anp_epg_staticport PATCHes the static port
matching real NDO 4.x, where the crash this PR fixes never triggers."""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "t1", "anps": []}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
anp_ops = [
{
"op": "add",
"path": "/templates/LAB1/anps/-",
"value": {"name": "app-Web_LAB1", "displayName": "app-Web_LAB1", "epgs": []},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=anp_ops)
assert resp.status_code == 200
site_anp = resp.json()["sites"][0]["anps"][0]
assert site_anp["anpRef"] == f"/schemas/{schema_id}/templates/LAB1/anps/app-Web_LAB1"
epg_ops = [
{
"op": "add",
"path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-",
"value": {"name": "epg-web", "displayName": "epg-web"},
}
]
resp2 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=epg_ops)
assert resp2.status_code == 200
site_epg = resp2.json()["sites"][0]["anps"][0]["epgs"][0]
assert site_epg["epgRef"] == f"/schemas/{schema_id}/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
assert site_epg["staticPorts"] == []
assert site_epg["domainAssociations"] == []
static_port_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-",
"value": {
"path": "topology/pod-1/protpaths-101-102/pathep-[ipg-vpc-LegacySW01]",
"portEncapVlan": 100,
"mode": "regular",
"deploymentImmediacy": "immediate",
"type": "vpc",
},
}
]
resp3 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=static_port_ops)
assert resp3.status_code == 200
static_ports = resp3.json()["sites"][0]["anps"][0]["epgs"][0]["staticPorts"]
assert len(static_ports) == 1
assert static_ports[0]["portEncapVlan"] == 100
# Independent GET after the PATCH must reflect the same mutation
# (get_site_anp-equivalent lookup: a fresh GET's sites[].anps[].epgs[]
# must resolve non-None for the static-port module's own lookup chain).
detail = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
got_site = detail["sites"][0]
got_anp = got_site["anps"][0]
got_epg = got_anp["epgs"][0]
assert got_anp["anpRef"].endswith("/anps/app-Web_LAB1")
assert got_epg["epgRef"].endswith("/epgs/epg-web")
assert got_epg["staticPorts"][0]["portEncapVlan"] == 100
def test_schema_patch_site_local_anp_epg_survives_repeated_get(client):
"""Regression guard: a plain GET (no PATCH in between) must keep
reporting the mirrored site-anp/site-epg i.e. normalization isn't a
PATCH-only side effect that a bare GET-only reader would miss."""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "anps": []}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
client.patch(
f"/mso/api/v1/schemas/{schema_id}",
json=[{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}],
)
first = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
second = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
assert first["sites"][0]["anps"][0]["anpRef"] == second["sites"][0]["anps"][0]["anpRef"]
# ---------------------------------------------------------------------------
# build_ndo_model — boot-time seed already has the site-local mirror
# ---------------------------------------------------------------------------
def test_build_ndo_model_seeds_site_local_anp_epg_for_topology_tenants(topo):
"""A topology.yaml tenant that already ships with ANPs/EPGs configured
(not built up via PATCH at runtime) must boot with its site-local
sites[].anps[].epgs[] already mirrored too otherwise a fresh sim
instance would hit the same set_site_anp()-returns-None crash on its
very first bind_epg_to_static_port run, before any PATCH ever ran."""
state = build_ndo_model(topo)
found_any_site_anp = False
for detail in state.schema_details.values():
for tmpl in detail.get("templates", []):
if not tmpl.get("anps"):
continue
# Every template-level anp/epg must carry its self-referencing ref.
for anp in tmpl["anps"]:
assert anp.get("anpRef", "").endswith(f"/anps/{anp['name']}")
for epg in anp.get("epgs", []):
assert epg.get("epgRef", "").endswith(f"/epgs/{epg['name']}")
for site in detail.get("sites", []):
if site.get("templateName") != tmpl["name"]:
continue
for site_anp in site.get("anps", []):
found_any_site_anp = True
assert site_anp.get("anpRef")
for site_epg in site_anp.get("epgs", []):
assert site_epg.get("epgRef")
assert site_epg.get("staticPorts") == []
assert site_epg.get("domainAssociations") == []
assert found_any_site_anp, "expected at least one topology tenant with a mirrored site-local anp"
+742
View File
@@ -0,0 +1,742 @@
"""PR-13 — NDO tenant-policy-template DHCP surface + schema serviceGraphs
for cisco.mso.
Two confirmed sim gaps from a real aci-ansible run against ACI hardware, both
blocking the last NDO write surfaces in the multi-site (MS) suite:
GAP 1 `cisco.mso.ndo_template`'s tenant-policy-template CREATE flow
(`prereq_tenantpol.yml`'s `Create TenantPol-<tenant> tenant policy template`
task) 404'd on `GET /api/v1/templates/summaries` (note: BARE path, no `/mso`
prefix) confirmed root cause: that task carries `delegate_to: localhost`,
which forces `ansible-mso`'s `MSOModule` down its direct-HTTP branch
(`module._socket_path is None`) instead of the persistent
`ansible.netcommon.httpapi` connection every OTHER task on the same
playbook host uses and the direct-HTTP branch never adds the `/mso`
prefix (`ansible-mso`'s `plugins/module_utils/mso.py::request()`:
`self.url = "{0}api/{1}/{2}".format(self.base_only_uri, api_version,
self.path...)`). Once the template exists, `cisco.mso.ndo_dhcp_relay_policy`
(`create_dhcp_relay`) and `cisco.mso.ndo_schema_template_bd_dhcp_policy`
(`bind_dhcp_relay_to_bd`) both running over the normal httpapi connection,
hence the mso-prefixed path must be able to create/read/cross-reference
DHCP relay policies against it, including resolving a schema-template EPG's
`uuid` (a field the sim never populated before this PR) as a DHCP relay
provider ref.
GAP 2 the "Create service graph" task (mso-model role's
`custom_mso_schema_service_graph.py`, a pre-`MSOTemplate`/`MSOSchema`
community module) bare-subscripts `schema_obj.get('sites')[site_idx]
['serviceGraphs']` a real hardware MS-TN2 `create_tenant` pass-2 run
(`-e automate_contract_graph=true`) hit `KeyError: 'serviceGraphs'` on
exactly this line. The TEMPLATE-level `serviceGraphs` bare-subscript was
already covered by PR-11's `TEMPLATE_COLLECTION_KEYS`; this PR closes the
SITE-level sibling gap (`SITE_TOP_LEVEL_DEFAULTS`) and the additional
`GET /schemas` full-detail + `schemas/service-node-types` + template
`tenantId` gaps this same legacy module needs, since it bare-subscripts the
raw `/schemas` list response instead of going through `MSOSchema`.
GAP 2 DEEPER LAYER (found while chasing GAP 2 to green on real hardware, with
`-e automate_contract_graph=true -e automate_site_redirect=true`) once
the site-level `serviceGraphs` KeyError was fixed, the same real run
progressed to the mso-model role's raw `cisco.mso.mso_rest` "Atomic PATCH —
bind service-graph redirect on ALL fabrics in one request" task, which
addresses a SITE-LOCAL contract by bare name:
`/sites/{siteId}-{template}/contracts/{contract}/serviceGraphRelationship`.
The sim never mirrored template-level contract adds into
`sites[].contracts[]` at all (unlike ANPs/EPGs, mirrored since PR-12), so
`apply_json_patch`'s `_find_by_name` raised `PatchError: path segment
'con-Firewall_LAB0' not found in list` confirmed verbatim on the real
hardware run. This PR adds `_mirror_template_contract_to_sites` (PATCH-time,
parallel to `_mirror_template_anp_to_sites`) and the equivalent boot-time
mirror in `build_ndo_model`, closing this fully the real hardware MS-TN2
`create_tenant` pass-2 run now reaches `failed=0`.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import TENANT_POLICY_TEMPLATE_ID, build_ndo_model
from aci_sim.ndo.patch import (
SITE_TOP_LEVEL_DEFAULTS,
apply_json_patch,
normalize_site,
normalize_template,
)
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
@pytest.fixture()
def topo():
return load_topology(TOPOLOGY_YAML)
@pytest.fixture()
def client(topo):
state = build_ndo_model(topo, sandbox=True)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
def _tenant_id(client, name: str) -> str:
tenants = client.get("/api/v1/tenants").json()["tenants"]
return next(t["id"] for t in tenants if t["name"] == name)
def _site_ids(client) -> list[str]:
return [s["id"] for s in client.get("/api/v1/sites").json()["sites"]]
# ---------------------------------------------------------------------------
# GAP 1a — bare (delegate_to: localhost) template routes
# ---------------------------------------------------------------------------
def test_bare_templates_summaries_route_exists(client):
"""`ndo_template`'s `delegate_to: localhost` prereq task hits the BARE
`/api/v1/templates/summaries` (no `/mso` prefix) a real hardware run
404'd here before this fix."""
resp = client.get("/api/v1/templates/summaries")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_bare_and_mso_prefixed_templates_summaries_share_data(client):
"""A template created via the bare path must be visible to a caller
using the mso-prefixed path (and vice versa) both back the same
mutable store, matching the existing /api/v1/tenants <-> /mso/api/v1/
tenants pattern PR-10/PR-11 established for the same delegate_to split."""
tenant_id = _tenant_id(client, "MS-TN1")
site_ids = _site_ids(client)
payload = {
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {"tenantId": tenant_id},
"sites": [{"siteId": s} for s in site_ids],
},
}
created = client.post("/api/v1/templates", json=payload)
assert created.status_code == 200
template_id = created.json()["templateId"]
mso_summaries = client.get("/mso/api/v1/templates/summaries").json()
match = [s for s in mso_summaries if s.get("templateName") == "TenantPol-MS-TN1"]
assert len(match) == 1
assert match[0]["templateId"] == template_id
assert match[0]["templateType"] == "tenantPolicy"
# And the reverse: mso-prefixed GET finds the bare-created template.
detail = client.get(f"/mso/api/v1/templates/{template_id}").json()
assert detail["tenantPolicyTemplate"]["template"]["tenantId"] == tenant_id
# ---------------------------------------------------------------------------
# GAP 1b — ndo_template create -> lookup round trip (full sequence)
# ---------------------------------------------------------------------------
def test_ndo_template_create_then_lookup_round_trip(client):
"""Full replica of `ndo_template`'s CREATE flow: `lookup_tenant()` (GET
tenants), `lookup_site()` per site (GET sites), `MSOTemplate.__init__`
(GET templates/summaries?templateName=... finds nothing) -> POST
templates with the tenantPolicyTemplate payload shape -> template must
then resolve by name via templates/summaries."""
tenant_id = _tenant_id(client, "MS-TN1")
site_ids = _site_ids(client)
# MSOTemplate.__init__ pre-create lookup: nothing found yet.
before = client.get("/api/v1/templates/summaries").json()
assert not any(s.get("templateName") == "TenantPol-MS-TN1" for s in before)
payload = {
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {"tenantId": tenant_id},
"sites": [{"siteId": s} for s in site_ids],
},
}
resp = client.post("/api/v1/templates", json=payload)
assert resp.status_code == 200
template_id = resp.json()["templateId"]
assert template_id
after = client.get("/api/v1/templates/summaries").json()
match = next(s for s in after if s.get("templateName") == "TenantPol-MS-TN1")
assert match["templateType"] == "tenantPolicy"
assert match["templateId"] == template_id
detail = client.get(f"/api/v1/templates/{template_id}").json()
assert detail["templateType"] == "tenantPolicy"
assert detail["tenantPolicyTemplate"]["template"]["tenantId"] == tenant_id
assert {s["siteId"] for s in detail["tenantPolicyTemplate"]["sites"]} == set(site_ids)
def test_ndo_template_create_is_idempotent_across_prefixes(client):
"""Creating the same-named template twice (e.g. a retried playbook
task) must not silently corrupt the summaries list this sim's create
handler always assigns a fresh id (matching MSOTemplate's own
"not found -> create" branch, which only runs when the by-name lookup
truly found nothing); the test asserts the store stays internally
consistent rather than asserting real NDO's idempotency semantics."""
tenant_id = _tenant_id(client, "MS-TN1")
payload = {
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": []},
}
r1 = client.post("/api/v1/templates", json=payload)
tid1 = r1.json()["templateId"]
summaries = client.get("/mso/api/v1/templates/summaries").json()
assert sum(1 for s in summaries if s["templateId"] == tid1) == 1
# ---------------------------------------------------------------------------
# GAP 1c — DHCP relay policy add/read (ndo_dhcp_relay_policy + bd binding)
# ---------------------------------------------------------------------------
def test_dhcp_relay_policy_add_and_read_via_patch(client):
"""Full replica of `create_dhcp_relay`'s NDO 4.x path
(`cisco.mso.ndo_dhcp_relay_policy`): PATCH `add
/tenantPolicyTemplate/template/dhcpRelayPolicies/-` against the
tenant-policy template, using a schema-template EPG's `uuid` as the
provider's `epgRef` (this PR backfills that uuid — previously absent)."""
tenant_id = _tenant_id(client, "MS-TN1")
site_ids = _site_ids(client)
template_id = client.post(
"/api/v1/templates",
json={
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": [{"siteId": s} for s in site_ids]},
},
).json()["templateId"]
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if "MS-TN1" in s["displayName"])
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl = detail["templates"][0]
anp = tmpl["anps"][0]
epg = anp["epgs"][0]
assert epg.get("uuid"), "schema-template EPG must carry a uuid for DHCP relay provider refs"
ops = [
{
"op": "add",
"path": "/tenantPolicyTemplate/template/dhcpRelayPolicies/-",
"value": {
"name": "pol-dhcp_relay-Test1",
"providers": [{"ip": "192.168.41.10", "useServerVrf": False, "epgRef": epg["uuid"], "epgName": epg["name"]}],
},
}
]
resp = client.patch(f"/mso/api/v1/templates/{template_id}", json=ops)
assert resp.status_code == 200
relays = resp.json()["tenantPolicyTemplate"]["template"]["dhcpRelayPolicies"]
added = next(r for r in relays if r["name"] == "pol-dhcp_relay-Test1")
assert added.get("uuid"), "sim must backfill a uuid on a newly-added DHCP relay policy"
assert added["providers"][0]["epgRef"] == epg["uuid"]
def test_dhcp_relay_policy_uuid_resolves_via_templates_objects(client):
"""`ndo_schema_template_bd_dhcp_policy`'s `get_dhcp_relay_policy_uuid()`
(the `bind_dhcp_relay_to_bd` playbook step) queries `GET
templates/objects?type=dhcpRelay&name=...&tenantId=<matched-client-side>`
and requires BOTH `tenantId` and `uuid` on the returned entry."""
tenant_id = _tenant_id(client, "MS-TN1")
template_id = client.post(
"/api/v1/templates",
json={
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": []},
},
).json()["templateId"]
client.patch(
f"/mso/api/v1/templates/{template_id}",
json=[
{
"op": "add",
"path": "/tenantPolicyTemplate/template/dhcpRelayPolicies/-",
"value": {"name": "pol-dhcp_relay-Test1", "providers": [{"ip": "10.0.0.1", "useServerVrf": False}]},
}
],
)
resp = client.get("/mso/api/v1/templates/objects", params={"type": "dhcpRelay", "name": "pol-dhcp_relay-Test1"})
assert resp.status_code == 200
matches = [r for r in resp.json() if r.get("tenantId") == tenant_id and r.get("name") == "pol-dhcp_relay-Test1"]
assert len(matches) == 1
relay_uuid = matches[0]["uuid"]
assert relay_uuid
# get_dhcp_relay_label_name() query-back-by-uuid (bind_dhcp_relay_to_bd's
# post-PATCH name resolution step).
by_uuid = client.get("/mso/api/v1/templates/objects", params={"type": "dhcpRelay", "uuid": relay_uuid}).json()
assert by_uuid.get("name") == "pol-dhcp_relay-Test1"
def test_bind_dhcp_relay_to_bd_end_to_end(client):
"""Full replica of `bind_dhcp_relay_to_bd`'s
`cisco.mso.ndo_schema_template_bd_dhcp_policy` PATCH: resolve the relay
policy's uuid (templates/objects), then PATCH `add
/templates/{t}/bds/{bd}/dhcpLabels/-` with `{ref: uuid, name}` on the
schema template must round-trip through a fresh GET."""
tenant_id = _tenant_id(client, "MS-TN1")
template_id = client.post(
"/api/v1/templates",
json={
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": []},
},
).json()["templateId"]
client.patch(
f"/mso/api/v1/templates/{template_id}",
json=[
{
"op": "add",
"path": "/tenantPolicyTemplate/template/dhcpRelayPolicies/-",
"value": {"name": "pol-dhcp_relay-Test1", "providers": [{"ip": "10.0.0.1", "useServerVrf": False}]},
}
],
)
relay_uuid = client.get(
"/mso/api/v1/templates/objects", params={"type": "dhcpRelay", "name": "pol-dhcp_relay-Test1"}
).json()[0]["uuid"]
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if "MS-TN1" in s["displayName"])
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl = detail["templates"][0]
bd = tmpl["bds"][0]
ops = [
{
"op": "add",
"path": f"/templates/{tmpl['name']}/bds/{bd['name']}/dhcpLabels/-",
"value": {"ref": relay_uuid, "name": "pol-dhcp_relay-Test1"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=ops)
assert resp.status_code == 200
bd_after = next(b for b in resp.json()["templates"][0]["bds"] if b["name"] == bd["name"])
assert any(label["ref"] == relay_uuid for label in bd_after["dhcpLabels"])
# Independent GET must reflect the same mutation.
fresh = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
bd_fresh = next(b for b in fresh["templates"][0]["bds"] if b["name"] == bd["name"])
assert any(label["ref"] == relay_uuid for label in bd_fresh["dhcpLabels"])
# ---------------------------------------------------------------------------
# GAP 2a — site-level serviceGraphs default
# ---------------------------------------------------------------------------
def test_site_top_level_defaults_include_service_graphs():
assert SITE_TOP_LEVEL_DEFAULTS["serviceGraphs"] == []
def test_normalize_site_backfills_service_graphs():
"""`custom_mso_schema_service_graph.py` bare-subscripts
`schema_obj.get('sites')[site_idx]['serviceGraphs']` a schema site
entry that never had this key backfilled must not KeyError."""
site = {"siteId": "1", "templateName": "LAB1"}
normalize_site(site)
assert site["serviceGraphs"] == []
assert site["bds"] == []
assert site["anps"] == []
def test_normalize_site_service_graphs_idempotent():
site = {"siteId": "1", "templateName": "LAB1", "serviceGraphs": [{"name": "sgt-1"}]}
normalize_site(site)
normalize_site(site)
assert site["serviceGraphs"] == [{"name": "sgt-1"}]
def test_apply_json_patch_add_site_service_graph_by_composite_key():
"""Full replica of `custom_mso_schema_service_graph.py`'s site-local
add: `/sites/{siteId}-{templateName}/serviceGraphs/-`."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "serviceGraphs": []}],
"sites": [{"siteId": "1", "templateName": "LAB1", "serviceGraphs": []}],
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/serviceGraphs/-",
"value": {
"serviceGraphRef": {"schemaId": "schema-abc", "templateName": "LAB1", "serviceGraphName": "sgt-FW_LAB0"},
"serviceNodes": [],
},
}
]
apply_json_patch(doc, ops)
site = doc["sites"][0]
assert len(site["serviceGraphs"]) == 1
assert site["serviceGraphs"][0]["serviceGraphRef"] == "/schemas/schema-abc/templates/LAB1/serviceGraphs/sgt-FW_LAB0"
# ---------------------------------------------------------------------------
# GAP 2b — full schema GET returns rich detail (not the trimmed summary)
# ---------------------------------------------------------------------------
def test_get_schemas_returns_full_detail_not_trimmed_summary(client):
"""`custom_mso_schema_service_graph.py` (and sibling legacy modules)
bare-subscript straight into the plain `GET /schemas` list response
`schema_obj.get('templates')[idx]['serviceGraphs']`,
`schema_obj.get('sites')[idx]['serviceGraphs']` so this route must
return the FULL nested doc per schema, not the `{name}`-only summary
`schemas/list-identity` is optimized for."""
resp = client.get("/mso/api/v1/schemas")
assert resp.status_code == 200
schemas = resp.json()["schemas"]
assert len(schemas) > 0
for s in schemas:
assert "sites" in s
for tmpl in s.get("templates", []):
assert "serviceGraphs" in tmpl
assert "bds" in tmpl
assert "anps" in tmpl
for s in schemas:
for site in s.get("sites", []):
assert "serviceGraphs" in site
def test_get_schemas_service_graph_bare_subscript_flow_end_to_end(client):
"""End-to-end replica of `custom_mso_schema_service_graph.py`'s full
read sequence: `mso.get_obj('schemas', displayName=schema)` ->
`schema_obj.get('templates')[idx]['serviceGraphs']` (create branch) ->
`schema_obj.get('sites')[site_idx]['serviceGraphs']` (site-bind
branch) none of these may KeyError, matching the real hardware MS-TN2
`create_tenant` pass-2 crash signature this PR fixes."""
all_schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
schema_obj = next(s for s in all_schemas if s.get("displayName") == "MS-TN1-schema")
templates = [t.get("name") for t in schema_obj.get("templates")]
template_idx = templates.index("MS-TN1-template")
assert schema_obj.get("templates")[template_idx]["serviceGraphs"] == []
assert schema_obj.get("templates")[template_idx]["tenantId"], "template must carry tenantId for the update branch"
sites = schema_obj.get("sites")
assert sites, "expected at least one site associated with MS-TN1-template"
assert sites[0]["serviceGraphs"] == []
def test_schemas_service_node_types_route(client):
"""`custom_mso_schema_service_graph.py` resolves Firewall/Load
Balancer/Other to a stable id via `schemas/service-node-types` before
every service-graph node add must be routed BEFORE the parameterized
`/schemas/{schema_id}` route (same ordering hazard as `list-identity`)."""
resp = client.get("/mso/api/v1/schemas/service-node-types")
assert resp.status_code == 200
types = {t["displayName"]: t["id"] for t in resp.json()["serviceNodeTypes"]}
assert "Firewall" in types
assert types["Firewall"]
# Bare-path variant too (defensive — no delegate_to precedent observed
# for this specific task, but kept consistent with every other bare/
# mso-prefixed pair this sim maintains).
resp2 = client.get("/api/v1/schemas/service-node-types")
assert resp2.status_code == 200
def test_schema_template_and_site_service_graph_patch_round_trip(client):
"""Full multi-step replica of the real "Create service graph" +
site-bind sequence against a live schema: add the template-level
serviceGraph, then the site-level serviceGraph binding, and confirm
both survive an independent re-GET."""
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if s["displayName"] == "MS-TN1-schema")
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl_name = detail["templates"][0]["name"]
site = detail["sites"][0]
site_key = f"{site['siteId']}-{tmpl_name}"
template_sg_ops = [
{
"op": "add",
"path": f"/templates/{tmpl_name}/serviceGraphs/-",
"value": {
"name": "sgt-FW_LAB0",
"displayName": "sgt-FW_LAB0",
"serviceNodes": [{"name": "node1", "serviceNodeTypeId": "svc-node-type-firewall", "index": 1}],
},
}
]
r1 = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=template_sg_ops)
assert r1.status_code == 200
site_sg_ops = [
{
"op": "add",
"path": f"/sites/{site_key}/serviceGraphs/-",
"value": {
"serviceGraphRef": {"schemaId": ms_schema["id"], "templateName": tmpl_name, "serviceGraphName": "sgt-FW_LAB0"},
"serviceNodes": [
{
"serviceNodeRef": {
"schemaId": ms_schema["id"],
"templateName": tmpl_name,
"serviceGraphName": "sgt-FW_LAB0",
"serviceNodeName": "node1",
},
"device": {"dn": "uni/tn-MS-TN1/lDevVip-fw-FW_LAB0"},
}
],
},
}
]
r2 = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=site_sg_ops)
assert r2.status_code == 200
fresh = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
fresh_tmpl = fresh["templates"][0]
assert any(sg["name"] == "sgt-FW_LAB0" for sg in fresh_tmpl["serviceGraphs"])
fresh_site = next(s for s in fresh["sites"] if s["siteId"] == site["siteId"])
assert len(fresh_site["serviceGraphs"]) == 1
assert fresh_site["serviceGraphs"][0]["serviceGraphRef"].endswith("/serviceGraphs/sgt-FW_LAB0")
def test_contract_service_graph_relationship_bind(client):
"""`cisco.mso.mso_schema_template_contract_service_graph`'s bind (used
when `automate_contract_graph=true`) PATCHes `add
/templates/{t}/contracts/{c}/serviceGraphRelationship` must resolve
against a contract that already has `serviceGraphs` normalized on its
parent template (this PR doesn't touch this module directly since it
already uses safe `.get()`, but the full chain is asserted here)."""
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if s["displayName"] == "MS-TN1-schema")
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl = detail["templates"][0]
contract = tmpl["contracts"][0]
assert contract.get("serviceGraphRelationship") is None
client.patch(
f"/mso/api/v1/schemas/{ms_schema['id']}",
json=[
{
"op": "add",
"path": f"/templates/{tmpl['name']}/serviceGraphs/-",
"value": {"name": "sgt-FW_LAB0", "displayName": "sgt-FW_LAB0", "serviceNodes": []},
}
],
)
bind_ops = [
{
"op": "add",
"path": f"/templates/{tmpl['name']}/contracts/{contract['name']}/serviceGraphRelationship",
"value": {
"serviceGraphRef": {"serviceGraphName": "sgt-FW_LAB0", "templateName": tmpl["name"], "schemaId": ms_schema["id"]},
"serviceNodesRelationship": [],
},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=bind_ops)
assert resp.status_code == 200
updated_contract = next(c for c in resp.json()["templates"][0]["contracts"] if c["name"] == contract["name"])
assert updated_contract["serviceGraphRelationship"]["serviceGraphRef"].endswith("/serviceGraphs/sgt-FW_LAB0")
# ---------------------------------------------------------------------------
# uuid backfill on schema-template epgs/externalEpgs (normalize_template)
# ---------------------------------------------------------------------------
def test_normalize_template_backfills_epg_uuid():
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}
normalize_template(tmpl, "schema-abc")
epg = tmpl["anps"][0]["epgs"][0]
assert epg.get("uuid")
assert len(epg["uuid"]) == 32
def test_normalize_template_epg_uuid_is_stable_and_idempotent():
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}
normalize_template(tmpl, "schema-abc")
first = tmpl["anps"][0]["epgs"][0]["uuid"]
normalize_template(tmpl, "schema-abc")
assert tmpl["anps"][0]["epgs"][0]["uuid"] == first
def test_normalize_template_backfills_external_epg_uuid():
tmpl = {"name": "LAB1", "externalEpgs": [{"name": "xepg-All_LAB0"}]}
normalize_template(tmpl, "schema-abc")
ext_epg = tmpl["externalEpgs"][0]
assert ext_epg.get("uuid")
def test_build_ndo_model_seeds_epg_uuid_for_topology_tenants(topo):
"""Every schema-template EPG a topology.yaml tenant boots with already
must carry a uuid not just ones added at PATCH-runtime since
`ndo_dhcp_relay_policy`'s provider resolution can target any
pre-existing topology EPG."""
state = build_ndo_model(topo)
found_any_epg = False
for detail in state.schema_details.values():
for tmpl in detail.get("templates", []):
for anp in tmpl.get("anps", []):
for epg in anp.get("epgs", []):
found_any_epg = True
assert epg.get("uuid"), f"EPG {epg.get('name')} missing uuid"
assert found_any_epg, "expected at least one topology tenant with an EPG"
# ---------------------------------------------------------------------------
# Seeded tenantPolicy template summary carries templateName (name-based
# lookup requirement)
# ---------------------------------------------------------------------------
def test_seeded_tenant_policy_template_summary_has_template_name(client):
"""`MSOTemplate.__init__`'s by-name lookup filters
`templateName`+`templateType` the boot-seeded tenantPolicy template
summary must carry a `templateName`, not just `templateId`/
`templateType`, or a real `ndo_template`/`ndo_dhcp_relay_policy` call
targeting it by name could never match."""
summaries = client.get("/mso/api/v1/templates/summaries").json()
seeded = next(s for s in summaries if s["templateId"] == TENANT_POLICY_TEMPLATE_ID)
assert seeded.get("templateName")
assert seeded["templateType"] == "tenantPolicy"
# ---------------------------------------------------------------------------
# GAP 2 deeper layer — site-local contracts[] mirroring (service-graph
# redirect atomic PATCH)
# ---------------------------------------------------------------------------
def test_site_top_level_defaults_include_contracts():
assert SITE_TOP_LEVEL_DEFAULTS["contracts"] == []
def test_build_ndo_model_seeds_site_local_contracts_for_topology_tenants(topo):
"""A topology.yaml tenant that already ships with contracts configured
must boot with its site-local `sites[].contracts[]` already mirrored
otherwise the FIRST raw mso_rest PATCH addressing a site-local contract
by bare name would hit the same 'not found in list' crash this PR
fixes for the PATCH-built case."""
state = build_ndo_model(topo, sandbox=True)
found_any = False
for detail in state.schema_details.values():
for tmpl in detail.get("templates", []):
if not tmpl.get("contracts"):
continue
for site in detail.get("sites", []):
if site.get("templateName") != tmpl["name"]:
continue
assert "contracts" in site
for contract in tmpl["contracts"]:
matches = [c for c in site["contracts"] if c.get("contractRef", "").endswith(f"/contracts/{contract['name']}")]
assert matches, f"site missing mirrored contractRef for {contract['name']}"
found_any = True
assert found_any, "expected at least one topology tenant with a mirrored site-local contract"
def test_template_contract_add_mirrors_into_associated_site():
"""Adding a template-level contract to a template that already has a
site attached must auto-create a matching site-local `{contractRef}`
entry mirrors `test_template_anp_add_mirrors_into_associated_site`
(PR-12) for the contracts array."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "contracts": []}],
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
}
ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/contracts/-",
"value": {"name": "con-Firewall_LAB0", "scope": "vrf", "filterRelationships": []},
}
]
apply_json_patch(doc, ops)
site = doc["sites"][0]
assert site["contracts"][0]["contractRef"] == "/schemas/schema-abc/templates/LAB1-LAB2/contracts/con-Firewall_LAB0"
def test_template_contract_add_mirror_is_idempotent():
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "contracts": []}],
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
}
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/contracts/-", "value": {"name": "con-Firewall_LAB0"}}]
apply_json_patch(doc, ops)
apply_json_patch(doc, ops)
assert len(doc["sites"][0]["contracts"]) == 1
def test_atomic_service_graph_redirect_patch_on_site_local_contract_end_to_end(client):
"""Full replica of the mso-model role's raw `cisco.mso.mso_rest`
"Atomic PATCH — bind service-graph redirect on ALL fabrics in one
request" task: a schema built up entirely via runtime PATCH (matching
MS-TN2's actual flow), with a template contract added, then a
site-local `serviceGraphRelationship` bound on EACH site's mirrored
contract in a single PATCH batch the real hardware MS-TN2 `create_tenant`
pass-2 crash signature (`"path segment 'con-Firewall_LAB0' not found in
list"`) this PR fixes."""
payload = {
"displayName": "MS-TN2-LAB0",
"templates": [{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "t2", "contracts": []}],
"sites": [
{"siteId": "1", "templateName": "LAB1-LAB2"},
{"siteId": "2", "templateName": "LAB1-LAB2"},
],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
client.patch(
f"/mso/api/v1/schemas/{schema_id}",
json=[
{
"op": "add",
"path": "/templates/LAB1-LAB2/contracts/-",
"value": {"name": "con-Firewall_LAB0", "scope": "vrf", "filterRelationships": []},
}
],
)
redirect_ops = [
{
"op": "add",
"path": f"/sites/{site_id}-LAB1-LAB2/contracts/con-Firewall_LAB0/serviceGraphRelationship",
"value": {
"serviceGraphRef": {"schemaId": schema_id, "serviceGraphName": "sgt-FW_LAB0", "templateName": "LAB1-LAB2"},
"serviceNodesRelationship": [
{
"serviceNodeRef": {
"schemaId": schema_id,
"serviceGraphName": "sgt-FW_LAB0",
"serviceNodeName": "node1",
"templateName": "LAB1-LAB2",
},
"consumerConnector": {"clusterInterface": {"dn": "uni/tn-MS-TN2/x"}, "redirectPolicy": {"dn": "uni/tn-MS-TN2/y"}, "subnets": []},
"providerConnector": {"clusterInterface": {"dn": "uni/tn-MS-TN2/x"}, "redirectPolicy": {"dn": "uni/tn-MS-TN2/y"}},
}
],
},
}
for site_id in ("1", "2")
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=redirect_ops)
assert resp.status_code == 200
fresh = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
for site_id in ("1", "2"):
site = next(s for s in fresh["sites"] if s["siteId"] == site_id)
contract = next(c for c in site["contracts"] if c["contractRef"].endswith("/contracts/con-Firewall_LAB0"))
assert contract["serviceGraphRelationship"]["serviceGraphRef"].endswith("/serviceGraphs/sgt-FW_LAB0")
+36
View File
@@ -0,0 +1,36 @@
"""PR-14: classic ND bare /login + /logout endpoints (used by aci-py, cisco.nd)."""
from __future__ import annotations
import copy
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.topology.loader import load_topology
def _client():
topo = load_topology("topology.yaml")
state = build_ndo_model(topo, sandbox=False)
return TestClient(make_ndo_app(copy.deepcopy(state)), raise_server_exceptions=False)
def test_bare_login_returns_token_and_jwttoken():
c = _client()
r = c.post("/login", json={"userName": "admin", "userPasswd": "cisco", "domain": "local"})
assert r.status_code == 200
body = r.json()
assert body["token"] and body["token"] == body["jwttoken"]
assert body["userName"] == "admin"
def test_bare_logout_ok():
c = _client()
assert c.post("/logout").status_code == 200
def test_new_auth_login_still_works():
c = _client()
r = c.post("/api/v1/auth/login", json={"userName": "admin", "userPasswd": "cisco"})
assert r.status_code == 200 and r.json()["token"]
+325
View File
@@ -0,0 +1,325 @@
"""PR-15 — gaps exposed by a real `aci-py` (pure-Python Ansible-to-ACI
compiler) run against the sandbox sim on real ACI hardware.
GAP 1 APIC login rejects a domain-qualified login name. `aci-py` posts
`POST /api/aaaLogin.json` with `aaaUser.attributes.name =
"apic:local\\admin"` a valid real-APIC login format
(`apic:<loginDomain>\\<username>`). The sim's `aaaLogin` handler
(`aci_sim/rest_aci/auth.py`) exact-matched the raw `name` attribute
against `SIM_USERNAME`, so every domain-qualified login 401'd even with the
correct password confirmed live against the hardware sandbox
(`name="apic:local\\admin"` -> 401, `name="admin"` -> 200 before this fix).
This PR strips an optional `apic:<domain>\\` / `<domain>\\` prefix before
comparing the bare username (`_bare_username()`), so both forms authenticate
identically; a wrong username in EITHER form still 401s.
GAP 2 NDO schema PATCH 400 mid `create_tenant`/`create_bd`. `aci-py`'s
`mso_schema_site_bd` shim (`shims/mso_mso_bd_vrf.py`) always PATCHes a
site-local BD shadow with `op: replace` (never `add`), because real NDO 4.x
auto-creates that shadow (the "BDDelta") the moment a template-level BD is
added to a template that already has sites attached a fresh `add` there
409s on real hardware ("Multiple BDDelta entries"), so the shim's own
docstring documents doing a GET-then-replace instead. The sim never mirrored
a template BD `add` into `sites[].bds[]` (unlike ANPs/EPGs since PR-12 and
contracts since PR-13 the exact same "auto-mirror on template add" pattern,
just missing one collection), so the site-BD `replace` 400'd with
`"replace: 'bd-FW_LAB0' not found at
'/sites/1-LAB1-LAB2/bds/bd-FW_LAB0'"` — confirmed verbatim against a real
hardware aci-py MS-TN2 `create_tenant`/`create_bd` run before this fix (the
sim's own log + the NdoError body). This PR adds
`_mirror_template_bd_to_sites` (PATCH-time, parallel to
`_mirror_template_anp_to_sites`/`_mirror_template_contract_to_sites`) plus
the equivalent boot-time mirror in `build_ndo_model`, closing this fully
a real hardware MS-TN2 run now reaches 13/13 steps rc=0 (was 9 ok / 4 fail
before this PR; the 4 failures were the `replace: 'bd-*' not found` 400 in
`create_tenant` pass1, `create_bd`, `create_tenant` pass2, and
`migrate_existing_vlan_gateway`), and MS-TN1 stays 11/11 rc=0 (1 skip, no
regression).
VERDICT: both gaps are genuine SIM FIDELITY gaps, not aci-py bugs aci-py's
domain-qualified login name and its GET-then-replace site-BD shim both mirror
documented real-APIC/real-NDO behavior (the latter cited verbatim in aci-py's
own shim docstring, matching the exact rationale this codebase already
accepted for ANPs/EPGs/contracts in PR-11/12/13).
"""
from __future__ import annotations
import copy
from pathlib import Path
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.ndo.patch import apply_json_patch
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
# ---------------------------------------------------------------------------
# GAP 1 — domain-qualified aaaLogin name
# ---------------------------------------------------------------------------
def _apic_client() -> TestClient:
topo = load_topology(TOPOLOGY_YAML)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(name=site.name, site=site, topo=topo, store=store,
baseline=copy.deepcopy(store))
return TestClient(make_apic_app(state))
def _login(client: TestClient, name: str, pwd: str = "cisco"):
return client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": name, "pwd": pwd}}},
)
def test_plain_username_login_still_works():
c = _apic_client()
resp = _login(c, "admin")
assert resp.status_code == 200
assert "APIC-cookie" in resp.cookies
def test_domain_qualified_apic_prefixed_login_works():
"""`apic:local\\admin` — the exact shape aci-py's APIC connector sends."""
c = _apic_client()
resp = _login(c, "apic:local\\admin")
assert resp.status_code == 200
data = resp.json()
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
assert token
assert "APIC-cookie" in resp.cookies
def test_domain_qualified_bare_domain_login_works():
"""`local\\admin` (no `apic:` scheme prefix) — the other real-APIC shape."""
c = _apic_client()
resp = _login(c, "local\\admin")
assert resp.status_code == 200
assert "APIC-cookie" in resp.cookies
def test_domain_qualified_wrong_user_still_401s():
c = _apic_client()
resp = _login(c, "apic:local\\bob")
assert resp.status_code == 401
data = resp.json()
assert "imdata" in data
assert data["imdata"][0]["error"]["attributes"]["code"] == "401"
def test_domain_qualified_correct_user_wrong_password_still_401s():
c = _apic_client()
resp = _login(c, "apic:local\\admin", pwd="wrong")
assert resp.status_code == 401
def test_domain_qualified_login_session_authenticates_subsequent_queries():
"""The minted session must work exactly like a plain-username session —
no lingering domain-qualified string leaking into the session's user."""
c = _apic_client()
resp = _login(c, "apic:local\\admin")
assert resp.status_code == 200
q = c.get("/api/class/fvTenant.json")
assert q.status_code == 200
# ---------------------------------------------------------------------------
# GAP 2 — template BD add mirrors into every associated site (PATCH-time)
# ---------------------------------------------------------------------------
def test_template_bd_add_mirrors_into_associated_site():
"""Adding a template-level BD to a template that already has a site
attached must auto-create a matching site-local BD shadow mirrors
`test_template_contract_add_mirrors_into_associated_site` (PR-13) /
`test_template_anp_add_mirrors_into_associated_site` (PR-12) for bds[]."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "bds": []}],
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
}
ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/bds/-",
"value": {"name": "bd-FW_LAB0", "displayName": "FW_LAB0"},
}
]
apply_json_patch(doc, ops)
site = doc["sites"][0]
assert len(site["bds"]) == 1
mirrored = site["bds"][0]
assert mirrored["bdRef"] == "/schemas/schema-abc/templates/LAB1-LAB2/bds/bd-FW_LAB0"
# Real NDO's auto-created shadow defaults hostBasedRouting=False and
# carries the full child-collection shape (mso_schema_site_bd_subnet.py
# bare-subscripts `subnets`).
assert mirrored["hostBasedRouting"] is False
assert mirrored["subnets"] == []
def test_template_bd_add_mirror_is_idempotent():
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "bds": []}],
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
}
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/bds/-", "value": {"name": "bd-FW_LAB0"}}]
apply_json_patch(doc, ops)
apply_json_patch(doc, ops)
assert len(doc["sites"][0]["bds"]) == 1
def test_template_bd_add_mirrors_into_multiple_sites():
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "bds": []}],
"sites": [
{"siteId": "1", "templateName": "LAB1-LAB2"},
{"siteId": "2", "templateName": "LAB1-LAB2"},
],
}
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/bds/-", "value": {"name": "bd-App1_LAB0"}}]
apply_json_patch(doc, ops)
for site in doc["sites"]:
assert len(site["bds"]) == 1
assert site["bds"][0]["bdRef"].endswith("/bds/bd-App1_LAB0")
def test_template_bd_add_does_not_mirror_into_unassociated_site():
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "bds": []}, {"name": "OtherTpl", "bds": []}],
"sites": [{"siteId": "1", "templateName": "OtherTpl"}],
}
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/bds/-", "value": {"name": "bd-FW_LAB0"}}]
apply_json_patch(doc, ops)
assert doc["sites"][0].get("bds", []) == []
def test_site_bd_replace_succeeds_after_template_bd_add():
"""The end-to-end sequence aci-py actually emits: template BD `add`
(mso_schema_template_bd) followed by a site-local BD `replace`
(mso_schema_site_bd) in a LATER, separate PATCH call exactly
reproducing the real hardware 400 this PR fixes, then proving it is gone."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "bds": []}],
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
}
add_ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/bds/-",
"value": {"name": "bd-FW_LAB0", "displayName": "FW_LAB0"},
}
]
apply_json_patch(doc, add_ops)
# Site-BD replace, exactly as `mso_schema_site_bd.py`/aci-py's shim sends
# it: {"op": "replace", "path": "/sites/{siteId}-{tpl}/bds/{bd}", ...}.
replace_ops = [
{
"op": "replace",
"path": "/sites/1-LAB1-LAB2/bds/bd-FW_LAB0",
"value": {
"bdRef": {
"schemaId": "schema-abc",
"templateName": "LAB1-LAB2",
"bdName": "bd-FW_LAB0",
},
"hostBasedRouting": False,
},
}
]
# Before this PR: PatchError("replace: 'bd-FW_LAB0' not found at
# '/sites/1-LAB1-LAB2/bds/bd-FW_LAB0'") — the site shadow never existed.
apply_json_patch(doc, replace_ops)
site = doc["sites"][0]
assert len(site["bds"]) == 1
assert site["bds"][0]["bdRef"] == "/schemas/schema-abc/templates/LAB1-LAB2/bds/bd-FW_LAB0"
# ---------------------------------------------------------------------------
# GAP 2 — boot-time mirror (topology.yaml tenants that ship with BDs already)
# ---------------------------------------------------------------------------
def test_build_ndo_model_seeds_site_local_bds_for_topology_tenants():
"""A topology.yaml tenant that already ships with BDs configured must
boot with its site-local `sites[].bds[]` already mirrored otherwise
the FIRST site-BD PATCH (`replace`) against a freshly-booted sim would
hit the same 'not found in list' 400 this PR fixes for the PATCH-built
case. Mirrors `test_build_ndo_model_seeds_site_local_contracts_for_topology_tenants`
(PR-13)."""
topo = load_topology(TOPOLOGY_YAML)
state = build_ndo_model(topo, sandbox=True)
found_any = False
for detail in state.schema_details.values():
for tmpl in detail.get("templates", []):
if not tmpl.get("bds"):
continue
for site in detail.get("sites", []):
if site.get("templateName") != tmpl["name"]:
continue
assert "bds" in site
for bd in tmpl["bds"]:
matches = [b for b in site["bds"] if b.get("bdRef", "").endswith(f"/bds/{bd['name']}")]
assert matches, f"site missing mirrored bdRef for {bd['name']}"
found_any = True
assert found_any, "expected at least one topology tenant with a mirrored site-local BD"
def test_boot_seeded_site_bd_replace_end_to_end():
"""Full HTTP round trip: boot the NDO app from topology.yaml, find a
schema/template/site that already has a BD, then PATCH a `replace` on
the site-local shadow exactly like `mso_schema_site_bd.py` would on a
re-run must succeed against the BOOT-seeded schema, not just one built
up entirely via runtime PATCH ops."""
topo = load_topology(TOPOLOGY_YAML)
state = build_ndo_model(topo, sandbox=True)
app = make_ndo_app(state)
with TestClient(app) as client:
schema_id, tmpl_name, site_id, bd_name = None, None, None, None
for sid, detail in state.schema_details.items():
for tmpl in detail.get("templates", []):
if not tmpl.get("bds"):
continue
for site in detail.get("sites", []):
if site.get("templateName") == tmpl["name"] and site.get("bds"):
schema_id = sid
tmpl_name = tmpl["name"]
site_id = site["siteId"]
bd_name = tmpl["bds"][0]["name"]
break
if schema_id:
break
if schema_id:
break
assert schema_id is not None, "expected a boot-seeded schema/template/site with a BD"
site_seg = f"{site_id}-{tmpl_name}"
replace_ops = [
{
"op": "replace",
"path": f"/sites/{site_seg}/bds/{bd_name}",
"value": {
"bdRef": {"schemaId": schema_id, "templateName": tmpl_name, "bdName": bd_name},
"hostBasedRouting": False,
},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=replace_ops)
assert resp.status_code == 200, resp.text
fresh = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
site = next(s for s in fresh["sites"] if s.get("templateName") == tmpl_name and s.get("siteId") == site_id)
matches = [b for b in site["bds"] if b.get("bdRef", "").endswith(f"/bds/{bd_name}")]
assert matches
+321
View File
@@ -0,0 +1,321 @@
"""PR-16 — NDO site-local BD duplication (bdRef normalization).
CONFIRMED PRODUCTION SYMPTOM: after deploying the Acme App1-Net tenant through
`create_tenant.yml` + `create_bd.yml` against a real NDO schema
(`App1-Net-LAB0`), `sites[0].bds` carried TWO entries for the same template
BD `App1-Net_VLAN10` one empty mirror
(`bdRef=".../templates/LAB1/bds/App1-Net_VLAN10"`, `subnets=[]`)
created by `_mirror_template_bd_to_sites` (PR-15) when the template BD was
added, and a SECOND entry carrying the real subnet
(`10.10.10.1/24`) added later by `mso_schema_site_bd`'s own
`add /sites/{siteId}-{tmpl}/bds/-` (append) payload confirmed by the
pre-existing `test_patch_add_site_bd_by_composite_key` (test_pr11_ndo_write.py)
to use the DICT bdRef form (`{bdName: ...}`, no `schemaId`/`templateName`),
a form `_find_by_name`'s ref-lookup never matched because the append (`-`)
branch of `apply_json_patch` didn't consult `_find_by_name`/ref-resolution
AT ALL before appending it always appended unconditionally.
ROOT CAUSE: `apply_json_patch`'s `op == "add"` branch has three
sub-branches keyed by the *last path token* `"-"` (append),
a numeric index (`insert`), or a name (resolved via `_find_by_name`, which
DOES already dedup by matching a `*Ref` field's bare trailing name). Only
the named-segment sub-branch deduped. The append (`"-"`) sub-branch the
exact form `mso_schema_site_bd.py`/`mso_schema_site_anp.py`/
`mso_schema_template_contract_filter.py`'s "does not exist yet" branches
all use when addressing a site-local `bds`/`anps`/`contracts` collection
never checked for an existing entry, so a template-add mirror followed by
a site-module add for the SAME underlying object always produced a
duplicate.
FIX: `_find_shadow_by_identity()` resolves a candidate append *value*'s own
IDENTITY ref for the target collection (`bds``bdRef`, `anps``anpRef`,
`epgs``epgRef`, `contracts``contractRef`), in either string or dict form,
to a bare name and looks for an existing NAMELESS shadow entry with that
same identity ref. The `add`+`-` branch calls it before appending: a match
REPLACES that entry in place (real NDO's "one site-BD row per template BD"
invariant and cisco.mso's GET-then-write idiom); only a genuinely new
shadow appends.
CRITICAL SCOPING (the regression the first PR-16 cut had): identity
matching is restricted to NAMELESS shadows and to the ONE identity ref per
collection. A NAMED object (a template-level BD add, which carries its own
`name`) always appends it is never deduped by a `*Ref`. And a *property*
ref like `vrfRef` (which every template BD under one L3-attached VRF shares,
e.g. `vrf-L3_LAB0`) is NEVER used to establish identity. Matching on shared
property refs collapsed every template's BDs into a single entry in the
first cut; `test_distinct_template_bds_sharing_vrfref_do_not_collapse`
guards against any recurrence.
"""
from __future__ import annotations
from aci_sim.ndo.patch import apply_json_patch
def _mirror_bd(doc: dict) -> None:
"""Template BD add — fires _mirror_template_bd_to_sites (PR-15)."""
ops = [
{
"op": "add",
"path": "/templates/LAB1/bds/-",
"value": {"name": "App1-Net_VLAN10", "displayName": "App1-Net_VLAN10"},
}
]
apply_json_patch(doc, ops)
def _base_doc() -> dict:
return {
"id": "schema-app1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
def test_site_module_add_by_dict_bdref_updates_mirrored_entry_not_duplicates():
"""The exact production sequence: template BD add (mirror fires, string
bdRef, subnets=[]) THEN a site-module-style `add /sites/{sid}-{tmpl}/
bds/-` addressed by the OTHER bdRef form (a dict, no schemaId/
templateName `mso_schema_site_bd.py`'s literal "BD does not exist"
payload per test_patch_add_site_bd_by_composite_key). Must converge on
ONE site-bd entry, not two."""
doc = _base_doc()
_mirror_bd(doc)
site = doc["sites"][0]
assert len(site["bds"]) == 1, "mirror must create exactly one shadow"
assert site["bds"][0]["bdRef"] == "/schemas/schema-app1/templates/LAB1/bds/App1-Net_VLAN10"
assert site["bds"][0]["subnets"] == []
site_add_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {
"bdRef": {"bdName": "App1-Net_VLAN10"},
"hostBasedRouting": False,
},
}
]
apply_json_patch(doc, site_add_ops)
# No duplicate entry: the site-module add must have resolved to the
# SAME list slot the mirror created (updating it in place), not
# appended a second row. The incoming bare {"bdName": ...} form (no
# schemaId/templateName) isn't stringified — matching pre-existing,
# unchanged `_stringify_refs` behavior — so the stored bdRef becomes
# whatever the caller sent; what matters is there is exactly ONE row.
assert len(site["bds"]) == 1, (
f"expected exactly one site-bd entry after site-module add, got {len(site['bds'])}: {site['bds']}"
)
assert site["bds"][0]["bdRef"] == {"bdName": "App1-Net_VLAN10"}
assert site["bds"][0]["hostBasedRouting"] is False
def test_site_module_add_by_full_schema_dict_bdref_then_subnet_converges_on_one_entry():
"""Same as above but the dict form carries schemaId+templateName (the
OTHER real cisco.mso payload shape, see test_pr11_ndo_write.py's
`test_patch_add_site_bd_subnet_by_ref_name_resolution` family), and a
subsequent subnet add lands on the SAME entry the mirror created this
is the exact Acme App1-Net symptom (empty mirror + subnet-carrying entry
must be ONE row, not two)."""
doc = _base_doc()
_mirror_bd(doc)
site_add_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {
"bdRef": {
"schemaId": "schema-app1",
"templateName": "LAB1",
"bdName": "App1-Net_VLAN10",
},
"hostBasedRouting": False,
},
}
]
apply_json_patch(doc, site_add_ops)
site = doc["sites"][0]
assert len(site["bds"]) == 1, f"expected one entry after site add, got {site['bds']}"
subnet_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/App1-Net_VLAN10/subnets/-",
"value": {"ip": "10.10.10.1/24", "scope": "private"},
}
]
apply_json_patch(doc, subnet_ops)
assert len(site["bds"]) == 1, (
f"subnet add must not create a second entry, got {len(site['bds'])}: {site['bds']}"
)
only_bd = site["bds"][0]
assert only_bd["bdRef"] == "/schemas/schema-app1/templates/LAB1/bds/App1-Net_VLAN10"
assert only_bd["subnets"] == [{"ip": "10.10.10.1/24", "scope": "private"}]
def test_site_module_add_before_mirror_still_dedups_on_later_mirror_call():
"""Order-independence: if the site-module add somehow lands BEFORE a
(re-applied/idempotent) template mirror call, the mirror's own
_find_by_name dedup (unchanged by this fix) still recognizes the
site-module's entry and does not append a second one."""
doc = _base_doc()
site_add_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {"bdRef": {"bdName": "App1-Net_VLAN10"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, site_add_ops)
assert len(doc["sites"][0]["bds"]) == 1
_mirror_bd(doc)
assert len(doc["sites"][0]["bds"]) == 1, "re-mirroring must not duplicate an existing site-bd"
def test_unrelated_appends_without_ref_field_still_append_normally():
"""Values with no identity `*Ref` (e.g. a template-level BD add, which
carries its own name, or a subnet add's own value) must keep appending
unconditionally the dedup-on-append behavior is scoped to nameless
site-local shadow objects only, via _find_shadow_by_identity returning
None for named / non-shadow values."""
doc = {"templates": [{"name": "LAB1", "bds": []}], "sites": []}
ops = [
{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-A"}},
{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-B"}},
]
apply_json_patch(doc, ops)
assert [b["name"] for b in doc["templates"][0]["bds"]] == ["bd-A", "bd-B"]
def test_multiple_distinct_bds_in_same_site_all_kept_separate():
"""Dedup must match by bare name, not accidentally collapse different
BDs mirrored into the same site."""
doc = {
"id": "schema-app1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-A"}}])
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-B"}}])
site = doc["sites"][0]
assert len(site["bds"]) == 2
apply_json_patch(
doc,
[
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {"bdRef": {"bdName": "bd-B"}, "hostBasedRouting": True},
}
],
)
assert len(site["bds"]) == 2, f"expected bd-A untouched, bd-B updated in place, got {site['bds']}"
def _name(bd: dict) -> str | None:
ref = bd["bdRef"]
if isinstance(ref, str):
return ref.rsplit("/", 1)[-1]
return ref.get("bdName")
names = {_name(b) for b in site["bds"]}
assert names == {"bd-A", "bd-B"}
bd_b = next(b for b in site["bds"] if _name(b) == "bd-B")
assert bd_b["hostBasedRouting"] is True
# ---------------------------------------------------------------------------
# REGRESSION GUARD — the first PR-16 cut collapsed every template's BDs to
# one entry because it deduped named objects via a SHARED non-identity ref.
# ---------------------------------------------------------------------------
def test_distinct_template_bds_sharing_vrfref_do_not_collapse():
"""CATASTROPHIC REGRESSION GUARD. In the real Acme create_bd run, every
template BD carries the SAME `vrfRef` (`vrf-L3_LAB0`). The first PR-16
cut resolved an appended value's identity by iterating ALL
`_REF_LOOKUP_KEYS` (including `vrfRef`), so two DISTINCT template BDs
(`bd-App2-...` and `bd-App3-...`) both matched via their shared
`vrfRef` and collapsed each `add /templates/{T}/bds/-` overwrote the
previous BD, leaving exactly ONE BD per template. A NAMED object must
match ONLY by name, and identity refs must exclude property refs like
`vrfRef`."""
doc = {"id": "s1", "templates": [{"name": "T", "bds": []}], "sites": []}
shared_vrf = {"schemaId": "s1", "templateName": "T", "vrfName": "vrf-L3_LAB0"}
for name in ("bd-App2-Net", "bd-App3-Hst", "bd-App4-Bkp"):
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/T/bds/-", "value": {"name": name, "vrfRef": dict(shared_vrf)}}],
)
names = [b["name"] for b in doc["templates"][0]["bds"]]
assert names == ["bd-App2-Net", "bd-App3-Hst", "bd-App4-Bkp"], (
f"distinct template BDs sharing a vrfRef must NOT collapse — got {names}"
)
def test_template_bd_add_with_shared_vrfref_mirrors_all_into_site():
"""The site-mirror side of the same regression: three distinct template
BDs sharing one `vrfRef`, added to a template with a site attached, must
each produce their OWN site-local shadow (3 shadows, not 1)."""
doc = {
"id": "s1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
shared_vrf = {"schemaId": "s1", "templateName": "LAB1", "vrfName": "vrf-L3_LAB0"}
for name in ("bd-One", "bd-Two", "bd-Three"):
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": name, "vrfRef": dict(shared_vrf)}}],
)
assert len(doc["templates"][0]["bds"]) == 3
site = doc["sites"][0]
mirrored = {b["bdRef"].rsplit("/", 1)[-1] for b in site["bds"]}
assert mirrored == {"bd-One", "bd-Two", "bd-Three"}, f"each BD needs its own shadow, got {site['bds']}"
def test_site_shadow_with_vrfref_property_still_dedups_only_on_bdref():
"""A site-BD shadow may legitimately carry a `vrfRef` property in
addition to its identity `bdRef`. Two site-module adds for DIFFERENT
BDs that happen to share a `vrfRef` must still stay separate (dedup is
on `bdRef` identity, not the shared `vrfRef` property)."""
doc = {
"id": "s1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-P"}}])
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-Q"}}])
site = doc["sites"][0]
assert len(site["bds"]) == 2
vrf = "/schemas/s1/templates/LAB1/vrfs/vrf-Shared"
apply_json_patch(
doc,
[{"op": "add", "path": "/sites/101-LAB1/bds/-", "value": {"bdRef": {"bdName": "bd-P"}, "vrfRef": vrf}}],
)
apply_json_patch(
doc,
[{"op": "add", "path": "/sites/101-LAB1/bds/-", "value": {"bdRef": {"bdName": "bd-Q"}, "vrfRef": vrf}}],
)
assert len(site["bds"]) == 2, f"distinct shadows sharing a vrfRef must not merge, got {site['bds']}"
def test_replace_named_object_still_resolves_by_name_only():
"""A `replace` addressing a named template BD by name must still work,
and must not accidentally resolve to a sibling via a shared ref."""
doc = {"id": "s1", "templates": [{"name": "T", "bds": []}], "sites": []}
shared_vrf = {"schemaId": "s1", "templateName": "T", "vrfName": "vrf-Shared"}
apply_json_patch(doc, [{"op": "add", "path": "/templates/T/bds/-", "value": {"name": "bd-A", "vrfRef": dict(shared_vrf)}}])
apply_json_patch(doc, [{"op": "add", "path": "/templates/T/bds/-", "value": {"name": "bd-B", "vrfRef": dict(shared_vrf)}}])
apply_json_patch(
doc,
[{"op": "replace", "path": "/templates/T/bds/bd-B", "value": {"name": "bd-B", "displayName": "B-updated"}}],
)
bds = {b["name"]: b for b in doc["templates"][0]["bds"]}
assert set(bds) == {"bd-A", "bd-B"}
assert bds["bd-B"].get("displayName") == "B-updated"
assert "displayName" not in bds["bd-A"], "replace must not have touched the sibling BD"
+252
View File
@@ -0,0 +1,252 @@
"""Tests for PR-18 — Tier-1 fabric configuration parameters.
Covers each Tier-1 param defaulting correctly when absent from
topology.yaml, overriding when set, the deterministic-serial fallback,
`site.pod` DN correctness, N-site scaffold collision-freedom, and
`infraWiNode` count tracking `site.controllers`.
See docs/DESIGN.md's "PR-18 — Tier-1 fabric configuration parameters"
section for the full design rationale, including the documented N-site
ISN/`other_site()` limitation this file's `TestNSite` class verifies
(builds without collision; does NOT assert a true N-site ISN full mesh,
which is explicitly out of scope for this PR).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import orchestrator
from aci_sim.build.fabric import default_serial
from aci_sim.cli import generate_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Fabric, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
@pytest.fixture(scope="module")
def repo_topo() -> Topology:
return load_topology(TOPOLOGY_YAML)
# ---------------------------------------------------------------------------
# Defaults — absent from topology.yaml, each param resolves to its default
# ---------------------------------------------------------------------------
class TestDefaults:
def test_controllers_defaults_to_1(self, repo_topo: Topology) -> None:
"""PR-21: single-APIC-per-site is now the default (was 3 pre-PR-21)."""
for site in repo_topo.sites:
assert site.controllers == 1
def test_pod_defaults_to_1(self, repo_topo: Topology) -> None:
for site in repo_topo.sites:
assert site.pod == 1
def test_tep_pool_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.tep_pool == "10.0.0.0/16"
def test_infra_vlan_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.infra_vlan == 3967
def test_gipo_pool_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.gipo_pool == "225.0.0.0/15"
def test_repo_topology_yaml_still_validates_unchanged(self) -> None:
"""The backward-compat acceptance bar: the real topology.yaml, with
no Tier-1 fields added, must still load + validate cleanly."""
topo = load_topology(TOPOLOGY_YAML)
assert len(topo.sites) == 2
# ---------------------------------------------------------------------------
# Overrides — each param actually takes effect when set
# ---------------------------------------------------------------------------
class TestOverrides:
def test_controllers_override(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = 5
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].controllers == 5
def test_pod_override(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["pod"] = 2
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].pod == 2
def test_tep_pool_override(self) -> None:
fab = Fabric(name="x", tep_pool="172.16.0.0/16")
assert fab.tep_pool == "172.16.0.0/16"
def test_infra_vlan_override(self) -> None:
fab = Fabric(name="x", infra_vlan=100)
assert fab.infra_vlan == 100
def test_gipo_pool_override(self) -> None:
fab = Fabric(name="x", gipo_pool="226.0.0.0/15")
assert fab.gipo_pool == "226.0.0.0/15"
def test_infra_vlan_out_of_range_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["infra_vlan"] = 4095
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_infra_vlan_zero_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["infra_vlan"] = 0
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_tep_pool_bad_cidr_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["tep_pool"] = "not-a-cidr"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_gipo_pool_bad_cidr_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["gipo_pool"] = "garbage"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# Deterministic serials
# ---------------------------------------------------------------------------
class TestSerials:
def test_default_serial_format(self) -> None:
assert default_serial("1", 101) == "SAL10101"
assert default_serial("2", 301) == "SAL20301"
def test_default_serial_deterministic(self) -> None:
assert default_serial("1", 101) == default_serial("1", 101)
def test_auto_node_gets_nonempty_serial_in_built_fabricNode(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
for mo in store.by_class("fabricNode"):
assert mo.attrs.get("serial"), f"empty serial for {mo.dn}"
def test_auto_node_serial_matches_default_serial_scheme(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
node101 = next(mo for mo in store.by_class("fabricNode") if mo.attrs.get("id") == "101")
assert node101.attrs["serial"] == default_serial(site.id, 101)
def test_explicit_yaml_serial_overrides_default(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["leaves"][0]["serial"] = "EXPLICIT-SERIAL-1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
leaf_id = site.leaf_nodes()[0].id
mo = next(m for m in store.by_class("fabricNode") if m.attrs.get("id") == str(leaf_id))
assert mo.attrs["serial"] == "EXPLICIT-SERIAL-1"
def test_fabricNodeIdentP_serial_matches_fabricNode_serial(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
fn_serials = {mo.attrs["id"]: mo.attrs["serial"] for mo in store.by_class("fabricNode")}
for mo in store.by_class("fabricNodeIdentP"):
assert mo.attrs["serial"] == fn_serials[mo.attrs["nodeId"]]
# ---------------------------------------------------------------------------
# Pod DN correctness
# ---------------------------------------------------------------------------
class TestPod:
def test_pod2_site_builds_pod2_dns(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["pod"] = 2
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
assert fabric_nodes, "expected at least one fabricNode"
for mo in fabric_nodes:
assert mo.dn.startswith("topology/pod-2/"), mo.dn
for mo in store.by_class("topSystem"):
assert mo.dn.startswith("topology/pod-2/"), mo.dn
assert mo.attrs["podId"] == "2"
# ---------------------------------------------------------------------------
# Controllers -> infraWiNode count
# ---------------------------------------------------------------------------
class TestControllersWiring:
@pytest.mark.parametrize("n_controllers", [1, 3, 5])
def test_infraWiNode_count_matches_controllers_squared(self, n_controllers: int) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = n_controllers
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
wi = store.by_class("infraWiNode")
assert len(wi) == n_controllers * n_controllers
def test_controller_fabricNode_count_matches_controllers(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = 4
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
controllers = [mo for mo in store.by_class("fabricNode") if mo.attrs.get("role") == "controller"]
assert len(controllers) == 4
# ---------------------------------------------------------------------------
# N-site — collision-free build; documented ISN limit not asserted as a bug
# ---------------------------------------------------------------------------
class TestNSite:
def test_3site_scaffold_builds_without_node_id_collision(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 3
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
assert len(all_ids) == len(set(all_ids))
def test_3site_scaffold_distinct_pods_serials_controllers(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1)
# Give each site a distinct pod to prove pod-per-site isn't collapsed.
for i, site_def in enumerate(topo_dict["sites"], start=1):
site_def["pod"] = i
site_def["controllers"] = i + 2 # 3, 4, 5
topo = Topology.model_validate(topo_dict)
assert [s.pod for s in topo.sites] == [1, 2, 3]
assert [s.controllers for s in topo.sites] == [3, 4, 5]
for site in topo.sites:
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
for mo in fabric_nodes:
assert mo.dn.startswith(f"topology/pod-{site.pod}/")
assert mo.attrs["serial"] # non-empty
controllers = [mo for mo in fabric_nodes if mo.attrs.get("role") == "controller"]
assert len(controllers) == site.controllers
def test_3site_new_cli_controllers_flag(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1, controllers=5)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 3
for site in topo.sites:
assert site.controllers == 5
+396
View File
@@ -0,0 +1,396 @@
"""Tests for PR-19 — Tier-2 topology-elasticity parameters.
Covers:
- ISN ospf_area/mtu: schema defaults+overrides, validation, and that
build/underlay.py's ospfIf/ospfAdjEp + build/interfaces.py's ISN uplink
l1PhysIf actually reflect the configured values (not just stored).
- VMM domain vCenter config: schema defaults+overrides, vlan_pool
cross-reference validation, and that build/access.py produces a
vmmDomP/vmmCtrlrP/vmmUsrAccP tree with the right attrs (hostOrIp,
rootContName, dvsName) only when vcenter_ip is set.
- Leaf/spine count elasticity: `aci-sim new` generates collision-free
topologies at larger counts (8 leaves / 4 spines, multiple border
pairs), and the previously-unguarded ID-scheme overrun (large
--leaves-per-site/--spines-per-site/--border-pairs) is now rejected
with a clear error at generation time instead of a confusing
Topology.normalize_and_validate collision error.
- isn.peer_mesh: partial is a documented validate-and-reject (unchanged
from PR-17/18 lineage; re-asserted here as part of PR-19's Tier-2 pass).
See docs/DESIGN.md's "PR-19 — Tier-2 topology-elasticity parameters"
section for the full design rationale, including why VMM domain MOs are a
pure-addition (no existing builder/production-chain reads a vmmDomP/
vmmCtrlrP object the real aci-py `bind_epg_to_vmm_domain` MS-TN1 chain
writes an NDO schema `domainAssociations` entry referencing the domain by
DN string only).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import orchestrator
from aci_sim.cli import generate_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import ISN, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
@pytest.fixture(scope="module")
def repo_topo() -> Topology:
return load_topology(TOPOLOGY_YAML)
# ---------------------------------------------------------------------------
# ISN ospf_area / mtu — defaults
# ---------------------------------------------------------------------------
class TestIsnDefaults:
def test_ospf_area_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.isn.ospf_area == "0.0.0.0"
def test_mtu_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.isn.mtu == 9150
def test_repo_topology_yaml_still_validates_unchanged(self) -> None:
"""Backward-compat bar: the real topology.yaml, with no Tier-2
ISN fields added, must still load + validate cleanly."""
topo = load_topology(TOPOLOGY_YAML)
assert len(topo.sites) == 2
def test_ospfIf_reflects_default_area(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
ospf_ifs = store.by_class("ospfIf")
assert ospf_ifs, "expected at least one ospfIf"
for mo in ospf_ifs:
assert mo.attrs["area"] == "0.0.0.0"
def test_isn_uplink_l1PhysIf_reflects_default_mtu(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
spine_ids = {n.id for n in site.spine_nodes()}
# Every spine's dedicated ISN uplink port (eth1/{49+i}, per
# interfaces.py's _UPLINK_START convention) must carry isn.mtu.
spine_isn_dns = {
f"topology/pod-{site.pod}/node-{sid}/sys/phys-[eth1/{49 + i}]"
for i, sid in enumerate(sorted(spine_ids))
}
found = [mo for mo in store.by_class("l1PhysIf") if mo.dn in spine_isn_dns]
assert len(found) == len(spine_ids)
for mo in found:
assert mo.attrs["mtu"] == "9150"
# ---------------------------------------------------------------------------
# ISN ospf_area / mtu — overrides
# ---------------------------------------------------------------------------
class TestIsnOverrides:
def test_ospf_area_override(self) -> None:
isn = ISN(ospf_area="10.0.0.0")
assert isn.ospf_area == "10.0.0.0"
def test_ospf_area_decimal_form_accepted(self) -> None:
isn = ISN(ospf_area="10")
assert isn.ospf_area == "10"
def test_mtu_override(self) -> None:
isn = ISN(mtu=9000)
assert isn.mtu == 9000
def test_ospf_area_garbage_rejected(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["ospf_area"] = "not-an-area"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_mtu_too_low_rejected(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["mtu"] = 100
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_mtu_too_high_rejected(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["mtu"] = 20000
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_ospfIf_reflects_overridden_area(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["ospf_area"] = "0.0.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ospf_ifs = store.by_class("ospfIf")
assert ospf_ifs
for mo in ospf_ifs:
assert mo.attrs["area"] == "0.0.0.1"
for mo in store.by_class("ospfAdjEp"):
assert mo.attrs["area"] == "0.0.0.1"
def test_isn_uplink_l1PhysIf_reflects_overridden_mtu(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["mtu"] = 9000
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
spine_id = site.spine_nodes()[0].id
isn_dn = f"topology/pod-{site.pod}/node-{spine_id}/sys/phys-[eth1/49]"
mo = next(m for m in store.by_class("l1PhysIf") if m.dn == isn_dn)
assert mo.attrs["mtu"] == "9000"
def test_fabric_uplink_mtu_unaffected_by_isn_mtu_override(self) -> None:
"""Only the dedicated ISN spine uplink port should pick up isn.mtu —
ordinary intra-fabric ports (leaves, spine fabric-uplinks used for
the Clos mesh) must keep the 9216 fabric default."""
topo_dict = generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=0)
topo_dict["isn"]["mtu"] = 9000
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
leaf_id = site.leaf_nodes()[0].id
# A leaf's fabric-uplink port is never an ISN port (ISN ports are
# spine-only) — its mtu must remain the fabric default.
leaf_ports = [mo for mo in store.by_class("l1PhysIf") if f"node-{leaf_id}/" in mo.dn]
assert leaf_ports
for mo in leaf_ports:
assert mo.attrs["mtu"] == "9216"
# ---------------------------------------------------------------------------
# isn.peer_mesh: partial — documented validate-and-reject (unchanged)
# ---------------------------------------------------------------------------
class TestPeerMeshPartial:
def test_partial_mesh_rejected_at_build_time(self) -> None:
"""isn.peer_mesh: partial passes schema validation (no schema-level
enum restriction see ISN.peer_mesh's `partial` note) but
build/overlay.py fails fast rather than silently building full-mesh
or an arbitrary subset."""
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["peer_mesh"] = "partial"
topo = Topology.model_validate(topo_dict) # schema-level: OK
site = topo.sites[0]
with pytest.raises(ValueError, match="peer_mesh"):
orchestrator.build_site(topo, site)
# ---------------------------------------------------------------------------
# VMM domain — defaults (empty list; backward compat)
# ---------------------------------------------------------------------------
class TestVmmDefaults:
def test_repo_topology_yaml_has_no_vmm_domains(self, repo_topo: Topology) -> None:
assert repo_topo.access.vmm_domains == []
def test_no_vmmDomP_built_when_vmm_domains_empty(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
assert store.by_class("vmmDomP") == []
assert store.by_class("vmmCtrlrP") == []
def test_bare_vmm_domain_no_vcenter_ip_produces_no_ctrlrp(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [{"name": "vmm-vmw-bare"}]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
dom_mos = store.by_class("vmmDomP")
assert len(dom_mos) == 1
assert dom_mos[0].attrs["name"] == "vmm-vmw-bare"
assert store.by_class("vmmCtrlrP") == []
# ---------------------------------------------------------------------------
# VMM domain — overrides (vCenter attrs populate vmmCtrlrP)
# ---------------------------------------------------------------------------
class TestVmmOverrides:
def test_vcenter_attrs_populate_vmmCtrlrP(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{
"name": "vmm-vmw-lab1",
"vcenter_ip": "10.50.1.10",
"vcenter_dvs": "DVS-LAB1",
"datacenter": "DC-LAB1",
}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrlrs = store.by_class("vmmCtrlrP")
assert len(ctrlrs) == 1
ctrlr = ctrlrs[0]
assert ctrlr.attrs["hostOrIp"] == "10.50.1.10"
assert ctrlr.attrs["rootContName"] == "DC-LAB1"
assert ctrlr.attrs["dvsName"] == "DVS-LAB1"
assert ctrlr.dn.startswith("uni/vmmp-VMware/dom-vmm-vmw-lab1/ctrlr-")
def test_vcenter_ip_produces_vmmUsrAccP_child(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vcenter_ip": "10.50.1.10"}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
usracc = store.by_class("vmmUsrAccP")
assert len(usracc) == 1
def test_datacenter_defaults_to_domain_name_when_unset(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vcenter_ip": "10.50.1.10"}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrlr = store.by_class("vmmCtrlrP")[0]
assert ctrlr.attrs["rootContName"] == "vmm-vmw-lab1"
def test_multiple_vmm_domains_each_get_own_ctrlrp(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vcenter_ip": "10.50.1.10"},
{"name": "vmm-vmw-lab2", "vcenter_ip": "10.50.2.10"},
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrlrs = {mo.attrs["hostOrIp"] for mo in store.by_class("vmmCtrlrP")}
assert ctrlrs == {"10.50.1.10", "10.50.2.10"}
# ---------------------------------------------------------------------------
# VMM domain — vlan_pool cross-reference validation
# ---------------------------------------------------------------------------
class TestVmmVlanPoolValidation:
def test_unknown_vlan_pool_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vlan_pool": "does-not-exist"}
]
with pytest.raises(ValidationError, match="vlan_pool"):
Topology.model_validate(topo_dict)
def test_known_vlan_pool_accepted(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
pool_name = topo_dict["access"]["vlan_pools"][0]["name"]
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vlan_pool": pool_name}
]
topo = Topology.model_validate(topo_dict)
assert topo.access.vmm_domains[0].vlan_pool == pool_name
def test_vlan_pool_bind_produces_infraRsVlanNs_child(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
pool_name = topo_dict["access"]["vlan_pools"][0]["name"]
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vlan_pool": pool_name}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
rsvlan = [
mo for mo in store.by_class("infraRsVlanNs")
if mo.dn.startswith("uni/vmmp-VMware/dom-vmm-vmw-lab1")
]
assert len(rsvlan) == 1
# ---------------------------------------------------------------------------
# Leaf/spine count elasticity — `aci-sim new` sugar
# ---------------------------------------------------------------------------
class TestLeafSpineElasticity:
def test_8_leaves_4_spines_validates_clean(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=8, spines_per_site=4)
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
assert len(site.leaf_nodes()) == 8
assert len(site.spine_nodes()) == 4
def test_8_leaves_4_spines_no_id_collision(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=8, spines_per_site=4, border_pairs=2)
topo = Topology.model_validate(topo_dict)
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
assert len(all_ids) == len(set(all_ids))
def test_border_pairs_greater_than_1_works(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=4, spines_per_site=2, border_pairs=3)
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
assert len(site.border_leaves) == 6 # 3 pairs = 6 border leaves
# each pair shares one vpc_domain
domains = {bl.vpc_domain for bl in site.border_leaves}
assert len(domains) == 3
@pytest.mark.parametrize(
"leaves,spines,borders",
[(101, 2, 0), (2, 101, 0), (150, 4, 3), (2, 2, 50)],
)
def test_id_scheme_overrun_rejected_with_clear_error(
self, leaves: int, spines: int, borders: int
) -> None:
"""Previously: these combinations silently produced a Topology dict
with colliding node ids, only caught deep inside
Topology.normalize_and_validate with a message that didn't name the
offending flag. Now: generate_topology itself rejects them."""
with pytest.raises(ValueError):
generate_topology(
sites=2, leaves_per_site=leaves, spines_per_site=spines, border_pairs=borders
)
def test_100_leaves_100_spines_boundary_still_works(self) -> None:
"""Exact boundary: 100 is the max safe leaves/spines-per-site count
before the next block/site is reached."""
topo_dict = generate_topology(sites=2, leaves_per_site=100, spines_per_site=100, border_pairs=0)
topo = Topology.model_validate(topo_dict)
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
assert len(all_ids) == len(set(all_ids))
def test_101_leaves_rejected(self) -> None:
with pytest.raises(ValueError, match="leaves-per-site"):
generate_topology(sites=2, leaves_per_site=101, spines_per_site=2, border_pairs=0)
def test_101_spines_rejected(self) -> None:
with pytest.raises(ValueError, match="spines-per-site"):
generate_topology(sites=2, leaves_per_site=2, spines_per_site=101, border_pairs=0)
def test_border_pairs_overrun_rejected(self) -> None:
with pytest.raises(ValueError, match="border-pairs"):
generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=50)
def test_full_build_succeeds_for_8_leaves_4_spines(self) -> None:
"""Confirms the orchestrator can actually build a site (not just
validate the schema) at the larger count."""
topo_dict = generate_topology(sites=2, leaves_per_site=8, spines_per_site=4, border_pairs=1)
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
switch_ids = {
int(mo.attrs["id"]) for mo in fabric_nodes if mo.attrs.get("role") != "controller"
}
expected_ids = {n.id for n in site.all_nodes()}
assert switch_ids == expected_ids
assert len(expected_ids) == 8 + 4 + 2 # leaves + spines + 1 border pair

Some files were not shown because too many files have changed in this diff Show More