mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
Initial public release — aci-sim v0.16.0
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO management planes) — for testing ACI automation (Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates without real hardware. Highlights: APIC + NDO REST surface served from one in-memory MIT built from a declarative topology.yaml; port mode + sandbox (real per-device IPs on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror (multi-site templates materialize onto the target sites' APIC stores); real-APIC fvBD default attributes; file-backed state save/restore; an `aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite. History squashed for the public release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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,
|
||||
))
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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 parent→child 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)
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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))
|
||||
@@ -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 spine↔leaf 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,
|
||||
))
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
"""Control-plane admin router mounted under /_sim on each APIC app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from aci_sim.control.persist import (
|
||||
deserialize_store,
|
||||
load_json,
|
||||
save_json,
|
||||
serialize_store,
|
||||
state_dir,
|
||||
)
|
||||
from aci_sim.mit.mo import MO
|
||||
|
||||
|
||||
def _apic_error(text: str, code: str = "103", status_code: int = 400) -> JSONResponse:
|
||||
"""Return an APIC error envelope as a JSONResponse.
|
||||
|
||||
Duplicated (rather than imported) from rest_aci.app to avoid a circular
|
||||
import: app.py imports make_admin_router from this module.
|
||||
"""
|
||||
body = {
|
||||
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
|
||||
"totalCount": "1",
|
||||
}
|
||||
return JSONResponse(status_code=status_code, content=body)
|
||||
|
||||
|
||||
def make_admin_router(state) -> APIRouter:
|
||||
"""Build the /_sim admin router.
|
||||
|
||||
state is an ApicSiteState dataclass; we mutate state.store directly.
|
||||
"""
|
||||
router = APIRouter(prefix="/_sim")
|
||||
_snapshots: dict[str, object] = {} # name → MITStore deepcopy
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset():
|
||||
state.store = copy.deepcopy(state.baseline)
|
||||
return {"status": "ok", "action": "reset"}
|
||||
|
||||
@router.post("/snapshot/{name}")
|
||||
async def snapshot(name: str):
|
||||
_snapshots[name] = copy.deepcopy(state.store)
|
||||
return {"status": "ok", "snapshot": name}
|
||||
|
||||
@router.post("/restore/{name}")
|
||||
async def restore(name: str):
|
||||
if name not in _snapshots:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "detail": f"snapshot '{name}' not found"},
|
||||
)
|
||||
state.store = copy.deepcopy(_snapshots[name])
|
||||
return {"status": "ok", "restored": name}
|
||||
|
||||
def _find_site(topo):
|
||||
"""Look up the site matching state.site in a freshly-loaded topology.
|
||||
|
||||
Matches by the stable Site.id first (the schema's documented stable
|
||||
key), falling back to Site.name for topologies that only vary node
|
||||
content but keep the same id. Returns None if the site no longer
|
||||
exists in the new topology at all.
|
||||
"""
|
||||
for candidate in topo.sites:
|
||||
if candidate.id == state.site.id:
|
||||
return candidate
|
||||
for candidate in topo.sites:
|
||||
if candidate.name == state.site.name:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@router.post("/reload")
|
||||
async def reload(request: Request):
|
||||
from aci_sim.topology.loader import load_topology
|
||||
from aci_sim.build.orchestrator import build_site
|
||||
from aci_sim.runtime.config import TOPOLOGY_PATH
|
||||
|
||||
topo = load_topology(TOPOLOGY_PATH)
|
||||
fresh_site = _find_site(topo)
|
||||
if fresh_site is None:
|
||||
return _apic_error(
|
||||
text=(
|
||||
f"Site '{state.site.name}' (id={state.site.id!r}) no longer "
|
||||
"exists in the reloaded topology"
|
||||
),
|
||||
code="103",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Use the FRESH site object from the just-loaded topology, not the
|
||||
# stale pre-reload state.site — otherwise edits to this site's nodes/
|
||||
# leaves in topology.yaml are silently ignored (finding #11).
|
||||
state.topo = topo
|
||||
state.site = fresh_site
|
||||
new_store = build_site(topo, fresh_site)
|
||||
state.store = new_store
|
||||
state.baseline = copy.deepcopy(new_store)
|
||||
return {"status": "ok", "action": "reload"}
|
||||
|
||||
@router.post("/save/{name}")
|
||||
async def save(name: str):
|
||||
"""Persist this site's MITStore to disk under *name*.
|
||||
|
||||
File is keyed by both *name* and ``state.site.id`` so a single
|
||||
SIM_STATE_DIR can hold saves for multiple sites without collision
|
||||
(mirrors the wrapper script's per-plane file naming).
|
||||
"""
|
||||
path = state_dir() / f"{name}.{state.site.id}.apic.json"
|
||||
data = serialize_store(state.store)
|
||||
save_json(path, data)
|
||||
return {"status": "ok", "file": str(path), "count": len(data)}
|
||||
|
||||
@router.post("/load/{name}")
|
||||
async def load(name: str):
|
||||
"""Restore this site's MITStore from a prior :func:`save`."""
|
||||
path = state_dir() / f"{name}.{state.site.id}.apic.json"
|
||||
if not path.exists():
|
||||
return _apic_error(
|
||||
f"state '{name}' not found for site {state.site.id}",
|
||||
status_code=404,
|
||||
)
|
||||
data = load_json(path)
|
||||
state.store = deserialize_store(data)
|
||||
return {"status": "ok", "count": len(data)}
|
||||
|
||||
@router.post("/add-leaf")
|
||||
async def add_leaf(request: Request):
|
||||
from aci_sim.rest_aci.writes import materialize_node_registration
|
||||
|
||||
body = await request.json()
|
||||
node_id = body.get("id")
|
||||
if node_id is None:
|
||||
existing = [int(mo.attrs.get("id", 0)) for mo in state.store.by_class("fabricNode")]
|
||||
node_id = max(existing, default=100) + 1
|
||||
name = body.get("name", f"leaf-{node_id}")
|
||||
# Shares the fabricNodeIdentP write-reaction's enrichment (finding #19):
|
||||
# fabricNode + topSystem + healthInst + fabricLink cabling to the
|
||||
# spines + l1PhysIf inventory, not just a bare fabricNode.
|
||||
materialize_node_registration(
|
||||
state.store,
|
||||
topo=state.topo,
|
||||
site=state.site,
|
||||
node_id=int(node_id),
|
||||
name=name,
|
||||
role=body.get("role", "leaf"),
|
||||
)
|
||||
return {"status": "ok", "node_id": node_id, "name": name}
|
||||
|
||||
@router.post("/remove-leaf")
|
||||
async def remove_leaf(request: Request):
|
||||
body = await request.json()
|
||||
node_id = body.get("id")
|
||||
if node_id is None:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "detail": "id required"})
|
||||
pod = state.site.pod
|
||||
dn = f"topology/pod-{pod}/node-{node_id}"
|
||||
mo = MO("fabricNode", dn=dn, status="deleted")
|
||||
state.store.upsert(mo)
|
||||
return {"status": "ok", "removed": node_id}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,122 @@
|
||||
"""File-backed state persistence for the sim's MITStore and NdoState.
|
||||
|
||||
Covers ALL planes — per-site APIC MITStores and the NDO model — so a whole
|
||||
fabric can be saved to disk and restored across a sim restart (sandbox/port
|
||||
mode has no other durability: everything else lives in memory only).
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- ``MO`` instances stored in a :class:`~aci_sim.mit.store.MITStore`
|
||||
are always childless (the store keeps the parent/child hierarchy only in
|
||||
its own ``_children`` index — see ``MITStore.add``'s docstring). So a
|
||||
faithful store round-trip only needs each MO's ``class_name`` + ``attrs``;
|
||||
replaying them through ``MITStore.add`` rebuilds the ancestor index and
|
||||
therefore all subtree queries.
|
||||
- ``NdoState`` is a plain ``@dataclass`` of JSON-friendly fields (lists/dicts
|
||||
of str/bool/int). ``apply_ndo`` mutates the SAME state object in place via
|
||||
``setattr`` — ``make_ndo_app`` closes over the ``state`` object identity,
|
||||
so replacing it with a new instance would leave the running app's routes
|
||||
pointed at stale data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aci_sim.mit.mo import MO
|
||||
from aci_sim.mit.store import MITStore
|
||||
from aci_sim.ndo.model import NdoState
|
||||
|
||||
#: NdoState fields that are persisted (mutable, JSON-friendly). Excludes
|
||||
#: nothing from the dataclass — every field NdoState carries is saved.
|
||||
FIELDS: list[str] = [
|
||||
"tenants",
|
||||
"schemas",
|
||||
"schema_details",
|
||||
"template_summaries",
|
||||
"tenant_policy_templates",
|
||||
"policy_states",
|
||||
"audit_records",
|
||||
"local_users",
|
||||
"remote_users",
|
||||
"extra_schemas",
|
||||
"sites",
|
||||
"fabric_connectivity",
|
||||
]
|
||||
|
||||
|
||||
def state_dir() -> Path:
|
||||
"""Return the base directory for persisted state, creating it if needed.
|
||||
|
||||
Defaults to ``~/.aci-sim/state``; override with ``SIM_STATE_DIR``
|
||||
(tests set this to an isolated ``tmp_path`` so nothing touches the
|
||||
real home directory).
|
||||
"""
|
||||
base = Path(os.environ.get("SIM_STATE_DIR", os.path.expanduser("~/.aci-sim/state")))
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def save_json(path: Path, obj: Any) -> None:
|
||||
"""Write *obj* to *path* as indented JSON."""
|
||||
with open(path, "w") as f:
|
||||
json.dump(obj, f, indent=2)
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
"""Read and return the JSON document at *path*."""
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MITStore (APIC plane) serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_store(store: MITStore) -> list[dict[str, Any]]:
|
||||
"""Flatten *store* to a JSON-friendly list of ``{class, attrs}`` dicts.
|
||||
|
||||
Stored MOs are childless (hierarchy lives only in the store's parent
|
||||
index — see ``MITStore.add``), so this list alone is sufficient to
|
||||
reconstruct the store via :func:`deserialize_store`.
|
||||
"""
|
||||
return [{"class": mo.class_name, "attrs": dict(mo.attrs)} for mo in store.all()]
|
||||
|
||||
|
||||
def deserialize_store(data: list[dict[str, Any]]) -> MITStore:
|
||||
"""Rebuild a :class:`MITStore` from :func:`serialize_store` output.
|
||||
|
||||
Replays each entry through ``MITStore.add``, which rebuilds the
|
||||
ancestor/``_children`` index as it goes — so subtree queries against the
|
||||
restored store behave exactly as they did before serialization.
|
||||
"""
|
||||
s = MITStore()
|
||||
for d in data:
|
||||
s.add(MO(d["class"], **d["attrs"]))
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NdoState (NDO plane) serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_ndo(state: NdoState) -> dict[str, Any]:
|
||||
"""Return a deep-copied, JSON-friendly dict of *state*'s persisted fields."""
|
||||
return {f: copy.deepcopy(getattr(state, f)) for f in FIELDS}
|
||||
|
||||
|
||||
def apply_ndo(state: NdoState, data: dict[str, Any]) -> None:
|
||||
"""Apply *data* (from :func:`serialize_ndo`) onto *state* IN PLACE.
|
||||
|
||||
Mutates the same object via ``setattr`` rather than returning a new
|
||||
``NdoState`` — ``make_ndo_app`` closes over this exact object, so
|
||||
replacing it would leave the running app's routes reading stale state.
|
||||
"""
|
||||
for f, v in data.items():
|
||||
setattr(state, f, copy.deepcopy(v))
|
||||
@@ -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>"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Main APIC FastAPI application.
|
||||
|
||||
URL routing matches exactly what aci_connector.py builds:
|
||||
- GET /api/class/{cls}.json
|
||||
- GET /api/mo/{dn:path} (dn may contain slashes + brackets)
|
||||
- POST /api/mo/{dn:path}
|
||||
- DELETE /api/mo/{dn:path} (PR-9 — cisco.aci state=absent)
|
||||
- GET/POST/DELETE /api/node/mo/{dn:path} (PR-10 — real-APIC alias, see below)
|
||||
- GET /api/node/class/{rest:path}
|
||||
- Auth: /api/aaaLogin.json, /api/aaaRefresh.json, /api/aaaLogout.json
|
||||
- Control: /_sim/reset, /_sim/snapshot/{name}, etc.
|
||||
|
||||
PR-10 — /api/node/mo/{dn} alias (live E2E blocker, 11 failures). Real APIC
|
||||
serves /api/node/mo/{dn}.json as a full equivalent of /api/mo/{dn}.json — it
|
||||
is the form the APIC GUI's API Inspector emits and what cisco.aci's
|
||||
aci_rest module (and some aci_connector.py callers) actually issue. The sim
|
||||
only registered /api/mo/*, so a node-alias request fell through to FastAPI's
|
||||
default 404 {"detail":"Not Found"} — a shape cisco.aci cannot parse
|
||||
("APIC Error None: None", status=-1). Each verb below is registered on BOTH
|
||||
paths, sharing one handler body (stacked route decorators) so behavior can
|
||||
never drift between the canonical and alias forms. A catch-all for any other
|
||||
unmatched /api/* path returns an APIC-shaped 400 error envelope instead of
|
||||
FastAPI's {"detail":...} — see `_unmatched_api_path` below.
|
||||
|
||||
PR-9 DELETE semantics: cisco.aci's module_utils/aci.py (`delete_config()`)
|
||||
only issues an HTTP DELETE after its own `get_existing()` GET has confirmed
|
||||
the MO is present — `if not self.existing: return` short-circuits before
|
||||
ever calling DELETE (verified read-only against upstream
|
||||
plugins/module_utils/aci.py). So the module itself never exercises a
|
||||
DELETE-on-already-absent-DN round trip. This sim still makes the route
|
||||
idempotent (200 + empty imdata on a missing DN, same as a present one)
|
||||
rather than 404, for two reasons: (1) it matches the MIT's natural "delete
|
||||
what's not there = no-op" semantics used elsewhere in this codebase
|
||||
(MITStore.upsert's status=deleted branch is unconditional, no existence
|
||||
check), and (2) it is the safer default for any OTHER client (raw curl,
|
||||
future modules) that DOES call DELETE without a prior GET — a 404 there
|
||||
would make state=absent playbooks non-idempotent on second-run against
|
||||
such a client, which is the one thing real APIC's actual DELETE endpoint
|
||||
is documented to guarantee. See docs/CONTRACT.md §Ansible-compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from aci_sim.mit.store import MITStore
|
||||
from aci_sim.query.engine import (
|
||||
params_from_dict,
|
||||
run_class_query,
|
||||
run_mo_query,
|
||||
run_node_scoped,
|
||||
)
|
||||
from aci_sim.query.filters import FilterParseError
|
||||
from aci_sim.rest_aci import subscriptions as subs
|
||||
from aci_sim.rest_aci.auth import (
|
||||
_auth_error_403,
|
||||
_lookup_session,
|
||||
make_auth_router,
|
||||
require_session,
|
||||
)
|
||||
from aci_sim.rest_aci.writes import WriteValidationError
|
||||
from aci_sim.rest_aci.writes import apply as write_apply
|
||||
from aci_sim.topology.schema import Site, Topology
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApicSiteState:
|
||||
name: str
|
||||
site: Site
|
||||
topo: Topology
|
||||
store: MITStore
|
||||
baseline: MITStore # deepcopy at construction time
|
||||
|
||||
|
||||
def _apic_ok(imdata: list[dict], total: int) -> dict:
|
||||
"""Wrap imdata in the APIC envelope with string totalCount."""
|
||||
return {"imdata": imdata, "totalCount": str(total)}
|
||||
|
||||
|
||||
def _apic_error(text: str, code: str = "103", status_code: int = 400) -> JSONResponse:
|
||||
"""Return an APIC error envelope as a JSONResponse."""
|
||||
body = {
|
||||
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
|
||||
"totalCount": "1",
|
||||
}
|
||||
return JSONResponse(status_code=status_code, content=body)
|
||||
|
||||
|
||||
def _maybe_subscribe(
|
||||
body: dict, request: Request, token: str | None, scope_kind: str, scope_value: str
|
||||
) -> dict:
|
||||
"""Opt-in subscription hook shared by all three query routes.
|
||||
|
||||
If the request carries ``?subscription=yes``, registers a subscription
|
||||
for *token* watching *scope_kind*/*scope_value* and adds a top-level
|
||||
``subscriptionId`` key to *body* (mirroring real APIC — the field sits
|
||||
alongside ``imdata``/``totalCount``, not inside them). Without that query
|
||||
param, *body* is returned completely untouched — no key added, no
|
||||
registry write — so plain queries stay byte-identical to pre-subscription
|
||||
behavior (backward-compat requirement).
|
||||
"""
|
||||
if request.query_params.get("subscription") != "yes":
|
||||
return body
|
||||
if not token:
|
||||
# Should not happen — require_session already gated the route — but
|
||||
# never register a subscription with no token to notify later.
|
||||
return body
|
||||
sid = subs.subscribe(token, scope_kind, scope_value)
|
||||
body = dict(body)
|
||||
body["subscriptionId"] = sid
|
||||
return body
|
||||
|
||||
|
||||
def make_apic_app(state: ApicSiteState) -> FastAPI:
|
||||
app = FastAPI(title=f"aci-sim APIC {state.name}")
|
||||
|
||||
# --- Auth routes ---
|
||||
app.include_router(make_auth_router())
|
||||
|
||||
# --- Control-plane admin routes ---
|
||||
from aci_sim.control.admin import make_admin_router
|
||||
app.include_router(make_admin_router(state))
|
||||
|
||||
# --- Class query ---
|
||||
@app.get("/api/class/{cls}.json")
|
||||
async def get_class(cls: str, request: Request):
|
||||
if require_session(request) is None:
|
||||
return _auth_error_403()
|
||||
try:
|
||||
params = params_from_dict(dict(request.query_params))
|
||||
imdata, total = run_class_query(state.store, cls, params)
|
||||
except FilterParseError as exc:
|
||||
return _apic_error(str(exc), code="107", status_code=400)
|
||||
body = _apic_ok(imdata, total)
|
||||
token = request.cookies.get("APIC-cookie")
|
||||
return _maybe_subscribe(body, request, token, "class", cls)
|
||||
|
||||
# --- MO query (GET) ---
|
||||
# dn:path captures "uni/tn-Corp.json" including any slashes and brackets.
|
||||
# We must strip the trailing ".json".
|
||||
# PR-10: /api/node/mo/{dn} is a full alias of /api/mo/{dn} on real APIC
|
||||
# (API Inspector / aci_rest form) — same handler, stacked route.
|
||||
@app.get("/api/mo/{dn:path}")
|
||||
@app.get("/api/node/mo/{dn:path}")
|
||||
async def get_mo(dn: str, request: Request):
|
||||
if require_session(request) is None:
|
||||
return _auth_error_403()
|
||||
if dn.endswith(".json"):
|
||||
dn = dn[:-5]
|
||||
# PR-9 FEATURE 6 (live blocker, verified against upstream
|
||||
# module_utils/aci.py's api_call(): status != 200 is UNCONDITIONALLY
|
||||
# fatal via fail_json — there is no "404 is fine for GET" special
|
||||
# case). cisco.aci's state=present flow always does a GET-before-POST
|
||||
# existence check first; on a brand-new object that GET's DN does not
|
||||
# exist yet. Real APIC returns 200 + empty imdata for a well-formed
|
||||
# DN that simply doesn't exist (not 404) — 404/error codes are for
|
||||
# malformed requests, not absent objects. A 404 here made every
|
||||
# aci_tenant/aci_* state=present playbook fail on its very first run
|
||||
# ("APIC Error 103: Unable to find the object specified"). Superseded
|
||||
# the prior 404-on-missing-DN design (originally justified as
|
||||
# matching an autoACI ACINotFoundError expectation — that consumer
|
||||
# is a different client with different semantics; cisco.aci is this
|
||||
# simulator's primary use case per DESIGN.md and takes precedence).
|
||||
try:
|
||||
params = params_from_dict(dict(request.query_params))
|
||||
imdata, total = run_mo_query(state.store, dn, params)
|
||||
except FilterParseError as exc:
|
||||
return _apic_error(str(exc), code="107", status_code=400)
|
||||
body = _apic_ok(imdata, total)
|
||||
token = request.cookies.get("APIC-cookie")
|
||||
return _maybe_subscribe(body, request, token, "dn", dn)
|
||||
|
||||
# --- MO write (POST) ---
|
||||
# PR-10: /api/node/mo/{dn} alias — see get_mo above.
|
||||
@app.post("/api/mo/{dn:path}")
|
||||
@app.post("/api/node/mo/{dn:path}")
|
||||
async def post_mo(dn: str, request: Request):
|
||||
if require_session(request) is None:
|
||||
return _auth_error_403()
|
||||
if dn.endswith(".json"):
|
||||
dn = dn[:-5]
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _apic_error("Invalid JSON body", code="103", status_code=400)
|
||||
if not body or not isinstance(body, dict):
|
||||
return _apic_error("Empty or non-object body", code="103", status_code=400)
|
||||
cls = next(iter(body))
|
||||
if not isinstance(body[cls], dict):
|
||||
return _apic_error("Invalid MO body structure", code="103", status_code=400)
|
||||
# Subscriptions need created-vs-modified, which writes.apply's own
|
||||
# response shape doesn't distinguish (it always reports "created"
|
||||
# for a non-delete write — see writes.py's `apply()`). Determine it
|
||||
# here, pre-write, purely for the push event; the HTTP response body
|
||||
# below is unchanged from before this feature.
|
||||
effective_dn = (body[cls].get("attributes") or {}).get("dn") or dn
|
||||
pre_existing = state.store.get(effective_dn) is not None
|
||||
try:
|
||||
imdata, total = write_apply(state.store, dn, body, topo=state.topo, site=state.site)
|
||||
except (WriteValidationError, AttributeError, TypeError, KeyError) as exc:
|
||||
return _apic_error(f"Malformed MO body: {exc}", code="103", status_code=400)
|
||||
# Push-on-change (subscriptions): notify after the store commit above.
|
||||
# imdata is the write result shape [{cls: {"attributes": {"dn":..., "status": "created"|"deleted"}}}]
|
||||
# from writes.apply — one entry per top-level POSTed class (children
|
||||
# are written but not individually reported here, matching the
|
||||
# existing non-subscription response shape).
|
||||
for entry in imdata:
|
||||
for mo_cls, mo_body in entry.items():
|
||||
mo_attrs = mo_body.get("attributes", {})
|
||||
mo_dn = mo_attrs.get("dn", dn)
|
||||
raw_status = mo_attrs.get("status", "created")
|
||||
if raw_status == "deleted":
|
||||
push_status = "deleted"
|
||||
elif mo_dn == effective_dn and pre_existing:
|
||||
push_status = "modified"
|
||||
else:
|
||||
push_status = "created"
|
||||
subs.notify(mo_cls, mo_dn, push_status, mo_attrs)
|
||||
return _apic_ok(imdata, total)
|
||||
|
||||
# --- MO delete (DELETE) ---
|
||||
# cisco.aci state=absent path: DELETE /api/mo/{dn}.json. Removes the DN's
|
||||
# entire subtree from the store. Idempotent: a missing DN also returns
|
||||
# 200 + empty imdata rather than 404 (see module docstring above for the
|
||||
# real-module-behavior justification).
|
||||
# PR-10: /api/node/mo/{dn} alias — see get_mo above.
|
||||
@app.delete("/api/mo/{dn:path}")
|
||||
@app.delete("/api/node/mo/{dn:path}")
|
||||
async def delete_mo(dn: str, request: Request):
|
||||
if require_session(request) is None:
|
||||
return _auth_error_403()
|
||||
if dn.endswith(".json"):
|
||||
dn = dn[:-5]
|
||||
# Capture the whole subtree BEFORE deleting so push-on-change can
|
||||
# notify for every removed MO (a DN-scoped subscription may be
|
||||
# watching a descendant, and a class-scoped one may be watching any
|
||||
# class present in the subtree, not just the root DN's own class).
|
||||
existing = state.store.get(dn)
|
||||
removed = ([existing] if existing is not None else []) + state.store.subtree(dn)
|
||||
state.store.delete(dn)
|
||||
for mo in removed:
|
||||
subs.notify(mo.class_name, mo.dn, "deleted", mo.attrs)
|
||||
return _apic_ok([], 0)
|
||||
|
||||
# --- Node-scoped class query ---
|
||||
# rest captures everything after /api/node/class/
|
||||
# e.g. rest = "topology/pod-1/node-101/faultInst.json" (node-scoped)
|
||||
# rest = "topSystem.json" (fabric-wide)
|
||||
#
|
||||
# Real APIC also supports the fabric-wide form of this endpoint — no
|
||||
# topology/pod-X/node-Y prefix, just the bare "{cls}.json" — which is
|
||||
# equivalent to /api/class/{cls}.json (finding #12). Distinguish the two
|
||||
# by whether *rest* (once ".json" is stripped) contains a "/": a
|
||||
# node-scoped DN always does (it's a full topology/... path), while the
|
||||
# fabric-wide form is a single path segment.
|
||||
@app.get("/api/node/class/{rest:path}")
|
||||
async def get_node_class(rest: str, request: Request):
|
||||
if require_session(request) is None:
|
||||
return _auth_error_403()
|
||||
token = request.cookies.get("APIC-cookie")
|
||||
stripped = rest[:-5] if rest.endswith(".json") else rest
|
||||
if "/" not in stripped:
|
||||
# Fabric-wide form: {cls}.json with no DN prefix at all.
|
||||
cls = stripped
|
||||
try:
|
||||
params = params_from_dict(dict(request.query_params))
|
||||
imdata, total = run_class_query(state.store, cls, params)
|
||||
except FilterParseError as exc:
|
||||
return _apic_error(str(exc), code="107", status_code=400)
|
||||
body = _apic_ok(imdata, total)
|
||||
return _maybe_subscribe(body, request, token, "class", cls)
|
||||
|
||||
parts = rest.rsplit("/", 1)
|
||||
if len(parts) != 2:
|
||||
return _apic_error("Malformed node-scoped URL", code="103", status_code=400)
|
||||
node_dn = parts[0]
|
||||
cls = parts[1]
|
||||
if cls.endswith(".json"):
|
||||
cls = cls[:-5]
|
||||
try:
|
||||
params = params_from_dict(dict(request.query_params))
|
||||
imdata, total = run_node_scoped(state.store, node_dn, cls, params)
|
||||
except FilterParseError as exc:
|
||||
return _apic_error(str(exc), code="107", status_code=400)
|
||||
body = _apic_ok(imdata, total)
|
||||
return _maybe_subscribe(body, request, token, "dn", node_dn)
|
||||
|
||||
# --- Subscription refresh ---
|
||||
# GET /api/subscriptionRefresh.json?id=<id> — keeps a subscription alive
|
||||
# past its TTL (real APIC's default subscription lifetime is 90s,
|
||||
# refreshed by polling this endpoint). Always 200s for a live session;
|
||||
# an unknown/expired id still returns 200 with a small APIC-shaped
|
||||
# imdata (real APIC does not error a stale refresh — the caller just
|
||||
# re-subscribes on its next query if the id no longer resolves).
|
||||
@app.get("/api/subscriptionRefresh.json")
|
||||
async def subscription_refresh(request: Request):
|
||||
if require_session(request) is None:
|
||||
return _auth_error_403()
|
||||
subscription_id = request.query_params.get("id", "")
|
||||
subs.refresh(subscription_id)
|
||||
return {"imdata": [], "totalCount": "0", "subscriptionId": subscription_id}
|
||||
|
||||
# --- Websocket push channel ---
|
||||
# GET /socket<token> — real APIC's subscription websocket path, where
|
||||
# <token> is the same APIC-cookie token minted by aaaLogin. FastAPI/
|
||||
# Starlette route params can't match a bare suffix glued onto a fixed
|
||||
# prefix with no separator (real APIC literally concatenates the token
|
||||
# onto "/socket" — no slash), so this uses a `{token}` path param on the
|
||||
# pattern "/socket{token}", which Starlette resolves correctly (the
|
||||
# literal "/socket" prefix consumes up to where the param begins).
|
||||
@app.websocket("/socket{token}")
|
||||
async def subscription_socket(websocket: WebSocket, token: str):
|
||||
session = _lookup_session(token)
|
||||
if session is None:
|
||||
# Unknown/expired token — reject before accept (real APIC closes
|
||||
# the upgrade for an invalid session token).
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
await websocket.accept()
|
||||
queue = subs.register_connection(token)
|
||||
|
||||
async def _pump_events() -> None:
|
||||
"""Drain the queue and push each event, forever."""
|
||||
while True:
|
||||
event = await queue.get()
|
||||
await websocket.send_json(event)
|
||||
|
||||
async def _watch_for_disconnect() -> None:
|
||||
"""Block on receive() so a client-initiated close is noticed
|
||||
promptly even while _pump_events is idle waiting on an empty
|
||||
queue (send_json alone only surfaces a disconnect on the NEXT
|
||||
push, which could be arbitrarily far in the future — or never,
|
||||
for a subscriber that stops getting matching events). receive()
|
||||
does not raise on a disconnect message (only the receive_text/
|
||||
receive_json/receive_bytes wrappers do) — it just returns it, so
|
||||
this checks the message type itself and raises to match
|
||||
_pump_events' failure mode.
|
||||
"""
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
raise WebSocketDisconnect(code=message.get("code", 1000))
|
||||
|
||||
pump_task = asyncio.ensure_future(_pump_events())
|
||||
watch_task = asyncio.ensure_future(_watch_for_disconnect())
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
{pump_task, watch_task}, return_when=asyncio.FIRST_EXCEPTION
|
||||
)
|
||||
for task in done:
|
||||
exc = task.exception()
|
||||
if exc is not None and not isinstance(exc, WebSocketDisconnect):
|
||||
raise exc
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
pump_task.cancel()
|
||||
watch_task.cancel()
|
||||
subs.drop_connection(token)
|
||||
|
||||
# --- Unmatched /api/* fallback ---
|
||||
# PR-10: real APIC never emits FastAPI's default 404 {"detail":"Not
|
||||
# Found"} shape — cisco.aci can't parse it (surfaces as "APIC Error
|
||||
# None: None", status=-1). This handler only fires once routing has
|
||||
# already failed to match every route above (including the /api/node/mo
|
||||
# alias and /api/node/class/*), so it can never shadow a real route —
|
||||
# it is Starlette's very last resort for an unmatched path, not a route
|
||||
# itself. Scoped to /api/* only; anything outside that prefix (e.g. a
|
||||
# typo'd /_sim/* control path) still gets the default FastAPI 404, since
|
||||
# this sim's control plane is not part of the APIC REST contract.
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def _unmatched_api_path(request: Request, exc: StarletteHTTPException):
|
||||
if exc.status_code == 404 and request.url.path.startswith("/api/"):
|
||||
return _apic_error(
|
||||
f"Invalid request path: {request.url.path}", code="400", status_code=400
|
||||
)
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Token store + auth routes for aaaLogin, aaaRefresh, aaaLogout.
|
||||
|
||||
Session tokens are minted on aaaLogin and validated (via `require_session`) by
|
||||
every protected data route in app.py. Real APIC semantics emulated here:
|
||||
- aaaLogin checks credentials (default admin/cisco, overridable via
|
||||
SIM_USERNAME/SIM_PASSWORD env vars) → 401 APIC envelope on mismatch. The
|
||||
login name may be plain ("admin") or domain-qualified
|
||||
("apic:local\\admin", "local\\admin") — real APIC accepts both for its
|
||||
local login domain; see `_bare_username()`.
|
||||
- Tokens expire after SESSION_LIFETIME_SECONDS (default 600s); aaaRefresh
|
||||
requires a live token and renews it to a full lifetime.
|
||||
- Queries/writes without a valid, unexpired APIC-cookie → 403 APIC envelope
|
||||
"Token was invalid (Error: Token timeout)".
|
||||
- Malformed aaaLogin bodies never leak FastAPI's {"detail": ...} shape —
|
||||
the route parses the request body manually and returns a 400 APIC
|
||||
envelope instead of letting FastAPI raise RequestValidationError.
|
||||
|
||||
PR-9 addition — certificate signature auth (accept-mode). cisco.aci's
|
||||
module_utils/aci.py supports password-less auth: when `private_key` is set,
|
||||
every request's `cert_auth()` sets a single `Cookie` header carrying four
|
||||
APIC-Certificate-* fields instead of (or alongside) the aaaLogin flow —
|
||||
verified read-only against upstream `plugins/module_utils/aci.py`:
|
||||
|
||||
sig_dn = "uni/userext/user-{username}/usercert-{certificate_name}"
|
||||
self.headers["Cookie"] = (
|
||||
"APIC-Certificate-Algorithm=v1.0; "
|
||||
"APIC-Certificate-DN={sig_dn}; "
|
||||
"APIC-Certificate-Fingerprint=fingerprint; "
|
||||
"APIC-Request-Signature={base64(RSA-SHA256(method+path+body))}"
|
||||
)
|
||||
|
||||
This means a cert-auth playbook never calls aaaLogin at all — every single
|
||||
request (GET/POST/DELETE) carries the Cookie header standalone. See
|
||||
`require_session` below for the accept-mode implementation and
|
||||
docs/DESIGN.md's "Certificate accept-mode trust model" note for what is
|
||||
and is not verified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
# Default lab credentials — overridable per CONTRACT.md §1 / README §Configuration.
|
||||
SIM_USERNAME: str = os.environ.get("SIM_USERNAME", "admin")
|
||||
SIM_PASSWORD: str = os.environ.get("SIM_PASSWORD", "cisco")
|
||||
|
||||
# Real APIC default session lifetime is 600s (refreshTimeoutSeconds).
|
||||
SESSION_LIFETIME_SECONDS: float = 600.0
|
||||
|
||||
# Certificate-DN shape cisco.aci's cert_auth() builds:
|
||||
# uni/userext/user-{username}/usercert-{certificate_name}
|
||||
# Captured group 1 = the username, checked against SIM_USERNAME below.
|
||||
_CERT_DN_RE = re.compile(r"^uni/userext/user-([^/]+)/usercert-[^/]+$")
|
||||
|
||||
# PR-15 addition — domain-qualified aaaLogin name. Real APIC accepts login
|
||||
# names of the shape "apic:<loginDomain>\<username>" (e.g. "apic:local\admin")
|
||||
# in addition to a bare username, per APIC's local/remote login-domain
|
||||
# handling. aci-py's client posts the domain-qualified form by default.
|
||||
# Captured group 1 = the domain (unused/ignored, any domain is accepted —
|
||||
# this sim has no concept of configured login domains), group 2 = the bare
|
||||
# username, which is what gets compared against SIM_USERNAME below.
|
||||
_DOMAIN_QUALIFIED_NAME_RE = re.compile(r"^(?:apic:)?[^\\]+\\(.+)$")
|
||||
|
||||
|
||||
def _bare_username(name: str) -> str:
|
||||
"""Strip an optional `apic:<domain>\\` or `<domain>\\` prefix from a login name.
|
||||
|
||||
"apic:local\\admin" -> "admin"; "local\\admin" -> "admin"; "admin" -> "admin"
|
||||
(no prefix present, returned unchanged).
|
||||
"""
|
||||
m = _DOMAIN_QUALIFIED_NAME_RE.match(name)
|
||||
return m.group(1) if m else name
|
||||
|
||||
# SIM_CERT_STRICT — documented placeholder, NOT YET IMPLEMENTED (see
|
||||
# docs/DESIGN.md). When unset/false (the only mode this sim supports today),
|
||||
# a well-formed cert-cookie set authenticates its claimed user WITHOUT any
|
||||
# RSA signature verification (accept-mode / trust-the-claimed-identity). A
|
||||
# future strict mode would verify APIC-Request-Signature against a
|
||||
# registered public key for that user — out of scope for PR-9.
|
||||
SIM_CERT_STRICT: bool = os.environ.get("SIM_CERT_STRICT", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Session:
|
||||
user: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
# Module-level session store: token -> _Session. Shared across all APIC apps
|
||||
# in this process (mirrors the pre-existing module-level behavior); each test
|
||||
# builds a fresh FastAPI app/state per fixture but the token namespace is
|
||||
# process-wide, which is fine since tokens are unguessable 128-hex-char values.
|
||||
_sessions: dict[str, _Session] = {}
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
return secrets.token_hex(64)
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _prune_expired() -> None:
|
||||
"""Opportunistically drop expired tokens. Called on every store access."""
|
||||
now = _now()
|
||||
expired = [tok for tok, sess in _sessions.items() if sess.expires_at <= now]
|
||||
for tok in expired:
|
||||
del _sessions[tok]
|
||||
|
||||
|
||||
def _create_session(user: str) -> str:
|
||||
_prune_expired()
|
||||
token = _new_token()
|
||||
_sessions[token] = _Session(user=user, expires_at=_now() + SESSION_LIFETIME_SECONDS)
|
||||
return token
|
||||
|
||||
|
||||
def _lookup_session(token: str | None) -> _Session | None:
|
||||
"""Return the live session for `token`, or None if missing/unknown/expired."""
|
||||
_prune_expired()
|
||||
if not token:
|
||||
return None
|
||||
return _sessions.get(token)
|
||||
|
||||
|
||||
def _touch_session(token: str) -> bool:
|
||||
"""Renew an existing session's expiry to a full lifetime. False if invalid."""
|
||||
sess = _lookup_session(token)
|
||||
if sess is None:
|
||||
return False
|
||||
sess.expires_at = _now() + SESSION_LIFETIME_SECONDS
|
||||
return True
|
||||
|
||||
|
||||
def _pop_session(token: str | None) -> None:
|
||||
_prune_expired()
|
||||
if token:
|
||||
_sessions.pop(token, None)
|
||||
|
||||
|
||||
def _cert_auth_user(request: Request) -> str | None:
|
||||
"""Return the authenticated username for a well-formed cert-cookie request.
|
||||
|
||||
Accept-mode (documented trust model, see docs/DESIGN.md): if all four
|
||||
APIC-Certificate-* cookies are present and APIC-Certificate-DN parses to
|
||||
"uni/userext/user-{user}/usercert-{name}" with user == SIM_USERNAME, the
|
||||
request is treated as authenticated WITHOUT verifying
|
||||
APIC-Request-Signature's RSA-SHA256 bytes. Any of: a missing cookie, an
|
||||
unparsable DN, or a DN whose user != SIM_USERNAME → returns None (caller
|
||||
falls through to the normal cookie-session check, which will also fail
|
||||
for a request that never called aaaLogin, yielding the standard 403).
|
||||
|
||||
This intentionally does NOT check SIM_CERT_STRICT — that flag is a
|
||||
documented placeholder for a future signature-verifying mode and has no
|
||||
effect yet (see auth.py module docstring + DESIGN.md).
|
||||
"""
|
||||
cookies = request.cookies
|
||||
required = (
|
||||
"APIC-Certificate-Algorithm",
|
||||
"APIC-Certificate-Fingerprint",
|
||||
"APIC-Certificate-DN",
|
||||
"APIC-Request-Signature",
|
||||
)
|
||||
if not all(cookies.get(name) for name in required):
|
||||
return None
|
||||
|
||||
cert_dn = cookies["APIC-Certificate-DN"]
|
||||
m = _CERT_DN_RE.match(cert_dn)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
user = m.group(1)
|
||||
if user != SIM_USERNAME:
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_session(request: Request) -> _Session | None:
|
||||
"""Dependency-style helper: return the caller's live session or None.
|
||||
|
||||
Callers (app.py routes) turn a None into a 403 APIC envelope. Kept as a
|
||||
plain function (not a FastAPI Depends) so routes can return the 403 body
|
||||
in the exact APIC envelope shape rather than a generic exception page.
|
||||
|
||||
PR-9: also accepts certificate-signature auth (accept-mode) — a request
|
||||
carrying a well-formed, matching-user cert-cookie set authenticates
|
||||
without ever having called aaaLogin. A synthetic, non-expiring _Session
|
||||
is returned for that request only (not stored — cheap to recompute per
|
||||
request, and there is no real token to leak/reuse).
|
||||
"""
|
||||
token = request.cookies.get("APIC-cookie")
|
||||
session = _lookup_session(token)
|
||||
if session is not None:
|
||||
return session
|
||||
|
||||
cert_user = _cert_auth_user(request)
|
||||
if cert_user is not None:
|
||||
return _Session(user=cert_user, expires_at=_now() + SESSION_LIFETIME_SECONDS)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _apic_error(text: str, code: str, status_code: int) -> JSONResponse:
|
||||
body = {
|
||||
"imdata": [{"error": {"attributes": {"text": text, "code": code}}}],
|
||||
"totalCount": "1",
|
||||
}
|
||||
return JSONResponse(status_code=status_code, content=body)
|
||||
|
||||
|
||||
def _auth_error_403() -> JSONResponse:
|
||||
return _apic_error("Token was invalid (Error: Token timeout)", code="403", status_code=403)
|
||||
|
||||
|
||||
def _login_response(token: str) -> dict:
|
||||
return {
|
||||
"imdata": [
|
||||
{
|
||||
"aaaLogin": {
|
||||
"attributes": {
|
||||
"token": token,
|
||||
"refreshTimeoutSeconds": str(int(SESSION_LIFETIME_SECONDS)),
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"totalCount": "1",
|
||||
}
|
||||
|
||||
|
||||
def make_auth_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/api/aaaLogin.json")
|
||||
async def aaa_login(request: Request, response: Response):
|
||||
# Parse the body manually (not via a FastAPI `body: dict` parameter)
|
||||
# so a malformed/non-dict body never triggers FastAPI's automatic
|
||||
# RequestValidationError -> {"detail": [...]} leak. We want a 400
|
||||
# APIC envelope for every malformed shape instead.
|
||||
try:
|
||||
raw = await request.json()
|
||||
except Exception:
|
||||
return _apic_error("Malformed request body", code="400", status_code=400)
|
||||
if not isinstance(raw, dict):
|
||||
return _apic_error("Malformed request body", code="400", status_code=400)
|
||||
|
||||
aaa_user = raw.get("aaaUser")
|
||||
if not isinstance(aaa_user, dict):
|
||||
return _apic_error("Malformed aaaUser body", code="400", status_code=400)
|
||||
attrs = aaa_user.get("attributes")
|
||||
if not isinstance(attrs, dict):
|
||||
return _apic_error("Malformed aaaUser body", code="400", status_code=400)
|
||||
|
||||
username = attrs.get("name")
|
||||
password = attrs.get("pwd")
|
||||
bare_username = _bare_username(username) if isinstance(username, str) else username
|
||||
if bare_username != SIM_USERNAME or password != SIM_PASSWORD:
|
||||
return _apic_error(
|
||||
"Authentication failed: invalid username or password",
|
||||
code="401",
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
token = _create_session(bare_username)
|
||||
response.set_cookie("APIC-cookie", token)
|
||||
return _login_response(token)
|
||||
|
||||
@router.get("/api/aaaRefresh.json")
|
||||
async def aaa_refresh(request: Request, response: Response):
|
||||
token = request.cookies.get("APIC-cookie")
|
||||
if not token or not _touch_session(token):
|
||||
return _auth_error_403()
|
||||
response.set_cookie("APIC-cookie", token)
|
||||
return _login_response(token)
|
||||
|
||||
@router.post("/api/aaaLogout.json")
|
||||
async def aaa_logout(request: Request, response: Response):
|
||||
token = request.cookies.get("APIC-cookie")
|
||||
_pop_session(token)
|
||||
response.delete_cookie("APIC-cookie")
|
||||
return {"imdata": [], "totalCount": "0"}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,212 @@
|
||||
"""APIC query-subscription registry + websocket push-on-change (PR — subscriptions).
|
||||
|
||||
Real APIC's subscription mechanism (verified against the documented behavior
|
||||
cisco.aci / ACI SDK clients and monitoring tools rely on):
|
||||
|
||||
1. **Subscribe**: any of the three query shapes (class/mo/node-class) accepts
|
||||
`?subscription=yes`. The response is the NORMAL query result (imdata +
|
||||
totalCount, unchanged) PLUS a top-level `"subscriptionId"` string. The
|
||||
query itself also establishes the "current state" the subscription is
|
||||
watching relative to.
|
||||
2. **Websocket**: the client opens `GET /socket<token>` where `<token>` is
|
||||
the same APIC-cookie token from aaaLogin. All of that session's live
|
||||
subscriptions are pushed over this one connection.
|
||||
3. **Push on change**: when a write (POST create/update or DELETE) commits,
|
||||
APIC evaluates every live subscription's scope against the changed
|
||||
MO(s). Any match gets a push:
|
||||
`{"subscriptionId":[<id>,...],"imdata":[{<class>:{"attributes":{...,
|
||||
"status":"created|modified|deleted","dn":...}}}]}`.
|
||||
4. **Refresh**: `GET /api/subscriptionRefresh.json?id=<id>` keeps a
|
||||
subscription alive past its TTL; real APIC's default subscription
|
||||
lifetime is 90s, refreshed by polling this endpoint.
|
||||
|
||||
This module is the in-memory registry + notify/bridge glue. It has ZERO
|
||||
FastAPI route decorators of its own — `app.py` wires the HTTP/websocket
|
||||
routes and calls into this module's functions, matching how `auth.py` keeps
|
||||
its session store separate from route registration.
|
||||
|
||||
Sync-write -> async-websocket bridge
|
||||
-------------------------------------
|
||||
`app.py`'s query/write handlers are `async def` but call straight into sync
|
||||
store code — there's no actual concurrency boundary within a single request.
|
||||
The awkward part is different: `notify()` must be callable from those
|
||||
handlers (regular function-call context, not itself a coroutine) but the
|
||||
actual send happens on a websocket that's being driven by its own `asyncio`
|
||||
task (`websocket.receive()` loop). Two connections could be subscribed to
|
||||
the same class, and a slow/stuck client must never block or break the write
|
||||
request that triggered the notification.
|
||||
|
||||
The fix: give every websocket connection its own `asyncio.Queue`. `notify()`
|
||||
is a plain sync function — it just does `queue.put_nowait(event)` (never
|
||||
blocks, drops nothing since the queue is unbounded) for every connection
|
||||
whose subscriptions match. The websocket route runs a small async task that
|
||||
does `event = await queue.get(); await websocket.send_json(event)` in a loop.
|
||||
This decouples "a write happened" (sync call stack) from "bytes went out on
|
||||
a socket" (async task on that connection's own schedule) with no shared
|
||||
event-loop reentrancy concerns and no risk of a write request awaiting
|
||||
network I/O on some other client's socket.
|
||||
|
||||
`app.py`'s route actually runs two tasks per connection, raced via
|
||||
``asyncio.wait(..., return_when=FIRST_EXCEPTION)``: one draining this
|
||||
queue and pushing events, one blocked on ``websocket.receive()`` purely to
|
||||
notice a client-initiated disconnect promptly (a lone queue-drain loop
|
||||
would only discover a dead connection the next time it tried to push —
|
||||
arbitrarily late, or never, for a subscriber sitting on a quiet class).
|
||||
Either task finishing tears down both and calls `drop_connection`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Real APIC's default subscription lifetime is 90s; refreshed via
|
||||
# GET /api/subscriptionRefresh.json?id=<id>. Kept generous here since this
|
||||
# sim has no eviction sweep beyond the refresh no-op / disconnect cleanup.
|
||||
SUBSCRIPTION_LIFETIME_SECONDS: float = 90.0
|
||||
|
||||
_id_counter = itertools.count(1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Connection:
|
||||
"""One live `/socket<token>` websocket connection for a session token."""
|
||||
|
||||
token: str
|
||||
queue: "asyncio.Queue[dict]" = field(default_factory=asyncio.Queue)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Subscription:
|
||||
"""A single registered subscription (one per `?subscription=yes` query).
|
||||
|
||||
scope is either:
|
||||
- ("class", "<className>") — class query / node-class fabric-wide form
|
||||
- ("dn", "<dn>") — mo query (dn-scoped; also matches the
|
||||
DN's subtree so a child MO's change
|
||||
still notifies a parent-DN subscriber)
|
||||
"""
|
||||
|
||||
id: str
|
||||
token: str
|
||||
scope_kind: str # "class" | "dn"
|
||||
scope_value: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
# subscriptionId -> Subscription
|
||||
_subscriptions: dict[str, Subscription] = {}
|
||||
# token -> _Connection (at most one live websocket per session token)
|
||||
_connections: dict[str, _Connection] = {}
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Test/isolation helper: drop all subscriptions + connections."""
|
||||
_subscriptions.clear()
|
||||
_connections.clear()
|
||||
|
||||
|
||||
def _prune_expired() -> None:
|
||||
now = _now()
|
||||
expired = [sid for sid, sub in _subscriptions.items() if sub.expires_at <= now]
|
||||
for sid in expired:
|
||||
del _subscriptions[sid]
|
||||
|
||||
|
||||
def subscribe(token: str, scope_kind: str, scope_value: str) -> str:
|
||||
"""Register a new subscription for *token* watching *scope_kind*/*scope_value*.
|
||||
|
||||
Returns the new subscriptionId (a string, matching real APIC's shape).
|
||||
"""
|
||||
_prune_expired()
|
||||
sid = str(next(_id_counter))
|
||||
_subscriptions[sid] = Subscription(
|
||||
id=sid,
|
||||
token=token,
|
||||
scope_kind=scope_kind,
|
||||
scope_value=scope_value,
|
||||
expires_at=_now() + SUBSCRIPTION_LIFETIME_SECONDS,
|
||||
)
|
||||
return sid
|
||||
|
||||
|
||||
def refresh(subscription_id: str) -> bool:
|
||||
"""Renew a subscription's TTL. Returns False if the id is unknown/expired."""
|
||||
_prune_expired()
|
||||
sub = _subscriptions.get(subscription_id)
|
||||
if sub is None:
|
||||
return False
|
||||
sub.expires_at = _now() + SUBSCRIPTION_LIFETIME_SECONDS
|
||||
return True
|
||||
|
||||
|
||||
def register_connection(token: str) -> "asyncio.Queue[dict]":
|
||||
"""Associate a websocket connection with *token*; returns its event queue.
|
||||
|
||||
Only one live connection per token is tracked (matches real APIC — a
|
||||
session has one websocket). A reconnect replaces the prior queue.
|
||||
"""
|
||||
conn = _Connection(token=token)
|
||||
_connections[token] = conn
|
||||
return conn.queue
|
||||
|
||||
|
||||
def drop_connection(token: str) -> None:
|
||||
"""Remove the connection + any subscriptions owned by *token* (disconnect cleanup)."""
|
||||
_connections.pop(token, None)
|
||||
_prune_expired()
|
||||
dead = [sid for sid, sub in _subscriptions.items() if sub.token == token]
|
||||
for sid in dead:
|
||||
del _subscriptions[sid]
|
||||
|
||||
|
||||
def is_known_token(token: str) -> bool:
|
||||
"""True if *token* has ever registered a connection (used only for tests/debug)."""
|
||||
return token in _connections
|
||||
|
||||
|
||||
def _dn_matches(scope_dn: str, changed_dn: str) -> bool:
|
||||
"""True if *changed_dn* is *scope_dn* itself or lives in its subtree."""
|
||||
return changed_dn == scope_dn or changed_dn.startswith(scope_dn + "/")
|
||||
|
||||
|
||||
def notify(cls: str, dn: str, status: str, attributes: dict) -> None:
|
||||
"""Push an event to every live subscription whose scope matches this change.
|
||||
|
||||
Called synchronously from the write path (POST/DELETE handlers in
|
||||
app.py) right after the store commit. Never awaits, never raises for a
|
||||
slow/absent client — `asyncio.Queue.put_nowait` on an unbounded queue
|
||||
cannot block, and a token with no live connection (subscribed but the
|
||||
websocket hasn't connected yet, or already disconnected) is skipped.
|
||||
|
||||
*attributes* should be the MO's attribute dict (will be shallow-copied)
|
||||
with ``dn``/``status`` already set or overridable via the explicit args.
|
||||
"""
|
||||
_prune_expired()
|
||||
matched: dict[str, list[str]] = {} # token -> [subscriptionId, ...]
|
||||
for sid, sub in _subscriptions.items():
|
||||
if sub.scope_kind == "class" and sub.scope_value == cls:
|
||||
matched.setdefault(sub.token, []).append(sid)
|
||||
elif sub.scope_kind == "dn" and _dn_matches(sub.scope_value, dn):
|
||||
matched.setdefault(sub.token, []).append(sid)
|
||||
|
||||
if not matched:
|
||||
return
|
||||
|
||||
attrs = dict(attributes)
|
||||
attrs["dn"] = dn
|
||||
attrs["status"] = status
|
||||
event_body = {cls: {"attributes": attrs}}
|
||||
|
||||
for token, sids in matched.items():
|
||||
conn = _connections.get(token)
|
||||
if conn is None:
|
||||
continue
|
||||
event = {"subscriptionId": sids, "imdata": [event_body]}
|
||||
conn.queue.put_nowait(event)
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Write handler for POST /api/mo/{dn}.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from aci_sim.build.fabric import loopback_ip, oob_ip
|
||||
from aci_sim.build.interfaces import _HOST_PORTS, _UPLINK_START, _add_port
|
||||
from aci_sim.build.neighbors import add_switch_adjacency
|
||||
from aci_sim.mit.mo import MO
|
||||
from aci_sim.mit.store import MITStore
|
||||
from aci_sim.topology.schema import Node
|
||||
|
||||
# Class -> RN template for children POSTed without an explicit ``dn``.
|
||||
#
|
||||
# Real APIC derives a child's RN from the class's RN *prefix*, not from the
|
||||
# class name itself (see aci_sim/mit/dn.py's dn_class_hint, which maps
|
||||
# the same prefixes back to classes for the read path). Templates below are
|
||||
# taken verbatim from the DNs the builders emit (aci_sim/build/*.py),
|
||||
# so POSTed objects land on the same canonical DN as builder-created ones:
|
||||
#
|
||||
# fvBD -> BD-{name} (build/tenants.py:_build_bd, dn=f"uni/tn-{t}/BD-{bd.name}")
|
||||
# fvAp -> ap-{name} (build/tenants.py:_build_tenant, dn=f"uni/tn-{t}/ap-{ap.name}")
|
||||
# fvAEPg -> epg-{name} (build/tenants.py:_build_epg, dn=.../ap-{ap_name}/epg-{epg.name})
|
||||
# fvCtx -> ctx-{name} (build/tenants.py:_build_tenant, dn=f"uni/tn-{t}/ctx-{vrf.name}")
|
||||
# fvSubnet -> subnet-[{ip}] (build/tenants.py:_build_bd, dn=.../BD-{bd.name}/subnet-[{cidr}])
|
||||
# fvRsCtx -> rsctx (build/tenants.py:_build_bd, dn=.../BD-{bd.name}/rsctx, fixed RN)
|
||||
# fvRsBd -> rsbd (build/tenants.py:_build_epg, dn=.../epg-{epg.name}/rsbd, fixed RN)
|
||||
# fvRsBDToOut -> rsBDToOut-{tnL3extOutName}
|
||||
# (build/tenants.py:_build_bd, dn=.../rsBDToOut-{l3out.name})
|
||||
# fvRsDomAtt -> rsdomAtt-[{tDn}] (build/tenants.py:_build_epg, dn=.../rsdomAtt-[uni/phys-{phys_dom}])
|
||||
# fvRsProv -> rsprov-{tnVzBrCPName}
|
||||
# (build/tenants.py:_build_epg, dn=.../rsprov-{c})
|
||||
# fvRsCons -> rscons-{tnVzBrCPName}
|
||||
# (build/tenants.py:_build_epg, dn=.../rscons-{c})
|
||||
# vzBrCP -> brc-{name} (build/tenants.py:_build_contract, dn=f"uni/tn-{t}/brc-{contract.name}")
|
||||
# vzSubj -> subj-{name} (build/tenants.py:_build_contract, dn=.../brc-{name}/subj-{name})
|
||||
# vzRsSubjFiltAtt -> rssubjFiltAtt-{tnVzFilterName}
|
||||
# (build/tenants.py:_build_contract, dn=.../rssubjFiltAtt-{flt.name})
|
||||
# vzFilter -> flt-{name} (build/tenants.py:_build_filter, dn=f"uni/tn-{t}/flt-{flt.name}")
|
||||
# vzEntry -> e-{name} (build/tenants.py:_build_filter, dn=.../flt-{name}/e-{ename})
|
||||
#
|
||||
# Batch-1 additions (RN schemes taken verbatim from the same builders that
|
||||
# now emit these classes — see build/access.py + build/l3out.py + build/
|
||||
# tenants.py docstrings for the full DN derivation/attribute-source notes):
|
||||
# infraAccPortP -> accportprof-{name} (build/access.py:_build_leaf_profile)
|
||||
# infraHPortS -> hports-{name}-typ-range (build/access.py:_build_hports)
|
||||
# infraPortBlk -> portblk-block1 (build/access.py:_build_hports; fixed —
|
||||
# one block per selector in this sim)
|
||||
# infraRsAccBaseGrp -> rsaccBaseGrp (build/access.py:_build_hports, fixed RN)
|
||||
# infraAccBndlGrp -> accbundle-{name} (build/access.py:_build_bndl_grp)
|
||||
# rtctrlProfile -> prof-{name} (build/l3out.py:_build_route_control)
|
||||
# rtctrlCtxP -> ctx-{name} (build/l3out.py:_build_route_control)
|
||||
# rtctrlSubjP -> subj-{name} (build/l3out.py:_build_tenant_subj)
|
||||
# rtctrlMatchRtDest -> dest-[{ip}] (build/l3out.py:_build_tenant_subj)
|
||||
# ipRouteP -> rt-[{ip}] (build/l3out.py:_build_node_profile)
|
||||
# ipNexthopP -> nh-[{nhAddr}] (build/l3out.py:_build_node_profile)
|
||||
# l3extMember -> mem-A / mem-B (build/l3out.py:_build_node_profile; side
|
||||
# picks the RN suffix directly, not a name/attr)
|
||||
# ospfExtP -> ospfExtP (build/l3out.py:_build_l3out_tree, fixed RN)
|
||||
# fvRsPathAtt -> rspathAtt-[{tDn}] (build/tenants.py:_build_epg)
|
||||
# vzRsSubjGraphAtt -> rsSubjGraphAtt (build/tenants.py:_build_contract, fixed RN)
|
||||
#
|
||||
# Batch-2 addition — fabricNodeIdentP, the ONE batch-2 class an actual Fabric
|
||||
# Build playbook POSTs (CONTRACT.md §3 "Node registration reaction":
|
||||
# initial_fabric_discovery/add_leaf_switch_pair/add_spine_switch all POST
|
||||
# fabricNodeIdentP; ansible's aci_fabric_node module keys the object on the
|
||||
# node id, matching both the RN template's "nodeId" attribute AND the real
|
||||
# ACI RN uni/controller/nodeidentpol/nodep-{id} — see build/fabric.py's
|
||||
# boot-seeding docstring, which seeds the SAME nodep-{id} scheme so a
|
||||
# boot-seeded registration and a later POSTed one always land on the same DN
|
||||
# instead of silently diverging into nodep-{id} vs nodep-{serial}):
|
||||
# fabricNodeIdentP -> nodep-{nodeId} (build/fabric.py:build, dn=f"uni/controller/
|
||||
# nodeidentpol/nodep-{node.id}")
|
||||
#
|
||||
# Each entry is (attribute-to-read-the-value-from, rn-format). A rn-format of
|
||||
# None means the RN is fixed (no suffix, no attribute lookup).
|
||||
_RN_TEMPLATES: dict[str, tuple[str | None, str]] = {
|
||||
"fvBD": ("name", "BD-{}"),
|
||||
"fvAp": ("name", "ap-{}"),
|
||||
"fvAEPg": ("name", "epg-{}"),
|
||||
"fvCtx": ("name", "ctx-{}"),
|
||||
"fvSubnet": ("ip", "subnet-[{}]"),
|
||||
"fvRsCtx": (None, "rsctx"),
|
||||
"fvRsBd": (None, "rsbd"),
|
||||
"fvRsBDToOut": ("tnL3extOutName", "rsBDToOut-{}"),
|
||||
"fvRsDomAtt": ("tDn", "rsdomAtt-[{}]"),
|
||||
"fvRsProv": ("tnVzBrCPName", "rsprov-{}"),
|
||||
"fvRsCons": ("tnVzBrCPName", "rscons-{}"),
|
||||
"vzBrCP": ("name", "brc-{}"),
|
||||
"vzSubj": ("name", "subj-{}"),
|
||||
"vzRsSubjFiltAtt": ("tnVzFilterName", "rssubjFiltAtt-{}"),
|
||||
"vzFilter": ("name", "flt-{}"),
|
||||
"vzEntry": ("name", "e-{}"),
|
||||
"infraAccPortP": ("name", "accportprof-{}"),
|
||||
"infraHPortS": ("name", "hports-{}-typ-range"),
|
||||
"infraPortBlk": (None, "portblk-block1"),
|
||||
"infraRsAccBaseGrp": (None, "rsaccBaseGrp"),
|
||||
"infraAccBndlGrp": ("name", "accbundle-{}"),
|
||||
"rtctrlProfile": ("name", "prof-{}"),
|
||||
"rtctrlCtxP": ("name", "ctx-{}"),
|
||||
"rtctrlSubjP": ("name", "subj-{}"),
|
||||
"rtctrlMatchRtDest": ("ip", "dest-[{}]"),
|
||||
"ipRouteP": ("ip", "rt-[{}]"),
|
||||
"ipNexthopP": ("nhAddr", "nh-[{}]"),
|
||||
"l3extMember": ("side", "mem-{}"),
|
||||
"ospfExtP": (None, "ospfExtP"),
|
||||
"fvRsPathAtt": ("tDn", "rspathAtt-[{}]"),
|
||||
"vzRsSubjGraphAtt": (None, "rsSubjGraphAtt"),
|
||||
"fabricNodeIdentP": ("nodeId", "nodep-{}"),
|
||||
}
|
||||
|
||||
#: Class-keyed default attribute sets, filled ONLY on CREATE (matching real
|
||||
#: APIC, which commits object defaults at create time; posted attrs always win).
|
||||
#: Scoped to fvBD — closes the "BD host_route / EP-move show empty" fidelity gap.
|
||||
_CLASS_DEFAULTS: dict[str, dict[str, str]] = {
|
||||
"fvBD": {
|
||||
"mac": "00:22:BD:F8:19:FF",
|
||||
"type": "regular",
|
||||
"arpFlood": "no",
|
||||
"unkMacUcastAct": "proxy",
|
||||
"unkMcastAct": "flood",
|
||||
"v6unkMcastAct": "nd",
|
||||
"multiDstPktAct": "bd-flood",
|
||||
"epMoveDetectMode": "", # real create-default: GARP detection OFF
|
||||
"hostBasedRouting": "no",
|
||||
"limitIpLearnToSubnets": "yes",
|
||||
"ipLearning": "yes",
|
||||
"unicastRoute": "yes",
|
||||
"intersiteBumTrafficAllow": "no",
|
||||
"mtu": "9000",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WriteValidationError(ValueError):
|
||||
"""Raised when a POST body fails shape validation before any store mutation."""
|
||||
|
||||
|
||||
def _child_dn(parent_dn: str, cls: str, attrs: dict, index: int) -> str:
|
||||
"""Derive a non-empty, correctly-parented DN for a child that omits ``dn``.
|
||||
|
||||
ACI POST bodies may nest children without an explicit dn. Storing them at
|
||||
dn="" clobbers (last-writer-wins). Real APIC derives the RN from the
|
||||
class's RN *prefix*, not from the class name, so known classes use the
|
||||
canonical template harvested from the builders (see ``_RN_TEMPLATES``
|
||||
above) — this keeps POSTed objects on the same DN as builder-created ones
|
||||
(e.g. fvBD -> "BD-{name}", not "fvBD-{name}").
|
||||
"""
|
||||
dn = attrs.get("dn")
|
||||
if dn:
|
||||
return dn
|
||||
|
||||
template = _RN_TEMPLATES.get(cls)
|
||||
if template is not None:
|
||||
attr_name, rn_format = template
|
||||
if attr_name is None:
|
||||
# Fixed RN (e.g. fvRsCtx -> "rsctx"): no per-instance suffix at all.
|
||||
rn = rn_format
|
||||
else:
|
||||
value = str(attrs.get(attr_name) or index)
|
||||
rn = rn_format.format(value)
|
||||
return f"{parent_dn}/{rn}"
|
||||
|
||||
# Fallback for classes not covered by _RN_TEMPLATES: best-effort
|
||||
# '{cls}-{name}' heuristic. This does NOT match real APIC's RN-prefix
|
||||
# scheme, so it may not round-trip with a canonical GET; kept only so
|
||||
# unknown/unlisted classes still get a stable, non-clobbering DN.
|
||||
name = (
|
||||
attrs.get("name")
|
||||
or attrs.get("ip")
|
||||
or attrs.get("tDn")
|
||||
or attrs.get("tnFvBDName")
|
||||
or str(index)
|
||||
)
|
||||
rn = f"[{name}]" if ("/" in name or "[" in name) else name
|
||||
return f"{parent_dn}/{cls}-{rn}"
|
||||
|
||||
|
||||
def _validate_node(cls: str, body: dict, path: str) -> None:
|
||||
"""Validate one {className: {"attributes": dict, "children"?: list}} node.
|
||||
|
||||
Raises WriteValidationError with a descriptive message on any shape
|
||||
violation. Does not touch the store — pure validation.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
raise WriteValidationError(f"{path}: expected an object body for class {cls!r}, got {type(body).__name__}")
|
||||
|
||||
attributes = body.get("attributes", {})
|
||||
if not isinstance(attributes, dict):
|
||||
raise WriteValidationError(f"{path}: 'attributes' must be an object")
|
||||
|
||||
children = body.get("children", [])
|
||||
if children is None:
|
||||
children = []
|
||||
if not isinstance(children, list):
|
||||
raise WriteValidationError(f"{path}: 'children' must be a list")
|
||||
|
||||
for index, child_entry in enumerate(children):
|
||||
child_path = f"{path}/children[{index}]"
|
||||
if not isinstance(child_entry, dict):
|
||||
raise WriteValidationError(
|
||||
f"{child_path}: expected a single-key object mapping class name to body, "
|
||||
f"got {type(child_entry).__name__}"
|
||||
)
|
||||
if len(child_entry) != 1:
|
||||
raise WriteValidationError(
|
||||
f"{child_path}: expected exactly one class key, got {len(child_entry)}"
|
||||
)
|
||||
child_cls = next(iter(child_entry))
|
||||
child_body = child_entry[child_cls]
|
||||
_validate_node(child_cls, child_body, child_path)
|
||||
|
||||
|
||||
def _plan_recursive(cls: str, attrs: dict, children: list, planned: list[tuple[str, dict]]) -> None:
|
||||
"""Build the ordered list of (class, attrs) MOs to write, without touching the store.
|
||||
|
||||
Appends to *planned* in the same order the old eager code used to upsert,
|
||||
so behavior (e.g. parent-before-child) is unchanged — only the timing of
|
||||
the actual store mutation moves to after this whole plan succeeds.
|
||||
"""
|
||||
planned.append((cls, attrs))
|
||||
# A deleted MO removes its whole subtree; do NOT plan children as orphans.
|
||||
if attrs.get("status") == "deleted":
|
||||
return
|
||||
for index, child_entry in enumerate(children):
|
||||
for child_cls, child_body in child_entry.items():
|
||||
child_attrs = dict(child_body.get("attributes") or {})
|
||||
child_attrs["dn"] = _child_dn(attrs["dn"], child_cls, child_attrs, index)
|
||||
child_children = child_body.get("children") or []
|
||||
_plan_recursive(child_cls, child_attrs, child_children, planned)
|
||||
|
||||
|
||||
def _upsert_recursive(store: MITStore, cls: str, attrs: dict, children: list) -> list[tuple[str, dict]]:
|
||||
"""Validate the entire body shape, then upsert the MO and its children.
|
||||
|
||||
Real APIC POST is all-or-nothing: a malformed descendant must not leave
|
||||
earlier siblings/ancestors partially written. So this first validates the
|
||||
complete subtree (raising WriteValidationError before touching the store
|
||||
on any shape violation), builds the full ordered list of MOs to write,
|
||||
and only then mutates the store.
|
||||
|
||||
Returns the full ordered ``(class, attrs)`` plan so callers (``apply``)
|
||||
can inspect it for reactions (e.g. a nested ``fabricNodeIdentP`` child)
|
||||
without re-walking the body themselves.
|
||||
"""
|
||||
# Re-wrap the already-parsed root attrs/children into the same node shape
|
||||
# _validate_node expects, so root and descendants share one validation
|
||||
# path (root attrs are pre-extracted by apply(), but must still satisfy
|
||||
# the same shape rules children do).
|
||||
_validate_node(cls, {"attributes": attrs, "children": children}, path=attrs.get("dn", cls))
|
||||
|
||||
planned: list[tuple[str, dict]] = []
|
||||
_plan_recursive(cls, attrs, children, planned)
|
||||
|
||||
# Validation passed for the entire subtree — now, and only now, mutate
|
||||
# the store (400 on validation failure => zero side effects).
|
||||
for mo_cls, mo_attrs in planned:
|
||||
# Real APIC commits a class's object defaults at CREATE time only —
|
||||
# a later partial-update POST never resets an already-set attribute
|
||||
# back to its default. So the overlay applies iff (a) this isn't a
|
||||
# delete (no defaults that could resurrect a deleted object's attrs)
|
||||
# and (b) the DN doesn't exist yet in the store. Posted attrs are
|
||||
# layered on top of the defaults dict, so they always win.
|
||||
dn = mo_attrs.get("dn")
|
||||
if mo_attrs.get("status") != "deleted" and store.get(dn) is None:
|
||||
mo_attrs = {**_CLASS_DEFAULTS.get(mo_cls, {}), **mo_attrs}
|
||||
store.upsert(MO(mo_cls, **mo_attrs))
|
||||
|
||||
return planned
|
||||
|
||||
|
||||
def materialize_node_registration(
|
||||
store: MITStore,
|
||||
*,
|
||||
topo,
|
||||
site,
|
||||
node_id: int,
|
||||
name: str,
|
||||
role: str = "leaf",
|
||||
) -> None:
|
||||
"""Reaction: materialize a full node-registration MO set for *node_id*.
|
||||
|
||||
Mirrors what the real builders (build/fabric.py, build/cabling.py,
|
||||
build/interfaces.py, build/health_faults.py) emit for a leaf that was
|
||||
present at boot time, so a node registered dynamically via
|
||||
fabricNodeIdentP or /_sim/add-leaf renders identically to one seeded from
|
||||
topology.yaml:
|
||||
|
||||
- fabricNode (inventory row)
|
||||
- topSystem (loopback/oob addresses, valid parseable IPv4 —
|
||||
reuses build/fabric.py's loopback_ip/oob_ip so
|
||||
the address scheme never drifts out of sync)
|
||||
- healthInst (node health, matching health_faults.py's shape)
|
||||
- fabricLink (+ lldpAdjEp/cdpAdjEp) to EVERY spine in the site, mirroring
|
||||
build/cabling.py's leaf-uplink port assignment (eth1/{49+spine_idx})
|
||||
- l1PhysIf/ethpmPhysIf inventory (fabric uplinks + host-access ports),
|
||||
reusing build/interfaces.py's own `_add_port` helper so port shapes
|
||||
never diverge from the boot-time build path
|
||||
|
||||
`pod` is derived from *site* (nodeidentpol DNs carry no pod of their
|
||||
own) rather than hardcoded, so multi-pod/multi-site sims register nodes
|
||||
under the correct pod.
|
||||
"""
|
||||
pod = site.pod
|
||||
node_dn = f"topology/pod-{pod}/node-{node_id}"
|
||||
|
||||
store.upsert(MO(
|
||||
"fabricNode",
|
||||
dn=node_dn,
|
||||
id=str(node_id),
|
||||
name=name,
|
||||
role=role,
|
||||
adSt="on",
|
||||
fabricSt="active",
|
||||
model="N9K-C9332C",
|
||||
serial="",
|
||||
version="n9000-14.2(7f)",
|
||||
))
|
||||
|
||||
store.upsert(MO(
|
||||
"topSystem",
|
||||
dn=f"{node_dn}/sys",
|
||||
id=str(node_id),
|
||||
name=name,
|
||||
role=role,
|
||||
version="n9000-14.2(7f)",
|
||||
address=loopback_ip(pod, node_id),
|
||||
oobMgmtAddr=oob_ip(pod, node_id),
|
||||
fabricDomain=site.fabric_name or (topo.fabric.name if topo else ""),
|
||||
state="in-service",
|
||||
podId=str(pod),
|
||||
))
|
||||
|
||||
store.upsert(MO(
|
||||
"healthInst",
|
||||
dn=f"{node_dn}/sys/health",
|
||||
cur="95",
|
||||
min="95",
|
||||
max="100",
|
||||
prev="95",
|
||||
))
|
||||
|
||||
# Cable this node to every spine in the site (mirrors build/cabling.py's
|
||||
# leaf-uplink port assignment: leaf uplink eth1/{49+spine_idx}, spine
|
||||
# downlink port picked as the next free slot after its existing leaves).
|
||||
spines = list(site.spine_nodes()) if site is not None else []
|
||||
leaf_uplinks: list[tuple[int, int]] = []
|
||||
for s_idx, spine in enumerate(spines):
|
||||
leaf_slot, leaf_port = 1, _UPLINK_START + s_idx
|
||||
# Spine-side port: next free downlink slot on that spine (after every
|
||||
# existing leaf/border-leaf this site already cabled at boot time).
|
||||
existing_downlinks = len(site.leaf_nodes()) + len(site.border_leaf_nodes())
|
||||
spine_slot, spine_port = 1, existing_downlinks + 1
|
||||
|
||||
lnk_dn = (
|
||||
f"topology/pod-{pod}"
|
||||
f"/lnk-{spine.id}-{spine_slot}-{spine_port}-to-{node_id}-{leaf_slot}-{leaf_port}"
|
||||
)
|
||||
store.upsert(MO(
|
||||
"fabricLink",
|
||||
dn=lnk_dn,
|
||||
n1=str(spine.id),
|
||||
n2=str(node_id),
|
||||
operSt="up",
|
||||
operSpeed="100G",
|
||||
linkType="leaf",
|
||||
))
|
||||
|
||||
spine_port_str = f"eth{spine_slot}/{spine_port}"
|
||||
leaf_port_str = f"eth{leaf_slot}/{leaf_port}"
|
||||
spine_oob = oob_ip(pod, spine.id)
|
||||
leaf_oob = oob_ip(pod, node_id)
|
||||
# The dynamically-registered node has no Node schema instance of its
|
||||
# own (only the id/name/role args this function received) — build a
|
||||
# throwaway one so add_switch_adjacency can derive model/version/mac
|
||||
# from it exactly like the boot-time builders do. model/version
|
||||
# match the hardcoded fabricNode/topSystem values a few lines above.
|
||||
new_node = Node(id=node_id, name=name, model="N9K-C9332C", version="n9000-14.2(7f)")
|
||||
|
||||
# Spine sees the new node as neighbor on spine_port_str
|
||||
add_switch_adjacency(
|
||||
store,
|
||||
pod=pod,
|
||||
local_node_id=spine.id,
|
||||
local_port=spine_port_str,
|
||||
remote_port=leaf_port_str,
|
||||
neighbor=new_node,
|
||||
neighbor_mgmt_ip=leaf_oob,
|
||||
)
|
||||
# The new node sees the spine as neighbor on leaf_port_str
|
||||
add_switch_adjacency(
|
||||
store,
|
||||
pod=pod,
|
||||
local_node_id=node_id,
|
||||
local_port=leaf_port_str,
|
||||
remote_port=spine_port_str,
|
||||
neighbor=spine,
|
||||
neighbor_mgmt_ip=spine_oob,
|
||||
)
|
||||
leaf_uplinks.append((leaf_slot, leaf_port))
|
||||
|
||||
# l1PhysIf inventory: fabric uplinks (one per spine) + host-access ports,
|
||||
# reusing build/interfaces.py's own port-building helper so shapes never
|
||||
# diverge from the boot-time build path.
|
||||
for slot, port in leaf_uplinks:
|
||||
_add_port(
|
||||
store, pod, node_id, slot, port,
|
||||
descr="Fabric uplink to spine",
|
||||
mode="trunk",
|
||||
with_fcot=True,
|
||||
)
|
||||
for hp in range(1, _HOST_PORTS + 1):
|
||||
_add_port(
|
||||
store, pod, node_id, 1, hp,
|
||||
descr=f"Host port eth1/{hp}",
|
||||
mode="access",
|
||||
usage="access",
|
||||
)
|
||||
|
||||
|
||||
def apply(store: MITStore, dn: str, body: dict, *, topo=None, site=None) -> tuple[list[dict], int]:
|
||||
"""Apply a write (upsert or delete) from a POST /api/mo/{dn}.json body.
|
||||
|
||||
Body shape: {"<cls>": {"attributes": {...}, "children": [...]}}
|
||||
Returns (imdata, total).
|
||||
|
||||
*topo*/*site* are optional context needed by the fabricNodeIdentP
|
||||
reaction (pod number, spine list for cabling) — passed through by the
|
||||
caller the same way it already threads ``state.store`` here.
|
||||
"""
|
||||
if not body:
|
||||
return [], 0
|
||||
|
||||
# Extract class name and body parts
|
||||
cls = next(iter(body))
|
||||
cls_body = body[cls]
|
||||
attrs = dict(cls_body.get("attributes") or {})
|
||||
children = cls_body.get("children") or []
|
||||
|
||||
# Honor attrs["dn"] if present, fall back to URL dn
|
||||
effective_dn = attrs.get("dn") or dn
|
||||
attrs["dn"] = effective_dn
|
||||
|
||||
# Perform the upsert/delete; get back the full ordered (class, attrs)
|
||||
# plan so the fabricNodeIdentP reaction fires for a nested child too,
|
||||
# not just when it's the top-level POSTed class (finding #19).
|
||||
planned = _upsert_recursive(store, cls, attrs, children)
|
||||
|
||||
if site is not None:
|
||||
for mo_cls, mo_attrs in planned:
|
||||
if mo_cls != "fabricNodeIdentP":
|
||||
continue
|
||||
if mo_attrs.get("status") == "deleted":
|
||||
continue
|
||||
node_dn = mo_attrs.get("dn", "")
|
||||
m = re.search(r"nodep-(\d+)$", node_dn)
|
||||
if not m:
|
||||
continue
|
||||
node_id = int(m.group(1))
|
||||
name = mo_attrs.get("name", f"leaf-{node_id}")
|
||||
role = mo_attrs.get("role", mo_attrs.get("nodeType", "leaf"))
|
||||
materialize_node_registration(
|
||||
store, topo=topo, site=site, node_id=node_id, name=name, role=role,
|
||||
)
|
||||
|
||||
status = "deleted" if attrs.get("status") == "deleted" else "created"
|
||||
result = [{cls: {"attributes": {"dn": effective_dn, "status": status}}}]
|
||||
return result, 1
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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` §8–9 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
@@ -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`.*
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Executable
+8
@@ -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
@@ -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
|
||||
Executable
+56
@@ -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."
|
||||
Executable
+116
@@ -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"
|
||||
Executable
+106
@@ -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
|
||||
Executable
+7
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user