From 7e9a175ce6c1bee350f02517e211ed85e74bbe90 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Sun, 5 Jul 2026 20:06:36 +1000 Subject: [PATCH] =?UTF-8?q?Initial=20public=20release=20=E2=80=94=20aci-si?= =?UTF-8?q?m=20v0.16.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 53 + .gitignore | 14 + CHANGELOG.md | 997 +++++++++++++ CLAUDE.md | 23 + CONTRIBUTING.md | 90 ++ LICENSE | 21 + README.md | 1057 ++++++++++++++ aci_sim/__init__.py | 0 aci_sim/build/__init__.py | 0 aci_sim/build/access.py | 291 ++++ aci_sim/build/cabling.py | 94 ++ aci_sim/build/endpoints.py | 313 ++++ aci_sim/build/fabric.py | 343 +++++ aci_sim/build/health_faults.py | 113 ++ aci_sim/build/interfaces.py | 191 +++ aci_sim/build/l3out.py | 294 ++++ aci_sim/build/mgmt.py | 122 ++ aci_sim/build/neighbors.py | 230 +++ aci_sim/build/orchestrator.py | 89 ++ aci_sim/build/overlay.py | 286 ++++ aci_sim/build/routing.py | 167 +++ aci_sim/build/tenants.py | 379 +++++ aci_sim/build/underlay.py | 106 ++ aci_sim/build/userprofile.py | 42 + aci_sim/build/zoning.py | 161 +++ aci_sim/cli.py | 1768 +++++++++++++++++++++++ aci_sim/control/__init__.py | 0 aci_sim/control/admin.py | 166 +++ aci_sim/control/persist.py | 122 ++ aci_sim/graph.py | 389 +++++ aci_sim/mit/__init__.py | 0 aci_sim/mit/dn.py | 115 ++ aci_sim/mit/mo.py | 50 + aci_sim/mit/store.py | 174 +++ aci_sim/ndo/__init__.py | 0 aci_sim/ndo/app.py | 785 ++++++++++ aci_sim/ndo/deploy_mirror.py | 435 ++++++ aci_sim/ndo/model.py | 530 +++++++ aci_sim/ndo/patch.py | 785 ++++++++++ aci_sim/query/__init__.py | 0 aci_sim/query/engine.py | 251 ++++ aci_sim/query/filters.py | 267 ++++ aci_sim/rest_aci/__init__.py | 0 aci_sim/rest_aci/app.py | 385 +++++ aci_sim/rest_aci/auth.py | 292 ++++ aci_sim/rest_aci/subscriptions.py | 212 +++ aci_sim/rest_aci/writes.py | 469 ++++++ aci_sim/runtime/__init__.py | 0 aci_sim/runtime/config.py | 19 + aci_sim/runtime/supervisor.py | 105 ++ aci_sim/topology/__init__.py | 0 aci_sim/topology/loader.py | 47 + aci_sim/topology/schema.py | 1021 +++++++++++++ deploy/README.md | 140 ++ deploy/aci-fabric-sim.service | 31 + docs/CONTRACT.md | 579 ++++++++ docs/DESIGN.md | 829 +++++++++++ docs/MANUAL.md | 317 ++++ examples/ansible/README.md | 96 ++ examples/ansible/inventory.ini | 21 + examples/ansible/smoke.yml | 145 ++ examples/ansible/teardown.yml | 83 ++ examples/ci/README.md | 52 + examples/ci/assert_mit.py | 115 ++ examples/ci/github-actions-aci-gate.yml | 65 + pyproject.toml | 68 + requirements.txt | 7 + scripts/e2e_live.py | 126 ++ scripts/e2e_sandbox.py | 93 ++ scripts/gen_certs.sh | 8 + scripts/run.sh | 10 + scripts/sandbox-down.sh | 56 + scripts/sandbox-up.sh | 116 ++ scripts/sim-state.sh | 106 ++ scripts/verify.sh | 7 + tests/__init__.py | 0 tests/test_admin_account.py | 372 +++++ tests/test_auth_fidelity.py | 273 ++++ tests/test_build_fabric.py | 288 ++++ tests/test_build_tenant.py | 219 +++ tests/test_cli.py | 461 ++++++ tests/test_control.py | 74 + tests/test_deploy_mirror.py | 496 +++++++ tests/test_graph.py | 185 +++ tests/test_lldp_cdp_fidelity.py | 456 ++++++ tests/test_mit.py | 330 +++++ tests/test_ndo.py | 666 +++++++++ tests/test_oob_gateway.py | 276 ++++ tests/test_p1_regression.py | 172 +++ tests/test_p5_fixes.py | 79 + tests/test_persist.py | 284 ++++ tests/test_pr10_ansible_gaps.py | 277 ++++ tests/test_pr11_ndo_write.py | 554 +++++++ tests/test_pr12_site_local.py | 447 ++++++ tests/test_pr13_ndo_dhcp_svcgraph.py | 742 ++++++++++ tests/test_pr14_nd_bare_login.py | 36 + tests/test_pr15_acipy_gaps.py | 325 +++++ tests/test_pr16_bd_mirror_dedup.py | 321 ++++ tests/test_pr18_tier1.py | 252 ++++ tests/test_pr19_tier2.py | 396 +++++ tests/test_pr20_tier3.py | 467 ++++++ tests/test_pr21_single_apic.py | 246 ++++ tests/test_pr22_wizard.py | 479 ++++++ tests/test_pr3_fixes.py | 213 +++ tests/test_pr3b_batch1.py | 440 ++++++ tests/test_pr3b_batch2.py | 471 ++++++ tests/test_pr5_topology_consistency.py | 276 ++++ tests/test_pr6_control_plane.py | 300 ++++ tests/test_pr8_platform.py | 206 +++ tests/test_pr9_ansible.py | 338 +++++ tests/test_query.py | 613 ++++++++ tests/test_query_numeric_semantics.py | 186 +++ tests/test_rest_aci.py | 717 +++++++++ tests/test_subscriptions.py | 285 ++++ tests/test_topology.py | 620 ++++++++ tests/verify_autoaci.py | 668 +++++++++ topology.yaml | 330 +++++ 117 files changed, 31769 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 aci_sim/__init__.py create mode 100644 aci_sim/build/__init__.py create mode 100644 aci_sim/build/access.py create mode 100644 aci_sim/build/cabling.py create mode 100644 aci_sim/build/endpoints.py create mode 100644 aci_sim/build/fabric.py create mode 100644 aci_sim/build/health_faults.py create mode 100644 aci_sim/build/interfaces.py create mode 100644 aci_sim/build/l3out.py create mode 100644 aci_sim/build/mgmt.py create mode 100644 aci_sim/build/neighbors.py create mode 100644 aci_sim/build/orchestrator.py create mode 100644 aci_sim/build/overlay.py create mode 100644 aci_sim/build/routing.py create mode 100644 aci_sim/build/tenants.py create mode 100644 aci_sim/build/underlay.py create mode 100644 aci_sim/build/userprofile.py create mode 100644 aci_sim/build/zoning.py create mode 100644 aci_sim/cli.py create mode 100644 aci_sim/control/__init__.py create mode 100644 aci_sim/control/admin.py create mode 100644 aci_sim/control/persist.py create mode 100644 aci_sim/graph.py create mode 100644 aci_sim/mit/__init__.py create mode 100644 aci_sim/mit/dn.py create mode 100644 aci_sim/mit/mo.py create mode 100644 aci_sim/mit/store.py create mode 100644 aci_sim/ndo/__init__.py create mode 100644 aci_sim/ndo/app.py create mode 100644 aci_sim/ndo/deploy_mirror.py create mode 100644 aci_sim/ndo/model.py create mode 100644 aci_sim/ndo/patch.py create mode 100644 aci_sim/query/__init__.py create mode 100644 aci_sim/query/engine.py create mode 100644 aci_sim/query/filters.py create mode 100644 aci_sim/rest_aci/__init__.py create mode 100644 aci_sim/rest_aci/app.py create mode 100644 aci_sim/rest_aci/auth.py create mode 100644 aci_sim/rest_aci/subscriptions.py create mode 100644 aci_sim/rest_aci/writes.py create mode 100644 aci_sim/runtime/__init__.py create mode 100644 aci_sim/runtime/config.py create mode 100644 aci_sim/runtime/supervisor.py create mode 100644 aci_sim/topology/__init__.py create mode 100644 aci_sim/topology/loader.py create mode 100644 aci_sim/topology/schema.py create mode 100644 deploy/README.md create mode 100644 deploy/aci-fabric-sim.service create mode 100644 docs/CONTRACT.md create mode 100644 docs/DESIGN.md create mode 100644 docs/MANUAL.md create mode 100644 examples/ansible/README.md create mode 100644 examples/ansible/inventory.ini create mode 100644 examples/ansible/smoke.yml create mode 100644 examples/ansible/teardown.yml create mode 100644 examples/ci/README.md create mode 100644 examples/ci/assert_mit.py create mode 100644 examples/ci/github-actions-aci-gate.yml create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/e2e_live.py create mode 100644 scripts/e2e_sandbox.py create mode 100755 scripts/gen_certs.sh create mode 100755 scripts/run.sh create mode 100755 scripts/sandbox-down.sh create mode 100755 scripts/sandbox-up.sh create mode 100755 scripts/sim-state.sh create mode 100755 scripts/verify.sh create mode 100644 tests/__init__.py create mode 100644 tests/test_admin_account.py create mode 100644 tests/test_auth_fidelity.py create mode 100644 tests/test_build_fabric.py create mode 100644 tests/test_build_tenant.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_control.py create mode 100644 tests/test_deploy_mirror.py create mode 100644 tests/test_graph.py create mode 100644 tests/test_lldp_cdp_fidelity.py create mode 100644 tests/test_mit.py create mode 100644 tests/test_ndo.py create mode 100644 tests/test_oob_gateway.py create mode 100644 tests/test_p1_regression.py create mode 100644 tests/test_p5_fixes.py create mode 100644 tests/test_persist.py create mode 100644 tests/test_pr10_ansible_gaps.py create mode 100644 tests/test_pr11_ndo_write.py create mode 100644 tests/test_pr12_site_local.py create mode 100644 tests/test_pr13_ndo_dhcp_svcgraph.py create mode 100644 tests/test_pr14_nd_bare_login.py create mode 100644 tests/test_pr15_acipy_gaps.py create mode 100644 tests/test_pr16_bd_mirror_dedup.py create mode 100644 tests/test_pr18_tier1.py create mode 100644 tests/test_pr19_tier2.py create mode 100644 tests/test_pr20_tier3.py create mode 100644 tests/test_pr21_single_apic.py create mode 100644 tests/test_pr22_wizard.py create mode 100644 tests/test_pr3_fixes.py create mode 100644 tests/test_pr3b_batch1.py create mode 100644 tests/test_pr3b_batch2.py create mode 100644 tests/test_pr5_topology_consistency.py create mode 100644 tests/test_pr6_control_plane.py create mode 100644 tests/test_pr8_platform.py create mode 100644 tests/test_pr9_ansible.py create mode 100644 tests/test_query.py create mode 100644 tests/test_query_numeric_semantics.py create mode 100644 tests/test_rest_aci.py create mode 100644 tests/test_subscriptions.py create mode 100644 tests/test_topology.py create mode 100644 tests/verify_autoaci.py create mode 100644 topology.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83f60c4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e60ff6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fe922d0 --- /dev/null +++ b/CHANGELOG.md @@ -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} ` 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` (real APIC's literal path shape, `` = the `APIC-cookie` session + token) opens a websocket; when a subscribed class or DN/subtree changes via `POST`/`DELETE`, + a `{"subscriptionId":[...],"imdata":[{:{"attributes":{...,"status":"created|modified| + deleted","dn":...}}}]}` event pushes over that connection. `GET /api/subscriptionRefresh.json? + 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=` 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:\`). 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:\` / `\` 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6a227da --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..75ed9a7 --- /dev/null +++ b/CONTRIBUTING.md @@ -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_.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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1e223ec --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c078626 --- /dev/null +++ b/README.md @@ -0,0 +1,1057 @@ +# aci-sim + +A stateful, dependency-free REST simulator of a 2-site Cisco ACI/NDO fabric — +run Ansible `cisco.aci` playbooks, ACI SDKs, or an AI agent against it like +real gear, with no hardware required. + +[![CI](https://github.com/dtzp555-max/aci-sim/actions/workflows/ci.yml/badge.svg)](https://github.com/dtzp555-max/aci-sim/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](pyproject.toml) + +A faithful REST **simulator of a 2-site Cisco ACI fabric** — a per-site APIC +plus a Nexus Dashboard Orchestrator (ND/NDO) plane — with no physical or +virtual ACI hardware required. + +The sim answers the same URLs, response envelopes, and query semantics as a +real APIC / NDO, so anything that speaks the ACI/NDO REST API — Ansible's +`cisco.aci` collection, custom ACI clients/SDKs, CI pipelines, or an AI +coding agent poking around a "fabric" — runs against it unmodified. The +entire fabric — nodes, tenants, VRFs/BDs/EPGs, contracts, L3Outs, faults, +endpoints, and the whole NDO schema tree — is generated deterministically +from a single `topology.yaml`. + +--- + +## 1. Purpose + +**What it simulates:** a 2-site Cisco ACI fabric (spines, leaves, border +leaves in vPC pairs, an inter-site ISN/EVPN mesh), one APIC REST API per +site, and one NDO/MSO REST plane tying the two sites together. All three +planes are derived from the same topology definition, so tenant/VRF/BD names +and IDs are guaranteed consistent between a site's APIC and NDO's view of it +— matching how a real multi-site deployment behaves. + +The simulator implements a **99-class object catalog** (`docs/CONTRACT.md` +§6) spanning fabric/topology, faults, interfaces, underlay neighbors, +BGP-EVPN overlay, COOP/EPM, routing tables, tenant policy, contracts, L3Out, +access policy, and capacity/backup classes — the subset of the real ACI +object model that REST clients and Ansible's `cisco.aci` modules actually +read and write. + +**Why it exists:** + +- **Test Ansible `cisco.aci` playbooks** (tenant/VRF/BD/AP/EPG create, + idempotency, teardown) without booking lab time on real or virtual ACI + hardware. See `examples/ansible/`. +- **Develop and exercise ACI REST clients/SDKs** against faithful envelope + shapes, filter grammar, pagination, and error codes — including edge cases + (malformed filters, expired sessions, atomic write validation) that are + tedious to provoke on a real controller. +- **CI gates** — spin the sim up in a GitHub Actions job, run integration + tests against real HTTP, tear it down; no external dependency, no + credentials to manage, fully deterministic. See `examples/ci/` for a + copy-pasteable GitHub Actions gate + `assert_mit.py`, a tiny MIT-end-state + assertion helper that fails the build when a change stops producing the + objects it should. +- **AI-agent sandboxes** — a safe, disposable target for agents that need to + "drive" an ACI/NDO-shaped API (query, write, verify) without touching + production infrastructure. The `/_sim` control API (snapshot / restore / + reset) makes a clean act-observe-reset eval harness; seeded faults let you + test an agent's diagnosis path. +- **Training / demos** — a full 2-site fabric with faults, endpoints, and + contracts to explore, with no hardware cost. + +It was originally built to exercise a companion project, autoACI, end-to-end +without a lab; that contract is preserved (`docs/CONTRACT.md`), but the +simulator's REST surface is generic ACI/NDO — nothing in it is autoACI- +specific. + +### More applications + +The same "stateful management-plane, real REST, resettable" core supports a +few more scenarios — with an honest note on what each needs today: + +| Use case | Status | +|---|---| +| **SDK / provider testing** (cobra, acitoolkit, terraform-provider-aci, custom Go/Python clients) | Ready — faithful envelope/DN/filter semantics | +| **Change pre-flight** — model a proposed change (new tenant, VLAN migration) against a sim seeded to mirror production, catch errors before touching real gear | Ready — this is what the production-var validation runs exercise | +| **REST-contract / regression testing** — pin the APIC/NDO REST behaviour your tooling depends on to a git-versioned target | Ready | +| **Multi-tool interop** — verify Ansible + Terraform + a Python script converge on the same MIT | Ready | +| **Monitoring/observability integration** — test a fault→alert pipeline or exporter against seeded faults/health | Ready — polling AND websocket **query subscriptions** (`?subscription=yes` + `/socket` push-on-change) both work, see §10a | +| **Client scale/perf testing** — hammer pagination / large-MIT / concurrent queries | Needs a large-topology seed (extension) | + +**Out of scope** (management-plane simulator, by design): the data plane +(packet forwarding, real VXLAN), real APIC software internals (upgrades, +cluster quorum), and live event-driven telemetry — operational objects +(faults, health, endpoints, routes) are synthesised from the topology at +build time, not generated from real events. + +--- + +## 2. Architecture + +### Module map + +``` +topology.yaml + │ load_topology() topology/loader.py + schema.py (Pydantic v2) + ▼ + Topology + │ + ├─ build_all(topo) ──► {site_name: MITStore} build/orchestrator.py + │ builders: fabric, cabling, underlay, overlay, interfaces, + │ tenants, access, l3out, endpoints, routing, + │ zoning, health_faults + │ + └─ build_ndo_model(topo) ──► NdoState ndo/model.py + (names derived from the SAME topo → APIC/NDO consistency) + + make_apic_app(ApicSiteState) ──► FastAPI (×2, one per site) rest_aci/app.py + ├─ query engine (class / mo / node-scoped) query/engine.py + ├─ write handler (upsert + fabricNodeIdentP reaction) rest_aci/writes.py + └─ /_sim control router control/admin.py + + make_ndo_app(NdoState) ──► FastAPI ndo/app.py + + runtime/supervisor.py → three uvicorn servers over TLS (asyncio.gather) +``` + +- **`mit/`** — the object store. `MO` (dn-keyed managed object with + attrs/children) + `MITStore` (dn→MO map, parent→children index, + deterministic insertion order). No dependencies — the foundation every + other layer builds on. +- **`query/`** — the ACI query engine: `filters.py` parses + `eq/wcard/and/or/gt/lt/ge/le/bw(...)` filter expressions into predicates; + `engine.py` implements the three query shapes (class, MO/DN, node-scoped) + plus both subtree modes (legacy `query-target=subtree` flat, modern + `rsp-subtree=children|full` nested) and pagination. +- **`topology/`** — Pydantic v2 schema + loader for `topology.yaml`. Fails + loudly (all errors at once) on bad cross-references (BD→VRF, EPG→BD, + L3Out→VRF, CSW peering mismatches, IP pools outside builder-derived + ranges). +- **`build/`** — one pure `build(topo, site, store)` function per concern + (fabric, cabling, underlay, overlay/ISN, interfaces, tenants, access, + l3out, endpoints, routing, zoning, health_faults); each only *adds* MOs. + `orchestrator.py` runs them in dependency order and returns a populated + `MITStore` per site. +- **`rest_aci/`** — the APIC FastAPI app: `auth.py` (aaaLogin/aaaRefresh/ + aaaLogout, cookie/session lifecycle, cert accept-mode), `app.py` (routes + + query-param parsing → query engine → envelope + error mapping), + `writes.py` (POST/DELETE body → MIT upsert, atomic validate-then-commit, + the `fabricNodeIdentP` node-registration reaction). +- **`ndo/`** — `model.py` builds the NDO schema/tenant/site model from the + same `Topology` object; `app.py` serves the NDO/MSO REST surface. +- **`runtime/`** — `config.py` (env-driven ports/binds/paths), + `supervisor.py` (loads topology, builds all stores, starts the three TLS + servers, keeps a baseline snapshot for `/_sim/reset`). +- **`control/`** — the `/_sim/*` test-orchestration router mounted on every + APIC app (reset/snapshot/restore/reload/add-leaf/remove-leaf) — not part + of the real ACI API surface. + +### Data flow + +`topology.yaml` → `load_topology()` validates and produces a typed +`Topology` → `build_all()` runs each site's builders in dependency order +(cabling before underlay/overlay; endpoints before tenants/routing/zoning, +so those can read back real endpoint ports) → one `MITStore` per site → +`make_apic_app()` wraps each store in a FastAPI app implementing the query +engine + write path → `build_ndo_model()` derives the NDO schema from the +same `Topology` → `make_ndo_app()` serves it. `runtime/supervisor.py` starts +all three apps as uvicorn servers over TLS, and keeps a baseline copy of +each store so `/_sim/reset` can restore it. + +### Two run modes + +This is the key differentiator versus "just run three dev servers": + +| | **Port mode** (default) | **Sandbox mode** | +| --- | --- | --- | +| Addressing | one shared bind address (`SIM_BIND`, default `127.0.0.1`), three distinct ports: `8443` (APIC site A), `8444` (APIC site B), `8445` (NDO) | one **loopback IP alias per device**, all on the standard HTTPS port **`:443`** — no port numbers, exactly like real gear | +| Started by | `scripts/run.sh` | `scripts/sandbox-up.sh` (root required) | +| Use case | everyday development, Ansible playbooks, CI, direct APIC login | NDO **auto-discovery** ("Connect All Sites"), which connects to each site's APIC by IP on port 443 and cannot be redirected to a `host:port` form | +| Platform handling | n/a | branches on `uname -s`: macOS uses `ifconfig lo0 alias`, Linux uses `ip addr add … dev lo`; the client under test is never modified | + +Sandbox mode exists because some NDO/multi-site-orchestration clients +hardcode port 443 for auto-discovered sites and have no field to enter an +alternate port — the only way to satisfy that client without patching it is +to give each simulated controller a real, individually addressable IP. The +IPs come from `topology.yaml` (`fabric.ndo_mgmt_ip` + each site's `mgmt_ip`) +and are added/removed as loopback aliases by `sandbox-up.sh`/`sandbox-down.sh`, +which also verify the previous instance is a genuine +`aci_sim.runtime.supervisor` process before killing it, and confirm +the new servers actually answer on `:443` before declaring success. + +--- + +## 3. Dependencies + +From `requirements.txt`: + +| Package | Purpose | +| --- | --- | +| `fastapi>=0.110` | REST app framework for the APIC/NDO planes | +| `uvicorn[standard]>=0.29` | ASGI server (TLS termination, `asyncio` event loop) | +| `pydantic>=2.6` | Topology schema validation | +| `pyyaml>=6.0` | `topology.yaml` parsing | +| `httpx>=0.27` | Used by FastAPI's `TestClient` in tests | +| `pytest>=8.0` | Test runner | +| `pytest-asyncio>=0.23` | Async test support (`asyncio_mode = "auto"`) | + +Python **3.11+** (see `pyproject.toml`, `requires-python = ">=3.11"`). +No database, no external services — everything is in-memory. + +--- + +## 4. Usage / Quickstart + +### Install + +Direct from GitHub (simplest — no clone needed): + +```bash +pip install git+https://github.com/dtzp555-max/aci-sim.git +``` + +From source (dev — editable install, test/lint extras): + +```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 +``` + +`pip install aci-sim` will work once the project is published to PyPI (not +yet published — use one of the two forms above until then). + +### Port mode (default) + +```bash +bash scripts/run.sh +# or, equivalently, via the aci-sim CLI (see §5): +aci-sim run +``` + +Generates a self-signed cert on first run (`scripts/gen_certs.sh`) and +starts: + +``` +[sim] APIC LAB1 (asn 65001) → https://127.0.0.1:8443 +[sim] APIC LAB2 (asn 65002) → https://127.0.0.1:8444 +[sim] NDO → https://127.0.0.1:8445 +``` + +Smoke test: + +```bash +curl -sk https://127.0.0.1:8443/api/class/fabricNode.json | python -m json.tool +``` + +### Sandbox mode (real per-device IPs on :443) + +Needed for NDO "Connect All Sites" auto-discovery, which addresses each site +by IP on the standard HTTPS port with no port field. + +**macOS:** + +```bash +sudo bash scripts/sandbox-up.sh # aliases 10.192.0.10/.11 + 10.192.128.11 on lo0, serves :443 +# point your NDO client at 10.192.0.10 to exercise auto-discovery +sudo bash scripts/sandbox-down.sh # stop the sim + remove the aliases +``` + +**Linux:** the same two scripts detect `uname -s == Linux` and use +`ip addr add/del … dev lo` instead of `ifconfig lo0 alias`; invocation is +identical (`sudo bash scripts/sandbox-up.sh` / `sandbox-down.sh`). + +The IPs live in `topology.yaml` — edit `fabric.ndo_mgmt_ip` and each site's +`mgmt_ip` if they clash with your network. + +### Ansible quickstart + +`examples/ansible/` contains a validated `cisco.aci` playbook set — see +`examples/ansible/README.md` for exact run instructions against either run +mode. + +```bash +cd examples/ansible +ansible-playbook -i inventory.ini smoke.yml # create + idempotency check +ansible-playbook -i inventory.ini teardown.yml # state=absent cleanup +``` + +### Running as a background service + +For a standing deployment (e.g. a lab host reachable from other machines), +see `deploy/README.md` for a systemd `--user` unit (Linux) and a launchd +plist sketch (macOS). + +--- + +## 5. CLI + +`pip install -e .` installs an `aci-sim` console script (backed by +`aci_sim/cli.py`) that makes `topology.yaml` easier to author, +validate, preview, and run — **without** a web UI and **without** changing +the config format. `topology.yaml` remains the single source of truth; +`aci-sim` is a thin, purely-additive convenience layer around +`topology.loader.load_topology()` and the existing supervisor. + +### `aci-sim validate [TOPOLOGY]` + +Loads and validates a topology file (default `./topology.yaml`) — the same +Pydantic v2 validation (`Topology.normalize_and_validate`) the sim always +runs at startup, wrapped in a friendly pass/fail summary. This is the CI +gate: exit 0 + summary on success, exit non-zero + readable error list on +failure. + +```console +$ aci-sim validate +OK: 2 site(s), 3 tenant(s) + - LAB1: 2 leaves, 2 spines, 2 border leaves + - LAB2: 2 leaves, 2 spines, 2 border leaves +``` + +### `aci-sim show [TOPOLOGY]` + +Prints the **derived** inventory you need before pointing a client at the +sim: each site's APIC host/mgmt IP/ASN/pod/fabric name/OOB gateway, every +node's role, model, version, and derived loopback/OOB IP (via the same +`build/fabric.py::loopback_ip`/`oob_ip` the builders use), the NDO mgmt IP, +and the port/IP each controller will actually listen on (port mode vs +sandbox mode, read from `runtime/config.py`). Add `--json` for +machine-readable output (e.g. to feed inventory into another tool). + +```console +$ aci-sim show +Fabric: LAB-IT-ACI (NDO mgmt IP: 10.192.0.10) + tep_pool=10.0.0.0/16 infra_vlan=3967 gipo_pool=225.0.0.0/15 + +Site LAB1: APIC host=127.0.0.1:8443 mgmt_ip=10.192.0.11 asn=65001 pod=1 controllers=1 fabric_name=LAB1-IT-ACI controller_ips=10.192.0.11 oob_gateway=- + ID NAME ROLE MODEL VERSION SERIAL LOOPBACK OOB IP + 101 LAB1-ACI-LF101 leaf N9K-C9332C n9000-14.2(7f) SAL10101 10.1.101.1 192.168.1.101 + ... + +Port bindings: + LAB1 (apic) -> https://127.0.0.1:8443 + LAB2 (apic) -> https://127.0.0.1:8444 + NDO (ndo ) -> https://127.0.0.1:8445 + +$ aci-sim show --json | jq '.sites[0].nodes[0]' +{ + "id": 101, + "name": "LAB1-ACI-LF101", + "role": "leaf", + "model": "N9K-C9332C", + "version": "n9000-14.2(7f)", + "serial": "SAL10101", + "loopback_ip": "10.1.101.1", + "oob_ip": "192.168.1.101" +} +``` + +### `aci-sim lldp [TOPOLOGY]` + +Builds the topology fresh (same builders the sim boots with) and prints a +**`show lldp neighbors`-style table** of every `lldpAdjEp` adjacency — +i.e. the neighbor relationships the cabling in `topology.yaml` implies +(spine<->leaf links, APIC<->leaf attachment), materialized exactly the way +they'd appear on a running sim (§10's LLDP/CDP fidelity). Pass `--cdp` to +read `cdpAdjEp` instead. This does not start any server — it's a static, +read-only view of one freshly-built site (or all sites). + +```console +$ aci-sim lldp +Site LAB1 — node-101 (LAB1-ACI-LF101) + Local Port Neighbor Neighbor Port Mgmt IP Platform + eth1/1 LAB1-ACI-APIC01 eth1/1 10.192.0.11 Cisco APIC + eth1/49 LAB1-ACI-SP01 eth1/1 192.168.1.201 N9K-C9332C + eth1/50 LAB1-ACI-SP02 eth1/1 192.168.1.202 N9K-C9332C + +Site LAB2 — node-301 (LAB2-ACI-LF301) + Local Port Neighbor Neighbor Port Mgmt IP Platform + eth1/1 LAB2-ACI-APIC01 eth1/1 10.192.128.11 Cisco APIC + eth1/49 LAB2-ACI-SP01 eth1/1 192.169.1.145 N9K-C9332C + ... + +$ aci-sim lldp --cdp --site LAB1 --node 101 --json +[ + { + "local_node_id": 101, + "local_node_name": "LAB1-ACI-LF101", + "local_port": "eth1/1", + "neighbor": "LAB1-ACI-APIC01", + "neighbor_port": "eth1/1", + "mgmt_ip": "10.192.0.11", + "platform": "APIC-SERVER-M3", + "site": "LAB1" + } +] +``` + +Flags: `TOPOLOGY` positional (default `./topology.yaml`), `--cdp` (show +`cdpAdjEp` neighbors instead of `lldpAdjEp`), `--site NAME` (filter to one +site; default all sites), `--node ID` (filter to one local node id; default +all nodes), `--json` (machine-readable output instead of the grouped text +table). + +### `aci-sim graph [TOPOLOGY] -o FILE.html` + +Renders a **self-contained** visual topology diagram of the built fabric — +spines, leaves, border leaves (with vPC pairs), per-site controllers, and (for +multi-site topologies) the ISN cloud connecting each site's spines — as +either an `.html` file (inline SVG + inline CSS, ready to double-click and +open in any browser) or a raw `.svg` file. Output format is inferred from the +`-o` extension. There is no CDN dependency, no local web server, and no npm +build step: the file is plain hand-generated SVG markup, so it opens directly +from disk. It complements `aci-sim show` (`show` = text inventory, `graph` = +visual diagram) and visually mirrors autoACI's topology-view color language +(spine = blue, leaf/border-leaf = green, controller = amber, ISN cloud = +slate) — a standalone Python reimplementation, not a code import. + +```console +$ aci-sim graph -o topology.html +[aci-sim] wrote topology diagram: topology.html + +$ aci-sim graph lab3.topology.yaml -o lab3.svg +[aci-sim] wrote topology diagram: lab3.svg +``` + +Flags: `TOPOLOGY` positional (default `./topology.yaml`), `-o/--output` +(default `topology.html`; `.html`/`.htm` writes an HTML wrapper, `.svg` +writes raw SVG). + +### `aci-sim run [TOPOLOGY]` + +A thin wrapper around `python -m aci_sim.runtime.supervisor` — it +validates the topology up front (a friendly error instead of a raw +traceback), sets `TOPOLOGY_PATH` to the given file, resolves + injects the +admin credentials the APIC plane will enforce (see "Admin account / +credentials" below), passes through `SIM_BIND`/`SIM_SANDBOX`/etc. from the +current environment unmodified, and execs the supervisor (so it prints the +same `[sim] APIC ... -> https://...` lines you'd see from `scripts/run.sh`). +It does not reimplement the supervisor — `scripts/run.sh` keeps working +unchanged. + +```console +$ aci-sim run +[aci-sim] Auth: APIC admin 'admin' (from topology.yaml's auth: section) +[aci-sim] running supervisor with TOPOLOGY_PATH=topology.yaml +[sim] APIC LAB1 (asn 65001) → https://127.0.0.1:8443 +[sim] APIC LAB2 (asn 65002) → https://127.0.0.1:8444 +[sim] NDO → https://127.0.0.1:8445 + +$ SIM_BIND=0.0.0.0 aci-sim run lab2.topology.yaml +``` + +### `aci-sim new` + +Scaffolds a fresh, minimal, **valid** `topology.yaml` to stdout (or `-o +FILE`), 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 output always passes +`aci-sim validate` and boots. + +```console +$ aci-sim new --sites 3 --leaves-per-site 4 -o lab3.topology.yaml +Wrote lab3.topology.yaml +$ aci-sim validate lab3.topology.yaml +OK: 3 site(s), 3 tenant(s) + - LABA: 4 leaves, 2 spines, 2 border leaves + - LABB: 4 leaves, 2 spines, 2 border leaves + - LABC: 4 leaves, 2 spines, 2 border leaves +``` + +Flags: `--sites N` (default 2), `--leaves-per-site N` (default 2, max 100 — +see "Leaf/spine count elasticity" note below), `--spines-per-site N` +(default 2, max 100), `--border-pairs N` (default 1, = 2 border leaves per +site; combined with `--leaves-per-site`/`--spines-per-site` is also subject +to the same per-site node-ID budget), `--asn A,B,...` (per-site fabric ASN; +default 65001,65002,...), `--fabric-name-prefix` (default `LAB`), `--ndo-ip` +(default `10.192.0.10`), `--apic-ip-base` (default `10.192`), +`--controllers N` (APIC cluster size per site; default **1**, range 1-5 — +single-APIC by design, see §6a below; set to 3+ for multi-controller/ +cluster-health testing), `--tep-pool CIDR` +(default `10.0.0.0/16`), `--infra-vlan N` (default 3967), `--gipo-pool CIDR` +(default `225.0.0.0/15`), `--isn-ospf-area` (default `0.0.0.0`; Tier-2, PR-19), +`--isn-mtu N` (default 9150, range 576-9216; Tier-2, PR-19), +`--isn-ospf-hello N` (default 10; Tier-3, PR-20), `--isn-ospf-dead N` +(default 40; Tier-3, PR-20), `--isn-bfd-min-rx/--isn-bfd-min-tx N` (default +50/50; Tier-3, PR-20, store-only), `--isn-bfd-multiplier N` (default 3, +range 1-50; Tier-3, PR-20, store-only), `--default-bd-mac MAC` (default +`00:22:BD:F8:19:FF`, the real ACI default; Tier-3, PR-20), `-o/--output` +(default: stdout). + +**Leaf/spine count elasticity (PR-19):** `--leaves-per-site`/ +`--spines-per-site` work at any count up to 100 (e.g. +`--leaves-per-site 8 --spines-per-site 4`), and `--border-pairs` works for +more than 1 pair — all generate collision-free node IDs. Beyond count 100 +per block (or a `--border-pairs` count that would push the border block +past the next site's leaf range), `aci-sim new` now **rejects the request +with a clear error naming the offending flag** instead of silently emitting +a `topology.yaml` that fails deep inside `Topology.normalize_and_validate` +with a generic "node ids collide" message — this is a hard ceiling of the +node-ID scheme itself (`site_offset=200` per `topology/schema.py`'s +docstring), not a bug fixable without a breaking change to node-ID +semantics for existing files. + +### `aci-sim init [-o topology.yaml]` + +An interactive Q&A wizard modeled on the real APIC's first-boot **setup +dialog** — it prompts for each field with a suggested default shown in +`[brackets]`; press ENTER to accept it, or type a custom value. It is a +thin front-end over the SAME `generate_topology()` function `aci-sim new` +calls (extended, PR-22, to also accept per-site overrides and per-site +node counts) — it does not duplicate topology-emission logic, and its +output always passes `aci-sim validate` and boots, same as `new`. + +Fields asked, mirroring real APIC's dialog plus this sim's multi-site +extras: + +- **Step 0 — admin account**: admin username (default `admin`), admin + password (default `cisco`), then `NDO uses the same admin account as + APIC?` (default yes). Answering "no" additionally asks for a separate NDO + username/password. See "Admin account / credentials" below for what this + account actually controls. +- **Step 1 — deployment type**: `1) Single fabric` or `2) Multi-site (2+ + fabrics via ISN+NDO)` (default 2). Real APIC's dialog also offers + "multi-pod" — this sim doesn't build true multi-pod fabrics (no per-pod + spine/leaf partitioning or IPN), so that choice is deliberately not + offered; a single fabric can still set a non-default **Pod ID** per-site. +- **Step 2 — per site** (looped once per site; multi-site additionally asks + "Number of sites"): fabric/site name, site ID, BGP AS, Pod ID, number of + APIC controllers (1-5), APIC OOB mgmt IP, then — **if controllers > 1** — + one prompt per additional controller, each suggesting **the previous + controller's IP + 1** (accept with ENTER or type a custom IP; maps to + PR-21's `Site.controller_ips`), then the OOB gateway (asked once per + site; maps to `Site.oob_gateway` — see §6c — and lands on the real + `mgmtRsOoBStNode.gw` MO for every node at that site, build/mgmt.py), TEP + pool, infra VLAN, multicast/GIPo pool, OOB mgmt subnet, number of + spines/leaves/border-leaf pairs. +- **Step 3 — multi-site only**: NDO/ND mgmt IP, ISN OSPF area, ISN MTU + (inter-site EVPN uses each fabric's own BGP AS from Step 2 — there is no + separate ISN AS). +- **Step 4 — confirm + write**: prints a summary, asks `Write to + topology.yaml? [yes]`, writes the file, then **auto-runs `aci-sim + validate`** on the result and prints OK (or the errors). + +```console +$ aci-sim init +This wizard collects the same setup information as a real APIC's +first-boot dialog, then generates a topology.yaml for aci-sim. +Press ENTER to accept a suggested default shown in [brackets]. + +Step 0: Admin account + Admin username [admin]: + Admin password [cisco]: + NDO uses the same admin account as APIC? [yes]: + +Step 1: Deployment type +Deployment type: 1) Single fabric 2) Multi-site (2+ fabrics via ISN+NDO) [2]: 1 + +Step 2: Site 1 of 1 + Fabric/site name [Site1-IT-ACI]: MyLab-IT-ACI + Site ID [1]: + BGP AS (fabric) [65001]: + Pod ID [1]: + Number of APIC controllers [1]: 3 + APIC OOB mgmt IP [10.192.0.11]: + APIC-2 OOB IP [10.192.0.12]: + APIC-3 OOB IP [10.192.0.13]: 10.192.0.20 + APIC OOB gateway (shared by all controllers at this site) [10.192.0.1]: + TEP pool [10.0.0.0/16]: + Infra VLAN (1-4094) [3967]: + Multicast/GIPo pool [225.0.0.0/15]: + OOB mgmt subnet [192.168.0.0/16]: + Number of spines [2]: + Number of leaves [2]: + Number of border-leaf pairs [1]: + +Summary: + Admin account: username=admin password=c*** (NDO: same account — lenient by design, accepts any credential) + Fabric: MyLab-IT-ACI + ISN/multi-site enabled: False + APIC OOB gateway (site 1): 10.192.0.1 + Site MyLab-IT-ACI (id=1, asn=65001, pod=1): 3 controller(s) ['10.192.0.11', '10.192.0.12', '10.192.0.20'], oob_gateway=10.192.0.1, 2 spines, 2 leaves, 2 border leaves (1 pair(s)) + +Write to topology.yaml? [yes]: +Wrote topology.yaml +OK: 1 site(s), 1 tenant(s) + - MyLab-IT-ACI: 2 leaves, 2 spines, 2 border leaves +``` + +This transcript was generated with `aci-sim init -o topology.yaml --answers` (see the "Admin account / credentials" section below for the exact command + real output); the prompt lines shown here mirror `run_wizard`'s literal prompt text. + +Note the third controller's IP was typed as a custom value (`10.192.0.20`) +overriding the suggested sequential default (`10.192.0.13` = controller +2's IP + 1) — pressing ENTER at any prompt always accepts the bracketed +suggestion instead. + +**Non-interactive use** (CI/scripting — never blocks waiting for input): + +- `--defaults` — accept every suggested default without prompting. + Auto-enabled whenever stdin is not a TTY (e.g. piped/redirected/ + `subprocess` with `stdin=DEVNULL`), so `aci-sim init -o topology.yaml` in + a script or CI job completes immediately with the wizard's defaults even + without the flag. +- `--answers FILE` — a YAML or JSON file of pre-filled field-name -> value + answers (e.g. `{"deployment_type": "2", "site1_controllers": "3"}`). + Listed fields are used verbatim (no prompt); unlisted fields fall back to + prompting/defaults as usual. Field names: `admin_username`, + `admin_password`, `ndo_same_account` (`yes`/`no`), `ndo_username`/ + `ndo_password` (only consulted if `ndo_same_account` is `no`); + `deployment_type`, `num_sites` (multi-site only), then per site index `N`: + `siteN_name`, `siteN_id`, `siteN_asn`, `siteN_pod`, `siteN_controllers`, + `siteN_apicC_ip` (C = 1..controllers), `siteN_gateway`, `siteN_tep_pool`, + `siteN_infra_vlan`, `siteN_gipo_pool`, `siteN_oob_subnet`, `siteN_spines`, + `siteN_leaves`, `siteN_border_pairs`; multi-site only: `ndo_mgmt_ip`, + `isn_ospf_area`, `isn_mtu`; and `confirm_write` (`yes`/`no`) to skip the + final confirm prompt too. + +```console +$ aci-sim init --defaults -o lab.topology.yaml +... +Wrote lab.topology.yaml +OK: 2 site(s), 2 tenant(s) + - Site1-IT-ACI: 2 leaves, 2 spines, 2 border leaves + - Site2-IT-ACI: 2 leaves, 2 spines, 2 border leaves +``` + +`-o/--output` (default: `./topology.yaml`). + +### Admin account / credentials + +`aci-sim init`'s **Step 0** is where you set the fabric admin account — it +ends up as an `auth:` section in `topology.yaml`: + +```yaml +auth: + username: admin + password: cisco +``` + +**The APIC plane ENFORCES this account** — `aci-sim run` resolves it and +injects `SIM_USERNAME`/`SIM_PASSWORD` before execing the supervisor, and +`rest_aci/auth.py`'s `aaaLogin` 401s on any mismatch (see §7 below). **NDO +shares the same account by default** (the wizard's `ndo_same_account: yes` +default) but stays **lenient by design regardless** — `ndo/app.py`'s +`/api/v1/auth/login`/`/login` accept any credential and never validate a +token, matching this sim's long-standing NDO client-compat behavior (see +§8 "Known limitations"). Answering "no" to `ndo_same_account` only changes +what NDO is *told* (`auth.ndo_username`/`auth.ndo_password`, informational), +never what it *checks*. + +**Precedence `aci-sim run` uses to resolve `SIM_USERNAME`/`SIM_PASSWORD`** +(highest first): + +1. `SIM_USERNAME` already set in your shell environment — an explicit env + override always wins (preserves existing CI/env-override workflows); + `topology.yaml`'s `auth:` is ignored even if present. +2. `topology.yaml` has an `auth:` section (written by the wizard) — its + `username`/`password` are injected. +3. Neither — nothing is injected; `rest_aci/auth.py` falls back to its own + `SIM_USERNAME`/`SIM_PASSWORD`-env-or-`admin`/`cisco` default, unchanged. + +`aci-sim run` always prints which source won, e.g.: + +```console +$ aci-sim run +[aci-sim] Auth: APIC admin 'admin' (from topology.yaml's auth: section) +``` + +An existing `topology.yaml` with no `auth:` section at all (every file that +predates this feature) keeps validating and behaves exactly as before — +`Topology.auth` is `Optional`, defaulting to `None`. + +--- + +## 6. Configuration + +All configuration is via environment variables, read at process start +(`aci_sim/runtime/config.py`, `aci_sim/rest_aci/auth.py`). + +| Variable | Default | Effect | +| --- | --- | --- | +| `SIM_BIND` | `127.0.0.1` | Bind address for port-mode servers. Defaults to loopback-only because the `/_sim` control plane is unauthenticated — set `SIM_BIND=0.0.0.0` explicitly for LAN deployments. Ignored in sandbox mode (always binds each device's own `mgmt_ip`). | +| `SIM_USERNAME` | `admin` | Username checked by `aaaLogin`. An explicit env value here always wins over a topology `auth:` section — see "Admin account / credentials" above. | +| `SIM_PASSWORD` | `cisco` | Password checked by `aaaLogin`; a mismatch returns a 401 APIC error envelope. Same env-wins-over-topology precedence as `SIM_USERNAME`. | +| `APIC_A_PORT` | `8443` | Port-mode port for APIC site A. | +| `APIC_B_PORT` | `8444` | Port-mode port for APIC site B. | +| `NDO_PORT` | `8445` | Port-mode port for the NDO plane. | +| `SIM_SANDBOX` | unset (`0`) | Set to `1`/`true`/`yes`/`on` to enable sandbox mode (bind each device to its own `mgmt_ip`). Set by `scripts/sandbox-up.sh`; not normally set by hand. | +| `SIM_SANDBOX_PORT` | `443` | Port sandbox-mode servers bind to on each device's own IP. | +| `CERT_FILE` | `certs/sim.crt` | TLS certificate path (self-signed, all three apps). | +| `KEY_FILE` | `certs/sim.key` | TLS private key path. | +| `TOPOLOGY_PATH` | `topology.yaml` | Path to the topology definition loaded at startup and by `/_sim/reload`. | +| `SIM_STATE_DIR` | `~/.aci-sim/state` | Directory for `/_sim/save`/`/_sim/load` state files (§10). | +| `SIM_CERT_STRICT` | `false` | **Placeholder, currently a no-op.** Reserved for a future mode that would verify `cisco.aci` certificate-signature auth (`APIC-Request-Signature`) against a registered public key. Today, certificate-auth requests are accepted based on the claimed identity alone (see §Error handling below) regardless of this flag. | + +### 6a. Configuration Reference — Tier-1 fabric parameters (topology.yaml) + +Unlike the environment variables above, these are `topology.yaml` fields +(`aci_sim/topology/schema.py`). All are **optional** with defaults +matching the pre-existing hardcoded behavior, so an existing `topology.yaml` +that omits them keeps validating and building byte-identical output +(verified: the repo's own `topology.yaml` is unchanged in value and still +passes `aci-sim validate` + the full test suite). See `docs/DESIGN.md`'s +"PR-18 — Tier-1 fabric configuration parameters" section for the full +wiring detail and exactly which fields are store-only vs. builder-wired. + +**Single-APIC-per-fabric/site is DELIBERATE (PR-21).** `aci-sim` +defaults every site to `controllers: 1` because APIC cluster size does not +affect Ansible playbook deployment testing — a playbook connects to and +pushes configuration through exactly **one** APIC endpoint, regardless of +how many controllers sit behind it in real ACI. Cluster size only matters +for cluster-health/appliance-vector tooling, such as autoACI's +`health_score.py`, which reads `infraWiNode` across every controller in the +cluster. Set `controllers: 3` (+ optionally `controller_ips: [...]`) on a +site **only if** you specifically need to exercise multi-controller +`infraWiNode`/cluster-health semantics — the sim still binds exactly one +REST endpoint per site either way (it does not run N separate APIC +processes); `controllers` only changes how many controller-role +`fabricNode`/`topSystem`/`infraWiNode` entries are built. Valid range: 1-5. + +| Field | Scope | Default | Wired into builders? | +| --- | --- | --- | --- | +| `site.controllers` | per-site | **`1`** (PR-21; was `3` pre-PR-21) | Yes — APIC cluster size, range **1-5**; drives the `fabricNode`/`topSystem`/`firmwareCtrlrRunning` controller loop, the `infraWiNode` appliance-vector (viewer × member), `health_faults.py`, and `endpoints.py`'s APIC-reserved-port accounting in `build/fabric.py` and friends. | +| `site.controller_ips` | per-site | unset (PR-21) | Yes, when set — one OOB mgmt IP per controller, in order (`controller_ips[0]` = controller 1's `topSystem.oobMgmtAddr`, etc.); length must equal `controllers`. When unset, IPs are auto-derived **sequentially from `site.mgmt_ip`** (controller 1 = `mgmt_ip`, controller 2 = `mgmt_ip`+1, ...). If `mgmt_ip` is also unset, falls back further to the legacy per-node `oob_ip(pod, cid)` scheme (pre-PR-21 behavior, unchanged). | +| `site.pod` | per-site | `1` | Yes — every `topology/pod-{N}/...` DN across all builders reads `site.pod`; a `pod: 2` site builds all its DNs under `pod-2`. | +| `fabric.tep_pool` | fabric-wide | `"10.0.0.0/16"` | Store + CIDR-validate + surface only (`aci-sim show`/`new`). No builder derives a per-node address from it — `loopback_ip()`/`oob_ip()` remain the actual address source. | +| `fabric.infra_vlan` | fabric-wide | `3967` | Store + range-validate (1-4094) + surface only. No MIT class in the current catalog carries a distinct infra-VLAN attribute. | +| `fabric.gipo_pool` | fabric-wide | `"225.0.0.0/15"` | Store + CIDR-validate + surface only. | +| Auto-generated node `serial` | per-node | `f"SAL{{site_id}}{{node_id:04d}}"` | Yes — `build/fabric.py`'s `default_serial()` fallback whenever a `Node.serial` is empty (used by `fabricNode.serial` and `fabricNodeIdentP.serial`, which `cisco.aci.aci_fabric_node` keys registration on). An explicit `serial:` in the YAML always overrides. | + +**N-site note:** `aci-sim new --sites N` and the node-ID/pod/controller +wiring above work for arbitrary N with zero collisions. **ISN inter-site +peering and stretched-tenant HOME/REMOTE endpoint placement do not** — both +rely on `Topology.other_site()`, which resolves to "the first other site in +YAML order", not "every other site" (pre-existing, not introduced by +PR-18). Practical effect: with 3+ sites, ISN is not a true full mesh (each +site only peers with one other), even though `isn.peer_mesh: full` is set. +See `docs/DESIGN.md` PR-18 section for the exact verification and why +fixing it is out of scope here. + +### 6b. Configuration Reference — Tier-2 topology-elasticity parameters (topology.yaml) + +Same backward-compat guarantee as §6a: all fields below are **optional** +with defaults matching pre-existing behavior. See `docs/DESIGN.md`'s +"PR-19 — Tier-2 topology-elasticity parameters" section for the full +wiring detail. + +| Field | Scope | Default | Wired into builders? | +| --- | --- | --- | --- | +| `isn.ospf_area` | fabric-wide | `"0.0.0.0"` | Yes — `build/underlay.py`'s `ospfIf.area`/`ospfAdjEp.area` (was a hardcoded `"0.0.0.0"` literal). Accepts either dotted-decimal (`"0.0.0.0"`) or decimal-integer (`"0"`) OSPF area notation. | +| `isn.mtu` | fabric-wide | `9150` | Yes — `build/interfaces.py`'s dedicated ISN/IPN spine uplink `l1PhysIf.mtu` (was hardcoded to the general fabric `9216` value, same as every other port). Only the ISN uplink port changes; ordinary intra-fabric ports keep `9216`. Range-validated 576-9216. | +| `isn.peer_mesh` | fabric-wide | `"full"` | Validate-and-reject for any value other than `"full"` (unchanged from PR-17/18). `"partial"` mesh has no sensible default partition without additional per-site config the schema doesn't carry (which spine pairs with which isn't inferable from spine count alone) — `build/overlay.py` fails fast rather than silently building full-mesh or an undefined subset. | +| `access.vmm_domains[].vcenter_ip` | per-domain | `""` (unset) | Yes, when set — `build/access.py` emits a `vmmCtrlrP` child of the domain's `vmmDomP` with `hostOrIp=vcenter_ip`. Unset → no `vmmCtrlrP`/`vmmUsrAccP` is built (just a bare `vmmDomP`). | +| `access.vmm_domains[].datacenter` | per-domain | `""` (unset) | Yes, when `vcenter_ip` is set — `vmmCtrlrP.rootContName`; defaults to the domain's own `name` if unset. | +| `access.vmm_domains[].vcenter_dvs` | per-domain | `""` (unset) | Yes, when set — `vmmCtrlrP.dvsName`. | +| `access.vmm_domains[].vlan_pool` | per-domain | `""` (unset) | Yes, when set — binds an `infraRsVlanNs` under the `vmmDomP` to the referenced `access.vlan_pools[]` entry (same pattern phys/l3 domains already use). Cross-referenced against `access.vlan_pools[].name` at validation time. | + +**VMM domain is a pure addition, not a rewire (PR-19 design note):** no +existing builder or the real production chains (autoACI sandbox e2e / aci-py +MS-TN1) read a `vmmDomP`/`vmmCtrlrP` MIT object today. The real +`bind_epg_to_vmm_domain` playbook (verified read-only against +`aci-py`'s `playbooks.py` + `content/templates/bind_epg_to_vmm_domain.j2.yml` +on the hardware test bed) writes an **NDO schema `domainAssociations` entry** +referencing the VMM domain purely by DN string +(`uni/vmmp-VMware/dom-`) — it never touches an APIC-side `vmmDomP` +object, so `access.vmm_domains` defaults to an **empty list** (unlike +`phys_domains`/`l3_domains`/`aaeps`, which each default to one seeded +entry) and adding it changes zero existing builder output. +`bind_epg_to_physical_domain`, by contrast, DOES read a real `physDomP`, +which is why that one already existed pre-PR-19. + +### 6c. Configuration Reference — Tier-3 fine-tuning parameters (topology.yaml) + +Same backward-compat guarantee as §6a/§6b: all fields below are **optional** +with defaults matching pre-existing behavior (or the real ACI factory +default, for fields that had no prior value at all). This is the last +planned param tier. See `docs/DESIGN.md`'s "PR-20 — Tier-3 fine-tuning +parameters" section for the full wiring detail. + +| Field | Scope | Default | Wired into builders? | +| --- | --- | --- | --- | +| `bd.mac` | per-BD | unset (falls back to `fabric.default_bd_mac`) | Yes — `build/tenants.py`'s `fvBD.mac`. | +| `fabric.default_bd_mac` | fabric-wide | `"00:22:BD:F8:19:FF"` (real ACI default) | Yes — same `fvBD.mac`, used when a BD doesn't set its own `bd.mac`. This is the exact literal `build/tenants.py` already hardcoded for every BD pre-PR-20, so the default produces byte-identical output. | +| `l3out.csw_peer.keepalive` / `.hold` | per-L3Out-peer | `60` / `180` (real ACI defaults) | Yes — `build/l3out.py`'s `bgpPeerP.keepAliveIntvl`/`holdIntvl`. `hold` must exceed `keepalive`. | +| `isn.ospf_hello` / `isn.ospf_dead` | fabric-wide | `10` / `40` (real ACI defaults) | Yes — `build/underlay.py`'s `ospfIf.helloIntvl`/`deadIntvl`. `ospf_dead` must exceed `ospf_hello`. | +| `isn.bfd_min_rx` / `isn.bfd_min_tx` / `isn.bfd_multiplier` | fabric-wide | `50` / `50` / `3` (real ACI defaults) | **No** — store + range-validate + surface only (`aci-sim show`/`new`). This sim builds no `bfdIfP`-style MO anywhere; adding a faithful one would additionally require a believable oper-state tree with no verified consumer to anchor its shape against. | +| `fabric.oob_subnet` | fabric-wide | unset | **No** — store + CIDR/range-validate (must fall within `192.168.0.0/16`) + surface only. `build/fabric.py`'s `oob_ip()` derives every node's OOB address from `(pod, node_id)` only, same documented limitation as `fabric.oob_pool` itself. | +| `fabric.inb_subnet` | fabric-wide | unset | **No** — store + CIDR/range-validate (must fall within a private RFC1918 range) + surface only. This sim has no in-band mgmt MO (`mgmtInB`/`inbMgmtAddr`) anywhere; adding one would require a new `mgmt`-tenant builder, out of scope for this PR. | +| `fabric.default_apic_version` | fabric-wide | unset (falls back to each site's own `site.apic_version`) | Yes — `build/fabric.py`'s controller `fabricNode.version`/`topSystem.version`/`firmwareCtrlrRunning.version`, taking precedence over `site.apic_version` when set. `site.apic_version` (PR-18 lineage) is already independently settable from switch-node `Node.version` (PR-18); this field adds a fabric-wide override so one value covers every site instead of repeating `apic_version:` per site. | +| `site.oob_gateway` | per-site | unset (`None`) | **Yes** — `build/mgmt.py`'s `mgmtRsOoBStNode.gw`, one per node (switches + controllers) at this site, under the new mgmt-tenant scaffolding `uni/tn-mgmt/mgmtp-default/oob-default` (`fvTenant`/`mgmtMgmtP`/`mgmtOoB`). This is the OOB-gateway PR: `aci-sim init`'s wizard already asked this question but discarded the answer (no schema field, no MO); it now writes it here. Unset → the same MOs still build, just with an empty `gw` (no gateway invented/derived — see build/mgmt.py). | + +**BD MAC default is a genuine backward-compat guarantee, not just a +plausible-looking default:** `build/tenants.py`'s `_build_bd` already +hardcoded the literal `"00:22:BD:F8:19:FF"` (the real ACI default gateway +MAC) for every BD before this PR — `fabric.default_bd_mac`'s schema default +reproduces that exact value, so omitting both `bd.mac` and +`fabric.default_bd_mac` from `topology.yaml` produces byte-identical +`fvBD.mac` output to pre-PR-20. + +**APIC version is already fully wired, independent of switch version +(PR-18/PR-9 lineage):** `site.apic_version` has driven the controller +`fabricNode`/`topSystem`/`firmwareCtrlrRunning` since PR-9/PR-18, completely +independently of the per-node switch `Node.version` field. PR-20 does not +change that wiring — it adds `fabric.default_apic_version` as a fabric-wide +override on top, and surfaces the *effective* per-site APIC version (after +resolving that override) in `aci-sim show`, which previously showed no APIC +version at all. + +--- + +## 7. Error handling + +The simulator follows APIC's real error-envelope shape and status-code +semantics rather than generic REST conventions — this is load-bearing for +any client (like `cisco.aci`) that branches on APIC's exact codes. + +**Error envelope** (all non-2xx responses): + +```json +{"imdata":[{"error":{"attributes":{"text":"","code":""}}}]} +``` + +**Auth semantics:** + +- `POST /api/aaaLogin.json` — credentials checked against + `SIM_USERNAME`/`SIM_PASSWORD`. Mismatch → **401** + (`"Authentication failed: invalid username or password"`). Malformed body + (wrong shape / non-JSON) → **400** APIC envelope — never FastAPI's raw + `{"detail": [...]}` validation-error shape. +- Every `/api/class/*`, `/api/mo/*`, and `/api/node/class/*` route requires + a live, unexpired `APIC-cookie` (minted by `aaaLogin`, renewed by + `aaaRefresh`, expiring after `refreshTimeoutSeconds` — 600s by default). + Missing/unknown/expired cookie → **403** + (`"Token was invalid (Error: Token timeout)"`). +- Certificate-signature auth (`cisco.aci`'s password-less `private_key` + mode) is accepted in **accept-mode**: the four `APIC-Certificate-*`/ + `APIC-Request-Signature` cookies are checked for presence and that the + claimed username matches `SIM_USERNAME` — the RSA-SHA256 signature bytes + are never verified. See `docs/DESIGN.md`'s "Certificate accept-mode trust + model" section. **Do not expose this to anything internet-reachable or + multi-tenant** — anyone who can reach the sim can authenticate as + `SIM_USERNAME` with four unsigned cookies. +- The `/_sim/*` control router and `/api/aaaLogin.json`/`aaaLogout.json` + are intentionally exempt from the session-cookie gate (bootstrapping + + test tooling depend on them being reachable pre-auth). + +**Query/write error codes:** + +- **400** — malformed `query-target-filter` expression (`code="107"`), + out-of-range/non-numeric `page`/`page-size` (`code="107"`), malformed + `aaaLogin` body, or a POST body with a malformed node anywhere in the + tree (atomic validation — the whole write is rejected, store untouched). +- **200 + empty envelope**, not 404, for a `GET /api/mo/{dn}.json` on a + well-formed but nonexistent DN (`{"imdata":[],"totalCount":"0"}`) — this + matches real APIC and is required for `cisco.aci`'s `state=present` + modules, which GET-before-write and treat any non-200 GET as fatal. + `/api/class/*` and `/api/node/class/*` already return 200-empty for a + query matching nothing. +- **DELETE `/api/mo/{dn}.json`** (`state=absent`) is server-side idempotent + — 200-empty whether the DN existed or not. + +**Never-500 contract:** every documented input shape (malformed filters, +bad pagination, atomic write validation failures, expired sessions) maps to +a **400/401/403 APIC error envelope**, never a bare 500 or an unhandled +exception. See `docs/CONTRACT.md` §5 for the full mapping and +`docs/DESIGN.md` for the write-path atomicity design. + +--- + +## 8. Known limitations + +- **ISN/stretched-tenant correctness is verified for exactly 2 sites** — + `Topology.other_site()` resolves to "the first other site in YAML order", + not "every other site". `aci-sim new --sites N` (N>2) builds without + node-ID collisions, but ISN inter-site eBGP-EVPN peering and stretched- + tenant HOME/REMOTE endpoint placement only reach one other site, so ISN + is not a true full mesh across 3+ sites even with `isn.peer_mesh: full`. + See §6a and `docs/DESIGN.md`'s PR-18 section for the verified detail. +- **Certificate signatures are not verified** — `cisco.aci` certificate + auth is accepted based on claimed identity alone ("accept-mode"), not a + real RSA-SHA256 signature check. `SIM_CERT_STRICT` is a documented, + currently-inert placeholder for a future verifying mode. Do not point + this at anything internet-reachable or multi-tenant. +- **NDO surface is partial** — the sim implements the NDO/MSO endpoints a + REST client typically needs (auth, sites, tenants, schemas/templates, + fabric-connectivity, policy-states, template summaries, audit records, + schema/deploy writes), not the entire NDO API. See `docs/CONTRACT.md` §7 + for the exact endpoint list. **`POST /mso/api/v1/task` (deploy) is no + longer a no-op on the APIC side** — see §10 "NDO → APIC deploy mirror"; + what remains partial is breadth of *endpoint* coverage, not the + deploy-materialization gap this used to describe. +- **Single-user auth model** — one username/password pair + (`SIM_USERNAME`/`SIM_PASSWORD`) per process; there is no per-user RBAC, + multiple local users, or AAA/TACACS+ integration. +- **Websocket subscriptions are class/DN-scoped, not filter-scoped** — + `?subscription=yes` + `/socket` push-on-change is implemented + (§10a), matching real APIC's class- and DN/subtree-scoped subscriptions. + Not implemented: subscribing to a *filtered* query (e.g. only + `faultInst` rows matching a `query-target-filter`) and getting pushes + scoped to just that filter — a filtered subscription still fires for + every change to the class/DN, same as an unfiltered one. +- **`/_sim/*` is a sim-only control plane** — `reset`, `snapshot`/`restore`, + `reload`, `add-leaf`/`remove-leaf` do not exist on a real APIC. It is + unauthenticated by design (test-orchestration convenience) and is exactly + why `SIM_BIND` defaults to loopback-only. +- **Static, not computed, control plane** — BGP/EVPN, COOP, IS-IS/OSPF + adjacencies, and forwarding state are represented as fixed, internally- + consistent MOs (peers always show `established`/`formed`), not the + output of a running protocol stack. There is no convergence, flapping, or + liveness engine. +- **Writes are a MIT upsert, not policy resolution** — a POST stores the + object (plus a small set of modeled reactions: `fabricNodeIdentP` → + `fabricNode` + supporting MOs, and — since the NDO → APIC deploy mirror, + §10 — an NDO template deploy materializing its objects into the target + sites' APIC stores); it does not cascade *arbitrary* dependent policy or + run the full validation a real APIC's policy engine would. +- **Faults are seeded, not generated** — `faults.seed` in `topology.yaml` + is the only source; the sim never raises new faults on its own. +- **Self-signed TLS only**, intended for local/lab use — not hardened for + production or internet exposure. +- **NX-OS SSH / N5K/N7K migration surface is out of scope** — the sim + covers the ACI/NDO **REST** API only. +- **Object coverage tracks real consumer needs**, not the full ACI object + model — see `docs/CONTRACT.md` §6 for the exact 99-class catalog. Classes + no known consumer queries may be absent; add a builder + topology knob if + you need more. + +This is a **test fixture**, not a Cisco product, and is not affiliated with +or endorsed by Cisco. + +--- + +## 9. Editing the topology + +`topology.yaml` is the single source of truth for the whole simulated +fabric. Edit it and restart (or call `POST /_sim/reload`) to change it. Key +knobs: sites (nodes, ASN, pod, mgmt IP), border-leaf vPC pairs + CSW +peering, tenants (VRFs/BDs/APs/EPGs/contracts/L3Outs), ISN, seeded faults, +endpoint generation count, and access-policy defaults (VLAN pools, physical/ +L3 domains, AAEPs). The loader validates the whole file at once (Pydantic +v2) and reports every cross-reference error together, so a typo'd BD/VRF +name fails fast rather than surfacing as a confusing runtime 404 later. + +## 10. The `/_sim` control API + +Mounted on each APIC app at `/_sim` for test orchestration (does not exist +on a real APIC): + +| Method + path | Effect | +| --- | --- | +| `POST /_sim/reset` | Restore the store to its build-time baseline. | +| `POST /_sim/snapshot/{name}` | Deep-copy the current store under `{name}`. | +| `POST /_sim/restore/{name}` | Restore a previously taken snapshot. | +| `POST /_sim/reload` | Re-read `topology.yaml` and rebuild the site from scratch. | +| `POST /_sim/add-leaf` | Add a `fabricNode` (`{"id": 105}` or auto-numbered). | +| `POST /_sim/remove-leaf` | Mark a `fabricNode` deleted (`{"id": 105}`). | +| `POST /_sim/save/{name}` | Persist this plane's store to disk under `{name}` (`SIM_STATE_DIR`). | +| `POST /_sim/load/{name}` | Restore this plane's store from a prior `save/{name}`. | + +Typical pattern: `snapshot` a clean state → mutate via writes → assert → +`restore` (or `reset`) between test cases. + +**`snapshot`/`restore` vs. `save`/`load`:** `snapshot`/`restore` are +**in-memory only** — a deep-copy kept in the running process, gone the +moment the sim restarts; they're the fast within-run rollback used between +test cases. `save`/`load` are **on-disk** — they serialize the store to a +JSON file under `SIM_STATE_DIR` (default `~/.aci-sim/state`) and survive a +full sim restart. Both `save` and `load` exist on **every plane** — each +APIC site's app and the NDO app all mount their own `/_sim/save/{name}` and +`/_sim/load/{name}`. `scripts/sim-state.sh {save|restore} ` is the +whole-fabric wrapper: it hits every plane's endpoint in one command (both +APIC sites' stores + the NDO state), so a full fabric state can be +snapshotted to disk and restored later in one call instead of one curl per +plane. + +**NDO → APIC deploy mirror:** `POST /mso/api/v1/task` (an NDO template +deploy, the request `cisco.mso.ndo_schema_template_deploy` sends) now +materializes the deployed multi-site template's VRFs/BDs/ANPs/EPGs and +domain/static-port binds into the **target sites' APIC stores** — previously +a pure acknowledgment no-op with no APIC-side effect. A multi-site tenant +created purely through NDO is now readable back from each target site's +APIC (including a per-site `fvTenant` shadow, so the tenant itself — not +just its child objects — shows up), matching how a real NDO-driven deploy +pushes policy down to the fabrics it targets. See +`aci_sim/ndo/deploy_mirror.py` for the mirroring logic. + +## 10a. Query subscriptions + websocket push-on-change + +The sim implements real APIC's subscription mechanism — the piece the +"Monitoring/observability integration" use case (§1) needed to move from +polling-only to event-driven. + +**1. Subscribe** — add `?subscription=yes` to any of the three query shapes: +`GET /api/class/{cls}.json`, `GET /api/mo/{dn}.json` (incl. the +`/api/node/mo/{dn}` alias), and `GET /api/node/class/{...}`. The response is +the **normal query result, unchanged**, plus a top-level `subscriptionId`: + +```json +{"imdata":[...], "totalCount":"3", "subscriptionId":"1"} +``` + +Without `?subscription=yes`, the response has no `subscriptionId` key at +all — byte-identical to every query before this feature existed. This is a +strict backward-compat guarantee, not just a default: nothing about a plain +query's shape or behavior changed. + +A class-query subscription (`/api/class/{cls}.json`) watches every MO of +that class fabric-wide. A DN-scoped subscription (`/api/mo/{dn}.json`, +node-scoped `/api/node/class/{dn}/{cls}.json`) watches that DN **and its +entire subtree** — a write to a child DN still notifies a parent-DN +subscriber, matching real APIC's subtree-subscription behavior. + +**2. Open the websocket** — `GET /socket`, where `` is the +same `APIC-cookie` token minted by `aaaLogin` (no separator between +`/socket` and the token — this matches real APIC's literal path shape). An +unknown/expired token is rejected before accept. One live connection per +token; all of that session's subscriptions push over this one socket. + +**3. Push on change** — after a write (`POST /api/mo/{dn}.json` create/ +update, or `DELETE /api/mo/{dn}.json`) commits, every subscription whose +scope matches the changed MO(s) gets an event on its owning token's socket: + +```json +{"subscriptionId":["1"], "imdata":[{"fvTenant":{"attributes":{"dn":"uni/tn-Corp","status":"created"}}}]} +``` + +`status` is `created` (new DN), `modified` (existing DN updated), or +`deleted` (DELETE, or a POST body with `status="deleted"`) — matching real +APIC's subscription payload shape. A DELETE on a DN with children emits one +event per removed MO in the subtree, so both class-scoped and DN-scoped +subscribers see everything that was actually removed. + +**4. Refresh** — `GET /api/subscriptionRefresh.json?id=` keeps a +subscription alive past its TTL (90s default, matching real APIC); always +200, even for an unknown/expired id (real APIC does not error a stale +refresh — the caller just re-subscribes on its next query). + +**Lifecycle**: disconnecting the websocket drops that connection's +subscriptions immediately — a later change that would have matched is +silently skipped (not an error), same as a client that never subscribed. + +See `docs/CONTRACT.md`'s subscription section for the exact response shapes +and `aci_sim/rest_aci/subscriptions.py` for the registry + the sync- +write-to-async-websocket bridge design (write handlers stay synchronous; +each websocket connection owns an `asyncio.Queue` that the write path enqueues +onto via `put_nowait`, decoupling "a write happened" from "bytes went out on +a socket"). + +## 11. Running the test suite + +```bash +.venv/bin/python -m pytest tests/ -q +``` + +`tests/verify_autoaci.py` is a verification harness replaying real ACI/NDO +query patterns end-to-end against a running sim instance +(`scripts/verify.sh`); the rest of `tests/` are unit/integration tests run +via plain pytest. + +--- + +## License + +MIT — see `LICENSE`. diff --git a/aci_sim/__init__.py b/aci_sim/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/build/__init__.py b/aci_sim/build/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/build/access.py b/aci_sim/build/access.py new file mode 100644 index 0000000..7612cb0 --- /dev/null +++ b/aci_sim/build/access.py @@ -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)) diff --git a/aci_sim/build/cabling.py b/aci_sim/build/cabling.py new file mode 100644 index 0000000..e466655 --- /dev/null +++ b/aci_sim/build/cabling.py @@ -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, + ) diff --git a/aci_sim/build/endpoints.py b/aci_sim/build/endpoints.py new file mode 100644 index 0000000..7cfb92a --- /dev/null +++ b/aci_sim/build/endpoints.py @@ -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 1..site.controllers) to eth1/ 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, + ) diff --git a/aci_sim/build/fabric.py b/aci_sim/build/fabric.py new file mode 100644 index 0000000..d38200b --- /dev/null +++ b/aci_sim/build/fabric.py @@ -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...1 + OOB mgmt / topSystem.oobMgmtAddr: 192.168.. + + 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...<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>.. + - 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...1 (legacy, unchanged) + node_id > 255: 10...<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.. (legacy, unchanged) + node_id > 255: 192.<168 + 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-: eth1/ + 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", + )) diff --git a/aci_sim/build/health_faults.py b/aci_sim/build/health_faults.py new file mode 100644 index 0000000..bfb8aa8 --- /dev/null +++ b/aci_sim/build/health_faults.py @@ -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, + )) diff --git a/aci_sim/build/interfaces.py b/aci_sim/build/interfaces.py new file mode 100644 index 0000000..d1aeab9 --- /dev/null +++ b/aci_sim/build/interfaces.py @@ -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", + )) diff --git a/aci_sim/build/l3out.py b/aci_sim/build/l3out.py new file mode 100644 index 0000000..7b921b9 --- /dev/null +++ b/aci_sim/build/l3out.py @@ -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) diff --git a/aci_sim/build/mgmt.py b/aci_sim/build/mgmt.py new file mode 100644 index 0000000..f95dc76 --- /dev/null +++ b/aci_sim/build/mgmt.py @@ -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-

/node-] mgmtRsOoBStNode + tDn=topology/pod-

/node- + addr=/ + gw= + +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=/`): 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..`, 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) diff --git a/aci_sim/build/neighbors.py b/aci_sim/build/neighbors.py new file mode 100644 index 0000000..a722981 --- /dev/null +++ b/aci_sim/build/neighbors.py @@ -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:::. + 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), + ) diff --git a/aci_sim/build/orchestrator.py b/aci_sim/build/orchestrator.py new file mode 100644 index 0000000..d673c3d --- /dev/null +++ b/aci_sim/build/orchestrator.py @@ -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} diff --git a/aci_sim/build/overlay.py b/aci_sim/build/overlay.py new file mode 100644 index 0000000..f2afc45 --- /dev/null +++ b/aci_sim/build/overlay.py @@ -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=/32 and 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) diff --git a/aci_sim/build/routing.py b/aci_sim/build/routing.py new file mode 100644 index 0000000..4958ea9 --- /dev/null +++ b/aci_sim/build/routing.py @@ -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", + )) diff --git a/aci_sim/build/tenants.py b/aci_sim/build/tenants.py new file mode 100644 index 0000000..3a79f25 --- /dev/null +++ b/aci_sim/build/tenants.py @@ -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)) diff --git a/aci_sim/build/underlay.py b/aci_sim/build/underlay.py new file mode 100644 index 0000000..9aeeee5 --- /dev/null +++ b/aci_sim/build/underlay.py @@ -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, + )) diff --git a/aci_sim/build/userprofile.py b/aci_sim/build/userprofile.py new file mode 100644 index 0000000..3ea29b5 --- /dev/null +++ b/aci_sim/build/userprofile.py @@ -0,0 +1,42 @@ +"""build/userprofile.py — aaaUserEp login-probe MO (uni/userprofile-). + +CONTRACT.md §1: during/after login autoACI probes + GET /api/mo/uni/userprofile-.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- 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) diff --git a/aci_sim/build/zoning.py b/aci_sim/build/zoning.py new file mode 100644 index 0000000..10775b6 --- /dev/null +++ b/aci_sim/build/zoning.py @@ -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", + )) diff --git a/aci_sim/cli.py b/aci_sim/cli.py new file mode 100644 index 0000000..5163333 --- /dev/null +++ b/aci_sim/cli.py @@ -0,0 +1,1768 @@ +"""aci-sim — CLI for authoring, validating, previewing, and running topology.yaml. + +`topology.yaml` (loaded via `aci_sim.topology.loader.load_topology`, +validated by the Pydantic v2 models in `aci_sim.topology.schema`) +remains the single source of truth for the whole simulated fabric — this CLI +is purely a convenience layer around it. It does NOT change the config +format, the schema, the builders, or the NDO/REST code; it only: + + validate — run the same load_topology()/Pydantic validation CI already + depends on, with a friendlier pass/fail summary. + show — print the DERIVED inventory (node IDs, loopback/OOB IPs, port + bindings) a user needs before pointing a client at the sim. + run — a thin wrapper around `python -m aci_sim.runtime.supervisor` + that points TOPOLOGY_PATH at the given file. + new — scaffold a minimal, valid topology.yaml from a handful of flags, + using the same node-ID scheme documented in topology/schema.py. + graph — render a self-contained SVG/HTML topology diagram (aci_sim.graph), + openable directly in a browser: no server, no CDN, no npm. + +No sockets are bound directly by this module (that stays supervisor.py's +job); `run` only builds an environment + argv and execs/spawns the +supervisor, which keeps the CLI itself trivially testable. +""" + +from __future__ import annotations + +import argparse +import copy +import ipaddress +import json +import os +import re +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any, TextIO + +import yaml +from pydantic import ValidationError + +from aci_sim.build.fabric import default_serial, loopback_ip, oob_ip +from aci_sim.topology.loader import load_topology +from aci_sim.topology.schema import Auth, Topology + +DEFAULT_TOPOLOGY_PATH = "topology.yaml" + + +# --------------------------------------------------------------------------- +# validate +# --------------------------------------------------------------------------- + + +def _summarize_topology(topo: Topology) -> str: + """Build the human-readable OK summary printed by `aci-sim validate`.""" + lines = [f"OK: {len(topo.sites)} site(s), {len(topo.tenants)} tenant(s)"] + for site in topo.sites: + lines.append( + f" - {site.name}: " + f"{len(site.leaf_nodes())} leaves, " + f"{len(site.spine_nodes())} spines, " + f"{len(site.border_leaves)} border leaves" + ) + return "\n".join(lines) + + +def cmd_validate(args: argparse.Namespace) -> int: + path = Path(args.topology) + try: + topo = load_topology(path) + except FileNotFoundError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + except ValueError as exc: + # Covers both YAML parse errors (loader) and pydantic ValidationError + # (a subclass of ValueError) with a readable rendering of each error. + print(f"ERROR: topology validation failed for {path}:\n", file=sys.stderr) + print(_render_validation_error(exc), file=sys.stderr) + return 1 + + print(_summarize_topology(topo)) + return 0 + + +def _render_validation_error(exc: Exception) -> str: + """Render a pydantic ValidationError (or plain ValueError) readably.""" + if isinstance(exc, ValidationError): + parts = [] + for i, err in enumerate(exc.errors(), start=1): + loc = " -> ".join(str(p) for p in err.get("loc", ())) + msg = err.get("msg", "") + parts.append(f" {i}. [{loc}] {msg}" if loc else f" {i}. {msg}") + return "\n".join(parts) + return f" {exc}" + + +# --------------------------------------------------------------------------- +# show +# --------------------------------------------------------------------------- + + +def _port_bindings(topo: Topology) -> list[dict[str, Any]]: + """Per-site controller port/address bindings (mirrors supervisor.py's logic). + + Returns one dict per site (APIC) plus one for NDO, each describing the + host/port a client would actually connect to, in whichever mode + (port vs sandbox) the current environment/config selects. + """ + # Imported lazily so `show`'s output reflects the environment at call + # time (config.py reads os.environ at import time). + from aci_sim.runtime import config as rt_config + from aci_sim.runtime.supervisor import apic_port_for_site + + bindings = [] + for i, site in enumerate(topo.sites): + if rt_config.SANDBOX: + host, port = (site.mgmt_ip or "127.0.0.1"), rt_config.SANDBOX_PORT + else: + host, port = rt_config.SIM_BIND, apic_port_for_site(i) + bindings.append( + { + "site": site.name, + "role": "apic", + "host": host, + "port": port, + "url": f"https://{host}:{port}", + } + ) + + if rt_config.SANDBOX: + ndo_host, ndo_port = (topo.fabric.ndo_mgmt_ip or "127.0.0.1"), rt_config.SANDBOX_PORT + else: + ndo_host = rt_config.SIM_BIND + last_apic = apic_port_for_site(len(topo.sites) - 1) if topo.sites else rt_config.APIC_A_PORT + ndo_port = rt_config.NDO_PORT if rt_config.NDO_PORT > last_apic else last_apic + 1 + bindings.append( + { + "site": None, + "role": "ndo", + "host": ndo_host, + "port": ndo_port, + "url": f"https://{ndo_host}:{ndo_port}", + } + ) + return bindings + + +def _node_role(site, node_id: int) -> str: + spine_ids = {n.id for n in site.spine_nodes()} + border_ids = {n.id for n in site.border_leaf_nodes()} + if node_id in spine_ids: + return "spine" + if node_id in border_ids: + return "border-leaf" + return "leaf" + + +def build_inventory(topo: Topology) -> dict[str, Any]: + """Build the derived-inventory data structure shared by text and --json `show`.""" + sites_out = [] + for site in topo.sites: + nodes_out = [] + for node in site.all_nodes(): + nodes_out.append( + { + "id": node.id, + "name": node.name, + "role": _node_role(site, node.id), + "model": node.model, + "version": node.version, + "serial": node.serial or default_serial(site.id, node.id), + "loopback_ip": loopback_ip(site.pod, node.id), + "oob_ip": oob_ip(site.pod, node.id), + } + ) + nodes_out.sort(key=lambda n: n["id"]) + sites_out.append( + { + "name": site.name, + "apic_host": site.apic_host, + "mgmt_ip": site.mgmt_ip, + "asn": site.asn, + "pod": site.pod, + "controllers": site.controllers, + # PR-21: effective per-controller OOB mgmt IPs — explicit + # controller_ips if set, else sequentially derived from + # mgmt_ip (empty list if mgmt_ip is also unset, matching the + # legacy per-node oob_ip(pod, cid) fallback in build/fabric.py). + "controller_ips": site.controller_oob_ips(), + # Tier-3 (PR-20): the EFFECTIVE APIC controller version this + # site's controller topSystem/firmwareCtrlrRunning actually + # carry — fabric.default_apic_version wins if set, else this + # site's own apic_version (build/fabric.py mirrors this + # exact resolution order). + "apic_version": topo.fabric.default_apic_version or site.apic_version, + "fabric_name": site.fabric_name or topo.fabric.name, + # OOB-gateway PR: this site's OOB mgmt default gateway (None + # if unset) — lands on the real mgmtRsOoBStNode.gw MO + # (build/mgmt.py), not just stored/informational. + "oob_gateway": site.oob_gateway, + "nodes": nodes_out, + } + ) + + vmm_domains_out = [ + { + "name": v.name, + "vcenter_ip": v.vcenter_ip, + "vcenter_dvs": v.vcenter_dvs, + "vlan_pool": v.vlan_pool, + "datacenter": v.datacenter, + } + for v in topo.access.vmm_domains + ] + + return { + "fabric_name": topo.fabric.name, + "ndo_mgmt_ip": topo.fabric.ndo_mgmt_ip, + "tep_pool": topo.fabric.tep_pool, + "infra_vlan": topo.fabric.infra_vlan, + "gipo_pool": topo.fabric.gipo_pool, + # Tier-3 (PR-20) fabric-wide fine-tuning params: + "oob_subnet": topo.fabric.oob_subnet, + "inb_subnet": topo.fabric.inb_subnet, + "default_bd_mac": topo.fabric.default_bd_mac, + "default_apic_version": topo.fabric.default_apic_version, + "isn_enabled": topo.isn.enabled, + "isn_peer_mesh": topo.isn.peer_mesh, + "isn_ospf_area": topo.isn.ospf_area, + "isn_mtu": topo.isn.mtu, + # Tier-3 (PR-20) ISN fine-tuning params: + "isn_ospf_hello": topo.isn.ospf_hello, + "isn_ospf_dead": topo.isn.ospf_dead, + "isn_bfd_min_rx": topo.isn.bfd_min_rx, + "isn_bfd_min_tx": topo.isn.bfd_min_tx, + "isn_bfd_multiplier": topo.isn.bfd_multiplier, + "vmm_domains": vmm_domains_out, + "sites": sites_out, + "ports": _port_bindings(topo), + } + + +def _format_show_text(inv: dict[str, Any]) -> str: + lines: list[str] = [] + lines.append(f"Fabric: {inv['fabric_name']} (NDO mgmt IP: {inv['ndo_mgmt_ip']})") + lines.append( + f" tep_pool={inv['tep_pool']} infra_vlan={inv['infra_vlan']} gipo_pool={inv['gipo_pool']}" + ) + lines.append( + f" oob_subnet={inv['oob_subnet'] or '-'} inb_subnet={inv['inb_subnet'] or '-'} " + f"(store-only) default_bd_mac={inv['default_bd_mac']}" + ) + if inv["default_apic_version"]: + lines.append(f" default_apic_version={inv['default_apic_version']} (overrides all sites)") + lines.append( + f" isn: enabled={inv['isn_enabled']} peer_mesh={inv['isn_peer_mesh']} " + f"ospf_area={inv['isn_ospf_area']} mtu={inv['isn_mtu']} " + f"ospf_hello={inv['isn_ospf_hello']} ospf_dead={inv['isn_ospf_dead']}" + ) + lines.append( + f" isn bfd (store-only): min_rx={inv['isn_bfd_min_rx']} " + f"min_tx={inv['isn_bfd_min_tx']} multiplier={inv['isn_bfd_multiplier']}" + ) + if inv["vmm_domains"]: + lines.append(" vmm_domains:") + for v in inv["vmm_domains"]: + lines.append( + f" {v['name']:<16} vcenter_ip={v['vcenter_ip'] or '-':<15} " + f"dvs={v['vcenter_dvs'] or '-':<12} datacenter={v['datacenter'] or '-'}" + ) + lines.append("") + + for site in inv["sites"]: + lines.append( + f"Site {site['name']}: APIC host={site['apic_host']} mgmt_ip={site['mgmt_ip'] or '-'} " + f"asn={site['asn']} pod={site['pod']} controllers={site['controllers']} " + f"apic_version={site['apic_version']} fabric_name={site['fabric_name']} " + f"controller_ips={','.join(site['controller_ips']) or '-'} " + f"oob_gateway={site['oob_gateway'] or '-'}" + ) + lines.append( + f" {'ID':<6} {'NAME':<20} {'ROLE':<12} {'MODEL':<14} {'VERSION':<16} " + f"{'SERIAL':<14} {'LOOPBACK':<14} {'OOB IP'}" + ) + for n in site["nodes"]: + lines.append( + f" {n['id']:<6} {n['name']:<20} {n['role']:<12} {n['model']:<14} " + f"{n['version']:<16} {n['serial']:<14} {n['loopback_ip']:<14} {n['oob_ip']}" + ) + lines.append("") + + lines.append("Port bindings:") + for b in inv["ports"]: + label = b["site"] if b["site"] else "NDO" + lines.append(f" {label:<8} ({b['role']:<4}) -> {b['url']}") + + return "\n".join(lines) + + +def cmd_show(args: argparse.Namespace) -> int: + path = Path(args.topology) + try: + topo = load_topology(path) + except (FileNotFoundError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + inv = build_inventory(topo) + if args.json: + print(json.dumps(inv, indent=2)) + else: + print(_format_show_text(inv)) + return 0 + + +# --------------------------------------------------------------------------- +# lldp — show lldp/cdp neighbors (materialized from a fresh build_site(), the +# same way `run`/the REST server derive their store — this CLI never starts +# a server, it just builds in-process and reads the resulting MIT store). +# --------------------------------------------------------------------------- + + +_ADJ_DN_RE = re.compile(r"^topology/pod-\d+/node-(\d+)/sys/(?:lldp|cdp)/inst/if-\[([^\]]+)\]/adj-1$") + + +def _parse_adj_dn(dn: str) -> tuple[int, str] | None: + """Extract (local_node_id, local_port) from an lldpAdjEp/cdpAdjEp DN. + + Returns None for a DN that doesn't match the expected shape (defensive — + should not happen for MOs this sim itself produces). + """ + m = _ADJ_DN_RE.match(dn) + if not m: + return None + return int(m.group(1)), m.group(2) + + +def _platform_from_lldp_sysdesc(sys_desc: str) -> str: + """Best-effort model/platform token out of lldpAdjEp.sysDesc. + + sysDesc is either "Cisco APIC" (controller neighbor) or + "{model} running {version}" (switch neighbor, see build/neighbors.py) — + the model token is everything before " running ". + """ + if not sys_desc: + return "" + if " running " in sys_desc: + return sys_desc.split(" running ", 1)[0] + return sys_desc + + +def _collect_neighbors(topo: Topology, site, *, cdp: bool, node_filter: int | None) -> list[dict[str, Any]]: + """Build site's store and return one row per in-scope lldpAdjEp/cdpAdjEp.""" + from aci_sim.build.orchestrator import build_site + + store = build_site(topo, site) + node_names = {n.id: n.name for n in site.all_nodes()} + # Controllers aren't in site.all_nodes() (switches only) but a leaf can + # legitimately have no local-node match if node_filter is a controller + # id — real APIC controllers don't run their own lldpAdjEp query target + # in this sim, so this is only used to label the LOCAL node (always a + # switch: the adjacency root is always .../node-{N}/sys/lldp|cdp/...). + + cls = "cdpAdjEp" if cdp else "lldpAdjEp" + rows: list[dict[str, Any]] = [] + for mo in store.by_class(cls): + parsed = _parse_adj_dn(mo.dn) + if parsed is None: + continue + local_node_id, local_port = parsed + if node_filter is not None and local_node_id != node_filter: + continue + attrs = mo.attrs + neighbor_port = (attrs.get("portId") if cdp else attrs.get("portIdV")) or "-" + platform = attrs.get("platId") or _platform_from_lldp_sysdesc(attrs.get("sysDesc", "")) + rows.append( + { + "local_node_id": local_node_id, + "local_node_name": node_names.get(local_node_id, f"node-{local_node_id}"), + "local_port": local_port, + "neighbor": attrs.get("sysName", ""), + "neighbor_port": neighbor_port, + "mgmt_ip": attrs.get("mgmtIp", ""), + "platform": platform, + } + ) + rows.sort(key=lambda r: (r["local_node_id"], r["local_port"])) + return rows + + +def _format_lldp_text(site_name: str, rows: list[dict[str, Any]]) -> str: + lines: list[str] = [] + by_node: dict[tuple[int, str], list[dict[str, Any]]] = {} + for r in rows: + key = (r["local_node_id"], r["local_node_name"]) + by_node.setdefault(key, []).append(r) + + for (node_id, node_name) in sorted(by_node.keys()): + lines.append(f"Site {site_name} — node-{node_id} ({node_name})") + lines.append(f" {'Local Port':<11} {'Neighbor':<17} {'Neighbor Port':<14} {'Mgmt IP':<14} {'Platform'}") + for r in by_node[(node_id, node_name)]: + lines.append( + f" {r['local_port']:<11} {r['neighbor']:<17} {r['neighbor_port']:<14} " + f"{r['mgmt_ip']:<14} {r['platform']}" + ) + lines.append("") + return "\n".join(lines).rstrip("\n") + + +def cmd_lldp(args: argparse.Namespace) -> int: + path = Path(args.topology) + try: + topo = load_topology(path) + except (FileNotFoundError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + sites = [s for s in topo.sites if not args.site or s.name == args.site] + node_filter = args.node + + all_rows: list[dict[str, Any]] = [] + for site in sites: + rows = _collect_neighbors(topo, site, cdp=args.cdp, node_filter=node_filter) + for r in rows: + r["site"] = site.name + all_rows.extend(rows) + + if args.json: + print(json.dumps(all_rows, indent=2)) + return 0 + + if not all_rows: + print("No neighbors found.") + return 0 + + for site in sites: + site_rows = [r for r in all_rows if r["site"] == site.name] + if not site_rows: + continue + text = _format_lldp_text(site.name, site_rows) + if text: + print(text) + return 0 + + +# --------------------------------------------------------------------------- +# run +# --------------------------------------------------------------------------- + +# Environment variables passed through unmodified from the caller's +# environment into the supervisor subprocess/exec, if present. +_PASSTHROUGH_ENV_VARS = ( + "SIM_BIND", + "SIM_SANDBOX", + "SIM_SANDBOX_PORT", + "SIM_USERNAME", + "SIM_PASSWORD", + "APIC_A_PORT", + "APIC_B_PORT", + "NDO_PORT", + "CERT_FILE", + "KEY_FILE", + "SIM_CERT_STRICT", +) + + +def resolve_admin_credentials( + base_env: dict[str, str], + auth: Auth | None, +) -> tuple[str | None, str | None, str]: + """Resolve which SIM_USERNAME/SIM_PASSWORD `aci-sim run` should inject, and why. + + Precedence (highest first): + 1. `base_env` already has EITHER `SIM_USERNAME` or `SIM_PASSWORD` set — + an explicit env override wins outright (preserves CI/env-override + behavior); `auth` is ignored entirely. Returns `(None, None, + "env override")` — i.e. inject nothing, so the caller's env is + authoritative and `rest_aci/auth.py` fills any var the caller left + unset with its own admin/cisco default. The override is atomic ON + PURPOSE: checking only `SIM_USERNAME` would let a caller who sets + only `SIM_PASSWORD` (e.g. rotating just the password from a secret, + keeping the default username) have their password SILENTLY discarded + in favor of the plaintext topology password — a partial-config trap. + So if the caller touched either credential var, they own both. + 2. `auth` is not None (the topology has an `auth:` section, written by + the wizard's Admin account step) — returns `(auth.username, + auth.password, "topology.yaml")` so the caller injects both vars. + 3. Neither — returns `(None, None, "built-in default")`; injecting + nothing lets `rest_aci/auth.py` fall back to its own + SIM_USERNAME/SIM_PASSWORD-env-or-admin/cisco default, unchanged. + + Only APIC is affected — the NDO plane is deliberately lenient (accepts + any credential) and has no env var this function would set. + """ + if base_env.get("SIM_USERNAME") or base_env.get("SIM_PASSWORD"): + return None, None, "env override" + if auth is not None: + return auth.username, auth.password, "topology.yaml" + return None, None, "built-in default" + + +def build_run_env_argv( + topology: str, + base_env: dict[str, str] | None = None, + executable: str | None = None, + admin_username: str | None = None, + admin_password: str | None = None, +) -> tuple[dict[str, str], list[str]]: + """Build the (env, argv) pair `aci-sim run` execs — factored out for testing. + + Does not touch the filesystem or spawn anything; pure function of its + inputs so tests can assert on the env/argv without booting a real + supervisor (a live boot binds real sockets, which we deliberately do not + exercise in the test suite). + + `admin_username`/`admin_password` (optional): when both are given, they + are injected as `SIM_USERNAME`/`SIM_PASSWORD` in the returned env. Callers + (`cmd_run`) are expected to have already resolved these via + `resolve_admin_credentials` — this function stays filesystem-pure and + topology-agnostic (it takes credentials as plain params, it never loads + a topology file itself). + """ + env = copy.deepcopy(base_env) if base_env is not None else dict(os.environ) + env["TOPOLOGY_PATH"] = str(topology) + if admin_username is not None and admin_password is not None: + env["SIM_USERNAME"] = admin_username + env["SIM_PASSWORD"] = admin_password + exe = executable or sys.executable + argv = [exe, "-m", "aci_sim.runtime.supervisor"] + return env, argv + + +def _ensure_certs(cert_file: str, key_file: str) -> None: + """Generate a self-signed cert/key pair if missing (mirrors scripts/gen_certs.sh). + + Keeps `aci-sim run` a drop-in replacement for `scripts/run.sh` (which + generates certs on first run) without requiring the caller to invoke a + separate script first. + """ + if Path(cert_file).exists() and Path(key_file).exists(): + return + import subprocess + + Path(cert_file).parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:4096", + "-keyout", key_file, "-out", cert_file, + "-days", "3650", "-nodes", + "-subj", "/CN=localhost", + "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1", + ], + check=True, + ) + print(f"[aci-sim] generated self-signed certs: {cert_file}, {key_file}") + + +def cmd_graph(args: argparse.Namespace) -> int: + path = Path(args.topology) + try: + topo = load_topology(path) + except (FileNotFoundError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + out_path = Path(args.output) + suffix = out_path.suffix.lower() + if suffix == ".svg": + fmt = "svg" + elif suffix in (".html", ".htm"): + fmt = "html" + else: + print( + f"ERROR: Unrecognized output extension {suffix!r} for {out_path}; " + f"use .html or .svg", + file=sys.stderr, + ) + return 1 + + from aci_sim.graph import render_topology + + content = render_topology(topo, fmt=fmt) + out_path.write_text(content, encoding="utf-8") + print(f"[aci-sim] wrote topology diagram: {out_path}") + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + path = Path(args.topology) + if not path.exists(): + print(f"ERROR: Topology file not found: {path}", file=sys.stderr) + return 1 + + # Fail fast with a friendly message before handing off to the supervisor, + # which would otherwise raise a raw ValidationError traceback. Also gives + # us the parsed Topology (specifically its optional `auth:` section) so + # we can resolve which admin credentials the supervisor should enforce — + # see resolve_admin_credentials's precedence docstring. + try: + topo = load_topology(path) + except ValueError as exc: + print(f"ERROR: topology validation failed for {path}:\n", file=sys.stderr) + print(_render_validation_error(exc), file=sys.stderr) + return 1 + + from aci_sim.runtime.config import CERT_FILE, KEY_FILE + + _ensure_certs(CERT_FILE, KEY_FILE) + + admin_username, admin_password, source = resolve_admin_credentials(dict(os.environ), topo.auth) + env, argv = build_run_env_argv(str(path), admin_username=admin_username, admin_password=admin_password) + # The operator should never be surprised about which credential is live — + # print the username + where it came from, but NEVER the password. + live_username = admin_username or env.get("SIM_USERNAME") or "admin" + if source == "env override": + print(f"[aci-sim] Auth: APIC admin {live_username!r} (from SIM_USERNAME/SIM_PASSWORD env override)") + elif source == "topology.yaml": + print(f"[aci-sim] Auth: APIC admin {live_username!r} (from {path}'s auth: section)") + else: + print(f"[aci-sim] Auth: APIC admin {live_username!r} (built-in default)") + print(f"[aci-sim] running supervisor with TOPOLOGY_PATH={path}") + os.execvpe(argv[0], argv, env) + return 0 # pragma: no cover — os.execvpe never returns on success + + +# --------------------------------------------------------------------------- +# new (scaffold) +# --------------------------------------------------------------------------- + + +def _default_asns(n_sites: int) -> list[int]: + return [65001 + i for i in range(n_sites)] + + +def _parse_asn_list(raw: str | None, n_sites: int) -> list[int]: + if not raw: + return _default_asns(n_sites) + values = [int(x.strip()) for x in raw.split(",") if x.strip()] + if len(values) != n_sites: + raise ValueError( + f"--asn must supply exactly {n_sites} value(s) (one per site), got {len(values)}: {raw!r}" + ) + return values + + +def _site_letter(index: int) -> str: + """A, B, C, ... Z, AA, AB, ... for site naming beyond 26 sites.""" + letters = "" + n = index + while True: + n, rem = divmod(n, 26) + letters = chr(ord("A") + rem) + letters + if n == 0: + break + n -= 1 + return letters + + +def generate_topology( + sites: int = 2, + leaves_per_site: int | list[int] = 2, + spines_per_site: int | list[int] = 2, + border_pairs: int | list[int] = 1, + asn: str | None = None, + fabric_name_prefix: str = "LAB", + ndo_ip: str = "10.192.0.10", + apic_ip_base: str = "10.192", + controllers: int = 1, + tep_pool: str = "10.0.0.0/16", + infra_vlan: int = 3967, + gipo_pool: str = "225.0.0.0/15", + isn_ospf_area: str = "0.0.0.0", + isn_mtu: int = 9150, + isn_ospf_hello: int = 10, + isn_ospf_dead: int = 40, + isn_bfd_min_rx: int = 50, + isn_bfd_min_tx: int = 50, + isn_bfd_multiplier: int = 3, + default_bd_mac: str = "00:22:BD:F8:19:FF", + site_overrides: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a fresh, minimal, valid topology dict (same shape as topology.yaml). + + Node-ID scheme matches topology/schema.py's documented convention: + leaf base=101, site_offset=200 -> site i: 101+200*i, 102+200*i, ... + spine base=201, site_offset=200 -> site i: 201+200*i, 202+200*i, ... + border leaves: explicit ids outside those ranges. The default shape + (2 leaves, 2 spines) reproduces the documented example + (site 0: 103,104; site 1: 303,304); for larger + --leaves-per-site/--spines-per-site counts, the border- + leaf base is pushed past whichever auto-generated range + (leaves or spines) extends further, so it can never + collide with an auto-generated leaf/spine id — this is + required by Topology.normalize_and_validate's global + node-id collision check. + + Border leaves are generated in vPC pairs (`border_pairs` pairs = 2 * N + border leaves), each pair sharing a vpc_domain and CSW. + + `controllers` (PR-18 Tier-1 param) sets APIC cluster size per site + (schema default 1, single-APIC by design as of PR-21 — see + Site.controllers docstring; range 1-5); `tep_pool`/`infra_vlan`/`gipo_pool` + are the Tier-1 + fabric-wide params (schema defaults 10.0.0.0/16, 3967, 225.0.0.0/15) — + all four are simply forwarded into the scaffolded YAML, using the same + schema defaults if the caller doesn't override them. + + `isn_ospf_area`/`isn_mtu` (Tier-2, PR-19) are the ISN underlay params + (schema defaults "0.0.0.0", 9150) forwarded into the scaffolded `isn:` + block the same way. + + `isn_ospf_hello`/`isn_ospf_dead`/`isn_bfd_min_rx`/`isn_bfd_min_tx`/ + `isn_bfd_multiplier` (Tier-3, PR-20) are further ISN timer params + (schema defaults 10, 40, 50, 50, 3) forwarded the same way; OSPF + hello/dead are wired into `ospfIf` (build/underlay.py), BFD timers are + store+validate+surface only (no `bfdIfP` MO built anywhere in this + sim). `default_bd_mac` (Tier-3, PR-20; schema default the real ACI + default `"00:22:BD:F8:19:FF"`) is the fabric-wide default gateway MAC + forwarded into `fabric.default_bd_mac`. + + `site_overrides` (PR-22, optional): a list of length `sites`, each entry a + dict of per-site overrides applied ON TOP of the auto-derived site_def + below (name/id/apic_host/mgmt_ip/fabric_name/asn/pod/controllers/ + controller_ips) — e.g. `[{"mgmt_ip": "10.192.0.11", "controller_ips": + ["10.192.0.11", "10.192.0.12"]}, {...}]`. Added so `aci-sim init` (the + interactive wizard) can collect per-site answers (including PR-21's + `controller_ips`, which the flat `--controllers`/`apic_ip_base` flags + above cannot express per-site) and feed them through this SAME generator + instead of duplicating topology-emission logic. `None` (default) leaves + every site's auto-derived fields untouched — fully backward compatible + with every pre-PR-22 caller of this function. + + `leaves_per_site`/`spines_per_site`/`border_pairs` (PR-22, widened): each + now also accepts a `list[int]` of length `sites` — a PER-SITE count — + instead of only a single `int` applied uniformly to every site (the + pre-PR-22 behavior, still the default and still what every existing + caller/flag passes). Added so `aci-sim init` can express sites with + different node counts (asked per-site in the wizard) without a second + topology-emission code path. + """ + if sites < 1: + raise ValueError("--sites must be >= 1") + if site_overrides is not None and len(site_overrides) != sites: + raise ValueError( + f"site_overrides must have exactly {sites} entries (one per site), " + f"got {len(site_overrides)}" + ) + + def _per_site(value: int | list[int], flag: str) -> list[int]: + if isinstance(value, list): + if len(value) != sites: + raise ValueError(f"{flag} list must have exactly {sites} entries (one per site), got {len(value)}") + return list(value) + return [value] * sites + + leaves_list = _per_site(leaves_per_site, "leaves_per_site") + spines_list = _per_site(spines_per_site, "spines_per_site") + border_list = _per_site(border_pairs, "border_pairs") + + if any(n < 0 for n in leaves_list) or any(n < 0 for n in spines_list) or any(n < 0 for n in border_list): + raise ValueError("--leaves-per-site/--spines-per-site/--border-pairs must be >= 0") + + # Tier-2 (PR-19) leaf/spine-count-sugar guard: the node-ID scheme + # (topology/schema.py docstring) gives each site a 200-wide ID budget — + # leaves start at 101+offset, spines at 201+offset, and the NEXT site's + # leaf block starts at 301+offset. Large --leaves-per-site/ + # --spines-per-site/--border-pairs values can overrun that budget in + # three distinct ways that Topology.normalize_and_validate would only + # catch AFTER the fact with a confusing "node ids collide" error that + # doesn't say which flag caused it: + # 1. leaf block itself reaches into the spine block (leaves_per_site > 100) + # 2. spine block itself reaches into the NEXT site's leaf block + # (spines_per_site > 100) + # 3. the border-leaf block (pushed past whichever of leaves/spines + # extends further) runs past the next site's leaf block + # (base_after_leaves_and_spines + 2*border_pairs - 1 >= 300) + # Fail fast here, at the CLI layer, with a message that names the + # specific flag(s) to reduce — same spirit as PR-17's border-ID fix, + # which pushed the border base to avoid case 3 for modest counts but + # did not guard against leaves/spines themselves overrunning the budget. + # (PR-22: checked per-site now that these can vary per site.) + for site_idx, (lps, sps, bp) in enumerate(zip(leaves_list, spines_list, border_list, strict=True)): + if lps > 100: + raise ValueError( + f"--leaves-per-site={lps} (site index {site_idx}) exceeds the per-site node-ID " + f"budget (max 100 — leaf ids run 101..200+site_offset before " + f"colliding with the auto-generated spine block at 201+site_offset)." + ) + if sps > 100: + raise ValueError( + f"--spines-per-site={sps} (site index {site_idx}) exceeds the per-site node-ID " + f"budget (max 100 — spine ids run 201..300+site_offset before " + f"colliding with the NEXT site's auto-generated leaf block at " + f"301+site_offset)." + ) + if bp > 0: + leaf_top = 100 + lps if lps else 100 + spine_top = 200 + sps if sps else 200 + border_base = max(leaf_top, spine_top) + 1 + border_top = border_base + 2 * bp - 1 + if border_top > 300: + raise ValueError( + f"--border-pairs={bp} (site index {site_idx}, combined with " + f"--leaves-per-site={lps}/--spines-per-site=" + f"{sps}) would push border-leaf ids up to " + f"{border_top}+site_offset, past the per-site node-ID budget " + f"of 300+site_offset (the NEXT site's auto-generated leaf " + f"block starts at 301+site_offset). Reduce --border-pairs or " + f"--leaves-per-site/--spines-per-site." + ) + + asns = _parse_asn_list(asn, sites) + + site_defs = [] + tenant_site_names = [] + fault_seeds = [] + + for i in range(sites): + letter = _site_letter(i) + site_name = f"{fabric_name_prefix}{letter}" if fabric_name_prefix else f"SITE{letter}" + site_offset = i * 200 + pod = 1 + n_leaves_i = leaves_list[i] + n_spines_i = spines_list[i] + n_border_i = border_list[i] + + leaves = [ + {"id": 101 + site_offset + j, "name": f"{site_name}-LF{101 + site_offset + j}"} + for j in range(n_leaves_i) + ] + spines = [ + {"id": 201 + site_offset + j, "name": f"{site_name}-SP{201 + site_offset + j}"} + for j in range(n_spines_i) + ] + + # Prefer the gap right after the leaf block and before the spine + # block (matches the documented example: leaves 101-102, border + # 103-104, spines 201-202). If leaves_per_site is large enough that + # the leaf block would run into the spine block, fall back to + # starting right after whichever range extends further, so border + # ids can never collide with an auto-generated leaf/spine id + # regardless of how many were requested. + leaf_top = 101 + site_offset + n_leaves_i - 1 if n_leaves_i else 101 + site_offset - 1 + spine_base = 201 + site_offset + spine_top = spine_base + n_spines_i - 1 if n_spines_i else spine_base - 1 + gap_start = leaf_top + 1 + gap_needed = 2 * n_border_i + if n_spines_i == 0 or gap_start + gap_needed - 1 < spine_base: + border_base = gap_start + else: + border_base = max(leaf_top, spine_top) + 1 + + border_leaves = [] + for p in range(n_border_i): + vpc_domain = f"vpcdom-{site_name}-{p + 1}" + bl_base = border_base + (p * 2) + csw_asn = 65100 + peer_ip = f"10.{100 + i}.{p}.1" + for leg, bl_id in enumerate((bl_base, bl_base + 1)): + border_leaves.append( + { + "id": bl_id, + "name": f"{site_name}-LF{bl_id}", + "vpc_domain": vpc_domain, + "csw": { + "name": f"{site_name}-CSW{p + 1:02d}", + "asn": csw_asn, + "peer_ip": peer_ip, + "local_ip": f"10.{100 + i}.{p}.{2 + leg}", + "vlan": 100 + i * 100 + p, + }, + } + ) + + apic_ip = f"{apic_ip_base}.{i}.11" if apic_ip_base else "" + site_def: dict[str, Any] = { + "name": site_name, + "id": str(i + 1), + "apic_host": f"127.0.0.1:{8443 + i}", + "mgmt_ip": apic_ip, + "fabric_name": f"{site_name}-IT-ACI", + "asn": asns[i], + "pod": pod, + "controllers": controllers, + "spines": spines, + "leaves": leaves, + "border_leaves": border_leaves, + "cabling": "auto", + } + if site_overrides is not None: + # Shallow-merge the caller's per-site overrides on top of the + # auto-derived fields above (spines/leaves/border_leaves are + # deliberately NOT overridable this way — node topology stays + # driven by leaves_per_site/spines_per_site/border_pairs). + site_def.update(site_overrides[i]) + site_name = site_def["name"] # re-read in case an override renamed the site + site_defs.append(site_def) + tenant_site_names.append(site_name) + if leaves: + fault_seeds.append( + { + "severity": "warning", + "code": "F0532", + "node": leaves[0]["id"], + "descr": f"Link flap detected on {leaves[0]['name']} eth1/1 (scaffolded seed)", + } + ) + + # One trivial, single-site tenant per site so the file is runnable + # end-to-end (endpoints/health/query builders all expect >=0 tenants, + # but a demo file should have at least something to look at). + tenants = [] + for site_name in tenant_site_names: + tenants.append( + { + "name": f"SF-DEMO-{site_name}", + "stretch": False, + "sites": [site_name], + "vrfs": [{"name": f"vrf-L3_{site_name}"}], + "bds": [ + { + "name": f"bd-DEMO_{site_name}", + "vrf": f"vrf-L3_{site_name}", + "subnets": ["192.168.100.0/24"], + } + ], + "aps": [ + { + "name": f"app-Demo_{site_name}", + "epgs": [ + { + "name": f"epg-Demo_{site_name}", + "bd": f"bd-DEMO_{site_name}", + "contracts": {"provide": [], "consume": []}, + } + ], + } + ], + "contracts": [], + "l3outs": [], + } + ) + + topology: dict[str, Any] = { + "fabric": { + "name": f"{fabric_name_prefix}-IT-ACI" if fabric_name_prefix else "SIM-IT-ACI", + "evpn_rr": "spine", + "loopback_pool": "10.0.0.0/16", + "oob_pool": "192.168.0.0/16", + "ndo_mgmt_ip": ndo_ip, + "tep_pool": tep_pool, + "infra_vlan": infra_vlan, + "gipo_pool": gipo_pool, + "default_bd_mac": default_bd_mac, + }, + "sites": site_defs, + "isn": { + "enabled": sites >= 2, + "peer_mesh": "full", + "ospf_area": isn_ospf_area, + "mtu": isn_mtu, + "ospf_hello": isn_ospf_hello, + "ospf_dead": isn_ospf_dead, + "bfd_min_rx": isn_bfd_min_rx, + "bfd_min_tx": isn_bfd_min_tx, + "bfd_multiplier": isn_bfd_multiplier, + }, + "tenants": tenants, + "endpoints": {"per_epg": 3, "ip_pool_from_subnet": True}, + "faults": { + "seed": fault_seeds, + "health_defaults": {"node": 95, "tenant": 98}, + }, + "access": { + "vlan_pools": [{"name": "vlp-general", "from_vlan": 100, "to_vlan": 200}], + "phys_domains": [{"name": "phy-general"}], + "l3_domains": [{"name": "l3d-l3out"}], + "aaeps": [{"name": "aep-general"}], + }, + } + return topology + + +_SCAFFOLD_HEADER = """\ +# Generated by `aci-sim new` — a minimal, valid topology.yaml. +# topology.yaml (whichever file you point the sim at) remains the single +# source of truth; edit freely, then re-validate with `aci-sim validate`. +""" + + +def cmd_new(args: argparse.Namespace) -> int: + try: + topology = generate_topology( + sites=args.sites, + leaves_per_site=args.leaves_per_site, + spines_per_site=args.spines_per_site, + border_pairs=args.border_pairs, + asn=args.asn, + fabric_name_prefix=args.fabric_name_prefix, + ndo_ip=args.ndo_ip, + apic_ip_base=args.apic_ip_base, + controllers=args.controllers, + tep_pool=args.tep_pool, + infra_vlan=args.infra_vlan, + gipo_pool=args.gipo_pool, + isn_ospf_area=args.isn_ospf_area, + isn_mtu=args.isn_mtu, + isn_ospf_hello=args.isn_ospf_hello, + isn_ospf_dead=args.isn_ospf_dead, + isn_bfd_min_rx=args.isn_bfd_min_rx, + isn_bfd_min_tx=args.isn_bfd_min_tx, + isn_bfd_multiplier=args.isn_bfd_multiplier, + default_bd_mac=args.default_bd_mac, + ) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + yaml_text = _SCAFFOLD_HEADER + yaml.dump(topology, sort_keys=False, default_flow_style=False) + + # Self-check: the generated file must pass the same validation `aci-sim + # validate` runs, so `new` never hands the user a broken starting point. + try: + Topology.model_validate(topology) + except ValidationError as exc: + print( + "ERROR: internal error — generated topology failed validation:\n" + + _render_validation_error(exc), + file=sys.stderr, + ) + return 1 + + if args.output: + Path(args.output).write_text(yaml_text, encoding="utf-8") + print(f"Wrote {args.output}") + else: + print(yaml_text, end="") + return 0 + + +# --------------------------------------------------------------------------- +# init (interactive APIC-setup-style wizard) +# --------------------------------------------------------------------------- +# +# Modeled on the real APIC first-boot "setup dialog" (the console Q&A a +# fresh APIC controller runs on first power-on): each field is prompted with +# a suggested default shown in [brackets] — press ENTER to accept it, or +# type a custom value. This wizard asks for the SAME fields `aci-sim new` +# takes as flags (fabric name, ASN, pod, TEP pool, infra VLAN, GIPo pool, +# OOB mgmt IP/gateway, controller count) plus this sim's multi-site extras +# (ISN OSPF area/MTU, NDO mgmt IP), then calls `generate_topology()` — the +# SAME generator `aci-sim new` uses — so it never duplicates topology- +# emission logic. It is purely a thin interactive front-end. +# +# Real APIC's dialog also asks about "multi-pod" — this sim doesn't build +# true multi-pod fabrics (no per-pod spine/leaf partitioning or IPN), so +# that choice is deliberately NOT offered; a single fabric can still set a +# non-default `pod` id per PR-19's pod-elasticity field, mentioned in the +# per-site prompt. + + +class WizardAbort(Exception): + """Raised internally when the user declines to write the file at the end.""" + + +def _prompt( + label: str, + default: str, + stream_in: TextIO, + stream_out: TextIO, + validator: Callable[[str], str | None] | None = None, + accept_defaults: bool = False, +) -> str: + """Print `label [default]: `, read one line, return default on empty input. + + Re-prompts (by recursing) if `validator` is given and returns a non-None + error message for the candidate value (default included — a bad default + should never happen, but we don't special-case it). + + `accept_defaults=True` (non-interactive: `--defaults` or a non-TTY stdin) + skips reading entirely and returns `default` immediately, so `aci-sim + init` can run unattended in CI/tests without ever blocking on stdin. + """ + if accept_defaults: + return default + + stream_out.write(f"{label} [{default}]: ") + stream_out.flush() + raw = stream_in.readline() + if raw == "": + # EOF on stdin (e.g. a scripted/short answer stream ran out) — treat + # exactly like accept_defaults for THIS field rather than hanging or + # raising, so scripted stdin that supplies answers for only a prefix + # of the prompts still completes deterministically. + return default + value = raw.strip() + if value == "": + value = default + + if validator is not None: + error = validator(value) + if error is not None: + stream_out.write(f" ! {error}\n") + return _prompt(label, default, stream_in, stream_out, validator, accept_defaults) + return value + + +def _prompt_yes_no(label: str, default_yes: bool, stream_in: TextIO, stream_out: TextIO, accept_defaults: bool) -> bool: + default = "yes" if default_yes else "no" + while True: + value = _prompt(label, default, stream_in, stream_out, accept_defaults=accept_defaults) + lowered = value.strip().lower() + if lowered in ("y", "yes"): + return True + if lowered in ("n", "no"): + return False + if accept_defaults: + return default_yes + stream_out.write(" ! please answer yes or no\n") + + +def _validate_int_range(lo: int, hi: int) -> Callable[[str], str | None]: + def _v(value: str) -> str | None: + try: + n = int(value) + except ValueError: + return f"must be an integer, got {value!r}" + if not (lo <= n <= hi): + return f"must be between {lo} and {hi}, got {n}" + return None + + return _v + + +def _validate_ip(value: str) -> str | None: + try: + ipaddress.ip_address(value) + except ValueError as exc: + return f"not a valid IP address: {exc}" + return None + + +def _validate_cidr(value: str) -> str | None: + try: + ipaddress.ip_network(value, strict=False) + except ValueError as exc: + return f"not a valid CIDR: {exc}" + return None + + +def _next_ip(ip_str: str) -> str: + """Return `ip_str` + 1, e.g. '10.192.0.11' -> '10.192.0.12' (PR-21 scheme).""" + return str(ipaddress.ip_address(ip_str) + 1) + + +class _AnswerSource: + """Wraps an optional pre-filled answers dict (from `--answers FILE`). + + `get(key, default)` returns the answers-file value for `key` if present + (coerced to str, matching what an interactive prompt would return), else + `default`. This lets `--answers` partially override just a few fields + while everything else still falls back to the wizard's own defaults — + scriptable without having to enumerate every field. + """ + + def __init__(self, data: dict[str, Any] | None) -> None: + self._data = data or {} + + def get(self, key: str, default: str) -> str: + if key in self._data: + return str(self._data[key]) + return default + + def has(self, key: str) -> bool: + return key in self._data + + +def _load_answers_file(path: str) -> dict[str, Any]: + text = Path(path).read_text(encoding="utf-8") + data = yaml.safe_load(text) # yaml.safe_load also parses plain JSON + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError(f"--answers file {path!r} must contain a mapping/object, got {type(data).__name__}") + return data + + +def _wizard_prompt_field( + key: str, + label: str, + default: str, + answers: _AnswerSource, + stream_in: TextIO, + stream_out: TextIO, + accept_defaults: bool, + validator: Callable[[str], str | None] | None = None, +) -> str: + """One wizard field: --answers value (if present) wins outright (no re-prompt, + matching a scriptable non-interactive contract); otherwise prompt/accept-default + with `default` as the suggested value.""" + if answers.has(key): + return answers.get(key, default) + return _prompt(label, default, stream_in, stream_out, validator, accept_defaults) + + +def run_wizard( + *, + stream_in: TextIO, + stream_out: TextIO, + accept_defaults: bool, + answers: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], str]: + """Drive the full Q&A flow; return `(topology_dict, oob_gateway_note)`. + + Pure-ish: takes explicit I/O streams (never touches real sys.stdin/stdout + directly) so tests can pass io.StringIO. Does not write any file — the + caller (`cmd_init`) handles the confirm-and-write + auto-validate steps. + + OOB-gateway PR: every site's answered OOB gateway is now written into + that site's own dict in `topology_dict["sites"][i]["oob_gateway"]` + (Site.oob_gateway — a real schema field with a real MO to land on, + build/mgmt.py's mgmtRsOoBStNode.gw), no longer discarded. The second + return value is kept for backward compatibility with existing callers + of this function (`cmd_init`'s summary print) — it is site-1's answered + gateway specifically, now simply a convenience echo of + `topology_dict["sites"][0]["oob_gateway"]` rather than the only place + the value lives. + """ + ans = _AnswerSource(answers) + out = stream_out + + out.write("This wizard collects the same setup information as a real APIC's\n") + out.write("first-boot dialog, then generates a topology.yaml for aci-sim.\n") + out.write("Press ENTER to accept a suggested default shown in [brackets].\n\n") + + # ---- Step 0: admin account --------------------------------------------- + # The account the APIC plane will ENFORCE (rest_aci/auth.py's aaaLogin + # 401s on a mismatch) once `aci-sim run` starts — see cmd_run's + # credential-resolution precedence. NDO shares this account by default + # (ndo_same_account=yes) but stays lenient regardless (accepts any + # credential, by design — see Auth's docstring in topology/schema.py); a + # "no" answer here only changes what NDO is TOLD, never what it checks. + out.write("Step 0: Admin account\n") + admin_username = _wizard_prompt_field( + "admin_username", " Admin username", "admin", ans, stream_in, out, accept_defaults + ) + admin_password = _wizard_prompt_field( + "admin_password", " Admin password", "cisco", ans, stream_in, out, accept_defaults + ) + if ans.has("ndo_same_account"): + ndo_same_account = ans.get("ndo_same_account", "yes").strip().lower() in ("y", "yes", "true", "1") + else: + ndo_same_account = _prompt_yes_no( + " NDO uses the same admin account as APIC?", True, stream_in, out, accept_defaults + ) + if not ndo_same_account: + ndo_username = _wizard_prompt_field( + "ndo_username", " NDO admin username", admin_username, ans, stream_in, out, accept_defaults + ) + ndo_password = _wizard_prompt_field( + "ndo_password", " NDO admin password", admin_password, ans, stream_in, out, accept_defaults + ) + else: + ndo_username = admin_username + ndo_password = admin_password + out.write("\n") + + # ---- Step 1: deployment type ----------------------------------------- + out.write("Step 1: Deployment type\n") + deploy_choice = _wizard_prompt_field( + "deployment_type", + "Deployment type: 1) Single fabric 2) Multi-site (2+ fabrics via ISN+NDO)", + "2", + ans, + stream_in, + out, + accept_defaults, + _validate_int_range(1, 2), + ) + multi_site = deploy_choice.strip() == "2" + + if multi_site: + n_sites_str = _wizard_prompt_field( + "num_sites", "Number of sites", "2", ans, stream_in, out, accept_defaults, _validate_int_range(2, 26) + ) + n_sites = int(n_sites_str) + else: + n_sites = 1 + out.write("\n") + + # ---- Step 2: per-site fields ------------------------------------------- + site_overrides: list[dict[str, Any]] = [] + per_site_leaves: list[int] = [] + per_site_spines: list[int] = [] + per_site_border_pairs: list[int] = [] + first_site_tep_pool = "10.0.0.0/16" + first_site_infra_vlan = 3967 + first_site_gipo_pool = "225.0.0.0/15" + first_site_oob_subnet = "192.168.0.0/16" + first_site_gateway = "" + + for i in range(n_sites): + idx = i + 1 + out.write(f"Step 2: Site {idx} of {n_sites}\n") + + default_name = f"Site{idx}-IT-ACI" + name = _wizard_prompt_field( + f"site{idx}_name", " Fabric/site name", default_name, ans, stream_in, out, accept_defaults + ) + + site_id = _wizard_prompt_field( + f"site{idx}_id", " Site ID", str(idx), ans, stream_in, out, accept_defaults, _validate_int_range(1, 9999) + ) + + default_asn = str(65000 + idx) + asn = _wizard_prompt_field( + f"site{idx}_asn", + " BGP AS (fabric)", + default_asn, + ans, + stream_in, + out, + accept_defaults, + _validate_int_range(1, 4294967295), + ) + + pod = _wizard_prompt_field( + f"site{idx}_pod", " Pod ID", "1", ans, stream_in, out, accept_defaults, _validate_int_range(1, 9999) + ) + + n_controllers_str = _wizard_prompt_field( + f"site{idx}_controllers", + " Number of APIC controllers", + "1", + ans, + stream_in, + out, + accept_defaults, + _validate_int_range(1, 5), + ) + n_controllers = int(n_controllers_str) + + # Sequential default OOB IP block per site: site1 10.192.0.11, site2 + # 10.192.128.11, ... — matches the existing sandbox convention + # (topology.yaml's LAB1/LAB2 10.192.0.0/25 vs 10.192.128.0/25 split). + default_apic_ip = f"10.192.{(i * 128) % 256}.11" + apic1_ip = _wizard_prompt_field( + f"site{idx}_apic1_ip", + " APIC OOB mgmt IP", + default_apic_ip, + ans, + stream_in, + out, + accept_defaults, + _validate_ip, + ) + + controller_ips = [apic1_ip] + for c in range(2, n_controllers + 1): + suggested = _next_ip(controller_ips[-1]) + ctrl_ip = _wizard_prompt_field( + f"site{idx}_apic{c}_ip", + f" APIC-{c} OOB IP", + suggested, + ans, + stream_in, + out, + accept_defaults, + _validate_ip, + ) + controller_ips.append(ctrl_ip) + + gw_block = ipaddress.ip_address(apic1_ip) + default_gateway = str(ipaddress.ip_address(int(gw_block) - (int(gw_block) % 256) + 1)) + gateway = _wizard_prompt_field( + f"site{idx}_gateway", + " APIC OOB gateway (shared by all controllers at this site)", + default_gateway, + ans, + stream_in, + out, + accept_defaults, + _validate_ip, + ) + + tep_pool = _wizard_prompt_field( + f"site{idx}_tep_pool", " TEP pool", "10.0.0.0/16", ans, stream_in, out, accept_defaults, _validate_cidr + ) + infra_vlan = _wizard_prompt_field( + f"site{idx}_infra_vlan", + " Infra VLAN (1-4094)", + "3967", + ans, + stream_in, + out, + accept_defaults, + _validate_int_range(1, 4094), + ) + gipo_pool = _wizard_prompt_field( + f"site{idx}_gipo_pool", + " Multicast/GIPo pool", + "225.0.0.0/15", + ans, + stream_in, + out, + accept_defaults, + _validate_cidr, + ) + oob_subnet = _wizard_prompt_field( + f"site{idx}_oob_subnet", + " OOB mgmt subnet", + "192.168.0.0/16", + ans, + stream_in, + out, + accept_defaults, + _validate_cidr, + ) + n_spines = _wizard_prompt_field( + f"site{idx}_spines", " Number of spines", "2", ans, stream_in, out, accept_defaults, _validate_int_range(0, 100) + ) + n_leaves = _wizard_prompt_field( + f"site{idx}_leaves", " Number of leaves", "2", ans, stream_in, out, accept_defaults, _validate_int_range(0, 100) + ) + n_border_pairs = _wizard_prompt_field( + f"site{idx}_border_pairs", + " Number of border-leaf pairs", + "1", + ans, + stream_in, + out, + accept_defaults, + _validate_int_range(0, 20), + ) + out.write("\n") + + site_overrides.append( + { + "name": name, + "id": site_id, + "mgmt_ip": apic1_ip, + "fabric_name": f"{name}-IT-ACI" if not name.endswith("-IT-ACI") else name, + "asn": int(asn), + "pod": int(pod), + "controllers": n_controllers, + "controller_ips": controller_ips, + # OOB-gateway PR: this site's own answered gateway now has a + # real per-site schema field (Site.oob_gateway) and a real MO + # to land on (build/mgmt.py's mgmtRsOoBStNode.gw) — unlike + # oob_subnet/tep_pool/infra_vlan/gipo_pool below, which stay + # fabric-wide (only site 1's answer applies) because their + # schema fields (Fabric.oob_subnet/tep_pool/infra_vlan/ + # gipo_pool) are fabric-wide by design, matching real APIC + # where these values are set once and inherited by every + # later controller joining the fabric. + "oob_gateway": gateway, + } + ) + per_site_leaves.append(int(n_leaves)) + per_site_spines.append(int(n_spines)) + per_site_border_pairs.append(int(n_border_pairs)) + # oob_subnet has no PER-SITE schema field (Fabric.oob_subnet is + # fabric-wide) — only site 1's answer is applied fabric-wide; + # tep_pool/infra_vlan/gipo_pool are likewise fabric-wide + # (Fabric.tep_pool/infra_vlan/gipo_pool), matching real APIC where + # these ARE fabric-wide values that only the first controller sets + # (later controllers join the existing fabric and inherit them). + if i == 0: + first_site_tep_pool = tep_pool + first_site_infra_vlan = int(infra_vlan) + first_site_gipo_pool = gipo_pool + first_site_oob_subnet = oob_subnet + first_site_gateway = gateway + # ---- Step 3: multi-site only (NDO + ISN) ------------------------------- + if multi_site: + out.write("Step 3: Multi-site (NDO + ISN)\n") + ndo_ip = _wizard_prompt_field( + "ndo_mgmt_ip", " NDO/ND mgmt IP", "10.192.0.10", ans, stream_in, out, accept_defaults, _validate_ip + ) + isn_ospf_area = _wizard_prompt_field( + "isn_ospf_area", " ISN OSPF area", "0.0.0.0", ans, stream_in, out, accept_defaults + ) + isn_mtu = int( + _wizard_prompt_field( + "isn_mtu", " ISN MTU", "9150", ans, stream_in, out, accept_defaults, _validate_int_range(576, 9216) + ) + ) + out.write( + " note: inter-site EVPN uses each fabric's own BGP AS above — " + "there is no separate ISN AS.\n\n" + ) + else: + ndo_ip = "10.192.0.10" + isn_ospf_area = "0.0.0.0" + isn_mtu = 9150 + + # ---- Assemble the generate_topology() call ----------------------------- + # A SINGLE call, reusing exactly the same generator `aci-sim new` calls — + # per-site heterogeneity (name/id/asn/pod/controllers/controller_ips/ + # mgmt_ip, and now leaves/spines/border_pairs counts too, PR-22) is + # expressed via `site_overrides` + the newly-list-capable + # leaves_per_site/spines_per_site/border_pairs params, so nothing here + # re-implements node-ID derivation, tenant scaffolding, or YAML shape. + fabric_name = "MULTI-SITE-ACI" if multi_site else site_overrides[0]["name"] + topology = generate_topology( + sites=n_sites, + leaves_per_site=per_site_leaves, + spines_per_site=per_site_spines, + border_pairs=per_site_border_pairs, + fabric_name_prefix="", + ndo_ip=ndo_ip, + tep_pool=first_site_tep_pool, + infra_vlan=first_site_infra_vlan, + gipo_pool=first_site_gipo_pool, + isn_ospf_area=isn_ospf_area, + isn_mtu=isn_mtu, + site_overrides=site_overrides, + ) + topology["fabric"]["name"] = f"{fabric_name}-IT-ACI" if not fabric_name.endswith("-IT-ACI") else fabric_name + topology["fabric"]["oob_subnet"] = first_site_oob_subnet + topology["isn"]["enabled"] = multi_site + + # Admin account (Step 0 above) — always written, even when every field is + # the wizard's own default (admin/cisco), so a generated topology.yaml is + # always explicit about which account `aci-sim run` will enforce on the + # APIC plane rather than relying on cmd_run's silent admin/cisco fallback. + auth: dict[str, Any] = {"username": admin_username, "password": admin_password} + if not ndo_same_account: + auth["ndo_username"] = ndo_username + auth["ndo_password"] = ndo_password + topology["auth"] = auth + + return topology, first_site_gateway + + +def _mask_password(password: str) -> str: + """First char + '***' (or just '***' if empty) — never echo it in full.""" + return f"{password[0]}***" if password else "***" + + +def _print_summary(topology: dict[str, Any], oob_gateway: str, stream_out: TextIO) -> None: + stream_out.write("Summary:\n") + auth = topology.get("auth") or {} + if auth: + admin_line = f" Admin account: username={auth['username']} password={_mask_password(auth['password'])}" + if "ndo_username" in auth: + admin_line += ( + f" (NDO: username={auth['ndo_username']} " + f"password={_mask_password(auth.get('ndo_password', ''))})" + ) + else: + admin_line += " (NDO: same account — lenient by design, accepts any credential)" + stream_out.write(admin_line + "\n") + stream_out.write(f" Fabric: {topology['fabric']['name']}\n") + stream_out.write(f" ISN/multi-site enabled: {topology['isn']['enabled']}\n") + if topology["isn"]["enabled"]: + stream_out.write( + f" NDO mgmt IP: {topology['fabric']['ndo_mgmt_ip']} " + f"ISN ospf_area: {topology['isn']['ospf_area']} ISN mtu: {topology['isn']['mtu']}\n" + ) + if oob_gateway: + stream_out.write(f" APIC OOB gateway (site 1): {oob_gateway}\n") + for site in topology["sites"]: + n_leaves = len(site["leaves"]) if isinstance(site["leaves"], list) else site["leaves"] + n_spines = len(site["spines"]) if isinstance(site["spines"], list) else site["spines"] + n_border = len(site.get("border_leaves", [])) + stream_out.write( + f" Site {site['name']} (id={site['id']}, asn={site['asn']}, pod={site['pod']}): " + f"{site['controllers']} controller(s) {site.get('controller_ips')}, " + f"oob_gateway={site.get('oob_gateway') or '-'}, " + f"{n_spines} spines, {n_leaves} leaves, {n_border} border leaves ({n_border // 2} pair(s))\n" + ) + stream_out.write("\n") + + +def cmd_init(args: argparse.Namespace) -> int: + accept_defaults = bool(args.defaults) or not sys.stdin.isatty() + stream_in: TextIO = sys.stdin + stream_out: TextIO = sys.stdout + + answers: dict[str, Any] = {} + if args.answers: + try: + answers = _load_answers_file(args.answers) + except (OSError, ValueError) as exc: + print(f"ERROR: could not read --answers file: {exc}", file=sys.stderr) + return 1 + + topology, oob_gateway = run_wizard( + stream_in=stream_in, + stream_out=stream_out, + accept_defaults=accept_defaults, + answers=answers, + ) + + _print_summary(topology, oob_gateway, stream_out) + + ans = _AnswerSource(answers) + if ans.has("confirm_write"): + confirmed = ans.get("confirm_write", "yes").strip().lower() in ("y", "yes", "true", "1") + else: + confirmed = _prompt_yes_no("Write to topology.yaml?", True, stream_in, stream_out, accept_defaults) + + if not confirmed: + stream_out.write("Aborted — nothing written.\n") + return 1 + + # Self-check (mirrors `aci-sim new`): the generated file must pass the + # same validation `aci-sim validate` runs before we ever write it. + try: + Topology.model_validate(topology) + except ValidationError as exc: + print( + "ERROR: internal error — generated topology failed validation:\n" + + _render_validation_error(exc), + file=sys.stderr, + ) + return 1 + + yaml_text = _SCAFFOLD_HEADER + yaml.dump(topology, sort_keys=False, default_flow_style=False) + output_path = args.output or DEFAULT_TOPOLOGY_PATH + Path(output_path).write_text(yaml_text, encoding="utf-8") + stream_out.write(f"Wrote {output_path}\n") + + # Auto-run validate on the result and print OK (or the errors), per spec. + try: + topo = load_topology(Path(output_path)) + except (FileNotFoundError, ValueError) as exc: + stream_out.write(f"ERROR: {exc}\n") + return 1 + stream_out.write(_summarize_topology(topo) + "\n") + return 0 + + +# --------------------------------------------------------------------------- +# argparse wiring +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="aci-sim", + description="Author, validate, preview, and run aci-sim topology.yaml files.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_validate = sub.add_parser("validate", help="Validate a topology.yaml (CI gate).") + p_validate.add_argument( + "topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)" + ) + p_validate.set_defaults(func=cmd_validate) + + p_show = sub.add_parser("show", help="Print the derived inventory (IDs, IPs, ports).") + p_show.add_argument( + "topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)" + ) + p_show.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of text.") + p_show.set_defaults(func=cmd_show) + + p_lldp = sub.add_parser( + "lldp", help="Show LLDP (or --cdp) neighbors, show-lldp-neighbors-style." + ) + p_lldp.add_argument( + "topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)" + ) + p_lldp.add_argument("--site", default=None, help="Filter to one site by name (default: all sites)") + p_lldp.add_argument("--node", type=int, default=None, help="Filter to one node id (default: all nodes)") + p_lldp.add_argument("--cdp", action="store_true", help="Show CDP neighbors instead of LLDP.") + p_lldp.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of text.") + p_lldp.set_defaults(func=cmd_lldp) + + p_graph = sub.add_parser( + "graph", help="Render a self-contained SVG/HTML topology diagram (no server, no CDN)." + ) + p_graph.add_argument( + "topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)" + ) + p_graph.add_argument( + "-o", "--output", default="topology.html", + help="Output file path; format is inferred from the extension (.html or .svg). Default: topology.html" + ) + p_graph.set_defaults(func=cmd_graph) + + p_run = sub.add_parser("run", help="Run the simulator supervisor against a topology.yaml.") + p_run.add_argument( + "topology", nargs="?", default=DEFAULT_TOPOLOGY_PATH, help="Path to topology.yaml (default: ./topology.yaml)" + ) + p_run.set_defaults(func=cmd_run) + + p_new = sub.add_parser("new", help="Scaffold a fresh topology.yaml.") + p_new.add_argument("--sites", type=int, default=2, help="Number of sites (default: 2)") + p_new.add_argument("--leaves-per-site", type=int, default=2, help="Regular leaves per site (default: 2)") + p_new.add_argument("--spines-per-site", type=int, default=2, help="Spines per site (default: 2)") + p_new.add_argument( + "--border-pairs", type=int, default=1, help="Border-leaf vPC pairs per site (default: 1 = 2 border leaves)" + ) + p_new.add_argument("--asn", default=None, help="Comma-separated per-site fabric ASNs (default: 65001,65002,...)") + p_new.add_argument("--fabric-name-prefix", default="LAB", help="Fabric/site name prefix (default: LAB)") + p_new.add_argument("--ndo-ip", default="10.192.0.10", help="NDO/ND mgmt IP (default: 10.192.0.10)") + p_new.add_argument("--apic-ip-base", default="10.192", help="First two octets for per-site APIC mgmt IPs (default: 10.192)") + p_new.add_argument( + "--controllers", type=int, default=1, help="APIC cluster size per site (default: 1, range 1-5; single APIC by design — see README)" + ) + p_new.add_argument( + "--tep-pool", default="10.0.0.0/16", help="Fabric infra TEP pool CIDR (default: 10.0.0.0/16)" + ) + p_new.add_argument( + "--infra-vlan", type=int, default=3967, help="Fabric infrastructure VLAN, 1-4094 (default: 3967)" + ) + p_new.add_argument( + "--gipo-pool", default="225.0.0.0/15", help="Multicast GIPo pool CIDR (default: 225.0.0.0/15)" + ) + p_new.add_argument( + "--isn-ospf-area", default="0.0.0.0", help="ISN OSPF area, dotted-decimal or decimal (default: 0.0.0.0)" + ) + p_new.add_argument( + "--isn-mtu", type=int, default=9150, help="ISN/IPN inter-site uplink MTU, 576-9216 (default: 9150)" + ) + p_new.add_argument( + "--isn-ospf-hello", type=int, default=10, help="ISN OSPF hello timer, seconds (default: 10; Tier-3, PR-20)" + ) + p_new.add_argument( + "--isn-ospf-dead", type=int, default=40, help="ISN OSPF dead timer, seconds (default: 40; Tier-3, PR-20)" + ) + p_new.add_argument( + "--isn-bfd-min-rx", type=int, default=50, + help="ISN BFD min-rx timer, ms; store-only, no bfdIfP MO built (default: 50; Tier-3, PR-20)" + ) + p_new.add_argument( + "--isn-bfd-min-tx", type=int, default=50, + help="ISN BFD min-tx timer, ms; store-only, no bfdIfP MO built (default: 50; Tier-3, PR-20)" + ) + p_new.add_argument( + "--isn-bfd-multiplier", type=int, default=3, + help="ISN BFD detect multiplier, 1-50; store-only, no bfdIfP MO built (default: 3; Tier-3, PR-20)" + ) + p_new.add_argument( + "--default-bd-mac", default="00:22:BD:F8:19:FF", + help="Fabric-wide default BD gateway MAC (default: 00:22:BD:F8:19:FF, the real ACI default; Tier-3, PR-20)" + ) + p_new.add_argument("-o", "--output", default=None, help="Write to this file instead of stdout") + p_new.set_defaults(func=cmd_new) + + p_init = sub.add_parser( + "init", help="Interactive APIC-setup-style wizard: Q&A, then write + validate a topology.yaml." + ) + p_init.add_argument("-o", "--output", default=None, help="Write to this path (default: ./topology.yaml)") + p_init.add_argument( + "--defaults", action="store_true", + help="Accept every suggested default without prompting (also auto-enabled when stdin is not a TTY)" + ) + p_init.add_argument( + "--answers", default=None, + help="Path to a YAML/JSON file of pre-filled answers (field name -> value); " + "unlisted fields fall back to prompting/defaults as usual" + ) + p_init.set_defaults(func=cmd_init) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/aci_sim/control/__init__.py b/aci_sim/control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/control/admin.py b/aci_sim/control/admin.py new file mode 100644 index 0000000..14ef1bd --- /dev/null +++ b/aci_sim/control/admin.py @@ -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 diff --git a/aci_sim/control/persist.py b/aci_sim/control/persist.py new file mode 100644 index 0000000..90f8df3 --- /dev/null +++ b/aci_sim/control/persist.py @@ -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)) diff --git a/aci_sim/graph.py b/aci_sim/graph.py new file mode 100644 index 0000000..7eb41fd --- /dev/null +++ b/aci_sim/graph.py @@ -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 +`...` 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'{escape(line)}' + ) + return ( + f'' + f'' + + "".join(text_parts) + + "" + ) + + +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'' + + +def _legend_svg(x: float, y: float, roles_present: list[str]) -> str: + parts = [f''] + swatch = 14 + gap = 150 + for i, role in enumerate(roles_present): + fill, border = ROLE_COLORS[role] + lx = i * gap + parts.append( + f'' + f'{escape(LEGEND_LABELS[role])}' + ) + parts.append("") + return "".join(parts) + + +def _svg_style() -> str: + return ( + "" + ) + + +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'{escape(graph.fabric_name)} — Topology' + f'{escape(subtitle)}' + ) + + legend_y = height - LEGEND_H + 16 + legend_svg = _legend_svg(MARGIN, legend_y, roles_present) + + body = ( + f'' + + _svg_style() + + f'' + + title_svg + + links_svg + + nodes_svg + + legend_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 `...` markup. + fmt="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'{title}' + "" + f"{svg}" + ) diff --git a/aci_sim/mit/__init__.py b/aci_sim/mit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/mit/dn.py b/aci_sim/mit/dn.py new file mode 100644 index 0000000..d45b048 --- /dev/null +++ b/aci_sim/mit/dn.py @@ -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 diff --git a/aci_sim/mit/mo.py b/aci_sim/mit/mo.py new file mode 100644 index 0000000..4e10438 --- /dev/null +++ b/aci_sim/mit/mo.py @@ -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})" diff --git a/aci_sim/mit/store.py b/aci_sim/mit/store.py new file mode 100644 index 0000000..6386016 --- /dev/null +++ b/aci_sim/mit/store.py @@ -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 diff --git a/aci_sim/ndo/__init__.py b/aci_sim/ndo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/ndo/app.py b/aci_sim/ndo/app.py new file mode 100644 index 0000000..5f3d8dd --- /dev/null +++ b/aci_sim/ndo/app.py @@ -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 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, + : {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 `-` 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 diff --git a/aci_sim/ndo/deploy_mirror.py b/aci_sim/ndo/deploy_mirror.py new file mode 100644 index 0000000..04eef0f --- /dev/null +++ b/aci_sim/ndo/deploy_mirror.py @@ -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//templates//bds/` -> 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-` 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-` 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 diff --git a/aci_sim/ndo/model.py b/aci_sim/ndo/model.py new file mode 100644 index 0000000..454022b --- /dev/null +++ b/aci_sim/ndo/model.py @@ -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://`` URL — exactly how real NDO + reports controller URLs, and what autoACI's discovery expects. Otherwise it + advertises ``https://`` (the 127.0.0.1: 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, + ) diff --git a/aci_sim/ndo/patch.py b/aci_sim/ndo/patch.py new file mode 100644 index 0000000..3564e84 --- /dev/null +++ b/aci_sim/ndo/patch.py @@ -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 diff --git a/aci_sim/query/__init__.py b/aci_sim/query/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/query/engine.py b/aci_sim/query/engine.py new file mode 100644 index 0000000..edba3fe --- /dev/null +++ b/aci_sim/query/engine.py @@ -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) diff --git a/aci_sim/query/filters.py b/aci_sim/query/filters.py new file mode 100644 index 0000000..5839fec --- /dev/null +++ b/aci_sim/query/filters.py @@ -0,0 +1,267 @@ +"""Parse APIC filter expressions into a callable predicate ``fn(MO) -> bool``. + +Supported grammar (CONTRACT §4): + + eq(., "") equal + ne(., "") not equal + wcard(., "") substring containment + gt|lt|ge|le(., "") numeric compare (string fallback) + bw(., "", "") inclusive range + and(, , ...) + or(, , ...) + +Rules: +- Nesting is allowed: ``and(or(...), eq(...))`` +- The ``.`` 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 "" + 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: + # (., "") + 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(., "", "") + 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 diff --git a/aci_sim/rest_aci/__init__.py b/aci_sim/rest_aci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/rest_aci/app.py b/aci_sim/rest_aci/app.py new file mode 100644 index 0000000..e4fa71a --- /dev/null +++ b/aci_sim/rest_aci/app.py @@ -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= — 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 — real APIC's subscription websocket path, where + # 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 diff --git a/aci_sim/rest_aci/auth.py b/aci_sim/rest_aci/auth.py new file mode 100644 index 0000000..cc1ae4a --- /dev/null +++ b/aci_sim/rest_aci/auth.py @@ -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:\" (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:\\` or `\\` 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 diff --git a/aci_sim/rest_aci/subscriptions.py b/aci_sim/rest_aci/subscriptions.py new file mode 100644 index 0000000..842133e --- /dev/null +++ b/aci_sim/rest_aci/subscriptions.py @@ -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` where `` 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":[,...],"imdata":[{:{"attributes":{..., + "status":"created|modified|deleted","dn":...}}}]}`. + 4. **Refresh**: `GET /api/subscriptionRefresh.json?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=. 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` 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", "") — class query / node-class fabric-wide form + - ("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) diff --git a/aci_sim/rest_aci/writes.py b/aci_sim/rest_aci/writes.py new file mode 100644 index 0000000..88f662b --- /dev/null +++ b/aci_sim/rest_aci/writes.py @@ -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: {"": {"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 diff --git a/aci_sim/runtime/__init__.py b/aci_sim/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/runtime/config.py b/aci_sim/runtime/config.py new file mode 100644 index 0000000..988eecf --- /dev/null +++ b/aci_sim/runtime/config.py @@ -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")) diff --git a/aci_sim/runtime/supervisor.py b/aci_sim/runtime/supervisor.py new file mode 100644 index 0000000..2e5ba5a --- /dev/null +++ b/aci_sim/runtime/supervisor.py @@ -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() diff --git a/aci_sim/topology/__init__.py b/aci_sim/topology/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aci_sim/topology/loader.py b/aci_sim/topology/loader.py new file mode 100644 index 0000000..a953697 --- /dev/null +++ b/aci_sim/topology/loader.py @@ -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) diff --git a/aci_sim/topology/schema.py b/aci_sim/topology/schema.py new file mode 100644 index 0000000..6bb4010 --- /dev/null +++ b/aci_sim/topology/schema.py @@ -0,0 +1,1021 @@ +""" +Pydantic v2 topology models for aci-sim. + +Node-ID numbering scheme (deterministic, globally unique within a Topology): + Regular leaves : base=101, site_offset=200 → site-0: 101,102,… site-1: 301,302,… + Spines : base=201, site_offset=200 → site-0: 201,202,… site-1: 401,402,… + Border leaves : always explicitly specified in the YAML (no auto-generation). + +A site_offset of 200 guarantees no collision between any node type across the +first four sites (max ~40 leaves + ~40 spines per site before the ranges touch). +Border-leaf IDs must be chosen outside those ranges or they will be caught by +the cross-site collision check in Topology.normalize_and_validate. + +All YAML field names match the Python attribute names except: + - FilterEntry: uses `from_port`/`to_port`/`ether_type` (DESIGN used `from`/`to`/`ether`, + which are Python reserved keywords or ambiguous abbreviations). + - VlanPool: uses `from_vlan`/`to_vlan` for the same reason. +""" + +from __future__ import annotations + +import ipaddress +import re + +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator + +# --------------------------------------------------------------------------- +# Leaf-level value objects +# --------------------------------------------------------------------------- + + +class Node(BaseModel): + """A single fabric node (spine, leaf, or border-leaf) with inventory attrs. + + `version` feeds both fabricNode.version and topSystem.version (CONTRACT §6). + Auto-expanded nodes inherit the default; explicit node dicts may override it. + """ + + id: int + name: str + model: str = "N9K-C9332C" + serial: str = "" + version: str = "n9000-14.2(7f)" + + +class CSW(BaseModel): + """Core-switch peer config attached to a border-leaf interface.""" + + name: str + asn: int + peer_ip: str # CSW's BGP peer IP (remote) + local_ip: str # border-leaf's own IP on the peering interface + vlan: int + + +class BorderLeaf(BaseModel): + """A border-leaf node; two entries with the same vpc_domain form a vPC pair.""" + + id: int + name: str + vpc_domain: str # shared by the two nodes in a vPC pair + csw: CSW + + +class Fabric(BaseModel): + """Fabric-wide settings + address spaces the underlay/overlay builders draw from. + + Address derivation the builders SHOULD use (documented here so it is one place): + - Node loopback (router-id / topSystem.address / EVPN peer source): + 10...1/32 drawn from `loopback_pool` (default 10.0.0.0/16) + - OOB mgmt (topSystem.oobMgmtAddr): + 192.168../24 drawn from `oob_pool` (default 192.168.0.0/16) + - ISN inter-site EVPN peer (bgpPeer.addr, always a /32): + the remote spine's loopback /32 (same 10...1 rule). + Pools are strings (CIDR) so a YAML author can move the whole address space at once. + """ + + name: str + evpn_rr: str = "spine" # which role acts as BGP-EVPN route-reflectors; see build/overlay.py::_route_reflectors + # loopback_pool/oob_pool (#27): the address-derivation scheme in build/fabric.py + # (loopback_ip/oob_ip) is hardcoded to the "10...1" / "192.168. + # ." families documented there — it is NOT parameterized by an + # arbitrary CIDR (6+ call sites across build/*.py call loopback_ip(pod, node_id) + # with no pool argument, and multiple tests assert the literal 10.x/192.168.x + # output). Rather than leave these fields purely cosmetic, normalize_and_validate + # below enforces that they match the base network the builder actually produces, + # so a topology author who edits them gets a clear error instead of silent + # config/behavior divergence. + loopback_pool: str = "10.0.0.0/16" # spine/leaf loopbacks + ISN /32 peers + oob_pool: str = "192.168.0.0/16" # topSystem.oobMgmtAddr space + ndo_mgmt_ip: str = "10.192.0.10" # sandbox: NDO/ND IP (served on :443 in sandbox mode) + + # ---- Tier-1 fabric parameters (PR-18) -------------------------------- + # These describe real ACI infra address-space knobs that Ansible/aci-py + # fidelity depends on (e.g. `cisco.aci.aci_fabric_node`'s serial, infra + # VLAN policy, etc.). All optional + defaulted so existing topology.yaml + # files keep validating and producing byte-identical builder output. + # + # tep_pool: the fabric's infra TEP (Tunnel End-Point) address space — + # real ACI default is 10.0.0.0/16. No builder currently derives a + # per-node address FROM this pool (loopback_ip()/oob_ip() above are the + # actual address source, per the loopback_pool/oob_pool note); tep_pool + # is stored + validated + surfaced (aci-sim show/new) so it is available + # for a topology author to record and for future infra-address wiring, + # rather than silently absent from the schema. See normalize_and_validate + # for the CIDR check. + tep_pool: str = "10.0.0.0/16" + # infra_vlan: the fabric infrastructure VLAN (ACI's dedicated VLAN + # carrying VXLAN/iVXLAN + APIC↔switch discovery traffic). Real ACI + # default is 3967; valid range is 1-4094. Store + validate + surface; + # no MIT object in this sim currently carries an infra-VLAN attribute + # to wire it into (see docs note in normalize_and_validate). + infra_vlan: int = 3967 + # gipo_pool: the multicast GIPo (Group IP outer) pool ACI uses for + # BUM traffic replication per bridge-domain. Real ACI default range is + # 225.0.0.0/15. Store + validate + surface. + gipo_pool: str = "225.0.0.0/15" + + # ---- Tier-3 fine-tuning parameters (PR-20) --------------------------- + # oob_subnet: an explicit CIDR describing the OOB mgmt address space, in + # addition to the existing `oob_pool` (#27, above). build/fabric.py's + # `oob_ip()` derives every node's OOB address from `(pod, node_id)` + # only — it does not consume an arbitrary pool/subnet CIDR (same + # documented limitation as `oob_pool` itself). `oob_subnet` is stored + + # validated (must fall within `192.168.0.0/16`, mirroring `oob_pool`'s + # own validation) so a topology author can record the *intended* OOB + # subnet distinctly from the pool `oob_ip()` actually draws from, and it + # is surfaced in `aci-sim show`. Default `None` (omitted) is fully + # backward compatible — when unset, `aci-sim show` falls back to + # displaying `oob_pool` as before. + oob_subnet: str | None = None + # inb_subnet: the fabric's in-band (INB) management address space. This + # sim has NO `mgmtInB`/`inbMgmtAddr`-style MO anywhere in build/*.py + # (grepped: only OOB — `topSystem.oobMgmtAddr` — is built); real ACI's + # in-band EPG/bridge-domain (`mgmtInB`, in the special `mgmt` tenant) is + # out of scope for this PR (would require a new `mgmt` tenant + `mgmtInB` + # + `mgmtRsMgmtBD` builder, a materially bigger addition than "wire an + # existing attr"). Store + validate (must fall within a private RFC1918 + # range) + surface only — explicitly STORE-ONLY, no MO reflects it. + inb_subnet: str | None = None + # default_bd_mac: fabric-wide default gateway MAC for every `fvBD` that + # doesn't set its own `BD.mac` (below). Real ACI's own default gateway + # MAC is `00:22:BD:F8:19:FF` — build/tenants.py's `_build_bd` already + # hardcoded exactly this literal for every BD pre-PR-20, so this default + # reproduces that value exactly (byte-identical `fvBD.mac` for any BD + # that doesn't override it — true backward compat, not just "a + # plausible-looking default"). + default_bd_mac: str = "00:22:BD:F8:19:FF" + # default_apic_version: fabric-wide override for the APIC controller + # version (`site.apic_version`, PR-18 lineage — used for the controller + # `fabricNode`/`topSystem`/`firmwareCtrlrRunning` in build/fabric.py). + # `site.apic_version` already defaults to `"4.2(7f)"` per-site and is + # already fully wired/independent of switch-node `Node.version`; this + # field lets a topology author set ONE fabric-wide APIC version instead + # of repeating `apic_version:` on every site. Default `None` (omitted) + # means "use each site's own `apic_version`" — unchanged pre-PR-20 + # behavior. When set, it takes precedence over `site.apic_version` for + # every site (build/fabric.py: `topo.fabric.default_apic_version or + # site.apic_version`). + default_apic_version: str | None = None + + +# --------------------------------------------------------------------------- +# Site +# --------------------------------------------------------------------------- + + +class Site(BaseModel): + """Per-site APIC context. + + `spines` and `leaves` accept either: + - an integer N → auto-expanded to N Node objects with deterministic IDs + - a list of node dicts/Node objects → used as-is + + The expansion is triggered by Topology.model_validator (which knows each + site's index). Use `spine_nodes()`, `leaf_nodes()`, `all_nodes()` to + access the expanded lists. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + name: str + id: str + apic_host: str + asn: int + pod: int = 1 + spines: int | list[Node] = 2 + leaves: int | list[Node] = 2 + border_leaves: list[BorderLeaf] = Field(default_factory=list) + cabling: str = "auto" # "auto" = full spine×leaf mesh; "explicit" = cabling section + # controllers: APIC cluster size (role=controller nodes 1..N). Default is + # 1 — DELIBERATE (PR-21): APIC cluster size is irrelevant to Ansible + # playbook deployment testing (a playbook connects to and pushes config + # through exactly ONE APIC endpoint; cluster size only shows up in + # cluster-health/appliance-vector tooling like autoACI's health_score.py, + # which reads infraWiNode across the cluster). Single-APIC-per-site is + # therefore the shipped default; set controllers: 3 (the pre-PR-21 + # default, and real ACI's minimum supported cluster size) on a site that + # specifically needs to exercise multi-controller/cluster-health + # semantics. Validated range 1-5 (real ACI supports 3/5/7 for production + # clusters, but this sim does not enforce odd-only since it never runs + # real cluster consensus — 1-5 covers "single APIC" through "test a + # meaningfully large cluster" without inviting nonsense values). + controllers: int = 1 + # controller_ips: OPTIONAL explicit per-controller OOB mgmt IP list (PR-21). + # If set, entry i (0-indexed) is controller (i+1)'s topSystem.oobMgmtAddr; + # len(controller_ips) must equal `controllers` when both are set (see + # Topology.normalize_and_validate). If NOT set (default, None), OOB IPs + # are auto-derived SEQUENTIALLY starting from this site's `mgmt_ip`: + # controller 1 = mgmt_ip, controller 2 = mgmt_ip + 1, controller 3 = + # mgmt_ip + 2, ... (build/fabric.py `_controller_oob_ip`). The sim's REST + # server always binds to `mgmt_ip` regardless of `controllers` (it never + # runs N separate APIC processes) — `mgmt_ip` is controller-1 / the + # cluster VIP from the REST server's point of view. + controller_ips: list[str] | None = None + apic_version: str = "4.2(7f)" # APIC version (pairs with switch n9000-14.2(7f)) + fabric_name: str = "" # topSystem.fabricDomain; defaults to topo.fabric.name + mgmt_ip: str = "" # sandbox: this APIC's IP (served on :443; NDO advertises it portless) + # oob_gateway (OOB-gateway PR): the OOB management default gateway for + # this site. Real ACI's first-boot dialog collects this same value (see + # `aci-sim init`'s "APIC OOB gateway" prompt, previously discarded — see + # cli.py history) and models it on the real MO + # `uni/tn-mgmt/mgmtp-default/oob-default/rsooBStNode-[]` + # (`mgmtRsOoBStNode.gw`) alongside each node's own OOB address (`.addr`). + # Per-site (not fabric-wide) because a real gateway is a property of the + # site's own OOB subnet/VLAN, matching the wizard's existing per-site + # question. Optional — default `None` is fully backward compatible: a + # topology that never sets this still builds the mgmt-tenant OOB + # scaffolding (build/mgmt.py), just with an empty `gw` attribute on every + # mgmtRsOoBStNode (no gateway is invented/derived — see build/mgmt.py's + # module docstring for why). + oob_gateway: str | None = None + + # Populated by Topology.model_validator after expansion + _spine_nodes: list[Node] = PrivateAttr(default_factory=list) + _leaf_nodes: list[Node] = PrivateAttr(default_factory=list) + + # ------------------------------------------------------------------ # + # Normalisation (called by Topology, not by users directly) # + # ------------------------------------------------------------------ # + + def _expand_nodes(self, site_index: int) -> None: + """Expand integer counts into concrete Node lists. + + ID scheme: + leaf_id = 101 + site_index * 200 + node_index (node_index starts at 0) + spine_id = 201 + site_index * 200 + node_index + """ + if isinstance(self.spines, int): + self._spine_nodes = [ + Node( + id=201 + site_index * 200 + i, + name=f"spine-{201 + site_index * 200 + i}", + ) + for i in range(self.spines) + ] + else: + self._spine_nodes = list(self.spines) + + if isinstance(self.leaves, int): + self._leaf_nodes = [ + Node( + id=101 + site_index * 200 + i, + name=f"leaf-{101 + site_index * 200 + i}", + ) + for i in range(self.leaves) + ] + else: + self._leaf_nodes = list(self.leaves) + + # ------------------------------------------------------------------ # + # Helpers used by builders # + # ------------------------------------------------------------------ # + + def spine_nodes(self) -> list[Node]: + """Expanded spine nodes (call after topology validation).""" + return self._spine_nodes + + def leaf_nodes(self) -> list[Node]: + """Expanded regular-leaf nodes (call after topology validation).""" + return self._leaf_nodes + + def border_leaf_nodes(self) -> list[Node]: + """Border-leaf nodes as Node objects (id/name only).""" + return [Node(id=bl.id, name=bl.name) for bl in self.border_leaves] + + def all_nodes(self) -> list[Node]: + """All nodes: spines + regular leaves + border leaves.""" + return self._spine_nodes + self._leaf_nodes + self.border_leaf_nodes() + + def controller_oob_ips(self) -> list[str]: + """Effective per-controller OOB mgmt IP list, length == self.controllers. + + - If `controller_ips` is explicitly set, return it verbatim (already + length-validated against `controllers` by + Topology.normalize_and_validate). + - Otherwise auto-derive SEQUENTIALLY from `mgmt_ip`: controller 1 = + mgmt_ip, controller 2 = mgmt_ip + 1, controller 3 = mgmt_ip + 2, ... + If `mgmt_ip` is unset (""), falls back to the legacy per-node + `oob_ip(pod, cid)` scheme by returning an empty list — callers + (build/fabric.py) detect this and keep the pre-PR-21 behavior. + """ + if self.controller_ips is not None: + return list(self.controller_ips) + if not self.mgmt_ip: + return [] + base = ipaddress.ip_address(self.mgmt_ip) + return [str(base + i) for i in range(self.controllers)] + + +# --------------------------------------------------------------------------- +# ISN +# --------------------------------------------------------------------------- + + +class ISN(BaseModel): + enabled: bool = True + transit_asn: int | None = None # optional transit ASN for ISN underlay + peer_mesh: str = "full" # "full" = every spine peers every remote spine; "partial" is + # validated-and-rejected (see build/overlay.py) — not implemented. + + # ---- Tier-2 ISN parameters (PR-19) ------------------------------------ + # ospf_area: the OSPF area spines run toward the IPN/ISN on their + # dedicated uplink (build/underlay.py's ospfIf/ospfAdjEp). Real ACI + # default is the backbone area "0.0.0.0" (some deployments write the + # decimal-integer form "0" — both are accepted; ACI itself renders area + # "0.0.0.0" or "0" interchangeably in ospfIf.area, and autoACI's + # fabric_ospf.py reads the field as an opaque string so both round-trip). + # Threaded into ospfIf.area in build/underlay.py instead of the prior + # hardcoded "0.0.0.0" literal. + ospf_area: str = "0.0.0.0" + # mtu: the ISN/IPN inter-pod/inter-site uplink MTU. Real ACI's default + # inter-pod-network / inter-site MTU is 9150 (accounts for VXLAN + + # outer-header overhead vs. the fabric's own 9216 intra-fabric MTU). + # Threaded into the ISN uplink l1PhysIf.mtu in build/interfaces.py + # instead of that module's general fabric-port mtu="9216" literal. + mtu: int = 9150 + + # ---- Tier-3 fine-tuning parameters (PR-20) ------------------------ + # ospf_hello / ospf_dead: the spine<->IPN/ISN OSPF adjacency's + # hello/dead timers (real ACI defaults: hello 10s, dead 40s — the + # standard OSPF 4x-hello-interval dead-timer relationship). Threaded + # into `ospfIf`'s `helloIntvl`/`deadIntvl` attrs in build/underlay.py + # (that MO is already built for `isn.ospf_area`, PR-19 — this PR adds + # two more attrs to the same object rather than hardcoding them). + ospf_hello: int = 10 + ospf_dead: int = 40 + # bfd_min_rx / bfd_min_tx / bfd_multiplier: BFD session timers (real ACI + # defaults: 50ms/50ms, multiplier 3). This sim builds NO `bfdIfP`/ + # `bfdRsIfPol`-style MO anywhere (grepped build/*.py — zero matches for + # "bfd"/"Bfd"/"BFD"); adding a faithful bfdIfP would additionally require + # a believable `bfdIfStats`/oper-state tree with no verified consumer to + # anchor its shape against. Store + validate + surface only — explicitly + # STORE-ONLY, no MO reflects these three fields. + bfd_min_rx: int = 50 + bfd_min_tx: int = 50 + bfd_multiplier: int = 3 + + +# --------------------------------------------------------------------------- +# Tenant sub-models +# --------------------------------------------------------------------------- + + +class VRF(BaseModel): + name: str + + +class BD(BaseModel): + name: str + vrf: str + subnets: list[str] = Field(default_factory=list) # CIDR strings + l2stretch: bool = False + # mac (Tier-3, PR-20): per-BD override of the default-gateway MAC + # (`fvBD.mac`). Optional — `None` means "use `fabric.default_bd_mac`" + # (build/tenants.py: `bd.mac or topo.fabric.default_bd_mac`), which + # itself defaults to the real ACI default `00:22:BD:F8:19:FF` (the + # literal this sim already hardcoded for every BD pre-PR-20). + mac: str | None = None + + +class FilterEntry(BaseModel): + """A single contract filter entry. + + Field names use `from_port`/`to_port`/`ether_type` instead of the DESIGN's + abbreviated `from`/`to`/`ether` because `from` is a Python reserved keyword. + """ + + proto: str = "tcp" + from_port: str = "unspecified" + to_port: str = "unspecified" + ether_type: str = "ip" + + +class Filter(BaseModel): + name: str + entries: list[FilterEntry] = Field(default_factory=list) + + +class Contract(BaseModel): + name: str + scope: str = "context" + filters: list[Filter] = Field(default_factory=list) + + +class ContractRefs(BaseModel): + provide: list[str] = Field(default_factory=list) + consume: list[str] = Field(default_factory=list) + + +class EPG(BaseModel): + name: str + bd: str + contracts: ContractRefs = Field(default_factory=ContractRefs) + + +class AP(BaseModel): + name: str + epgs: list[EPG] = Field(default_factory=list) + + +class ExtEPG(BaseModel): + name: str + subnets: list[str] = Field(default_factory=list) + + +class CswPeer(BaseModel): + """Summary CSW peer info attached to an L3Out (name + BGP peering details).""" + + name: str + asn: int + peer_ip: str + # keepalive / hold (Tier-3, PR-20): the eBGP session's keepalive/hold + # timers (real ACI defaults: 60s/180s, the standard 1:3 keepalive:hold + # ratio). Threaded into `bgpPeerP` (build/l3out.py's + # `_build_node_profile`, the control-plane BGP peer policy object) as + # its `ctrl`-adjacent timer attrs. Optional — default matches the value + # this sim's `bgpPeerP`/`bgpPeer` MOs implicitly carried pre-PR-20 (real + # ACI's own out-of-the-box default), so an unset `csw_peer` produces the + # same session timers as before. + keepalive: int = 60 + hold: int = 180 + + +class L3Out(BaseModel): + name: str + vrf: str + site: str | None = None # optional explicit site association (validated if set) + border_leaves: list[int] = Field(default_factory=list) # border-leaf node IDs + csw_peer: CswPeer | None = None + ext_epgs: list[ExtEPG] = Field(default_factory=list) + + +class Tenant(BaseModel): + name: str + stretch: bool = False + sites: list[str] = Field(default_factory=list) + vrfs: list[VRF] = Field(default_factory=list) + bds: list[BD] = Field(default_factory=list) + aps: list[AP] = Field(default_factory=list) + contracts: list[Contract] = Field(default_factory=list) + l3outs: list[L3Out] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Endpoints / Faults / Access +# --------------------------------------------------------------------------- + + +class Endpoints(BaseModel): + per_epg: int = 3 + ip_pool_from_subnet: bool = True + + +class FaultSeed(BaseModel): + severity: str # critical | major | minor | warning + code: str # e.g. "F0532" + node: int # node ID + descr: str + + +class HealthDefaults(BaseModel): + node: int = 95 + tenant: int = 98 + + +class Faults(BaseModel): + seed: list[FaultSeed] = Field(default_factory=list) + health_defaults: HealthDefaults = Field(default_factory=HealthDefaults) + + +class VlanPool(BaseModel): + """VLAN pool. + + Uses `from_vlan`/`to_vlan` instead of `from`/`to` (DESIGN notation) because + `from` is a Python reserved keyword. + """ + + name: str + from_vlan: int + to_vlan: int + + +class PhysDomain(BaseModel): + name: str + + +class L3Domain(BaseModel): + """L3 external domain; the l3out builder binds l3extRsL3DomAtt(tDn) to it.""" + + name: str + + +class AAEP(BaseModel): + name: str + + +class VMMDomain(BaseModel): + """VMM domain (vmmDomP); optional vCenter connection attrs (Tier-2, PR-19). + + All connection attrs are optional + defaulted so a bare `{name: ...}` + entry (or an omitted `vmm_domains` list entirely) keeps validating — + the sim seeds no VMM domain by default (unlike phys/l3 domains/AAEPs, + which have a default single-entry list), since no existing builder or + production chain (autoACI e2e / aci-py MS-TN1) reads a vmmDomP/vmmCtrlrP + MIT object today (see docs/DESIGN.md PR-19 note — `bind_epg_to_vmm_domain` + in the real aci-py chain writes an NDO schema `domainAssociations` entry + referencing the domain purely by DN string, never touching an APIC-side + vmmDomP). Populating these fields lets `aci-sim show`/`new` surface a + vCenter-backed VMM domain and produces a matching vmmCtrlrP/vmmProvP tree + in build/access.py for a topology author who wants one, without any + existing flow depending on it. + """ + + name: str + vcenter_ip: str = "" # vmmCtrlrP.hostOrIp; empty = no vmmCtrlrP emitted + vcenter_dvs: str = "" # DVS name (informational; stored on vmmCtrlrP.name-adjacent attr) + vlan_pool: str = "" # name of a topo.access.vlan_pools[] entry to bind (dynamic pool) + datacenter: str = "" # vCenter datacenter; vmmCtrlrP.rootContName + + +class Access(BaseModel): + vlan_pools: list[VlanPool] = Field( + default_factory=lambda: [VlanPool(name="vlan-pool-1", from_vlan=100, to_vlan=200)] + ) + phys_domains: list[PhysDomain] = Field( + default_factory=lambda: [PhysDomain(name="phys-dom-1")] + ) + l3_domains: list[L3Domain] = Field( + default_factory=lambda: [L3Domain(name="l3dom-1")] + ) + aaeps: list[AAEP] = Field(default_factory=lambda: [AAEP(name="aaep-1")]) + # vmm_domains: Tier-2 (PR-19) — optional, defaults to an EMPTY list (unlike + # the other access collections above, which default to one seeded entry). + # No existing builder/topology.yaml/production-chain references a VMM + # domain by name today, so an empty default is the true backward-compat + # baseline: omitting `access.vmm_domains` entirely produces byte-identical + # builder output to pre-PR-19. + vmm_domains: list[VMMDomain] = Field(default_factory=list) + + +class Auth(BaseModel): + """Fabric admin credentials, set by the `aci-sim init` wizard's Admin + account step. + + The APIC plane ENFORCES these — `rest_aci/auth.py`'s `aaaLogin` 401s on a + username/password mismatch, resolved into `SIM_USERNAME`/`SIM_PASSWORD` + by `cli.py`'s `cmd_run` (topology `auth:` loses to an explicit + `SIM_USERNAME`/`SIM_PASSWORD` already set in the caller's environment — + see `build_run_env_argv`'s docstring for the exact precedence). The NDO + plane (`ndo/app.py`) is lenient BY DESIGN — it accepts any credential + (client-compat for cisco.mso/aci-py) and is not affected by this model at + all. `ndo_username`/`ndo_password` exist only so an operator can + deliberately give NDO a different (informational-only, since NDO never + checks it) account than APIC; `None` (the default) means "same account as + APIC", which is what the wizard offers by default. + """ + + username: str = "admin" + password: str = "cisco" + ndo_username: str | None = None + ndo_password: str | None = None + + +# --------------------------------------------------------------------------- +# Top-level Topology +# --------------------------------------------------------------------------- + + +class Topology(BaseModel): + """Root topology model. Load via `topology.loader.load_topology(path)`.""" + + fabric: Fabric + sites: list[Site] + isn: ISN = Field(default_factory=ISN) + tenants: list[Tenant] = Field(default_factory=list) + endpoints: Endpoints = Field(default_factory=Endpoints) + faults: Faults = Field(default_factory=Faults) + access: Access = Field(default_factory=Access) + # auth: optional fabric admin credentials (see Auth docstring above). + # None (the default) means no `auth:` section was set — every topology.yaml + # predating this field keeps validating and behaves exactly as before: + # `cmd_run` injects nothing and auth.py falls back to its own + # SIM_USERNAME/SIM_PASSWORD env-or-admin/cisco default. + auth: Auth | None = None + + @model_validator(mode="after") + def normalize_and_validate(self) -> Topology: + """Expand int counts → Node lists, then check all cross-references.""" + # Step 1 — expand + for idx, site in enumerate(self.sites): + site._expand_nodes(site_index=idx) + + # Step 1b — controllers range + controller_ips (PR-21). APIC cluster + # size is configurable 1-5 (default 1, single-APIC-per-site by + # design — see Site.controllers docstring); controller_ips, if set, + # must be a valid-IP list of exactly `controllers` entries. + for site in self.sites: + if not (1 <= site.controllers <= 5): + raise ValueError( + f"Site '{site.name}': controllers={site.controllers} is out of range. " + f"Must be 1-5 (default 1 — single APIC per site by design; set 3 for " + f"real ACI's minimum multi-controller cluster size if you need to " + f"exercise cluster-health/infraWiNode semantics)." + ) + if site.controller_ips is not None: + if len(site.controller_ips) != site.controllers: + raise ValueError( + f"Site '{site.name}': controller_ips has {len(site.controller_ips)} " + f"entries but controllers={site.controllers}. When controller_ips is " + f"set it must have exactly one IP per controller." + ) + for ip in site.controller_ips: + try: + ipaddress.ip_address(ip) + except ValueError as exc: + raise ValueError( + f"Site '{site.name}': controller_ips entry {ip!r} is not a valid IP " + f"address: {exc}" + ) from exc + + # oob_gateway (OOB-gateway PR): if set, must be a valid IP address. + # Optional (None = no gateway configured for this site — see + # Site.oob_gateway docstring); no further range/subnet check is + # enforced here (the gateway may legitimately sit outside + # fabric.oob_pool/oob_subnet, e.g. a shared OOB VLAN gateway that + # predates this sim's address scheme). + if site.oob_gateway is not None: + try: + ipaddress.ip_address(site.oob_gateway) + except ValueError as exc: + raise ValueError( + f"Site '{site.name}': oob_gateway {site.oob_gateway!r} is not a valid " + f"IP address: {exc}" + ) from exc + + # Step 2 — collect global node/border identity sets (used by several checks) + site_names = {s.name for s in self.sites} + all_node_ids: list[int] = [] # every node id across every site (with dups) + border_leaf_ids: set[int] = set() # all border-leaf ids across all sites + for site in self.sites: + all_node_ids.extend(n.id for n in site.all_nodes()) + border_leaf_ids.update(bl.id for bl in site.border_leaves) + node_id_set = set(all_node_ids) + + # Global node-id uniqueness — border-leaf ids must NOT collide with the + # auto-generated spine/leaf ids, and no two sites may share a node id. + duplicates = sorted({nid for nid in all_node_ids if all_node_ids.count(nid) > 1}) + if duplicates: + raise ValueError( + f"Node ids collide across sites (border-leaf vs auto-generated spine/leaf, " + f"or two sites overlapping): {duplicates}. " + f"Adjust border_leaf ids or the site offset." + ) + + # Step 3 — cross-reference validation + for tenant in self.tenants: + vrf_names = {v.name for v in tenant.vrfs} + bd_names = {b.name for b in tenant.bds} + contract_names = {c.name for c in tenant.contracts} + + # tenant.sites → real site names + for sname in tenant.sites: + if sname not in site_names: + raise ValueError( + f"Tenant '{tenant.name}' references unknown site '{sname}'. " + f"Known sites: {sorted(site_names)}" + ) + + # stretched tenant must name ≥2 sites + if tenant.stretch and len(tenant.sites) < 2: + raise ValueError( + f"Tenant '{tenant.name}' has stretch=true but lists fewer than 2 sites: " + f"{tenant.sites}" + ) + + # BD.vrf → tenant VRFs + for bd in tenant.bds: + if bd.vrf not in vrf_names: + raise ValueError( + f"Tenant '{tenant.name}', BD '{bd.name}' references unknown VRF " + f"'{bd.vrf}'. Known VRFs: {sorted(vrf_names)}" + ) + + # EPG.bd → tenant BDs + EPG contract refs → tenant contracts + for ap in tenant.aps: + for epg in ap.epgs: + if epg.bd not in bd_names: + raise ValueError( + f"Tenant '{tenant.name}', AP '{ap.name}', EPG '{epg.name}' " + f"references unknown BD '{epg.bd}'. Known BDs: {sorted(bd_names)}" + ) + for cname in epg.contracts.provide + epg.contracts.consume: + if cname not in contract_names: + raise ValueError( + f"Tenant '{tenant.name}', AP '{ap.name}', EPG '{epg.name}' " + f"references unknown contract '{cname}'. " + f"Known contracts: {sorted(contract_names)}" + ) + + # L3Out cross-refs: vrf, optional site, border-leaf ids + for l3out in tenant.l3outs: + if l3out.vrf not in vrf_names: + raise ValueError( + f"Tenant '{tenant.name}', L3Out '{l3out.name}' references unknown VRF " + f"'{l3out.vrf}'. Known VRFs: {sorted(vrf_names)}" + ) + if l3out.site is not None and l3out.site not in site_names: + raise ValueError( + f"Tenant '{tenant.name}', L3Out '{l3out.name}' references unknown site " + f"'{l3out.site}'. Known sites: {sorted(site_names)}" + ) + for bl_id in l3out.border_leaves: + if bl_id not in border_leaf_ids: + raise ValueError( + f"Tenant '{tenant.name}', L3Out '{l3out.name}' references border-leaf " + f"id {bl_id} which is not a border-leaf of any site. " + f"Known border-leaf ids: {sorted(border_leaf_ids)}" + ) + + # Fault seeds must target a real node id + for fseed in self.faults.seed: + if fseed.node not in node_id_set: + raise ValueError( + f"Fault seed (code {fseed.code}) targets node {fseed.node} which is not a " + f"real node id. Known node ids: {sorted(node_id_set)}" + ) + + # ISN peer_mesh:full requires ≥2 sites + if self.isn.enabled and self.isn.peer_mesh == "full" and len(self.sites) < 2: + raise ValueError("ISN peer_mesh:'full' requires at least 2 sites.") + + # fabric.loopback_pool/oob_pool (#27): builders hardcode the "10.x"/ + # "192.168.x" families (see build/fabric.py loopback_ip/oob_ip) — the + # pool fields are not threaded through as an arbitrary CIDR, so make + # sure they at least describe the network the builder actually uses, + # instead of being silently ignorable config drift. + try: + loopback_net = ipaddress.ip_network(self.fabric.loopback_pool, strict=False) + except ValueError as exc: + raise ValueError( + f"fabric.loopback_pool {self.fabric.loopback_pool!r} is not a valid CIDR: {exc}" + ) from exc + if not loopback_net.overlaps(ipaddress.ip_network("10.0.0.0/8")): + raise ValueError( + f"fabric.loopback_pool {self.fabric.loopback_pool!r} must fall within " + f"10.0.0.0/8 — the builder's loopback_ip() always emits " + f"10...1-style addresses (build/fabric.py) and does not " + f"honor an arbitrary pool." + ) + try: + oob_net = ipaddress.ip_network(self.fabric.oob_pool, strict=False) + except ValueError as exc: + raise ValueError( + f"fabric.oob_pool {self.fabric.oob_pool!r} is not a valid CIDR: {exc}" + ) from exc + if not oob_net.overlaps(ipaddress.ip_network("192.168.0.0/16")): + raise ValueError( + f"fabric.oob_pool {self.fabric.oob_pool!r} must fall within " + f"192.168.0.0/16 — the builder's oob_ip() always emits " + f"192.168..-style addresses (build/fabric.py) and does " + f"not honor an arbitrary pool." + ) + + # Tier-1 fabric params (PR-18): tep_pool/gipo_pool must be valid CIDRs; + # infra_vlan must fall in the real ACI VLAN range (1-4094). None of + # these are threaded into a builder's address-derivation the way + # loopback_pool/oob_pool partially are (see Fabric docstring above), + # so this is a pure well-formedness check — it exists to catch a + # typo'd CIDR/VLAN before it reaches `aci-sim show`/CI, not to + # enforce agreement with a builder's hardcoded output. + try: + ipaddress.ip_network(self.fabric.tep_pool, strict=False) + except ValueError as exc: + raise ValueError( + f"fabric.tep_pool {self.fabric.tep_pool!r} is not a valid CIDR: {exc}" + ) from exc + try: + ipaddress.ip_network(self.fabric.gipo_pool, strict=False) + except ValueError as exc: + raise ValueError( + f"fabric.gipo_pool {self.fabric.gipo_pool!r} is not a valid CIDR: {exc}" + ) from exc + if not (1 <= self.fabric.infra_vlan <= 4094): + raise ValueError( + f"fabric.infra_vlan {self.fabric.infra_vlan!r} must be in the range " + f"1-4094 (real ACI VLAN ID range)." + ) + + # Tier-2 ISN params (PR-19): ospf_area must be a valid OSPF area + # (dotted-decimal like an IPv4 address, e.g. "0.0.0.0", or the + # decimal-integer shorthand ACI also accepts, e.g. "0"); mtu must + # fall within real ACI's inter-pod/inter-site MTU range (576-9216, + # matching the fabric's own l1PhysIf.mtu ceiling in interfaces.py). + area = self.isn.ospf_area + area_ok = False + if area.isdigit(): + area_ok = True # decimal-integer area form (e.g. "0") + else: + try: + ipaddress.IPv4Address(area) + area_ok = True # dotted-decimal area form (e.g. "0.0.0.0") + except ValueError: + area_ok = False + if not area_ok: + raise ValueError( + f"isn.ospf_area {area!r} must be a dotted-decimal OSPF area " + f"(e.g. '0.0.0.0') or a decimal-integer area (e.g. '0')." + ) + if not (576 <= self.isn.mtu <= 9216): + raise ValueError( + f"isn.mtu {self.isn.mtu!r} must be in the range 576-9216 " + f"(real ACI inter-pod/inter-site MTU range)." + ) + + # Tier-3 fine-tuning params (PR-20) ------------------------------ + # OSPF hello/dead: both must be positive, and (matching real ACI's + # own OSPF sanity check) dead must exceed hello — a dead timer + # shorter than (or equal to) the hello interval can never detect a + # live neighbor before declaring it down. + if self.isn.ospf_hello <= 0 or self.isn.ospf_dead <= 0: + raise ValueError( + f"isn.ospf_hello ({self.isn.ospf_hello}) and isn.ospf_dead " + f"({self.isn.ospf_dead}) must both be positive integers (seconds)." + ) + if self.isn.ospf_dead <= self.isn.ospf_hello: + raise ValueError( + f"isn.ospf_dead ({self.isn.ospf_dead}) must be greater than " + f"isn.ospf_hello ({self.isn.ospf_hello}) — a dead timer at or " + f"below the hello interval can never detect a live neighbor." + ) + # BFD timers: store+validate only (no bfdIfP MO built anywhere in + # this sim — see ISN docstring). min_rx/min_tx must be positive + # milliseconds; multiplier must be a plausible BFD detect multiplier + # (real ACI UI range is 1-50). + if self.isn.bfd_min_rx <= 0 or self.isn.bfd_min_tx <= 0: + raise ValueError( + f"isn.bfd_min_rx ({self.isn.bfd_min_rx}) and isn.bfd_min_tx " + f"({self.isn.bfd_min_tx}) must both be positive integers (milliseconds)." + ) + if not (1 <= self.isn.bfd_multiplier <= 50): + raise ValueError( + f"isn.bfd_multiplier ({self.isn.bfd_multiplier}) must be in the " + f"range 1-50 (real ACI BFD detect-multiplier range)." + ) + + # BGP keepalive/hold (per l3out.csw_peer): must be positive, and + # (matching real ACI's own BGP timer sanity check) hold must be at + # least 2x keepalive's typical relationship — real ACI enforces + # hold >= 3 or hold==0; we enforce the simpler, always-correct + # invariant that hold exceeds keepalive (a hold at or below the + # keepalive interval can never survive a single missed keepalive). + for tenant in self.tenants: + for l3out in tenant.l3outs: + if l3out.csw_peer is None: + continue + cp = l3out.csw_peer + if cp.keepalive <= 0 or cp.hold <= 0: + raise ValueError( + f"Tenant '{tenant.name}', L3Out '{l3out.name}': csw_peer " + f"keepalive ({cp.keepalive}) and hold ({cp.hold}) must both " + f"be positive integers (seconds)." + ) + if cp.hold <= cp.keepalive: + raise ValueError( + f"Tenant '{tenant.name}', L3Out '{l3out.name}': csw_peer " + f"hold ({cp.hold}) must be greater than keepalive " + f"({cp.keepalive}) — a hold timer at or below the keepalive " + f"interval can never survive a single missed keepalive." + ) + + # BD MAC / fabric.default_bd_mac: must look like a real MAC address + # (real ACI's fvBD.mac accepts colon-separated hex octets, e.g. + # "00:22:BD:F8:19:FF"). Reject anything else at load time rather + # than silently writing a malformed fvBD.mac attribute. + _mac_re = re.compile(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$") + if not _mac_re.match(self.fabric.default_bd_mac): + raise ValueError( + f"fabric.default_bd_mac {self.fabric.default_bd_mac!r} is not a " + f"valid MAC address (expected colon-separated hex octets, e.g. " + f"'00:22:BD:F8:19:FF')." + ) + for tenant in self.tenants: + for bd in tenant.bds: + if bd.mac is not None and not _mac_re.match(bd.mac): + raise ValueError( + f"Tenant '{tenant.name}', BD '{bd.name}': bd.mac {bd.mac!r} " + f"is not a valid MAC address (expected colon-separated hex " + f"octets, e.g. '00:22:BD:F8:19:FF')." + ) + + # oob_subnet / inb_subnet: pure well-formedness + range checks (both + # are store+validate-only — see Fabric docstring above). oob_subnet + # must fall within 192.168.0.0/16 (same family oob_pool is + # validated against); inb_subnet must be a private RFC1918 range + # (ACI's in-band mgmt EPG is conventionally carved out of RFC1918 + # space, same as OOB) and must be a valid CIDR. + if self.fabric.oob_subnet is not None: + try: + oob_subnet_net = ipaddress.ip_network(self.fabric.oob_subnet, strict=False) + except ValueError as exc: + raise ValueError( + f"fabric.oob_subnet {self.fabric.oob_subnet!r} is not a valid " + f"CIDR: {exc}" + ) from exc + if not oob_subnet_net.overlaps(ipaddress.ip_network("192.168.0.0/16")): + raise ValueError( + f"fabric.oob_subnet {self.fabric.oob_subnet!r} must fall " + f"within 192.168.0.0/16, matching fabric.oob_pool's own " + f"address family." + ) + if self.fabric.inb_subnet is not None: + try: + inb_net = ipaddress.ip_network(self.fabric.inb_subnet, strict=False) + except ValueError as exc: + raise ValueError( + f"fabric.inb_subnet {self.fabric.inb_subnet!r} is not a valid " + f"CIDR: {exc}" + ) from exc + _rfc1918 = ( + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ) + if not any(inb_net.overlaps(net) for net in _rfc1918): + raise ValueError( + f"fabric.inb_subnet {self.fabric.inb_subnet!r} must fall " + f"within a private RFC1918 range (10.0.0.0/8, " + f"172.16.0.0/12, or 192.168.0.0/16)." + ) + + # Tier-2 VMM domain params (PR-19): a VMM domain's `vlan_pool` (if + # set) must reference a real access.vlan_pools[] entry, same + # cross-reference discipline as tenant.bds[].vrf / epg.bd above. + vlan_pool_names = {p.name for p in self.access.vlan_pools} + for vmm in self.access.vmm_domains: + if vmm.vlan_pool and vmm.vlan_pool not in vlan_pool_names: + raise ValueError( + f"access.vmm_domains '{vmm.name}' references unknown vlan_pool " + f"'{vmm.vlan_pool}'. Known vlan pools: {sorted(vlan_pool_names)}" + ) + + # CSW peering params are duplicated between site.border_leaves[].csw and + # tenant.l3outs[].csw_peer (#28) — the border-leaf's `csw` is authoritative + # (it carries local_ip/vlan too, which l3out.csw_peer lacks; l3out.py's + # per-path encap/local_ip/l3extMember addressing comes only from + # border_leaf.csw). Any l3out.csw_peer covering the same border leaves + # must agree with the border-leaf's csw on name/asn/peer_ip, or the + # rendered BGP session (from csw_peer) and the physical sub-interface + # (from border_leaf.csw) would silently describe two different CSWs. + bl_csw_by_id = { + bl.id: bl.csw + for site in self.sites + for bl in site.border_leaves + } + for tenant in self.tenants: + for l3out in tenant.l3outs: + if l3out.csw_peer is None: + continue + for bl_id in l3out.border_leaves: + bl_csw = bl_csw_by_id.get(bl_id) + if bl_csw is None: + continue # already reported by the border-leaf existence check above + mismatch = ( + bl_csw.name != l3out.csw_peer.name + or bl_csw.asn != l3out.csw_peer.asn + or bl_csw.peer_ip != l3out.csw_peer.peer_ip + ) + if mismatch: + raise ValueError( + f"Tenant '{tenant.name}', L3Out '{l3out.name}': csw_peer " + f"{{name={l3out.csw_peer.name!r}, asn={l3out.csw_peer.asn}, " + f"peer_ip={l3out.csw_peer.peer_ip!r}}} does not match border-leaf " + f"{bl_id}'s csw {{name={bl_csw.name!r}, asn={bl_csw.asn}, " + f"peer_ip={bl_csw.peer_ip!r}}}. These must agree — the border-leaf's " + f"csw is authoritative (see docs/DESIGN.md)." + ) + + return self + + # ------------------------------------------------------------------ # + # Topology-level helpers # + # ------------------------------------------------------------------ # + + def site_by_name(self, name: str) -> Site: + """Look up a site by name; raises KeyError if not found.""" + for site in self.sites: + if site.name == name: + return site + raise KeyError(f"Site '{name}' not found in topology") + + def other_site(self, name: str) -> Site: + """Return the first site that is NOT `name`. + + Useful for ISN remote-ASN lookup in a 2-site topology. + Raises KeyError if no other site exists. + """ + others = [s for s in self.sites if s.name != name] + if not others: + raise KeyError(f"No site other than '{name}' in topology") + return others[0] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..c4c4d64 --- /dev/null +++ b/deploy/README.md @@ -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 ~/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 + + + + + Label + com.aci-sim.supervisor + + WorkingDirectory + /Users/YOURUSER/aci-sim + + ProgramArguments + + /Users/YOURUSER/aci-sim/.venv/bin/python + -m + aci_sim.runtime.supervisor + + + EnvironmentVariables + + + + + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + /tmp/aci-sim.log + StandardErrorPath + /tmp/aci-sim.log + + +``` + +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. diff --git a/deploy/aci-fabric-sim.service b/deploy/aci-fabric-sim.service new file mode 100644 index 0000000..6436497 --- /dev/null +++ b/deploy/aci-fabric-sim.service @@ -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 /.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 diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md new file mode 100644 index 0000000..3eb9e52 --- /dev/null +++ b/docs/CONTRACT.md @@ -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":,"pwd":}}}`. + `name` may be a bare username (`admin`) or **domain-qualified** + (`apic:\` or `\`, 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:\`/`\` 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":"","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":}}}` — + 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-.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":[ {"":{"attributes":{...}, "children":[ {"":{"attributes":{...}}} ]}} ], "totalCount":""} +``` +- `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":"", "subscriptionId":""} +``` + +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` — `` 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":["",...], "imdata":[{"":{"attributes":{...,"status":"created|modified|deleted","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=` — always 200 +(`{"imdata":[],"totalCount":"0","subscriptionId":""}`), 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 `{"":{"attributes":{"name":...,"dn":...,"status":"created|modified|deleted"?}, "children":[...]}}`. + Response `{"imdata":[{"":{"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(.,"")` exact match (numbers unquoted). +- `ne(.,"")` not-equal (a missing attr is treated as ≠, i.e. matches). +- `wcard(.,"")` substring/wildcard (commonly on `.dn`). +- `gt|lt|ge|le(.,"")` compare — numeric when both sides parse as + numbers (e.g. `gt(fabricNode.id,"200")`), else lexical. +- `bw(.,"","")` inclusive range. +- `and(,,...)`, `or(,,...)` 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":"","code":""}}}]}`. + 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: ","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=/` (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,:{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 (`-`, 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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..3a9423d --- /dev/null +++ b/docs/DESIGN.md @@ -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. diff --git a/docs/MANUAL.md b/docs/MANUAL.md new file mode 100644 index 0000000..333e8dc --- /dev/null +++ b/docs/MANUAL.md @@ -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+") +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:///api/aaaLogin.json \ + -d '{"aaaUser":{"attributes":{"name":"admin","pwd":"cisco"}}}' +# class query (== moquery -c fvTenant) +curl -sk -b j.txt https:///api/class/fvTenant.json +# mo query + subtree +curl -sk -b j.txt "https:///api/mo/uni/tn-MS-TN1.json?query-target=subtree&target-subtree-class=fvBD" +# LLDP / CDP neighbors +curl -sk -b j.txt https:///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`). + +> 讲标准 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 ` / +> `scripts/sim-state.sh restore `(`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`.* diff --git a/examples/ansible/README.md b/examples/ansible/README.md new file mode 100644 index 0000000..4797897 --- /dev/null +++ b/examples/ansible/README.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`). diff --git a/examples/ansible/inventory.ini b/examples/ansible/inventory.ini new file mode 100644 index 0000000..50fce36 --- /dev/null +++ b/examples/ansible/inventory.ini @@ -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 diff --git a/examples/ansible/smoke.yml b/examples/ansible/smoke.yml new file mode 100644 index 0000000..47b0ac8 --- /dev/null +++ b/examples/ansible/smoke.yml @@ -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. diff --git a/examples/ansible/teardown.yml b/examples/ansible/teardown.yml new file mode 100644 index 0000000..f7811dd --- /dev/null +++ b/examples/ansible/teardown.yml @@ -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. diff --git a/examples/ci/README.md b/examples/ci/README.md new file mode 100644 index 0000000..e76e60e --- /dev/null +++ b/examples/ci/README.md @@ -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:[>=N]` | the class query returns ≥ N objects (default N=1) | +| `mo:` | `GET /api/mo/.json` returns the object (exists) | +| `absent:` | the MO does NOT exist (e.g. after a `state=absent` / delete) | +| `attr::=` | the MO at `` has attribute `` == `` | + +## 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 --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. diff --git a/examples/ci/assert_mit.py b/examples/ci/assert_mit.py new file mode 100644 index 0000000..644d61d --- /dev/null +++ b/examples/ci/assert_mit.py @@ -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:[>=N] the class query returns at least N objects (default N=1) + mo: GET /api/mo/.json returns the object (exists) + absent: GET /api/mo/.json returns empty (does NOT exist) + attr::= the MO at has attribute equal to + +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()) diff --git a/examples/ci/github-actions-aci-gate.yml b/examples/ci/github-actions-aci-gate.yml new file mode 100644 index 0000000..239e612 --- /dev/null +++ b/examples/ci/github-actions-aci-gate.yml @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3abaa7 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..405ff34 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/e2e_live.py b/scripts/e2e_live.py new file mode 100644 index 0000000..66efd5f --- /dev/null +++ b/scripts/e2e_live.py @@ -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 "")) + +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) diff --git a/scripts/e2e_sandbox.py b/scripts/e2e_sandbox.py new file mode 100644 index 0000000..da685fb --- /dev/null +++ b/scripts/e2e_sandbox.py @@ -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) diff --git a/scripts/gen_certs.sh b/scripts/gen_certs.sh new file mode 100755 index 0000000..5fde596 --- /dev/null +++ b/scripts/gen_certs.sh @@ -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/" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..35b2750 --- /dev/null +++ b/scripts/run.sh @@ -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 diff --git a/scripts/sandbox-down.sh b/scripts/sandbox-down.sh new file mode 100755 index 0000000..71ba2a4 --- /dev/null +++ b/scripts/sandbox-down.sh @@ -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." diff --git a/scripts/sandbox-up.sh b/scripts/sandbox-up.sh new file mode 100755 index 0000000..eaa8860 --- /dev/null +++ b/scripts/sandbox-up.sh @@ -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" diff --git a/scripts/sim-state.sh b/scripts/sim-state.sh new file mode 100755 index 0000000..8147e62 --- /dev/null +++ b/scripts/sim-state.sh @@ -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}/ endpoint. +# +# scripts/sim-state.sh save # snapshot every plane to disk +# scripts/sim-state.sh restore # 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 +# +# 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://:443 +# APIC site -> https://: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/ 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} " >&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 " [ ...]" +# 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 diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..193b241 --- /dev/null +++ b/scripts/verify.sh @@ -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 "$@" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_admin_account.py b/tests/test_admin_account.py new file mode 100644 index 0000000..45c2ad1 --- /dev/null +++ b/tests/test_admin_account.py @@ -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 diff --git a/tests/test_auth_fidelity.py b/tests/test_auth_fidelity.py new file mode 100644 index 0000000..7930686 --- /dev/null +++ b/tests/test_auth_fidelity.py @@ -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" diff --git a/tests/test_build_fabric.py b/tests/test_build_fabric.py new file mode 100644 index 0000000..244adb9 --- /dev/null +++ b/tests/test_build_fabric.py @@ -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...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}" diff --git a/tests/test_build_tenant.py b/tests/test_build_tenant.py new file mode 100644 index 0000000..9e74d18 --- /dev/null +++ b/tests/test_build_tenant.py @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b55590c --- /dev/null +++ b/tests/test_cli.py @@ -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" diff --git a/tests/test_control.py b/tests/test_control.py new file mode 100644 index 0000000..93a8fac --- /dev/null +++ b/tests/test_control.py @@ -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 diff --git a/tests/test_deploy_mirror.py b/tests/test_deploy_mirror.py new file mode 100644 index 0000000..fe4dcb2 --- /dev/null +++ b/tests/test_deploy_mirror.py @@ -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- 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 diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..2607a85 --- /dev/null +++ b/tests/test_graph.py @@ -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"", html, re.S) + assert m, "no ... 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 " None: + topo = load_topology(TOPOLOGY_YAML) + svg = render_topology(topo, fmt="svg") + assert svg.startswith("" 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' 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' 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(" 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(" 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 diff --git a/tests/test_lldp_cdp_fidelity.py b/tests/test_lldp_cdp_fidelity.py new file mode 100644 index 0000000..90aacbb --- /dev/null +++ b/tests/test_lldp_cdp_fidelity.py @@ -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 diff --git a/tests/test_mit.py b/tests/test_mit.py new file mode 100644 index 0000000..993dd99 --- /dev/null +++ b/tests/test_mit.py @@ -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()} diff --git a/tests/test_ndo.py b/tests/test_ndo.py new file mode 100644 index 0000000..fe3333b --- /dev/null +++ b/tests/test_ndo.py @@ -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 diff --git a/tests/test_oob_gateway.py b/tests/test_oob_gateway.py new file mode 100644 index 0000000..d610813 --- /dev/null +++ b/tests/test_oob_gateway.py @@ -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-/node-. + 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 diff --git a/tests/test_p1_regression.py b/tests/test_p1_regression.py new file mode 100644 index 0000000..a4c8f31 --- /dev/null +++ b/tests/test_p1_regression.py @@ -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" diff --git a/tests/test_p5_fixes.py b/tests/test_p5_fixes.py new file mode 100644 index 0000000..ab5ee49 --- /dev/null +++ b/tests/test_p5_fixes.py @@ -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 diff --git a/tests/test_persist.py b/tests/test_persist.py new file mode 100644 index 0000000..59b30f0 --- /dev/null +++ b/tests/test_persist.py @@ -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"] diff --git a/tests/test_pr10_ansible_gaps.py b/tests/test_pr10_ansible_gaps.py new file mode 100644 index 0000000..43d2591 --- /dev/null +++ b/tests/test_pr10_ansible_gaps.py @@ -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 diff --git a/tests/test_pr11_ndo_write.py b/tests/test_pr11_ndo_write.py new file mode 100644 index 0000000..e61165e --- /dev/null +++ b/tests/test_pr11_ndo_write.py @@ -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 +(`-`, 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)}" diff --git a/tests/test_pr12_site_local.py b/tests/test_pr12_site_local.py new file mode 100644 index 0000000..5721cbb --- /dev/null +++ b/tests/test_pr12_site_local.py @@ -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" diff --git a/tests/test_pr13_ndo_dhcp_svcgraph.py b/tests/test_pr13_ndo_dhcp_svcgraph.py new file mode 100644 index 0000000..4c5c98e --- /dev/null +++ b/tests/test_pr13_ndo_dhcp_svcgraph.py @@ -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 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=` + 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") diff --git a/tests/test_pr14_nd_bare_login.py b/tests/test_pr14_nd_bare_login.py new file mode 100644 index 0000000..1d9f0ce --- /dev/null +++ b/tests/test_pr14_nd_bare_login.py @@ -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"] diff --git a/tests/test_pr15_acipy_gaps.py b/tests/test_pr15_acipy_gaps.py new file mode 100644 index 0000000..e5ce268 --- /dev/null +++ b/tests/test_pr15_acipy_gaps.py @@ -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:\\`). 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:\\` / `\\` 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 diff --git a/tests/test_pr16_bd_mirror_dedup.py b/tests/test_pr16_bd_mirror_dedup.py new file mode 100644 index 0000000..d8cd1ca --- /dev/null +++ b/tests/test_pr16_bd_mirror_dedup.py @@ -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" diff --git a/tests/test_pr18_tier1.py b/tests/test_pr18_tier1.py new file mode 100644 index 0000000..b302ec2 --- /dev/null +++ b/tests/test_pr18_tier1.py @@ -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 diff --git a/tests/test_pr19_tier2.py b/tests/test_pr19_tier2.py new file mode 100644 index 0000000..183c92e --- /dev/null +++ b/tests/test_pr19_tier2.py @@ -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 diff --git a/tests/test_pr20_tier3.py b/tests/test_pr20_tier3.py new file mode 100644 index 0000000..bc9df08 --- /dev/null +++ b/tests/test_pr20_tier3.py @@ -0,0 +1,467 @@ +"""Tests for PR-20 — Tier-3 fine-tuning parameters (the last param tier). + +Covers: + - fabric.oob_subnet / fabric.inb_subnet: schema defaults (None/omitted), + validation (CIDR well-formedness + address-family range), and that + `aci-sim show` surfaces both — inb_subnet explicitly STORE-ONLY (no MO + reflects it; this sim has no mgmtInB/inbMgmtAddr-style object anywhere). + - fvBD.mac: `bd.mac` (per-BD) falling back to `fabric.default_bd_mac` + (fabric-wide), which itself defaults to the real ACI default gateway + MAC "00:22:BD:F8:19:FF" — the exact literal build/tenants.py already + hardcoded pre-PR-20, so an unset bd.mac/default_bd_mac reproduces + byte-identical fvBD.mac output. + - BGP keepalive/hold (l3out.csw_peer): schema defaults (60/180, real ACI + defaults) reflected on the built bgpPeerP MO; overrides honored; + hold<=keepalive rejected. + - OSPF hello/dead (isn.ospf_hello/ospf_dead): schema defaults (10/40, + real ACI defaults) reflected on the built ospfIf MO; overrides + honored; dead<=hello rejected. + - BFD timers (isn.bfd_min_rx/min_tx/multiplier): schema defaults (50/50/3, + real ACI defaults), validated, surfaced in `aci-sim show` — explicitly + STORE-ONLY, no bfdIfP MO built anywhere in this sim. + - fabric.default_apic_version: fabric-wide override that takes + precedence over site.apic_version (already independently settable from + switch-node Node.version since PR-18/PR-9 lineage) on the controller's + fabricNode/topSystem/firmwareCtrlrRunning. + +See docs/DESIGN.md's "PR-20 — Tier-3 fine-tuning parameters" section for +the full design rationale, including which timers are builder-wired vs +store-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 BD, CswPeer, Fabric, 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) + + +def _base_topo_dict(**kwargs) -> dict: + return generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=1, **kwargs) + + +def _topo_dict_with_l3out(**kwargs) -> dict: + """`_base_topo_dict()` plus one l3out+csw_peer on site 0, agreeing with + that site's own border-leaf `csw` (name/asn/peer_ip), matching + `topology.yaml`'s real l3o-bgp-Core_LAB1 shape — needed because + `generate_topology()`'s scaffold never emits an l3out (verified: no + "l3outs"/"csw_peer" call sites in cli.py's generator; l3outs is always + an empty list `[]`).""" + topo_dict = _base_topo_dict(**kwargs) + site0 = topo_dict["sites"][0] + bl = site0["border_leaves"][0] + tenant = topo_dict["tenants"][0] + tenant["l3outs"] = [ + { + "name": "l3o-bgp-test", + "vrf": tenant["vrfs"][0]["name"], + "site": site0["name"], + "border_leaves": [bl["id"]], + "csw_peer": { + "name": bl["csw"]["name"], + "asn": bl["csw"]["asn"], + "peer_ip": bl["csw"]["peer_ip"], + }, + "ext_epgs": [], + } + ] + return topo_dict + + +# --------------------------------------------------------------------------- +# Backward compat — repo topology.yaml unchanged +# --------------------------------------------------------------------------- + + +class TestBackwardCompat: + def test_repo_topology_yaml_still_validates_unchanged(self) -> None: + topo = load_topology(TOPOLOGY_YAML) + assert len(topo.sites) == 2 + + def test_all_new_fabric_fields_default(self, repo_topo: Topology) -> None: + assert repo_topo.fabric.oob_subnet is None + assert repo_topo.fabric.inb_subnet is None + assert repo_topo.fabric.default_bd_mac == "00:22:BD:F8:19:FF" + assert repo_topo.fabric.default_apic_version is None + + def test_all_new_isn_fields_default(self, repo_topo: Topology) -> None: + assert repo_topo.isn.ospf_hello == 10 + assert repo_topo.isn.ospf_dead == 40 + assert repo_topo.isn.bfd_min_rx == 50 + assert repo_topo.isn.bfd_min_tx == 50 + assert repo_topo.isn.bfd_multiplier == 3 + + def test_repo_fvBD_mac_is_real_aci_default(self, repo_topo: Topology) -> None: + """Critical backward-compat assertion: every existing BD's fvBD.mac + stays exactly "00:22:BD:F8:19:FF" (the literal build/tenants.py + already hardcoded pre-PR-20) with no topology.yaml changes.""" + site = repo_topo.site_by_name("LAB1") + store = orchestrator.build_site(repo_topo, site) + bds = store.by_class("fvBD") + assert bds, "expected at least one fvBD" + for mo in bds: + assert mo.attrs["mac"] == "00:22:BD:F8:19:FF" + + def test_repo_csw_peer_keepalive_hold_default(self, repo_topo: Topology) -> None: + for tenant in repo_topo.tenants: + for l3out in tenant.l3outs: + if l3out.csw_peer is not None: + assert l3out.csw_peer.keepalive == 60 + assert l3out.csw_peer.hold == 180 + + def test_repo_bgpPeerP_reflects_default_timers(self, repo_topo: Topology) -> None: + site = repo_topo.site_by_name("LAB1") + store = orchestrator.build_site(repo_topo, site) + peers = store.by_class("bgpPeerP") + assert peers, "expected at least one bgpPeerP" + for mo in peers: + assert mo.attrs["keepAliveIntvl"] == "60" + assert mo.attrs["holdIntvl"] == "180" + + def test_repo_ospfIf_reflects_default_hello_dead(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["helloIntvl"] == "10" + assert mo.attrs["deadIntvl"] == "40" + + def test_site_apic_version_unaffected_when_default_apic_version_unset( + self, repo_topo: Topology + ) -> None: + site = repo_topo.site_by_name("LAB1") + store = orchestrator.build_site(repo_topo, site) + ctrl_top_system = next( + m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-1/sys" + ) + assert ctrl_top_system.attrs["version"] == site.apic_version == "4.2(7f)" + + +# --------------------------------------------------------------------------- +# fvBD.mac — per-BD override + fabric-wide default override +# --------------------------------------------------------------------------- + + +class TestBdMac: + def test_bd_mac_schema_default_is_none(self) -> None: + bd = BD(name="bd-x", vrf="vrf-x") + assert bd.mac is None + + def test_fabric_default_bd_mac_schema_default(self) -> None: + fab = Fabric(name="fab") + assert fab.default_bd_mac == "00:22:BD:F8:19:FF" + + def test_per_bd_mac_override_wins(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["tenants"][0]["bds"][0]["mac"] = "AA:BB:CC:DD:EE:FF" + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + bd_name = topo_dict["tenants"][0]["bds"][0]["name"] + mo = next(m for m in store.by_class("fvBD") if m.attrs["name"] == bd_name) + assert mo.attrs["mac"] == "AA:BB:CC:DD:EE:FF" + + def test_fabric_wide_default_bd_mac_applies_when_bd_mac_unset(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["default_bd_mac"] = "02:00:00:00:00:01" + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + bds = store.by_class("fvBD") + assert bds + for mo in bds: + assert mo.attrs["mac"] == "02:00:00:00:00:01" + + def test_per_bd_mac_wins_over_fabric_wide_default(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["default_bd_mac"] = "02:00:00:00:00:01" + topo_dict["tenants"][0]["bds"][0]["mac"] = "AA:BB:CC:DD:EE:FF" + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + bd_name = topo_dict["tenants"][0]["bds"][0]["name"] + mo = next(m for m in store.by_class("fvBD") if m.attrs["name"] == bd_name) + assert mo.attrs["mac"] == "AA:BB:CC:DD:EE:FF" + + def test_invalid_bd_mac_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["tenants"][0]["bds"][0]["mac"] = "not-a-mac" + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + def test_invalid_fabric_default_bd_mac_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["default_bd_mac"] = "nope" + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + +# --------------------------------------------------------------------------- +# BGP keepalive/hold — l3out.csw_peer -> bgpPeerP +# --------------------------------------------------------------------------- + + +class TestBgpTimers: + def test_schema_defaults(self) -> None: + cp = CswPeer(name="csw", asn=65100, peer_ip="10.0.0.1") + assert cp.keepalive == 60 + assert cp.hold == 180 + + def test_override_reflected_on_bgpPeerP(self) -> None: + topo_dict = _topo_dict_with_l3out() + l3out = topo_dict["tenants"][0]["l3outs"][0] + l3out["csw_peer"]["keepalive"] = 10 + l3out["csw_peer"]["hold"] = 30 + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + peers = store.by_class("bgpPeerP") + assert peers + for mo in peers: + assert mo.attrs["keepAliveIntvl"] == "10" + assert mo.attrs["holdIntvl"] == "30" + + def test_hold_at_or_below_keepalive_rejected(self) -> None: + topo_dict = _topo_dict_with_l3out() + l3out = topo_dict["tenants"][0]["l3outs"][0] + l3out["csw_peer"]["keepalive"] = 60 + l3out["csw_peer"]["hold"] = 60 + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + def test_negative_timers_rejected(self) -> None: + topo_dict = _topo_dict_with_l3out() + l3out = topo_dict["tenants"][0]["l3outs"][0] + l3out["csw_peer"]["keepalive"] = -1 + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + +# --------------------------------------------------------------------------- +# OSPF hello/dead — isn.ospf_hello/ospf_dead -> ospfIf +# --------------------------------------------------------------------------- + + +class TestOspfTimers: + def test_schema_defaults(self) -> None: + isn = ISN() + assert isn.ospf_hello == 10 + assert isn.ospf_dead == 40 + + def test_override_reflected_on_ospfIf(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["isn"]["ospf_hello"] = 5 + topo_dict["isn"]["ospf_dead"] = 20 + 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["helloIntvl"] == "5" + assert mo.attrs["deadIntvl"] == "20" + + def test_dead_at_or_below_hello_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["isn"]["ospf_hello"] = 10 + topo_dict["isn"]["ospf_dead"] = 10 + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + def test_non_positive_hello_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["isn"]["ospf_hello"] = 0 + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + +# --------------------------------------------------------------------------- +# BFD timers — store+validate+surface only (no bfdIfP MO anywhere) +# --------------------------------------------------------------------------- + + +class TestBfdTimersStoreOnly: + def test_schema_defaults(self) -> None: + isn = ISN() + assert isn.bfd_min_rx == 50 + assert isn.bfd_min_tx == 50 + assert isn.bfd_multiplier == 3 + + def test_override_accepted_and_stored(self) -> None: + isn = ISN(bfd_min_rx=100, bfd_min_tx=100, bfd_multiplier=5) + assert isn.bfd_min_rx == 100 + assert isn.bfd_min_tx == 100 + assert isn.bfd_multiplier == 5 + + def test_no_bfd_mo_built_anywhere(self) -> None: + """Confirms the store-only claim: building a full site produces no + MO with 'bfd' anywhere in its class name.""" + topo_dict = _base_topo_dict() + topo_dict["isn"]["bfd_min_rx"] = 999 + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + bfd_classes = {mo.class_name for mo in store.all() if "bfd" in mo.class_name.lower()} + assert bfd_classes == set() + + def test_multiplier_out_of_range_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["isn"]["bfd_multiplier"] = 51 + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + def test_non_positive_min_rx_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["isn"]["bfd_min_rx"] = 0 + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + +# --------------------------------------------------------------------------- +# oob_subnet / inb_subnet — store+validate+surface +# --------------------------------------------------------------------------- + + +class TestMgmtSubnets: + def test_defaults_are_none(self) -> None: + fab = Fabric(name="fab") + assert fab.oob_subnet is None + assert fab.inb_subnet is None + + def test_oob_subnet_accepted_within_family(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["oob_subnet"] = "192.168.50.0/24" + topo = Topology.model_validate(topo_dict) + assert topo.fabric.oob_subnet == "192.168.50.0/24" + + def test_oob_subnet_outside_family_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["oob_subnet"] = "172.20.0.0/24" + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + def test_oob_subnet_malformed_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["oob_subnet"] = "not-a-cidr" + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + def test_inb_subnet_accepted_within_rfc1918(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["inb_subnet"] = "10.50.0.0/16" + topo = Topology.model_validate(topo_dict) + assert topo.fabric.inb_subnet == "10.50.0.0/16" + + def test_inb_subnet_outside_rfc1918_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["inb_subnet"] = "8.8.8.0/24" + with pytest.raises(ValidationError): + Topology.model_validate(topo_dict) + + def test_inb_subnet_no_mo_built(self) -> None: + """Confirms the store-only claim: no mgmtInB/inbMgmtAddr-style MO + exists anywhere in the built store.""" + topo_dict = _base_topo_dict() + topo_dict["fabric"]["inb_subnet"] = "10.50.0.0/16" + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + inb_classes = { + mo.class_name for mo in store.all() + if "inb" in mo.class_name.lower() or "mgmtinb" in mo.class_name.lower() + } + assert inb_classes == set() + + def test_show_surfaces_both_subnets(self) -> None: + from aci_sim.cli import build_inventory + + topo_dict = _base_topo_dict() + topo_dict["fabric"]["oob_subnet"] = "192.168.50.0/24" + topo_dict["fabric"]["inb_subnet"] = "10.50.0.0/16" + topo = Topology.model_validate(topo_dict) + inv = build_inventory(topo) + assert inv["oob_subnet"] == "192.168.50.0/24" + assert inv["inb_subnet"] == "10.50.0.0/16" + + +# --------------------------------------------------------------------------- +# fabric.default_apic_version — fabric-wide override, independent of +# switch-node Node.version +# --------------------------------------------------------------------------- + + +class TestApicVersion: + def test_schema_default_is_none(self) -> None: + fab = Fabric(name="fab") + assert fab.default_apic_version is None + + def test_unset_uses_site_apic_version(self) -> None: + topo_dict = _base_topo_dict() + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + ctrl_top_system = next( + m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-1/sys" + ) + assert ctrl_top_system.attrs["version"] == site.apic_version + + def test_override_drives_controller_topSystem_independent_of_switch_version(self) -> None: + """apic_version override must show up on the controller's topSystem/ + firmwareCtrlrRunning while switch-node fabricNode/topSystem.version + (Node.version, PR-18) stays completely unaffected.""" + topo_dict = _base_topo_dict() + topo_dict["fabric"]["default_apic_version"] = "5.2(7g)" + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + + ctrl_top_system = next( + m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-1/sys" + ) + assert ctrl_top_system.attrs["version"] == "5.2(7g)" + + ctrl_node = next(m for m in store.by_class("fabricNode") if m.dn == f"topology/pod-{site.pod}/node-1") + assert ctrl_node.attrs["version"] == "5.2(7g)" + + fw = next(m for m in store.by_class("firmwareCtrlrRunning")) + assert fw.attrs["version"] == "5.2(7g)" + + # Switch-node version is untouched — independence confirmed. + leaf_id = site.leaf_nodes()[0].id + leaf_top_system = next( + m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-{leaf_id}/sys" + ) + assert leaf_top_system.attrs["version"] == "n9000-14.2(7f)" + + def test_override_applies_to_all_controllers(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["fabric"]["default_apic_version"] = "5.2(7g)" + topo_dict["sites"][0]["controllers"] = 3 + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + fw_versions = {m.attrs["version"] for m in store.by_class("firmwareCtrlrRunning")} + assert fw_versions == {"5.2(7g)"} + + def test_show_surfaces_effective_apic_version(self) -> None: + from aci_sim.cli import build_inventory + + topo_dict = _base_topo_dict() + topo_dict["fabric"]["default_apic_version"] = "5.2(7g)" + topo = Topology.model_validate(topo_dict) + inv = build_inventory(topo) + assert all(s["apic_version"] == "5.2(7g)" for s in inv["sites"]) diff --git a/tests/test_pr21_single_apic.py b/tests/test_pr21_single_apic.py new file mode 100644 index 0000000..3414a35 --- /dev/null +++ b/tests/test_pr21_single_apic.py @@ -0,0 +1,246 @@ +"""Tests for PR-21 — single-APIC default + per-controller OOB IPs. + +Design intent (owner directive, see README §6a / docs/DESIGN.md "PR-21"): +APIC cluster size is irrelevant to Ansible playbook *deployment* testing (a +playbook connects to and pushes config through exactly one APIC endpoint); +cluster size only matters for cluster-health/appliance-vector tooling (e.g. +autoACI's health_score.py, which reads infraWiNode across the cluster). +`Site.controllers` therefore now defaults to 1 (was 3 pre-PR-21), while +remaining fully configurable in the validated range 1-5. + +Covers: + - `site.controllers` defaults to 1 (both the repo topology.yaml and a + freshly-scaffolded `generate_topology()` dict). + - `controllers` range validation: 1-5 accepted, 0/6 rejected. + - `site.controller_ips` unset (default): sequential OOB-IP derivation from + `site.mgmt_ip` (controller 1 = mgmt_ip, controller 2 = mgmt_ip+1, ...), + reflected on the built controller `topSystem.oobMgmtAddr`. + - `site.controller_ips` explicit: those exact IPs used verbatim, honored + on the built controller `topSystem.oobMgmtAddr`; length mismatch + against `controllers` is rejected; an invalid IP entry is rejected. + - `infraWiNode` count continues to track `controllers` (== controllers²) + regardless of default/override — this was already true pre-PR-21 + (test_pr3b_batch2.py asserts it dynamically), verified again here at + the new default. +""" +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 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=0, **kwargs) + + +# --------------------------------------------------------------------------- +# Default: controllers == 1 +# --------------------------------------------------------------------------- + + +class TestDefaultIsSingleApic: + def test_repo_topology_defaults_to_1_controller_per_site(self) -> None: + topo = load_topology(TOPOLOGY_YAML) + for site in topo.sites: + assert site.controllers == 1 + + def test_scaffolded_topology_defaults_to_1_controller_per_site(self) -> None: + topo_dict = generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=1) + topo = Topology.model_validate(topo_dict) + for site in topo.sites: + assert site.controllers == 1 + + def test_single_apic_default_yields_7_fabricnodes_for_2x2x2_site(self) -> None: + """2 spines + 2 leaves + 2 border-leaves + 1 controller (new default) = 7 + (was 9 pre-PR-21 at the old controllers=3 default).""" + topo_dict = generate_topology(sites=1, leaves_per_site=2, spines_per_site=2, border_pairs=1) + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + nodes = store.by_class("fabricNode") + assert len(nodes) == 7, f"Expected 7 fabricNodes, got {len(nodes)}" + controllers = [n for n in nodes if n.attrs["role"] == "controller"] + assert len(controllers) == 1 + + +# --------------------------------------------------------------------------- +# Range validation: 1-5 +# --------------------------------------------------------------------------- + + +class TestControllersRangeValidation: + @pytest.mark.parametrize("n", [1, 2, 3, 4, 5]) + def test_in_range_accepted(self, n: int) -> None: + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["controllers"] = n + topo = Topology.model_validate(topo_dict) + assert topo.sites[0].controllers == n + + @pytest.mark.parametrize("n", [0, -1, 6, 10]) + def test_out_of_range_rejected(self, n: int) -> None: + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["controllers"] = n + with pytest.raises(ValidationError, match="controllers"): + Topology.model_validate(topo_dict) + + +# --------------------------------------------------------------------------- +# controller_ips unset (default): sequential derivation from mgmt_ip +# --------------------------------------------------------------------------- + + +class TestSequentialDerivationFromMgmtIp: + def test_controller_oob_ips_helper_sequential(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.11" + topo_dict["sites"][0]["controllers"] = 3 + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + assert site.controller_oob_ips() == ["10.192.0.11", "10.192.0.12", "10.192.0.13"] + + def test_built_topSystem_oob_addrs_are_sequential(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.11" + topo_dict["sites"][0]["controllers"] = 3 + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + + ctrl_top_systems = { + mo.attrs["id"]: mo.attrs["oobMgmtAddr"] + for mo in store.by_class("topSystem") + if mo.attrs.get("role") == "controller" + } + assert ctrl_top_systems == { + "1": "10.192.0.11", + "2": "10.192.0.12", + "3": "10.192.0.13", + } + + def test_sequential_derivation_rolls_over_octet(self) -> None: + """mgmt_ip near the end of a /24-style boundary still derives + correctly via plain ipaddress integer arithmetic (no wraparound bug).""" + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.254" + topo_dict["sites"][0]["controllers"] = 3 + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + assert site.controller_oob_ips() == ["10.192.0.254", "10.192.0.255", "10.192.1.0"] + + def test_unset_mgmt_ip_falls_back_to_legacy_oob_ip_scheme(self) -> None: + """No mgmt_ip and no controller_ips -> controller_oob_ips() is empty, + and build/fabric.py falls back to the legacy oob_ip(pod, cid) scheme + (pre-PR-21 behavior), unchanged for topologies that never set mgmt_ip.""" + from aci_sim.build.fabric import oob_ip + + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["mgmt_ip"] = "" + topo_dict["sites"][0]["controllers"] = 3 + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + assert site.controller_oob_ips() == [] + + store = orchestrator.build_site(topo, site) + ctrl_top_systems = { + mo.attrs["id"]: mo.attrs["oobMgmtAddr"] + for mo in store.by_class("topSystem") + if mo.attrs.get("role") == "controller" + } + assert ctrl_top_systems == { + "1": oob_ip(site.pod, 1), + "2": oob_ip(site.pod, 2), + "3": oob_ip(site.pod, 3), + } + + +# --------------------------------------------------------------------------- +# controller_ips explicit: honored verbatim + validated +# --------------------------------------------------------------------------- + + +class TestExplicitControllerIps: + def test_explicit_controller_ips_honored_on_built_topSystem(self) -> None: + explicit_ips = ["172.20.0.5", "172.20.0.9", "172.20.0.20"] + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["controllers"] = 3 + topo_dict["sites"][0]["controller_ips"] = explicit_ips + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + assert site.controller_oob_ips() == explicit_ips + + store = orchestrator.build_site(topo, site) + ctrl_top_systems = { + mo.attrs["id"]: mo.attrs["oobMgmtAddr"] + for mo in store.by_class("topSystem") + if mo.attrs.get("role") == "controller" + } + assert ctrl_top_systems == {"1": explicit_ips[0], "2": explicit_ips[1], "3": explicit_ips[2]} + + def test_explicit_controller_ips_also_honored_on_lldp_adjep(self) -> None: + """The leaf-side lldpAdjEp.mgmtIp must match the controller's own + topSystem.oobMgmtAddr — a real LLDP neighbor report always announces + its own mgmt IP; a mismatch would mean the sim's topology view is + internally inconsistent.""" + explicit_ips = ["172.20.0.5", "172.20.0.9"] + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["controllers"] = 2 + topo_dict["sites"][0]["controller_ips"] = explicit_ips + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + store = orchestrator.build_site(topo, site) + + lldp_ips = {mo.attrs["mgmtIp"] for mo in store.by_class("lldpAdjEp") if "APIC" in mo.attrs.get("sysName", "")} + assert lldp_ips == set(explicit_ips) + + def test_controller_ips_length_mismatch_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["controllers"] = 3 + topo_dict["sites"][0]["controller_ips"] = ["10.0.0.1", "10.0.0.2"] # only 2, need 3 + with pytest.raises(ValidationError, match="controller_ips"): + Topology.model_validate(topo_dict) + + def test_controller_ips_invalid_ip_rejected(self) -> None: + topo_dict = _base_topo_dict() + topo_dict["sites"][0]["controllers"] = 1 + topo_dict["sites"][0]["controller_ips"] = ["not-an-ip"] + with pytest.raises(ValidationError, match="controller_ips"): + Topology.model_validate(topo_dict) + + +# --------------------------------------------------------------------------- +# infraWiNode count still tracks controllers (unchanged wiring, new default) +# --------------------------------------------------------------------------- + + +class TestInfraWiNodeTracksControllers: + @pytest.mark.parametrize("n_controllers", [1, 2, 3, 5]) + def test_infraWiNode_count_equals_controllers_squared(self, n_controllers: int) -> None: + topo_dict = _base_topo_dict() + 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_infraWiNode_count_at_new_default(self) -> None: + """At the new default (controllers=1), infraWiNode is a 1x1 matrix — + a single controller's self-health-check entry.""" + topo_dict = _base_topo_dict() + topo = Topology.model_validate(topo_dict) + site = topo.sites[0] + assert site.controllers == 1 + store = orchestrator.build_site(topo, site) + wi = store.by_class("infraWiNode") + assert len(wi) == 1 + assert wi[0].attrs["health"] == "fully-fit" diff --git a/tests/test_pr22_wizard.py b/tests/test_pr22_wizard.py new file mode 100644 index 0000000..b1ec633 --- /dev/null +++ b/tests/test_pr22_wizard.py @@ -0,0 +1,479 @@ +"""Tests for PR-22 — `aci-sim init`, an interactive APIC-setup-style wizard. + +Covers: + - `--defaults` (and non-TTY stdin) accepts every suggested default without + ever reading stdin, producing a file that passes `load_topology()` + + validation and is BYTE-IDENTICAL to calling `generate_topology()` + directly with the same known default parameter set (the wizard is a + thin front-end — it must not duplicate topology-emission logic or drift + from it). + - A multi-site run with `controllers=3` produces `controller_ips` + sequential from the answered OOB IP (PR-21's `Site.controller_ips`). + - Custom answers (via `--answers`/`run_wizard(answers=...)` and via + scripted stdin) override the suggested defaults. + - The deployment-type branch: single-fabric -> exactly 1 site, no ISN/NDO + prompts/section; multi-site -> ISN block printed + N sites. + - Non-TTY stdin auto-accepts defaults with no hang (exercised via + `subprocess` with stdin explicitly closed/empty, which is what makes + `sys.stdin.isatty()` false in a test process). + +No test ever blocks on real interactive stdin — every wizard drive in this +file uses `io.StringIO` (fully-buffered, returns EOF instead of hanging) or +`--defaults`/`accept_defaults=True` (skips reading stdin entirely). +""" +from __future__ import annotations + +import io +import subprocess +import sys +from pathlib import Path + +import yaml + +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 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _wizard_defaults_topology() -> dict: + """The topology `aci-sim init --defaults` produces at every wizard default + (2 sites, multi-site — the wizard's own Step-0 default is "2").""" + out = io.StringIO() + topo, _gateway = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=True) + return topo + + +def _direct_equivalent_topology() -> dict: + """The SAME topology built by calling `generate_topology()` directly with + the wizard's own known default values — i.e. what a caller would get if + they replicated the wizard's Step-1/Step-2 defaults by hand. Asserting + the wizard's output equals this proves `run_wizard` is a thin front-end + that does not duplicate/diverge from `generate_topology()`.""" + site_overrides = [ + { + "name": "Site1-IT-ACI", + "id": "1", + "mgmt_ip": "10.192.0.11", + "fabric_name": "Site1-IT-ACI", + "asn": 65001, + "pod": 1, + "controllers": 1, + "controller_ips": ["10.192.0.11"], + # OOB-gateway PR: the wizard's own default-gateway derivation is + # "first usable IP of the /24 block containing the site's APIC + # OOB IP" (run_wizard: gw_block - (gw_block % 256) + 1). + "oob_gateway": "10.192.0.1", + }, + { + "name": "Site2-IT-ACI", + "id": "2", + "mgmt_ip": "10.192.128.11", + "fabric_name": "Site2-IT-ACI", + "asn": 65002, + "pod": 1, + "controllers": 1, + "controller_ips": ["10.192.128.11"], + "oob_gateway": "10.192.128.1", + }, + ] + topo = generate_topology( + sites=2, + leaves_per_site=[2, 2], + spines_per_site=[2, 2], + border_pairs=[1, 1], + fabric_name_prefix="", + ndo_ip="10.192.0.10", + tep_pool="10.0.0.0/16", + infra_vlan=3967, + gipo_pool="225.0.0.0/15", + isn_ospf_area="0.0.0.0", + isn_mtu=9150, + site_overrides=site_overrides, + ) + topo["fabric"]["name"] = "MULTI-SITE-ACI-IT-ACI" + topo["fabric"]["oob_subnet"] = "192.168.0.0/16" + topo["isn"]["enabled"] = True + # Admin account step (Step 0): the wizard's own defaults (admin/cisco), + # ndo_same_account=yes so no ndo_username/ndo_password keys are written. + topo["auth"] = {"username": "admin", "password": "cisco"} + return topo + + +# --------------------------------------------------------------------------- +# 1. --defaults consistency: passes validation + equals generate_topology() +# called directly with the wizard's own default values. +# --------------------------------------------------------------------------- + + +class TestDefaultsConsistency: + def test_defaults_topology_passes_validation(self) -> None: + topo_dict = _wizard_defaults_topology() + topo = Topology.model_validate(topo_dict) + assert len(topo.sites) == 2 + + def test_defaults_topology_loads_and_builds_from_disk(self, tmp_path: Path) -> None: + topo_dict = _wizard_defaults_topology() + out_file = tmp_path / "wizard.topology.yaml" + out_file.write_text(yaml.dump(topo_dict, sort_keys=False), encoding="utf-8") + + topo = load_topology(out_file) # same validation `aci-sim validate` runs + assert len(topo.sites) == 2 + + from aci_sim.build import orchestrator + + for site in topo.sites: + store = orchestrator.build_site(topo, site) + assert store.by_class("fabricNode") + + def test_defaults_topology_equals_direct_generate_topology_call(self) -> None: + wizard_topo = _wizard_defaults_topology() + direct_topo = _direct_equivalent_topology() + assert wizard_topo == direct_topo + + +# --------------------------------------------------------------------------- +# 2. Multi-site + controllers=3 -> sequential controller_ips from the +# answered OOB IP (PR-21's Site.controller_ips). +# --------------------------------------------------------------------------- + + +class TestControllersThreeSequentialIps: + def test_answers_file_controllers_3_sequential_from_oob_ip(self) -> None: + answers = { + "deployment_type": "2", + "num_sites": "2", + "site1_apic1_ip": "10.50.0.20", + "site1_controllers": "3", + } + out = io.StringIO() + topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers) + + site1 = topo["sites"][0] + assert site1["controllers"] == 3 + assert site1["controller_ips"] == ["10.50.0.20", "10.50.0.21", "10.50.0.22"] + + # Round-trips through full validation + Site.controller_oob_ips(). + validated = Topology.model_validate(topo) + assert validated.sites[0].controller_oob_ips() == ["10.50.0.20", "10.50.0.21", "10.50.0.22"] + + def test_scripted_stdin_controllers_3_sequential_suggestion_accepted(self) -> None: + """Drives the wizard with a real scripted stdin stream (not + --answers/accept_defaults) accepting the SUGGESTED sequential IP at + each of the 3 controller prompts by pressing ENTER (blank line).""" + stdin_script = ( + "\n" # admin username -> default + "\n" # admin password -> default + "\n" # ndo_same_account -> default (yes) + "1\n" # deployment type -> single fabric + "\n" # site name -> default + "\n" # site id -> default + "\n" # asn -> default + "\n" # pod -> default + "3\n" # controllers -> 3 + "10.7.7.10\n" # apic1 ip -> custom + "\n" # apic2 ip -> accept suggested 10.7.7.11 + "\n" # apic3 ip -> accept suggested 10.7.7.12 + ) + out = io.StringIO() + topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False) + site1 = topo["sites"][0] + assert site1["controller_ips"] == ["10.7.7.10", "10.7.7.11", "10.7.7.12"] + + def test_generated_file_with_controllers_3_validates_and_builds(self, tmp_path: Path) -> None: + answers = {"deployment_type": "1", "site1_controllers": "3", "site1_apic1_ip": "10.192.0.11"} + out = io.StringIO() + topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers) + + out_file = tmp_path / "ctrl3.topology.yaml" + out_file.write_text(yaml.dump(topo, sort_keys=False), encoding="utf-8") + loaded = load_topology(out_file) # raises on validation failure + + from aci_sim.build import orchestrator + + store = orchestrator.build_site(loaded, loaded.sites[0]) + ctrl_top_systems = { + mo.attrs["id"]: mo.attrs["oobMgmtAddr"] + for mo in store.by_class("topSystem") + if mo.attrs.get("role") == "controller" + } + assert ctrl_top_systems == {"1": "10.192.0.11", "2": "10.192.0.12", "3": "10.192.0.13"} + + +# --------------------------------------------------------------------------- +# 3. Custom answers override defaults (both --answers-style dict and +# scripted stdin). +# --------------------------------------------------------------------------- + + +class TestCustomAnswersOverrideDefaults: + def test_answers_dict_overrides_site_name_and_asn(self) -> None: + answers = { + "deployment_type": "1", + "site1_name": "CustomFabric", + "site1_asn": "70000", + } + out = io.StringIO() + topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers) + assert topo["sites"][0]["name"] == "CustomFabric" + assert topo["sites"][0]["asn"] == 70000 + # Untouched fields still take the wizard's own default. + assert topo["sites"][0]["pod"] == 1 + + def test_scripted_stdin_overrides_fabric_name(self) -> None: + # admin username/password/ndo_same_account -> default (3 blank lines), + # then deployment=1, site name=MyCustomLab, rest EOF->defaults. + stdin_script = "\n\n\n1\nMyCustomLab\n" + out = io.StringIO() + topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False) + assert topo["sites"][0]["name"] == "MyCustomLab" + + def test_custom_topology_still_validates(self) -> None: + answers = {"deployment_type": "1", "site1_name": "CustomFabric", "site1_leaves": "4", "site1_spines": "3"} + out = io.StringIO() + topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers) + validated = Topology.model_validate(topo) + assert len(validated.sites[0].leaf_nodes()) == 4 + assert len(validated.sites[0].spine_nodes()) == 3 + + def test_invalid_custom_answer_reprompts_then_accepts_valid_value(self) -> None: + """A bad value typed at a validated prompt re-prompts (per _prompt's + validator contract) rather than silently accepting garbage.""" + stdin_script = ( + "\n" # admin username -> default + "\n" # admin password -> default + "\n" # ndo_same_account -> default (yes) + "1\n" # deployment type + "\n" # site name + "\n" # site id + "\n" # asn + "\n" # pod + "not-a-number\n2\n" # controllers: invalid then valid + ) + out = io.StringIO() + topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False) + assert topo["sites"][0]["controllers"] == 2 + assert "must be an integer" in out.getvalue() + + +# --------------------------------------------------------------------------- +# 4. Deployment-type branch: single-fabric vs multi-site. +# --------------------------------------------------------------------------- + + +class TestDeploymentTypeBranch: + def test_single_fabric_yields_exactly_one_site_no_isn(self) -> None: + out = io.StringIO() + topo, _gw = run_wizard( + stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers={"deployment_type": "1"} + ) + assert len(topo["sites"]) == 1 + assert topo["isn"]["enabled"] is False + # "Step 3" is the multi-site-only NDO+ISN step (run_wizard) — must be + # absent entirely on the single-fabric branch. + assert "Step 3" not in out.getvalue() + assert "ISN" not in out.getvalue().split("Step 3", 1)[-1] if "Step 3" in out.getvalue() else True + + def test_single_fabric_never_prompts_for_ndo_or_isn_fields(self) -> None: + # Deliberately supply an --answers value for an ISN-only field to + # prove it's simply never consulted on the single-fabric branch + # (rather than testing an absence of output, which is brittle). + out = io.StringIO() + topo, _gw = run_wizard( + stream_in=io.StringIO(""), + stream_out=out, + accept_defaults=False, + answers={"deployment_type": "1", "isn_mtu": "1234"}, + ) + assert topo["isn"]["mtu"] == 9150 # wizard default, NOT the supplied 1234 (never asked) + + def test_multi_site_default_yields_two_sites_and_isn_enabled(self) -> None: + out = io.StringIO() + topo, _gw = run_wizard( + stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers={"deployment_type": "2"} + ) + assert len(topo["sites"]) == 2 + assert topo["isn"]["enabled"] is True + assert "Step 3" in out.getvalue() + + def test_multi_site_n_sites_override(self) -> None: + out = io.StringIO() + topo, _gw = run_wizard( + stream_in=io.StringIO(""), + stream_out=out, + accept_defaults=False, + answers={"deployment_type": "2", "num_sites": "3"}, + ) + assert len(topo["sites"]) == 3 + assert topo["isn"]["enabled"] is True + # Sequential ASN/site-id/name defaults continue past site 2. + assert topo["sites"][2]["asn"] == 65003 + assert topo["sites"][2]["name"] == "Site3-IT-ACI" + + def test_multi_site_topology_validates_and_builds_all_sites(self) -> None: + out = io.StringIO() + topo, _gw = run_wizard( + stream_in=io.StringIO(""), stream_out=out, accept_defaults=True, answers={"deployment_type": "2"} + ) + topo_obj = Topology.model_validate(topo) + + from aci_sim.build import orchestrator + + for site in topo_obj.sites: + store = orchestrator.build_site(topo_obj, site) + assert store.by_class("fabricNode") + + +# --------------------------------------------------------------------------- +# 5. Non-TTY stdin auto-accepts defaults with no hang. +# --------------------------------------------------------------------------- + + +class TestNonTtyAutoAccept: + def test_accept_defaults_flag_never_reads_stdin(self) -> None: + """accept_defaults=True must short-circuit _prompt before it ever + calls stream_in.readline() — assert by passing a stream that raises + if read from.""" + + class _ExplodingStream(io.StringIO): + def readline(self, *a, **kw): # noqa: D401 - test double + raise AssertionError("stdin should never be read when accept_defaults=True") + + out = io.StringIO() + topo, _gw = run_wizard(stream_in=_ExplodingStream(""), stream_out=out, accept_defaults=True) + assert len(topo["sites"]) == 2 # completed without ever touching stdin + + def test_cli_subprocess_with_empty_stdin_completes_without_defaults_flag(self, tmp_path: Path) -> None: + """End-to-end: invoke the real `aci-sim init` subcommand (via -m, + matching how the CLI is actually launched) with stdin explicitly + set to DEVNULL (non-TTY, immediate EOF) and NO --defaults flag — + must still complete (isatty()==False auto-enables accept_defaults) + rather than hang waiting for input. A timeout means this test caught + a real hang, not a flaky assertion.""" + out_file = tmp_path / "auto.topology.yaml" + result = subprocess.run( + [sys.executable, "-m", "aci_sim.cli", "init", "-o", str(out_file)], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + cwd=REPO_ROOT, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert out_file.exists() + loaded = load_topology(out_file) + assert len(loaded.sites) == 2 + + +# --------------------------------------------------------------------------- +# Misc: --answers file loading (YAML and JSON), CLI wiring smoke test. +# --------------------------------------------------------------------------- + + +class TestAnswersFileLoading: + def test_answers_file_yaml(self, tmp_path: Path) -> None: + answers_file = tmp_path / "answers.yaml" + answers_file.write_text("deployment_type: '1'\nsite1_name: FromYaml\n", encoding="utf-8") + out_file = tmp_path / "out.yaml" + + result = subprocess.run( + [ + sys.executable, "-m", "aci_sim.cli", "init", + "-o", str(out_file), "--answers", str(answers_file), + ], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + cwd=REPO_ROOT, + ) + assert result.returncode == 0, result.stdout + result.stderr + loaded = load_topology(out_file) + assert loaded.sites[0].name == "FromYaml" + + def test_answers_file_json(self, tmp_path: Path) -> None: + answers_file = tmp_path / "answers.json" + answers_file.write_text('{"deployment_type": "1", "site1_name": "FromJson"}', encoding="utf-8") + out_file = tmp_path / "out.yaml" + + result = subprocess.run( + [ + sys.executable, "-m", "aci_sim.cli", "init", + "-o", str(out_file), "--answers", str(answers_file), + ], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + cwd=REPO_ROOT, + ) + assert result.returncode == 0, result.stdout + result.stderr + loaded = load_topology(out_file) + assert loaded.sites[0].name == "FromJson" + + def test_answers_file_not_found_reports_error_exit_1(self, tmp_path: Path) -> None: + result = subprocess.run( + [sys.executable, "-m", "aci_sim.cli", "init", "--answers", str(tmp_path / "nope.yaml")], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + cwd=REPO_ROOT, + ) + assert result.returncode == 1 + assert "ERROR" in result.stderr + + +class TestConfirmWrite: + def test_confirm_write_false_aborts_without_writing(self, tmp_path: Path) -> None: + answers_file = tmp_path / "answers.yaml" + answers_file.write_text("deployment_type: '1'\nconfirm_write: 'no'\n", encoding="utf-8") + out_file = tmp_path / "should_not_exist.yaml" + + result = subprocess.run( + [ + sys.executable, "-m", "aci_sim.cli", "init", + "-o", str(out_file), "--answers", str(answers_file), + ], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + cwd=REPO_ROOT, + ) + assert result.returncode == 1 + assert not out_file.exists() + assert "Aborted" in result.stdout + + +class TestCliSmoke: + def test_init_appears_in_help(self) -> None: + result = subprocess.run( + [sys.executable, "-m", "aci_sim.cli", "--help"], + capture_output=True, + text=True, + timeout=30, + cwd=REPO_ROOT, + ) + assert "init" in result.stdout + + def test_defaults_flag_and_output_flag_smoke(self, tmp_path: Path) -> None: + out_file = tmp_path / "smoke.topology.yaml" + result = subprocess.run( + [sys.executable, "-m", "aci_sim.cli", "init", "--defaults", "-o", str(out_file)], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + cwd=REPO_ROOT, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "OK: 2 site(s)" in result.stdout + loaded = load_topology(out_file) + assert len(loaded.sites) == 2 diff --git a/tests/test_pr3_fixes.py b/tests/test_pr3_fixes.py new file mode 100644 index 0000000..4cc0b83 --- /dev/null +++ b/tests/test_pr3_fixes.py @@ -0,0 +1,213 @@ +"""Regression tests for PR-3 P0 findings. + +FIX 1 — node IDs > 255 (LAB2: 301-402) previously produced invalid IPv4 +addresses (e.g. 10.1.401.1 has a 401 octet, 192.168.1.401 likewise) via +build/fabric.py's loopback_ip/oob_ip. Now every derived address must parse as +valid IPv4, stay unique per node within a site, and node IDs <= 255 (LAB1) +must keep their exact legacy addresses unchanged. + +FIX 2 — GET /api/mo/uni/userprofile-.json?query-target=subtree& +target-subtree-class=aaaUserDomain (CONTRACT.md §1 login probe) 404'd because +no userprofile MO was ever built. build/userprofile.py now seeds a static +uni/userprofile-admin (aaaUserEp) + aaaUserDomain child in every site's +baseline store. +""" +from __future__ import annotations + +import copy +import ipaddress + +import pytest +from fastapi.testclient import TestClient + +from aci_sim.build.orchestrator import build_site +from aci_sim.build.fabric import loopback_ip, oob_ip +from aci_sim.build.l3out import _build_node_profile, _upsert_ebgp_sessions +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 + +TOPO_PATH = "topology.yaml" # run from project root + + +@pytest.fixture(scope="module") +def topo(): + return load_topology(TOPO_PATH) + + +@pytest.fixture(scope="module") +def site_a(topo): + return topo.site_by_name("LAB1") + + +@pytest.fixture(scope="module") +def site_b(topo): + return topo.site_by_name("LAB2") + + +@pytest.fixture(scope="module") +def store_a(topo, site_a) -> MITStore: + return build_site(topo, site_a) + + +@pytest.fixture(scope="module") +def store_b(topo, site_b) -> MITStore: + return build_site(topo, site_b) + + +@pytest.fixture(scope="module") +def client_a(topo, site_a, store_a): + state = ApicSiteState( + name=site_a.name, site=site_a, topo=topo, store=store_a, + baseline=copy.deepcopy(store_a), + ) + c = TestClient(make_apic_app(state)) + c.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + return c + + +# --------------------------------------------------------------------------- +# FIX 1 — Test 1: all topSystem address/oobMgmtAddr parse as valid IPv4 and +# are unique within each site (both sites, with Site B's 301-402 explicit). +# --------------------------------------------------------------------------- + +def _topsystem_addrs(store: MITStore) -> list[tuple[str, str, str]]: + """Return (node_id, address, oobMgmtAddr) for every topSystem MO.""" + out = [] + for mo in store.by_class("topSystem"): + out.append((mo.attrs["id"], mo.attrs["address"], mo.attrs["oobMgmtAddr"])) + return out + + +def test_site_a_addresses_valid_and_unique(store_a): + rows = _topsystem_addrs(store_a) + assert rows, "no topSystem MOs found for LAB1" + addrs = [r[1] for r in rows] + oobs = [r[2] for r in rows] + for a in addrs: + ipaddress.ip_address(a) + for o in oobs: + ipaddress.ip_address(o) + assert len(addrs) == len(set(addrs)), f"duplicate loopback addresses in LAB1: {addrs}" + assert len(oobs) == len(set(oobs)), f"duplicate oob addresses in LAB1: {oobs}" + + +def test_site_b_addresses_valid_and_unique(store_b): + """LAB2 node IDs are 301-402 — the previously-invalid range.""" + rows = _topsystem_addrs(store_b) + node_ids = {r[0] for r in rows} + # sanity: LAB2's high-numbered nodes are actually present in this fixture + assert {"301", "302", "303", "304", "401", "402"} <= node_ids, node_ids + + addrs = [r[1] for r in rows] + oobs = [r[2] for r in rows] + for node_id, addr, oob in rows: + # Every address must parse as valid IPv4 -- this is the core regression + # check: pre-fix, node 401 produced "10.1.401.1" / "192.168.1.401", + # both of which ipaddress.ip_address() rejects (octet > 255). + ipaddress.ip_address(addr) + ipaddress.ip_address(oob) + + assert len(addrs) == len(set(addrs)), f"duplicate loopback addresses in LAB2: {addrs}" + assert len(oobs) == len(set(oobs)), f"duplicate oob addresses in LAB2: {oobs}" + + +# --------------------------------------------------------------------------- +# FIX 1 — Test 2: node IDs <= 255 keep the exact legacy addresses +# (backward compatibility for LAB1: nodes 1-3, 101-104, 201-202). +# --------------------------------------------------------------------------- + +def test_legacy_node_ids_unchanged(site_a): + """Node IDs <= 255 must keep the pre-fix 10...1 / 192.168.. form.""" + pod = site_a.pod + assert loopback_ip(pod, 101) == "10.1.101.1" + assert oob_ip(pod, 101) == "192.168.1.101" + assert loopback_ip(pod, 201) == "10.1.201.1" + assert oob_ip(pod, 201) == "192.168.1.201" + + +def test_legacy_top_system_addresses_in_store(store_a, site_a): + """The actual built topSystem MOs for LAB1 (all node IDs <= 255) must show + the unchanged legacy addresses.""" + pod = site_a.pod + node = next(n for n in site_a.all_nodes() if n.id == 101) + ts_dn = f"topology/pod-{pod}/node-{node.id}/sys" + mo = store_a.get(ts_dn) + assert mo is not None + assert mo.attrs["address"] == "10.1.101.1" + assert mo.attrs["oobMgmtAddr"] == "192.168.1.101" + + +# --------------------------------------------------------------------------- +# FIX 1 — Test 4: refactored producers (l3out.py rtr_id) also emit valid IPv4 +# --------------------------------------------------------------------------- + +def test_l3out_node_profile_rtr_id_valid_ipv4(topo, site_b): + """l3out.py's _build_node_profile derives rtr_id via loopback_ip for + border leaves; LAB2's border leaves (303/304) are <= 255, but this + verifies the refactored call-site (no more inline f"10.{pod}.{id}.1") + still produces valid IPv4, and stays consistent with fabric.py.""" + tenant = next(t for t in topo.tenants if t.l3outs) + l3out = next(lo for lo in tenant.l3outs if lo.site == site_b.name or + any(bl in {b.id for b in site_b.border_leaves} for bl in lo.border_leaves)) + np_mo = _build_node_profile(f"uni/tn-{tenant.name}/out-{l3out.name}", l3out, site_b, site_b.pod) + rs_atts = [c for c in np_mo.children if c.class_name == "l3extRsNodeL3OutAtt"] + assert rs_atts, "no l3extRsNodeL3OutAtt children built" + for att in rs_atts: + ipaddress.ip_address(att.attrs["rtrId"]) + + +def test_l3out_ebgp_session_rtr_id_valid_ipv4(topo, site_b, store_b): + """_upsert_ebgp_sessions's rtr_id (also refactored onto loopback_ip) must + be valid IPv4 in the built store.""" + bgp_peer_entries = [ + mo for mo in store_b.by_class("bgpPeerEntry") + if mo.attrs.get("type") == "ebgp" + ] + assert bgp_peer_entries, "no ebgp bgpPeerEntry found for LAB2" + for mo in bgp_peer_entries: + ipaddress.ip_address(mo.attrs["rtrId"]) + + +def test_cabling_lldp_cdp_mgmt_ip_valid_ipv4(store_b): + """cabling.py's lldpAdjEp/cdpAdjEp mgmtIp (built via oob_ip) must be valid + IPv4 even for LAB2 nodes > 255.""" + adj_mos = list(store_b.by_class("lldpAdjEp")) + list(store_b.by_class("cdpAdjEp")) + assert adj_mos, "no lldp/cdp adjacency MOs found for LAB2" + for mo in adj_mos: + ipaddress.ip_address(mo.attrs["mgmtIp"]) + + +def test_overlay_isn_peer_addr_valid_ipv4(store_a): + """overlay.py's ISN bgpPeer addr (loopback_ip + '/32') must have a valid + IPv4 host part even when the remote site's spines are > 255 (LAB2: 401/402).""" + isn_peers = [ + mo for mo in store_a.by_class("bgpPeer") + if mo.attrs.get("type") == "inter-site" + ] + assert isn_peers, "no ISN bgpPeer found on LAB1 spines" + for mo in isn_peers: + addr = mo.attrs["addr"] + assert addr.endswith("/32") + host = addr.rsplit("/", 1)[0] + ipaddress.ip_address(host) + + +# --------------------------------------------------------------------------- +# FIX 2 — Test 3: login probe target resolves with totalCount >= 1 +# --------------------------------------------------------------------------- + +def test_userprofile_login_probe(client_a): + resp = client_a.get( + "/api/mo/uni/userprofile-admin.json" + "?query-target=subtree&target-subtree-class=aaaUserDomain" + ) + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) >= 1 + assert data["imdata"], "expected at least one aaaUserDomain row" + for item in data["imdata"]: + assert "aaaUserDomain" in item diff --git a/tests/test_pr3b_batch1.py b/tests/test_pr3b_batch1.py new file mode 100644 index 0000000..b2a99db --- /dev/null +++ b/tests/test_pr3b_batch1.py @@ -0,0 +1,440 @@ +"""Regression/coverage tests for PR-3b-batch1 — 21 previously-documented-but- +unbuilt classes from docs/CONTRACT.md §6, seeded from the existing topology: + + Access-policy cluster (build/access.py): infraAccPortP, infraHPortS, + infraPortBlk, infraRsAccBaseGrp, infraAccBndlGrp. + Route-control cluster (build/l3out.py): rtctrlProfile, rtctrlCtxP, + rtctrlSubjP, rtctrlMatchRtDest, rtctrlSetComm, rtctrlSetPref, ipRouteP, + ipNexthopP, l3extMember, ospfExtP. + EPG/contract classes (build/tenants.py): fvRsPathAtt, vzRsSubjGraphAtt. + Operational/routing classes (build/routing.py + build/underlay.py): + uribv4Route, uribv4Nexthop, arpAdjEp, ospfIf. + +Auth is enforced (PR-4): every client fixture logs in via +POST /api/aaaLogin.json (admin/cisco) before making authenticated calls, +following the existing tests/test_pr5_topology_consistency.py pattern. +""" +from __future__ import annotations + +import copy +import re + +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(scope="module") +def topo(): + return load_topology(TOPO_PATH) + + +@pytest.fixture(scope="module") +def site_a(topo): + return topo.site_by_name("LAB1") + + +@pytest.fixture(scope="module") +def site_b(topo): + return topo.site_by_name("LAB2") + + +@pytest.fixture(scope="module") +def store_a(topo, site_a): + return build_site(topo, site_a) + + +@pytest.fixture(scope="module") +def store_b(topo, site_b): + return build_site(topo, site_b) + + +def _make_client(topo, site, store) -> TestClient: + 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 + + +@pytest.fixture(scope="module") +def client_a(topo, site_a, store_a): + return _make_client(topo, site_a, store_a) + + +@pytest.fixture(scope="module") +def client_b(topo, site_b, store_b): + return _make_client(topo, site_b, store_b) + + +_ALL_STORES = ["store_a", "store_b"] + + +def _l1physif_ports(store) -> dict[int, set[str]]: + """Return {node_id: {port_id, ...}} from every l1PhysIf DN in the store.""" + ports: dict[int, set[str]] = {} + for mo in store.by_class("l1PhysIf"): + m = re.search(r"/node-(\d+)/sys/phys-\[([^\]]+)\]", mo.dn) + assert m is not None, f"unparsable l1PhysIf dn {mo.dn!r}" + node_id = int(m.group(1)) + ports.setdefault(node_id, set()).add(m.group(2)) + return ports + + +# --------------------------------------------------------------------------- +# Access-policy cluster +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_access_policy_classes_nonempty(store_name, request): + store = request.getfixturevalue(store_name) + for cls in ( + "infraAccPortP", "infraHPortS", "infraPortBlk", + "infraRsAccBaseGrp", "infraAccBndlGrp", + ): + mos = store.by_class(cls) + assert mos, f"{cls} query returned totalCount 0" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_infra_port_blk_ports_exist_in_l1physif_inventory(store_name, request): + """Every infraPortBlk from/to port must exist as a real l1PhysIf — the + port block's fromCard/from/to describe a leaf host-port range, but + infraPortBlk itself carries no node id, so we resolve it via its parent + infraHPortS -> infraRsAccBaseGrp -> infraAccBndlGrp path is not node- + scoped either (access profiles are site-independent in this sim); we + instead assert the from/to port NUMBERS fall within the host-port range + (1.._HOST_PORTS) that interfaces.py builds on every leaf, i.e. the block + is representable on any leaf's real inventory. + """ + store = request.getfixturevalue(store_name) + ports = _l1physif_ports(store) + all_host_ports = {int(p.split("/")[1]) for ps in ports.values() for p in ps if "/" in p} + blks = store.by_class("infraPortBlk") + assert blks, "no infraPortBlk found" + for blk in blks: + from_p = int(blk.attrs["fromPort"]) + to_p = int(blk.attrs["toPort"]) + assert blk.attrs["fromCard"] == "1" + assert from_p <= to_p + assert from_p in all_host_ports, ( + f"infraPortBlk {blk.dn!r} fromPort {from_p} does not match any " + f"real l1PhysIf port number" + ) + assert to_p in all_host_ports, ( + f"infraPortBlk {blk.dn!r} toPort {to_p} does not match any real " + f"l1PhysIf port number" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_hports_rs_acc_base_grp_targets_real_bndl_grp(store_name, request): + store = request.getfixturevalue(store_name) + bndl_dns = {mo.dn for mo in store.by_class("infraAccBndlGrp")} + assert bndl_dns, "no infraAccBndlGrp found" + rs_grps = store.by_class("infraRsAccBaseGrp") + assert rs_grps, "no infraRsAccBaseGrp found" + for rs in rs_grps: + assert rs.attrs["tDn"] in bndl_dns, ( + f"infraRsAccBaseGrp {rs.dn!r} tDn {rs.attrs['tDn']!r} does not " + f"resolve to a real infraAccBndlGrp" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_at_least_one_vpc_bndl_grp(store_name, request): + """One infraAccBndlGrp (lagT=node) per border-leaf vPC domain, consistent + with fabric.py's vpcDom/vpcIf for the same pairs.""" + store = request.getfixturevalue(store_name) + vpc_bndls = [m for m in store.by_class("infraAccBndlGrp") if m.attrs.get("lagT") == "node"] + assert vpc_bndls, "expected at least one vPC (lagT=node) infraAccBndlGrp" + vpc_doms = store.by_class("vpcDom") + assert vpc_doms, "no vpcDom found to cross-check against" + + +def test_infra_subtree_full_returns_nested_access_policy_tree(client_a): + """rsp-subtree=full on uni/infra returns the nested access-policy tree.""" + resp = client_a.get("/api/mo/uni/infra.json?rsp-subtree=full") + assert resp.status_code == 200, f"uni/infra -> {resp.status_code}: {resp.text[:200]}" + data = resp.json() + assert data["imdata"], "expected uni/infra to resolve to a real MO" + body = str(data) + for cls in ("infraAccPortP", "infraHPortS", "infraPortBlk", "infraRsAccBaseGrp", "infraAccBndlGrp"): + assert cls in body, f"{cls} missing from uni/infra rsp-subtree=full response" + + +# --------------------------------------------------------------------------- +# Route-control cluster +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_route_control_classes_nonempty(store_name, request): + store = request.getfixturevalue(store_name) + for cls in ( + "rtctrlProfile", "rtctrlCtxP", "rtctrlSubjP", "rtctrlMatchRtDest", + "rtctrlSetComm", "rtctrlSetPref", "ipRouteP", "ipNexthopP", + "l3extMember", "ospfExtP", + ): + mos = store.by_class(cls) + assert mos, f"{cls} query returned totalCount 0" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_rtctrl_match_rt_dest_uses_real_bd_subnet(store_name, request, topo): + store = request.getfixturevalue(store_name) + all_subnets = { + cidr + for tenant in topo.tenants + for bd in tenant.bds + for cidr in bd.subnets + } + dests = store.by_class("rtctrlMatchRtDest") + assert dests, "no rtctrlMatchRtDest found" + for d in dests: + assert d.attrs["ip"] in all_subnets, ( + f"rtctrlMatchRtDest {d.dn!r} ip {d.attrs['ip']!r} is not a real BD subnet" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_ip_route_p_under_real_rsnode_l3out_att(store_name, request): + """Every ipRouteP dn must nest under a real l3extRsNodeL3OutAtt (i.e. an + actual border-leaf node attachment), per the placement table.""" + store = request.getfixturevalue(store_name) + rsnode_dns = {mo.dn for mo in store.by_class("l3extRsNodeL3OutAtt")} + assert rsnode_dns, "no l3extRsNodeL3OutAtt found" + routes = store.by_class("ipRouteP") + assert routes, "no ipRouteP found" + for r in routes: + parent = r.dn.rsplit("/rt-[", 1)[0] + assert parent in rsnode_dns, f"ipRouteP {r.dn!r} not nested under a real rsnodeL3OutAtt" + assert r.attrs["ip"] == "0.0.0.0/0" + assert r.attrs.get("pref") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_ip_nexthop_p_matches_csw_peer_ip(store_name, request, topo): + store = request.getfixturevalue(store_name) + nhs = store.by_class("ipNexthopP") + assert nhs, "no ipNexthopP found" + all_csw_peer_ips = { + bl.csw.peer_ip + for site in topo.sites + for bl in site.border_leaves + } + for nh in nhs: + assert nh.attrs["nhAddr"] in all_csw_peer_ips, ( + f"ipNexthopP {nh.dn!r} nhAddr {nh.attrs['nhAddr']!r} is not a real CSW peer IP" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_l3ext_member_on_real_path(store_name, request): + store = request.getfixturevalue(store_name) + path_dns = {mo.dn for mo in store.by_class("l3extRsPathL3OutAtt")} + assert path_dns, "no l3extRsPathL3OutAtt found" + members = store.by_class("l3extMember") + assert members, "no l3extMember found" + for m in members: + parent = m.dn.rsplit("/mem-", 1)[0] + assert parent in path_dns, f"l3extMember {m.dn!r} not nested under a real l3extRsPathL3OutAtt" + assert m.attrs["side"] == "A" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_ospf_ext_p_coexists_with_bgp_ext_p_on_same_l3out(store_name, request): + """Batch-1 design choice: ospfExtP added to the SAME L3Out as the + existing bgpExtP (real ACI allows OSPF+BGP coexistence on one L3Out).""" + store = request.getfixturevalue(store_name) + ospf_ext = store.by_class("ospfExtP") + bgp_ext = store.by_class("bgpExtP") + assert ospf_ext, "no ospfExtP found" + assert bgp_ext, "no bgpExtP found" + ospf_parents = {mo.dn.rsplit("/ospfExtP", 1)[0] for mo in ospf_ext} + bgp_parents = {mo.dn.rsplit("/bgpExtP", 1)[0] for mo in bgp_ext} + assert ospf_parents & bgp_parents, "ospfExtP and bgpExtP do not share a common L3Out parent" + + +# --------------------------------------------------------------------------- +# EPG/contract classes +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_epg_contract_classes_nonempty(store_name, request): + store = request.getfixturevalue(store_name) + for cls in ("fvRsPathAtt", "vzRsSubjGraphAtt"): + mos = store.by_class(cls) + assert mos, f"{cls} query returned totalCount 0" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_fv_rs_path_att_tdn_is_real_path_with_existing_port(store_name, request): + """Every fvRsPathAtt tDn must be a real path whose port exists as a real + l1PhysIf (same port an EPG's own endpoints already use).""" + store = request.getfixturevalue(store_name) + ports = _l1physif_ports(store) + bindings = store.by_class("fvRsPathAtt") + assert bindings, "no fvRsPathAtt found" + for b in bindings: + m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", b.attrs["tDn"]) + assert m is not None, f"unparsable fvRsPathAtt tDn {b.attrs['tDn']!r}" + node_id, port_id = int(m.group(1)), m.group(2) + assert port_id in ports.get(node_id, set()), ( + f"fvRsPathAtt {b.dn!r} tDn references {port_id!r} on node {node_id}, " + f"which has no matching l1PhysIf" + ) + # encap must reuse the EPG's own vlan (parsed from the same store's fvCEp) + assert b.attrs["encap"].startswith("vlan-") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_vz_rs_subj_graph_att_on_real_subject(store_name, request): + store = request.getfixturevalue(store_name) + subj_dns = {mo.dn for mo in store.by_class("vzSubj")} + assert subj_dns, "no vzSubj found" + graphs = store.by_class("vzRsSubjGraphAtt") + assert graphs, "no vzRsSubjGraphAtt found" + for g in graphs: + parent = g.dn.rsplit("/rsSubjGraphAtt", 1)[0] + assert parent in subj_dns, f"vzRsSubjGraphAtt {g.dn!r} not nested under a real vzSubj" + assert g.attrs.get("tnVnsAbsGraphName") + + +# --------------------------------------------------------------------------- +# Operational/routing classes +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_routing_classes_nonempty(store_name, request): + store = request.getfixturevalue(store_name) + for cls in ("uribv4Route", "uribv4Nexthop", "arpAdjEp", "ospfIf"): + mos = store.by_class(cls) + assert mos, f"{cls} query returned totalCount 0" + + +_RE_NH_PARENT = re.compile(r"^(.*/rt-\[[^\]]+\])/nh-") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_uribv4_nexthop_rn_parses_into_4_bracketed_parts(store_name, request): + """Every uribv4Nexthop RN must parse into 4 bracketed parts: + nh-[proto]-[addr]-[ifname]-[vrf] — matching autoACI's + _RE_NH_PARENT = re.compile(r"^(.*/rt-\\[[^\\]]+\\])/nh-") parent-recovery + regex (verified read-only against route_table.py / routing_state.py).""" + store = request.getfixturevalue(store_name) + nhs = store.by_class("uribv4Nexthop") + assert nhs, "no uribv4Nexthop found" + for nh in nhs: + m = _RE_NH_PARENT.match(nh.dn) + assert m is not None, f"uribv4Nexthop {nh.dn!r} does not match autoACI's parent-recovery regex" + rn = nh.dn.rsplit("/nh-", 1)[1] + bracket_groups = re.findall(r"\[([^\]]*)\]", rn) + assert len(bracket_groups) == 4, ( + f"uribv4Nexthop {nh.dn!r} RN has {len(bracket_groups)} bracketed " + f"parts, expected 4 (proto, addr, ifname, vrf)" + ) + # parent route must actually exist in the store + parent_dn = m.group(1) + assert store.get(parent_dn) is not None, f"parent uribv4Route {parent_dn!r} missing" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_uribv4_route_dom_segment_matches_real_vrf(store_name, request, topo): + store = request.getfixturevalue(store_name) + routes = store.by_class("uribv4Route") + assert routes, "no uribv4Route found" + known_vrf_doms = { + f"{tenant.name}:{vrf.name}" + for tenant in topo.tenants + for vrf in tenant.vrfs + } + for r in routes: + m = re.search(r"/dom-([^/]+)/db-rt/", r.dn) + assert m is not None, f"unparsable uribv4Route dn {r.dn!r} (no dom- segment)" + assert m.group(1) in known_vrf_doms, ( + f"uribv4Route {r.dn!r} dom {m.group(1)!r} is not a real tenant:vrf" + ) + assert r.attrs.get("prefix") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_arp_adj_ep_count_equals_local_endpoint_count(store_name, request): + """arpAdjEp count == (locally-learned) endpoint count.""" + store = request.getfixturevalue(store_name) + local_ceps = [c for c in store.by_class("fvCEp") if c.attrs.get("lcC") == "learned"] + arp_adjs = store.by_class("arpAdjEp") + assert local_ceps, "no locally-learned fvCEp found" + assert len(arp_adjs) == len(local_ceps), ( + f"arpAdjEp count {len(arp_adjs)} != local endpoint count {len(local_ceps)}" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_arp_adj_ep_mac_matches_a_real_endpoint(store_name, request): + store = request.getfixturevalue(store_name) + local_ceps = {c.attrs["mac"] for c in store.by_class("fvCEp") if c.attrs.get("lcC") == "learned"} + for adj in store.by_class("arpAdjEp"): + assert adj.attrs["mac"] in local_ceps, f"arpAdjEp {adj.dn!r} mac not a real endpoint MAC" + assert adj.attrs["operSt"] == "up" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_ospf_adj_ep_sits_on_an_ospf_if(store_name, request): + """Every ospfAdjEp must nest directly under a real ospfIf (the ospfIf's + own DN being the ospfAdjEp's parent — CONTRACT.md batch-1 placement: + ospfIf nested between ospfDom and ospfAdjEp).""" + store = request.getfixturevalue(store_name) + ospf_if_dns = {mo.dn for mo in store.by_class("ospfIf")} + adjs = store.by_class("ospfAdjEp") + assert adjs, "no ospfAdjEp found (multi-site topology expected)" + assert ospf_if_dns, "no ospfIf found" + for adj in adjs: + parent = adj.dn.rsplit("/adj-[", 1)[0] + assert parent in ospf_if_dns, f"ospfAdjEp {adj.dn!r} does not sit on a real ospfIf" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_ospf_if_references_real_port(store_name, request): + store = request.getfixturevalue(store_name) + ports = _l1physif_ports(store) + ifs = store.by_class("ospfIf") + assert ifs, "no ospfIf found" + for ospf_if in ifs: + m = re.search(r"/node-(\d+)/sys/ospf/.*?/if-\[([^\]]+)\]", ospf_if.dn) + assert m is not None, f"unparsable ospfIf dn {ospf_if.dn!r}" + node_id, port_id = int(m.group(1)), m.group(2) + assert port_id in ports.get(node_id, set()), ( + f"ospfIf {ospf_if.dn!r} references {port_id!r} on node {node_id}, " + f"which has no matching l1PhysIf" + ) + assert ospf_if.attrs.get("id") == port_id + + +# --------------------------------------------------------------------------- +# Cross-cluster sanity: full-site build still round-trips through the REST +# layer for at least one class per cluster (auth-enforced client). +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "cls", + [ + "infraAccPortP", "rtctrlProfile", "ipRouteP", "fvRsPathAtt", + "uribv4Route", "arpAdjEp", "ospfIf", "l3extMember", "ospfExtP", + "vzRsSubjGraphAtt", + ], +) +def test_class_query_returns_total_count_gt_0(client_a, cls): + resp = client_a.get(f"/api/class/{cls}.json") + assert resp.status_code == 200, f"{cls} -> {resp.status_code}: {resp.text[:200]}" + data = resp.json() + assert int(data["totalCount"]) > 0, f"{cls} totalCount is 0" diff --git a/tests/test_pr3b_batch2.py b/tests/test_pr3b_batch2.py new file mode 100644 index 0000000..6a28dff --- /dev/null +++ b/tests/test_pr3b_batch2.py @@ -0,0 +1,471 @@ +"""Regression/coverage tests for PR-3b-batch2 — remaining previously- +catalogued-but-unbuilt classes from docs/CONTRACT.md §6 (batches 2+3), +seeded from the existing topology: + + Port-channel/vPC (build/fabric.py): pcAggrIf, pcRsMbrIfs (CONTRACT.md + corrected: was catalogued as "vpcRsMbrIfs", real ACI/autoACI class is + pcRsMbrIfs — see docs/CONTRACT.md §6 changelog note). + Optics (build/interfaces.py): ethpmFcot. + Cluster health (build/fabric.py): infraWiNode. + Zoning-rule / pcTag plumbing (build/tenants.py + new build/zoning.py): + fvEpP, actrlRule (+ fvAEPg.pcTag / fvCtx.pcTag+scope, not separately + catalogued but required for the above to cross-reference). + EVPN routes (build/overlay.py): bgpVpnRoute, bgpPath. + Node registration (build/fabric.py + rest_aci/writes.py): fabricNodeIdentP. + +infraNodeIdentP is intentionally NOT built — see docs/CONTRACT.md §6 for the +decision (no autoACI consumer, and real ACI's infraNodeIdentP is a node +*provisioning policy* object, not the same thing as fabricNodeIdentP's +Fabric Membership registration; building it under the same semantics as +fabricNodeIdentP would be a faithfulness regression, not a coverage gain). + +Auth is enforced (PR-4): every client fixture logs in via +POST /api/aaaLogin.json (admin/cisco) before making authenticated calls, +following the existing tests/test_pr3b_batch1.py pattern. +""" +from __future__ import annotations + +import copy +import re + +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(scope="module") +def topo(): + return load_topology(TOPO_PATH) + + +@pytest.fixture(scope="module") +def site_a(topo): + return topo.site_by_name("LAB1") + + +@pytest.fixture(scope="module") +def site_b(topo): + return topo.site_by_name("LAB2") + + +@pytest.fixture(scope="module") +def store_a(topo, site_a): + return build_site(topo, site_a) + + +@pytest.fixture(scope="module") +def store_b(topo, site_b): + return build_site(topo, site_b) + + +def _make_client(topo, site, store) -> TestClient: + 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 + + +@pytest.fixture(scope="module") +def client_a(topo, site_a, store_a): + return _make_client(topo, site_a, store_a) + + +@pytest.fixture(scope="module") +def client_b(topo, site_b, store_b): + return _make_client(topo, site_b, store_b) + + +_ALL_STORES = ["store_a", "store_b"] + + +def _l1physif_dns(store) -> set[str]: + return {mo.dn for mo in store.by_class("l1PhysIf")} + + +# --------------------------------------------------------------------------- +# Non-empty coverage — every batch-2 class must have totalCount > 0 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_batch2_classes_nonempty(store_name, request): + store = request.getfixturevalue(store_name) + for cls in ( + "pcAggrIf", "pcRsMbrIfs", "ethpmFcot", "infraWiNode", + "fvEpP", "actrlRule", "bgpVpnRoute", "bgpPath", "fabricNodeIdentP", + ): + mos = store.by_class(cls) + assert mos, f"{cls} query returned totalCount 0" + + +@pytest.mark.parametrize( + "cls", + [ + "pcAggrIf", "pcRsMbrIfs", "ethpmFcot", "infraWiNode", + "fvEpP", "actrlRule", "bgpVpnRoute", "bgpPath", "fabricNodeIdentP", + ], +) +def test_class_query_returns_total_count_gt_0(client_a, cls): + resp = client_a.get(f"/api/class/{cls}.json") + assert resp.status_code == 200, f"{cls} -> {resp.status_code}: {resp.text[:200]}" + data = resp.json() + assert int(data["totalCount"]) > 0, f"{cls} totalCount is 0" + + +# --------------------------------------------------------------------------- +# pcAggrIf / pcRsMbrIfs — vPC port-channel + member ports +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_pc_aggr_if_matches_a_real_vpc_if(store_name, request): + """vpc_status.py matches pcAggrIf to vpcIf by (node, name==ipg_name).""" + store = request.getfixturevalue(store_name) + vpc_by_node_name = set() + for vif in store.by_class("vpcIf"): + m = re.search(r"/node-(\d+)/", vif.dn) + assert m is not None + vpc_by_node_name.add((m.group(1), vif.attrs["name"])) + + aggrs = store.by_class("pcAggrIf") + assert aggrs, "no pcAggrIf found" + for aggr in aggrs: + m = re.search(r"/node-(\d+)/", aggr.dn) + assert m is not None, f"unparsable pcAggrIf dn {aggr.dn!r}" + key = (m.group(1), aggr.attrs["name"]) + assert key in vpc_by_node_name, ( + f"pcAggrIf {aggr.dn!r} (node={key[0]}, name={key[1]}) has no matching vpcIf" + ) + assert aggr.attrs["id"].startswith("po") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_pc_rs_mbr_ifs_member_dns_exist_in_l1physif(store_name, request): + """Every pcRsMbrIfs.tDn must resolve to a real l1PhysIf — vpc_status.py + parses the member port out of tDn's phys-[...] segment and expects it to + be a real interface.""" + store = request.getfixturevalue(store_name) + phys_dns = _l1physif_dns(store) + assert phys_dns, "no l1PhysIf found" + members = store.by_class("pcRsMbrIfs") + assert members, "no pcRsMbrIfs found" + for mbr in members: + t_dn = mbr.attrs["tDn"] + assert "phys-[" in t_dn, f"pcRsMbrIfs {mbr.dn!r} tDn {t_dn!r} has no phys-[...] segment" + assert t_dn in phys_dns, ( + f"pcRsMbrIfs {mbr.dn!r} tDn {t_dn!r} does not resolve to a real l1PhysIf" + ) + assert mbr.attrs["parentSKey"], "pcRsMbrIfs missing parentSKey (po id)" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_pc_rs_mbr_ifs_nested_under_real_pc_aggr_if(store_name, request): + store = request.getfixturevalue(store_name) + aggr_dns = {mo.dn for mo in store.by_class("pcAggrIf")} + assert aggr_dns, "no pcAggrIf found" + for mbr in store.by_class("pcRsMbrIfs"): + parent = mbr.dn.rsplit("/rsmbrIfs-[", 1)[0] + assert parent in aggr_dns, f"pcRsMbrIfs {mbr.dn!r} not nested under a real pcAggrIf" + + +# --------------------------------------------------------------------------- +# ethpmFcot — SFP/transceiver inventory +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_ethpm_fcot_sits_on_real_port(store_name, request): + store = request.getfixturevalue(store_name) + phys_dns = _l1physif_dns(store) + assert phys_dns, "no l1PhysIf found" + fcots = store.by_class("ethpmFcot") + assert fcots, "no ethpmFcot found" + for fcot in fcots: + parent = fcot.dn.rsplit("/fcot", 1)[0] + assert parent in phys_dns, f"ethpmFcot {fcot.dn!r} does not sit on a real l1PhysIf port" + assert fcot.attrs.get("typeName") + assert fcot.attrs.get("vendorName") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_ethpm_fcot_only_on_ethpm_phys_if_ports(store_name, request): + """Every ethpmFcot DN must share its port with a real ethpmPhysIf (sibling + placement, per the module docstring) — i.e. ethpmFcot is a subset of the + ports that have ethpmPhysIf, never a port that has no ethpmPhysIf.""" + store = request.getfixturevalue(store_name) + ethpm_ports = {mo.dn.rsplit("/phys", 1)[0] for mo in store.by_class("ethpmPhysIf")} + for fcot in store.by_class("ethpmFcot"): + port_dn = fcot.dn.rsplit("/fcot", 1)[0] + assert port_dn in ethpm_ports, f"ethpmFcot {fcot.dn!r} has no sibling ethpmPhysIf" + + +# --------------------------------------------------------------------------- +# infraWiNode — APIC cluster fitness +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_infra_wi_node_all_fully_fit(store_name, request): + store = request.getfixturevalue(store_name) + wi_nodes = store.by_class("infraWiNode") + assert wi_nodes, "no infraWiNode found" + for wi in wi_nodes: + assert wi.attrs["health"] == "fully-fit", f"infraWiNode {wi.dn!r} not fully-fit" + + +@pytest.mark.parametrize("store_name,site_name", [("store_a", "site_a"), ("store_b", "site_b")]) +def test_infra_wi_node_count_matches_cluster_squared(store_name, site_name, request): + """Every controller has its OWN view of every other controller — an NxN + appliance-vector matrix (N = site.controllers).""" + store = request.getfixturevalue(store_name) + site = request.getfixturevalue(site_name) + n = site.controllers + wi_nodes = store.by_class("infraWiNode") + assert len(wi_nodes) == n * n, ( + f"infraWiNode count {len(wi_nodes)} != controllers^2 ({n}^2={n*n})" + ) + + +# --------------------------------------------------------------------------- +# fvEpP / fvAEPg.pcTag / fvCtx.pcTag+scope — zoning-rule EPG name resolution +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_fv_epp_epg_pkey_matches_a_real_epg(store_name, request): + store = request.getfixturevalue(store_name) + epg_dns = {mo.dn for mo in store.by_class("fvAEPg")} + assert epg_dns, "no fvAEPg found" + epps = store.by_class("fvEpP") + assert epps, "no fvEpP found" + for epp in epps: + epg_pkey = epp.attrs.get("epgPKey", "") + assert epg_pkey in epg_dns, f"fvEpP {epp.dn!r} epgPKey {epg_pkey!r} is not a real fvAEPg" + assert epp.dn == f"uni/epp/fv-[{epg_pkey}]" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_fv_epp_pctag_matches_owning_epg(store_name, request): + store = request.getfixturevalue(store_name) + epg_pctag = {mo.dn: mo.attrs.get("pcTag") for mo in store.by_class("fvAEPg")} + for epp in store.by_class("fvEpP"): + epg_dn = epp.attrs["epgPKey"] + assert epp.attrs.get("pcTag"), f"fvEpP {epp.dn!r} missing pcTag" + assert epp.attrs["pcTag"] == epg_pctag.get(epg_dn), ( + f"fvEpP {epp.dn!r} pcTag does not match its owning fvAEPg's pcTag" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_fv_aepg_pctag_is_stable_and_nonempty(store_name, request): + store = request.getfixturevalue(store_name) + epgs = store.by_class("fvAEPg") + assert epgs, "no fvAEPg found" + seen_tags = set() + for epg in epgs: + tag = epg.attrs.get("pcTag") + assert tag, f"fvAEPg {epg.dn!r} missing pcTag" + assert tag not in seen_tags, f"pcTag {tag!r} collides across EPGs" + seen_tags.add(tag) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_fv_ctx_has_pctag_and_scope(store_name, request): + store = request.getfixturevalue(store_name) + ctxs = store.by_class("fvCtx") + assert ctxs, "no fvCtx found" + for ctx in ctxs: + assert ctx.attrs.get("pcTag"), f"fvCtx {ctx.dn!r} missing pcTag" + assert ctx.attrs.get("scope"), f"fvCtx {ctx.dn!r} missing scope" + + +# --------------------------------------------------------------------------- +# actrlRule — compiled zoning rules +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_actrl_rule_pctags_match_fv_aepg(store_name, request): + """actrlRule.sPcTag/dPcTag must both resolve to real fvAEPg.pcTag values — + i.e. the rule's provider/consumer EPG pcTags are consistent with the + fvAEPg/fvEpP data zoning_rules.py cross-references against.""" + store = request.getfixturevalue(store_name) + real_pctags = {mo.attrs["pcTag"] for mo in store.by_class("fvAEPg")} + assert real_pctags, "no fvAEPg pcTags found" + rules = store.by_class("actrlRule") + assert rules, "no actrlRule found" + for rule in rules: + assert rule.attrs["sPcTag"] in real_pctags, ( + f"actrlRule {rule.dn!r} sPcTag {rule.attrs['sPcTag']!r} is not a real EPG pcTag" + ) + assert rule.attrs["dPcTag"] in real_pctags, ( + f"actrlRule {rule.dn!r} dPcTag {rule.attrs['dPcTag']!r} is not a real EPG pcTag" + ) + assert rule.attrs["action"] == "permit" + assert rule.attrs.get("ctrctName") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_actrl_rule_scope_matches_a_real_vrf_scope(store_name, request): + store = request.getfixturevalue(store_name) + real_scopes = {mo.attrs["scope"] for mo in store.by_class("fvCtx")} + assert real_scopes, "no fvCtx scopes found" + for rule in store.by_class("actrlRule"): + assert rule.attrs["scopeId"] in real_scopes, ( + f"actrlRule {rule.dn!r} scopeId {rule.attrs['scopeId']!r} is not a real VRF scope" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_actrl_rule_dn_under_real_node(store_name, request): + store = request.getfixturevalue(store_name) + node_ids = set() + for node in store.by_class("fabricNode"): + if node.attrs.get("role") != "controller": + node_ids.add(node.attrs["id"]) + rules = store.by_class("actrlRule") + assert rules, "no actrlRule found" + for rule in rules: + m = re.search(r"/node-(\d+)/sys/actrl/", rule.dn) + assert m is not None, f"unparsable actrlRule dn {rule.dn!r}" + assert m.group(1) in node_ids, f"actrlRule {rule.dn!r} references a non-real node" + + +# --------------------------------------------------------------------------- +# bgpVpnRoute / bgpPath — EVPN route table (two-pass parseable) +# --------------------------------------------------------------------------- + +_RE_PATH_PARENT = re.compile(r"^(.*)/path-[^/]+$") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_bgp_path_is_child_of_bgp_vpn_route_two_pass_parseable(store_name, request): + """Mirrors autoACI's fabric_bgp_evpn.py two-pass parse: pass 1 collects + bgpVpnRoute by dn, pass 2 recovers the parent route dn from a bgpPath dn + via `path_dn.rsplit("/path-", 1)[0]`.""" + store = request.getfixturevalue(store_name) + routes = {mo.dn for mo in store.by_class("bgpVpnRoute")} + assert routes, "no bgpVpnRoute found" + paths = store.by_class("bgpPath") + assert paths, "no bgpPath found" + for path in paths: + assert "/path-" in path.dn, f"bgpPath {path.dn!r} has no /path- segment" + parent_dn = path.dn.rsplit("/path-", 1)[0] + assert parent_dn in routes, ( + f"bgpPath {path.dn!r} parent {parent_dn!r} is not a real bgpVpnRoute" + ) + assert path.attrs.get("nh"), f"bgpPath {path.dn!r} missing nh" + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_bgp_vpn_route_prefix_is_real_bd_subnet(store_name, request, topo): + store = request.getfixturevalue(store_name) + all_subnets = { + cidr for tenant in topo.tenants for bd in tenant.bds for cidr in bd.subnets + } + routes = store.by_class("bgpVpnRoute") + assert routes, "no bgpVpnRoute found" + for route in routes: + assert route.attrs["pfx"] in all_subnets, ( + f"bgpVpnRoute {route.dn!r} pfx {route.attrs['pfx']!r} is not a real BD subnet" + ) + assert route.attrs.get("rd") + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_bgp_vpn_route_dn_under_a_real_spine(store_name, request): + store = request.getfixturevalue(store_name) + spine_ids = { + mo.attrs["id"] for mo in store.by_class("fabricNode") if mo.attrs.get("role") == "spine" + } + assert spine_ids, "no spine fabricNode found" + for route in store.by_class("bgpVpnRoute"): + m = re.search(r"/node-(\d+)/sys/bgp/inst/", route.dn) + assert m is not None, f"unparsable bgpVpnRoute dn {route.dn!r}" + assert m.group(1) in spine_ids, f"bgpVpnRoute {route.dn!r} not seeded on a real spine" + + +# --------------------------------------------------------------------------- +# fabricNodeIdentP — boot-seeded node registration +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_fabric_node_ident_p_count_equals_real_switch_count(store_name, request): + """One fabricNodeIdentP per real switch fabricNode (spines/leaves/ + border-leaves) — controllers are NOT registered via nodeidentpol (real + ACI: that's Fabric Membership for switches joining the fabric, not the + APICs themselves).""" + store = request.getfixturevalue(store_name) + switch_nodes = [ + mo for mo in store.by_class("fabricNode") if mo.attrs.get("role") != "controller" + ] + assert switch_nodes, "no switch fabricNode found" + idents = store.by_class("fabricNodeIdentP") + assert idents, "no fabricNodeIdentP found" + assert len(idents) == len(switch_nodes), ( + f"fabricNodeIdentP count {len(idents)} != real switch fabricNode count {len(switch_nodes)}" + ) + + +@pytest.mark.parametrize("store_name", _ALL_STORES) +def test_fabric_node_ident_p_nodeid_matches_real_fabric_node(store_name, request): + store = request.getfixturevalue(store_name) + switch_ids = { + mo.attrs["id"] for mo in store.by_class("fabricNode") if mo.attrs.get("role") != "controller" + } + for ident in store.by_class("fabricNodeIdentP"): + m = re.search(r"nodep-(\d+)$", ident.dn) + assert m is not None, f"unparsable fabricNodeIdentP dn {ident.dn!r}" + assert m.group(1) in switch_ids, f"fabricNodeIdentP {ident.dn!r} not a real switch node" + assert ident.attrs.get("nodeId") == m.group(1) + assert ident.attrs.get("serial") + + +def test_add_leaf_reaction_still_materializes_new_node(client_a): + """Regression guard (per task instructions): a POSTed new nodeidentpol + must still materialize a fabricNode, exactly as + tests/test_rest_aci.py::test_add_leaf_reaction already verifies — batch-2 + boot-seeding fabricNodeIdentP for EXISTING nodes must not interfere with + the write-path reaction for a NEW node id.""" + resp = client_a.post( + "/api/mo/uni/controller/nodeidentpol/nodep-950.json", + json={ + "fabricNodeIdentP": { + "attributes": { + "dn": "uni/controller/nodeidentpol/nodep-950", + "name": "leaf-950", + "nodeType": "leaf", + } + } + }, + ) + assert resp.status_code == 200, f"POST fabricNodeIdentP -> {resp.status_code}: {resp.text[:200]}" + resp2 = client_a.get("/api/class/fabricNode.json") + ids = [item["fabricNode"]["attributes"]["id"] for item in resp2.json()["imdata"]] + assert "950" in ids, "add-leaf reaction did not materialize the new fabricNode" + + +# --------------------------------------------------------------------------- +# Cross-cluster sanity: full-site build still round-trips through the REST +# layer with rsp-subtree=full for the node-scoped actrlRule shape. +# --------------------------------------------------------------------------- + +def test_actrl_subtree_query_returns_rules(client_a, store_a): + """Mirrors zoning_rules.py's node-scoped subtree query shape.""" + rule = store_a.by_class("actrlRule")[0] + node_m = re.search(r"topology/pod-(\d+)/node-(\d+)/sys/actrl", rule.dn) + assert node_m is not None + pod, node_id = node_m.group(1), node_m.group(2) + actrl_dn = f"topology/pod-{pod}/node-{node_id}/sys/actrl" + resp = client_a.get( + f"/api/mo/{actrl_dn}.json?query-target=subtree&target-subtree-class=actrlRule" + ) + assert resp.status_code == 200, f"{actrl_dn} -> {resp.status_code}: {resp.text[:200]}" + data = resp.json() + assert data["imdata"], f"expected actrlRule descendants under {actrl_dn}" + assert any("actrlRule" in item for item in data["imdata"]) diff --git a/tests/test_pr5_topology_consistency.py b/tests/test_pr5_topology_consistency.py new file mode 100644 index 0000000..31de0e0 --- /dev/null +++ b/tests/test_pr5_topology_consistency.py @@ -0,0 +1,276 @@ +"""Regression tests for PR-5 topology-consistency findings #13-#18 (all in +aci_sim/build/): + +FIX 1 (#13) — l3extRsPathL3OutAtt tDn referenced a nonexistent leaf port + (eth1/48 on border leaves, outside the eth1/1-8 host range). interfaces.py + now builds a dedicated routed l1PhysIf at that exact port on border leaves. +FIX 2 (#14) — spine ospfAdjEp referenced ports outside the spine's fabric- + uplink range (eth1/1-4). interfaces.py now builds a dedicated ISN uplink + l1PhysIf on spines (multi-site only) at the same eth1/{49+i} ports + underlay.py already used. +FIX 3 (#15) — endpoint host ports collided with APIC-controller-reserved + leaf ports (both used eth1/1..3 on the same leaves). endpoints.py now + starts endpoint port allocation right after the APIC-reserved range. +FIX 4 (#16) — a stretched tenant's endpoint appeared identically + front-panel-local on BOTH sites. endpoints.py now picks one HOME site per + endpoint (front-panel port, lcC="learned") and represents it on the peer + site as learned via a multi-site overlay tunnel (lcC="learned,vxlan", + fabricPathDn -> tunnelIf). See docs/DESIGN.md. +FIX 5 (#17) — routed BDs (has fvSubnet children) reported unicastRoute="no" + whenever l2stretch was true, disagreeing with NDO's unicastRouting=true. + tenants.py now derives unicastRoute from whether the BD has subnets. +FIX 6 (#18) — controller nodes had no healthInst (404 on GET .../sys/health). + health_faults.py now emits healthInst for controller node ids too. + +Auth is enforced (PR-4): every client fixture logs in via +POST /api/aaaLogin.json (admin/cisco) before making authenticated calls, +following the existing tests/test_p5_fixes.py / test_pr3_fixes.py pattern. +""" +from __future__ import annotations + +import copy +import re + +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(scope="module") +def topo(): + return load_topology(TOPO_PATH) + + +@pytest.fixture(scope="module") +def site_a(topo): + return topo.site_by_name("LAB1") + + +@pytest.fixture(scope="module") +def site_b(topo): + return topo.site_by_name("LAB2") + + +@pytest.fixture(scope="module") +def store_a(topo, site_a): + return build_site(topo, site_a) + + +@pytest.fixture(scope="module") +def store_b(topo, site_b): + return build_site(topo, site_b) + + +def _make_client(topo, site, store) -> TestClient: + 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 + + +@pytest.fixture(scope="module") +def client_a(topo, site_a, store_a): + return _make_client(topo, site_a, store_a) + + +@pytest.fixture(scope="module") +def client_b(topo, site_b, store_b): + return _make_client(topo, site_b, store_b) + + +# --------------------------------------------------------------------------- +# FIX 1 (#13) — every l3extRsPathL3OutAtt tDn's port exists as a real l1PhysIf +# --------------------------------------------------------------------------- + +def _l1physif_ports(store) -> dict[int, set[str]]: + """Return {node_id: {port_id, ...}} from every l1PhysIf DN in the store.""" + ports: dict[int, set[str]] = {} + for mo in store.by_class("l1PhysIf"): + m = re.search(r"/node-(\d+)/sys/phys-\[([^\]]+)\]", mo.dn) + assert m is not None, f"unparsable l1PhysIf dn {mo.dn!r}" + node_id = int(m.group(1)) + ports.setdefault(node_id, set()).add(m.group(2)) + return ports + + +@pytest.mark.parametrize("store_name", ["store_a", "store_b"]) +def test_l3out_path_att_references_real_port(store_name, request): + store = request.getfixturevalue(store_name) + ports = _l1physif_ports(store) + atts = list(store.by_class("l3extRsPathL3OutAtt")) + assert atts, "no l3extRsPathL3OutAtt found" + for att in atts: + m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", att.attrs["tDn"]) + assert m is not None, f"unparsable tDn {att.attrs['tDn']!r}" + node_id, port_id = int(m.group(1)), m.group(2) + assert port_id in ports.get(node_id, set()), ( + f"l3extRsPathL3OutAtt {att.dn!r} references {port_id!r} on node " + f"{node_id}, which has no matching l1PhysIf (has {ports.get(node_id)})" + ) + + +# --------------------------------------------------------------------------- +# FIX 2 (#14) — every ospfAdjEp interface exists as a real l1PhysIf on that node +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", ["store_a", "store_b"]) +def test_ospf_adjacency_references_real_port(store_name, request): + store = request.getfixturevalue(store_name) + ports = _l1physif_ports(store) + adjs = list(store.by_class("ospfAdjEp")) + assert adjs, "no ospfAdjEp found (multi-site topology expected)" + for adj in adjs: + m = re.search(r"/node-(\d+)/sys/ospf/.*?/if-\[([^\]]+)\]", adj.dn) + assert m is not None, f"unparsable ospfAdjEp dn {adj.dn!r}" + node_id, port_id = int(m.group(1)), m.group(2) + assert port_id in ports.get(node_id, set()), ( + f"ospfAdjEp {adj.dn!r} references {port_id!r} on node {node_id}, " + f"which has no matching l1PhysIf (has {ports.get(node_id)})" + ) + + +# --------------------------------------------------------------------------- +# FIX 3 (#15) — no port hosts both a controller lldpAdjEp and an endpoint path +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", ["store_a", "store_b"]) +def test_no_port_double_booked_between_apic_and_endpoint(store_name, request): + store = request.getfixturevalue(store_name) + + controller_ports: set[tuple[int, str]] = set() + for mo in store.by_class("lldpAdjEp"): + # topology/pod-1/node-101/sys/lldp/inst/if-[eth1/1]/adj-1 + m = re.search(r"/node-(\d+)/sys/lldp/inst/if-\[([^\]]+)\]", mo.dn) + if not m: + continue + if "APIC" in mo.attrs.get("sysName", ""): + controller_ports.add((int(m.group(1)), m.group(2))) + assert controller_ports, "no controller lldpAdjEp found" + + endpoint_ports: set[tuple[int, str]] = set() + for mo in store.by_class("fvCEp"): + path = mo.attrs.get("fabricPathDn", "") + m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", path) + if not m: + continue + endpoint_ports.add((int(m.group(1)), m.group(2))) + assert endpoint_ports, "no endpoint fabricPathDn found" + + collisions = controller_ports & endpoint_ports + assert not collisions, f"port(s) double-booked between APIC and endpoint: {collisions}" + + +# --------------------------------------------------------------------------- +# FIX 4 (#16) — each stretched EP is local on exactly one site, vxlan-learned +# on the other (cross-site test: build BOTH sites' stores and compare). +# --------------------------------------------------------------------------- + +def test_stretched_endpoint_local_on_exactly_one_site(store_a, store_b): + ceps_a = { + m.attrs["mac"]: m for m in store_a.by_class("fvCEp") + if m.dn.startswith("uni/tn-MS-TN1/") + } + ceps_b = { + m.attrs["mac"]: m for m in store_b.by_class("fvCEp") + if m.dn.startswith("uni/tn-MS-TN1/") + } + common_macs = set(ceps_a) & set(ceps_b) + assert common_macs, "no stretched (MS-TN1) endpoints found on both sites" + + for mac in common_macs: + cep_a, cep_b = ceps_a[mac], ceps_b[mac] + lc_a, lc_b = cep_a.attrs.get("lcC"), cep_b.attrs.get("lcC") + + # Exactly one side is HOME (front-panel-learned)... + homes = [lc for lc in (lc_a, lc_b) if lc == "learned"] + assert len(homes) == 1, ( + f"MAC {mac}: expected exactly one site with lcC='learned', " + f"got A={lc_a!r} B={lc_b!r}" + ) + # ...and the other is REMOTE (vxlan/tunnel-learned). + remotes = [lc for lc in (lc_a, lc_b) if lc == "learned,vxlan"] + assert len(remotes) == 1, ( + f"MAC {mac}: expected exactly one site with lcC='learned,vxlan', " + f"got A={lc_a!r} B={lc_b!r}" + ) + + home_cep = cep_a if lc_a == "learned" else cep_b + remote_cep = cep_b if lc_a == "learned" else cep_a + + # HOME: fabricPathDn is a real front-panel port (pathep-[ethX/Y]). + assert re.search(r"pathep-\[eth\d+/\d+\]", home_cep.attrs["fabricPathDn"]), ( + f"HOME fvCEp {home_cep.dn!r} fabricPathDn not a front-panel port: " + f"{home_cep.attrs['fabricPathDn']!r}" + ) + # REMOTE: fabricPathDn points at a tunnel interface, not a front port. + assert re.search(r"pathep-\[tunnel\d+\]", remote_cep.attrs["fabricPathDn"]), ( + f"REMOTE fvCEp {remote_cep.dn!r} fabricPathDn not a tunnel path: " + f"{remote_cep.attrs['fabricPathDn']!r}" + ) + + +def test_stretched_endpoint_remote_tunnel_if_exists(store_a, store_b): + """The REMOTE fvCEp's tunnelIf must actually exist in that site's store.""" + for store in (store_a, store_b): + for cep in store.by_class("fvCEp"): + if cep.attrs.get("lcC") != "learned,vxlan": + continue + m = re.search(r"paths-(\d+)/pathep-\[(tunnel\d+)\]", cep.attrs["fabricPathDn"]) + assert m is not None + spine_id, tunnel_id = m.group(1), m.group(2) + matches = [ + t for t in store.by_class("tunnelIf") + if t.attrs.get("id") == tunnel_id and f"/node-{spine_id}/" in t.dn + ] + assert matches, ( + f"no tunnelIf with id={tunnel_id!r} on node {spine_id} for " + f"remote fvCEp {cep.dn!r}" + ) + + +# --------------------------------------------------------------------------- +# FIX 5 (#17) — every BD with fvSubnet children has unicastRoute="yes" +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("store_name", ["store_a", "store_b"]) +def test_routed_bd_has_unicast_route_yes(store_name, request): + store = request.getfixturevalue(store_name) + bds = list(store.by_class("fvBD")) + assert bds, "no fvBD found" + checked = 0 + for bd in bds: + subnets = store.children(bd.dn, {"fvSubnet"}) + if not subnets: + continue + checked += 1 + assert bd.attrs.get("unicastRoute") == "yes", ( + f"fvBD {bd.dn!r} has {len(subnets)} fvSubnet children but " + f"unicastRoute={bd.attrs.get('unicastRoute')!r} (expected 'yes')" + ) + assert checked, "no routed (subnet-bearing) BD found to check" + + +# --------------------------------------------------------------------------- +# FIX 6 (#18) — node-1 health returns 200 on both sites' controllers +# --------------------------------------------------------------------------- + +def test_controller_health_returns_200_both_sites(client_a, client_b): + for client in (client_a, client_b): + resp = client.get("/api/mo/topology/pod-1/node-1/sys/health.json") + assert resp.status_code == 200, ( + f"controller node-1 health -> {resp.status_code}: {resp.text[:200]}" + ) + data = resp.json() + assert data["imdata"], "expected a healthInst row for controller node-1" + assert "healthInst" in data["imdata"][0] diff --git a/tests/test_pr6_control_plane.py b/tests/test_pr6_control_plane.py new file mode 100644 index 0000000..4d2ff00 --- /dev/null +++ b/tests/test_pr6_control_plane.py @@ -0,0 +1,300 @@ +"""Tests for PR-6 — control-plane fixes: + +1. /_sim/reload rebuilds from the FRESH post-reload Site object, not the + stale pre-reload one (finding #11). +2. fabricNodeIdentP write reaction materializes a full node-registration MO + set (fabricNode + topSystem + healthInst + fabricLink cabling + l1PhysIf + inventory), fires for a nested (not just top-level) fabricNodeIdentP, and + derives pod from the site instead of hardcoding 1 (finding #19). +3. GET /api/node/class/{cls}.json (no DN prefix) runs a fabric-wide class + query, matching /api/class/{cls}.json (finding #12). +""" + +from __future__ import annotations + +import copy +import ipaddress + +import yaml +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" # run from project root + + +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 = load_topology(TOPO_PATH) + site = topo.site_by_name(site_name) + store = build_site(topo, site) + state = ApicSiteState( + name=site.name, + site=site, + topo=topo, + store=store, + baseline=copy.deepcopy(store), + ) + client = TestClient(make_apic_app(state)) + _login(client) + return state, client + + +# --------------------------------------------------------------------------- +# Fix 1 — /_sim/reload uses the fresh Site, not the stale one +# --------------------------------------------------------------------------- + + +def test_reload_picks_up_topology_yaml_edit(tmp_path, monkeypatch): + """Editing topology.yaml (adding a leaf) and POSTing /_sim/reload must + make the new leaf show up — the old code rebuilt against the STALE + pre-reload state.site object, so the edit was silently ignored. + """ + with open(TOPO_PATH, encoding="utf-8") as f: + raw = yaml.safe_load(f) + + # Add a brand-new leaf to LAB1 in a modified copy of the topology. + lab1 = next(s for s in raw["sites"] if s["name"] == "LAB1") + lab1["leaves"].append({"id": 199, "name": "LAB1-ACI-LF199"}) + + modified_path = tmp_path / "topology_modified.yaml" + modified_path.write_text(yaml.safe_dump(raw), encoding="utf-8") + + # Build initial state from the ORIGINAL topology (no node 199 yet). + state, client = _fresh_client("LAB1") + ids_before = [ + item["fabricNode"]["attributes"]["id"] + for item in client.get("/api/class/fabricNode.json").json()["imdata"] + ] + assert "199" not in ids_before + + # Point TOPOLOGY_PATH at the modified file and reload. + monkeypatch.setenv("TOPOLOGY_PATH", str(modified_path)) + import aci_sim.runtime.config as config_mod + monkeypatch.setattr(config_mod, "TOPOLOGY_PATH", str(modified_path)) + + resp = client.post("/_sim/reload") + assert resp.status_code == 200, resp.text + + ids_after = [ + item["fabricNode"]["attributes"]["id"] + for item in client.get("/api/class/fabricNode.json").json()["imdata"] + ] + assert "199" in ids_after, f"reload did not pick up the new leaf; ids: {ids_after}" + + # state.site must now be the fresh Site object (has the new leaf), not + # the stale one captured at client construction time. + leaf_ids = {n.id for n in state.site.leaf_nodes()} + assert 199 in leaf_ids + + +def test_reload_errors_when_site_removed(tmp_path, monkeypatch): + """If the reloaded topology no longer contains this site, /_sim/reload + must return an APIC-style 400 error, not crash or silently keep stale + state. + """ + with open(TOPO_PATH, encoding="utf-8") as f: + raw = yaml.safe_load(f) + + # Remove LAB2 entirely; also drop anything that references it so the + # remaining topology still validates. + raw["sites"] = [s for s in raw["sites"] if s["name"] != "LAB2"] + raw["isn"] = {"enabled": False} + for tenant in raw.get("tenants", []): + tenant["sites"] = [s for s in tenant.get("sites", []) if s != "LAB2"] + if tenant.get("stretch") and len(tenant["sites"]) < 2: + tenant["stretch"] = False + tenant["l3outs"] = [ + l3o for l3o in tenant.get("l3outs", []) if l3o.get("site") != "LAB2" + ] + + modified_path = tmp_path / "topology_no_lab2.yaml" + modified_path.write_text(yaml.safe_dump(raw), encoding="utf-8") + + state, client = _fresh_client("LAB2") + + import aci_sim.runtime.config as config_mod + monkeypatch.setattr(config_mod, "TOPOLOGY_PATH", str(modified_path)) + + resp = client.post("/_sim/reload") + assert resp.status_code == 400, resp.text + data = resp.json() + assert "error" in data["imdata"][0] + + # Original state must be left untouched (no crash / partial mutation). + assert state.site.name == "LAB2" + + +# --------------------------------------------------------------------------- +# Fix 2 — enriched fabricNodeIdentP reaction (+ nested form) + /_sim/add-leaf +# --------------------------------------------------------------------------- + + +def _assert_enriched_node(client: TestClient, node_id: int, pod: int = 1) -> None: + node_dn = f"topology/pod-{pod}/node-{node_id}" + + # fabricNode + fn = client.get("/api/class/fabricNode.json").json()["imdata"] + ids = [item["fabricNode"]["attributes"]["id"] for item in fn] + assert str(node_id) in ids, f"fabricNode {node_id} missing; ids={ids}" + + # topSystem with valid, parseable IPs + ts_resp = client.get(f"/api/mo/{node_dn}/sys.json") + assert ts_resp.status_code == 200, ts_resp.text + ts_attrs = ts_resp.json()["imdata"][0]["topSystem"]["attributes"] + ipaddress.IPv4Address(ts_attrs["address"]) # raises if invalid + ipaddress.IPv4Address(ts_attrs["oobMgmtAddr"]) # raises if invalid + + # healthInst + health_resp = client.get(f"/api/mo/{node_dn}/sys/health.json") + assert health_resp.status_code == 200, health_resp.text + + # At least one fabricLink to a spine + links = client.get("/api/class/fabricLink.json").json()["imdata"] + node_links = [ + item["fabricLink"]["attributes"] + for item in links + if str(node_id) in (item["fabricLink"]["attributes"].get("n1"), item["fabricLink"]["attributes"].get("n2")) + ] + assert node_links, f"no fabricLink found for node {node_id}" + + # l1PhysIf inventory present under this node + resp = client.get(f"/api/node/class/{node_dn}/l1PhysIf.json") + assert resp.status_code == 200, resp.text + assert int(resp.json()["totalCount"]) >= 1, "expected at least one l1PhysIf for the new node" + + +def test_fabric_node_ident_reaction_top_level(): + _, client = _fresh_client("LAB1") + resp = client.post( + "/api/mo/uni/controller/nodeidentpol/nodep-910.json", + json={ + "fabricNodeIdentP": { + "attributes": { + "dn": "uni/controller/nodeidentpol/nodep-910", + "name": "leaf-910", + "nodeType": "leaf", + } + } + }, + ) + assert resp.status_code == 200, resp.text + _assert_enriched_node(client, 910, pod=1) + + +def test_fabric_node_ident_reaction_nested_under_parent(): + """A nested (non-top-level) fabricNodeIdentP must ALSO trigger the + reaction — previously the reaction only fired when fabricNodeIdentP was + the top-level POSTed class. + """ + _, client = _fresh_client("LAB1") + resp = client.post( + "/api/mo/uni/controller.json", + json={ + "fabricInst": { + "attributes": {"dn": "uni/controller"}, + "children": [ + { + "fabricNodeIdentP": { + "attributes": { + "dn": "uni/controller/nodeidentpol/nodep-911", + "name": "leaf-911", + "nodeType": "leaf", + } + } + } + ], + } + }, + ) + assert resp.status_code == 200, resp.text + _assert_enriched_node(client, 911, pod=1) + + +def test_add_leaf_produces_same_enriched_set(): + _, client = _fresh_client("LAB1") + resp = client.post("/_sim/add-leaf", json={"id": 912, "name": "leaf-912"}) + assert resp.status_code == 200, resp.text + _assert_enriched_node(client, 912, pod=1) + + +def test_add_leaf_reaction_still_materializes_new_node_class_query(): + """Regression guard for the existing test_add_leaf_reaction / + verify_autoaci T1-09 behavior: fabricNode must still show up by class + query after the enrichment.""" + _, client = _fresh_client("LAB1") + resp = client.post( + "/api/mo/uni/controller/nodeidentpol/nodep-913.json", + json={ + "fabricNodeIdentP": { + "attributes": { + "dn": "uni/controller/nodeidentpol/nodep-913", + "name": "leaf-913", + "nodeType": "leaf", + } + } + }, + ) + assert resp.status_code == 200 + ids = [ + item["fabricNode"]["attributes"]["id"] + for item in client.get("/api/class/fabricNode.json").json()["imdata"] + ] + assert "913" in ids + + +def test_node_registration_derives_pod_from_site_not_hardcoded(): + """Registering a node on a site whose pod != 1 must use that site's pod, + not a hardcoded 1. topology.yaml's sites both use pod=1 today, so this + test builds a synthetic site with pod=2 directly to prove the reaction + reads site.pod instead of hardcoding. + """ + from aci_sim.mit.store import MITStore + from aci_sim.rest_aci.writes import materialize_node_registration + from aci_sim.topology.loader import load_topology + + topo = load_topology(TOPO_PATH) + site = topo.site_by_name("LAB1").model_copy(deep=True) + site.pod = 7 + + store = MITStore() + materialize_node_registration(store, topo=topo, site=site, node_id=950, name="leaf-950", role="leaf") + + assert store.get("topology/pod-7/node-950") is not None + assert store.get("topology/pod-1/node-950") is None + + +# --------------------------------------------------------------------------- +# Fix 3 — fabric-wide node/class query (no DN prefix) +# --------------------------------------------------------------------------- + + +def test_fabric_wide_node_class_query_matches_class_query(): + _, client = _fresh_client("LAB1") + fabric_wide = client.get("/api/node/class/topSystem.json") + assert fabric_wide.status_code == 200, fabric_wide.text + class_query = client.get("/api/class/topSystem.json") + assert class_query.status_code == 200 + + assert fabric_wide.json()["totalCount"] == class_query.json()["totalCount"] + fw_ids = sorted(item["topSystem"]["attributes"]["id"] for item in fabric_wide.json()["imdata"]) + cq_ids = sorted(item["topSystem"]["attributes"]["id"] for item in class_query.json()["imdata"]) + assert fw_ids == cq_ids + + +def test_node_scoped_class_query_still_works(): + _, client = _fresh_client("LAB1") + resp = client.get("/api/node/class/topology/pod-1/node-101/faultInst.json") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data["imdata"], list) + assert isinstance(data["totalCount"], str) diff --git a/tests/test_pr8_platform.py b/tests/test_pr8_platform.py new file mode 100644 index 0000000..dc18c77 --- /dev/null +++ b/tests/test_pr8_platform.py @@ -0,0 +1,206 @@ +"""Tests for PR-8 cross-platform + deployment engineering (findings #23-#28). + +Covers: + - SIM_BIND default/override parsing (runtime/config.py, #23) + - port-selection function (A/B/fallback) (runtime/supervisor.py, #25) + - CSW peer/border-leaf mismatch validation raises (topology/schema.py, #28) + - `bash -n` syntax check for both sandbox scripts (#24) +""" + +from __future__ import annotations + +import importlib +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from aci_sim.topology.schema import Topology + +REPO_ROOT = Path(__file__).parent.parent + + +# --------------------------------------------------------------------------- +# SIM_BIND (#23) +# --------------------------------------------------------------------------- + + +class TestSimBind: + def test_default_is_loopback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SIM_BIND", raising=False) + from aci_sim.runtime import config + + importlib.reload(config) + assert config.SIM_BIND == "127.0.0.1" + + def test_override_via_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SIM_BIND", "0.0.0.0") + from aci_sim.runtime import config + + importlib.reload(config) + try: + assert config.SIM_BIND == "0.0.0.0" + finally: + monkeypatch.delenv("SIM_BIND", raising=False) + importlib.reload(config) # restore default for any later importers + + +# --------------------------------------------------------------------------- +# Port selection (#25) +# --------------------------------------------------------------------------- + + +class TestApicPortForSite: + def test_site_zero_uses_apic_a_port(self) -> None: + from aci_sim.runtime.config import APIC_A_PORT + from aci_sim.runtime.supervisor import apic_port_for_site + + assert apic_port_for_site(0) == APIC_A_PORT + + def test_site_one_uses_apic_b_port(self) -> None: + """APIC_B_PORT (config.py) must actually be honored for the second + site, not silently shadowed by an APIC_A_PORT+i formula.""" + from aci_sim.runtime.config import APIC_B_PORT + from aci_sim.runtime.supervisor import apic_port_for_site + + assert apic_port_for_site(1) == APIC_B_PORT + + def test_site_two_falls_back_to_apic_a_port_plus_index(self) -> None: + from aci_sim.runtime.config import APIC_A_PORT + from aci_sim.runtime.supervisor import apic_port_for_site + + assert apic_port_for_site(2) == APIC_A_PORT + 2 + + def test_apic_b_port_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APIC_B_PORT", "9999") + from aci_sim.runtime import config + + importlib.reload(config) + from aci_sim.runtime import supervisor + + importlib.reload(supervisor) + try: + assert supervisor.apic_port_for_site(1) == 9999 + finally: + monkeypatch.delenv("APIC_B_PORT", raising=False) + importlib.reload(config) + importlib.reload(supervisor) + + +# --------------------------------------------------------------------------- +# CSW mismatch validation (#28) +# --------------------------------------------------------------------------- + + +_BASE_TOPOLOGY = { + "fabric": {"name": "test-fabric"}, + "sites": [ + { + "name": "SiteA", + "id": "1", + "apic_host": "127.0.0.1:8443", + "asn": 65001, + "pod": 1, + "spines": 1, + "leaves": 1, + "cabling": "auto", + "border_leaves": [ + { + "id": 103, + "name": "SiteA-BL103", + "vpc_domain": "vpcdom-A", + "csw": { + "name": "CSW01", + "asn": 65100, + "peer_ip": "10.100.0.1", + "local_ip": "10.100.0.2", + "vlan": 100, + }, + } + ], + } + ], + "isn": {"enabled": False}, + "tenants": [ + { + "name": "TN1", + "stretch": False, + "sites": ["SiteA"], + "vrfs": [{"name": "vrf1"}], + "bds": [], + "aps": [], + "contracts": [], + "l3outs": [ + { + "name": "l3o-test", + "vrf": "vrf1", + "site": "SiteA", + "border_leaves": [103], + "csw_peer": { + "name": "CSW01", + "asn": 65100, + "peer_ip": "10.100.0.1", + }, + } + ], + } + ], +} + + +def _deep_copy(d): + return yaml.safe_load(yaml.safe_dump(d)) + + +class TestCswMismatchValidation: + def test_matching_csw_peer_loads_fine(self) -> None: + Topology.model_validate(_deep_copy(_BASE_TOPOLOGY)) + + def test_mismatched_asn_raises(self) -> None: + data = _deep_copy(_BASE_TOPOLOGY) + data["tenants"][0]["l3outs"][0]["csw_peer"]["asn"] = 65999 + with pytest.raises(ValidationError, match="does not match border-leaf"): + Topology.model_validate(data) + + def test_mismatched_peer_ip_raises(self) -> None: + data = _deep_copy(_BASE_TOPOLOGY) + data["tenants"][0]["l3outs"][0]["csw_peer"]["peer_ip"] = "10.100.0.99" + with pytest.raises(ValidationError, match="does not match border-leaf"): + Topology.model_validate(data) + + def test_mismatched_name_raises(self) -> None: + data = _deep_copy(_BASE_TOPOLOGY) + data["tenants"][0]["l3outs"][0]["csw_peer"]["name"] = "CSW-WRONG" + with pytest.raises(ValidationError, match="does not match border-leaf"): + Topology.model_validate(data) + + +# --------------------------------------------------------------------------- +# Sandbox scripts syntax (#24) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "script", + ["scripts/sandbox-up.sh", "scripts/sandbox-down.sh"], +) +def test_sandbox_script_bash_syntax_ok(script: str) -> None: + result = subprocess.run( + ["bash", "-n", str(REPO_ROOT / script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "script", + ["scripts/sandbox-up.sh", "scripts/sandbox-down.sh"], +) +def test_sandbox_script_has_linux_branch(script: str) -> None: + text = (REPO_ROOT / script).read_text() + assert "Linux" in text + assert "ip addr" in text diff --git a/tests/test_pr9_ansible.py b/tests/test_pr9_ansible.py new file mode 100644 index 0000000..8788e7c --- /dev/null +++ b/tests/test_pr9_ansible.py @@ -0,0 +1,338 @@ +"""Regression tests for PR-9: cisco.aci Ansible-fidelity hardening. + +Covers (see docs/CONTRACT.md §Ansible-compatibility for the full contract): + 1. HTTP DELETE /api/mo/{dn}.json (cisco.aci state=absent) — existing DN + removes the subtree; missing DN is idempotent 200-empty. + 2. status="deleted" cascade actually removes the store subtree (not just + skips planning children); status="created,modified" is treated as a + normal upsert (ignored gracefully, not a KeyError/malformed-body 400). + 3. Arbitrary attribute passthrough (annotation="orchestrator:ansible", + nameAlias, descr) round-trips exactly through POST -> mo GET -> class + query. + 4. Certificate signature accept-mode auth — a request carrying the four + APIC-Certificate-*/APIC-Request-Signature cookies (matching SIM_USERNAME) + authenticates without ever calling aaaLogin; a cookie set naming a + different user is rejected with the standard 403 envelope. + 5. firmwareCtrlrRunning is seeded per controller with the site's + apic_version (if implemented — see build/fabric.py). + +FEATURE 6 (live-blocker fold-in): GET /api/mo/{dn}.json on a nonexistent DN +must be 200 + empty imdata, not 404 — verified against upstream cisco.aci's +module_utils/aci.py `api_call()`, which treats ANY non-200 GET response as +fatal (`fail_json`) with no special-case for "not found is fine". Every +`state=present` module does a GET-before-POST existence check +(`get_existing()`) before ever building its diff, so a 404 there breaks the +very first task of any create playbook run against a brand-new object — +this was caught as a live blocker running an actual cisco.aci playbook +against the sim (see chapter note / commit message). Regression test named +`test_mo_get_missing_returns_200_empty_ansible_precheck` below. +""" + +from __future__ import annotations + +import base64 +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" + + +def _new_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_client() -> TestClient: + c = _new_client() + resp = c.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + assert resp.status_code == 200 + return c + + +# --------------------------------------------------------------------------- +# FEATURE 6 (live blocker) — GET on missing DN is 200-empty, not 404 +# --------------------------------------------------------------------------- + + +def test_mo_get_missing_returns_200_empty_ansible_precheck(): + c = _logged_in_client() + resp = c.get("/api/mo/uni/tn-ANSIBLE-SMOKE.json") + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "0" + assert data["imdata"] == [] + + +def test_mo_get_missing_then_create_flow_succeeds_end_to_end(): + # Full cisco.aci state=present shape: GET (existence check, empty) -> + # POST (create) -> GET (post-verification, now present). + c = _logged_in_client() + pre = c.get("/api/mo/uni/tn-ANSIBLE-SMOKE.json") + assert pre.status_code == 200 + assert pre.json()["totalCount"] == "0" + + post = c.post( + "/api/mo/uni/tn-ANSIBLE-SMOKE.json", + json={"fvTenant": {"attributes": {"name": "ANSIBLE-SMOKE", "dn": "uni/tn-ANSIBLE-SMOKE"}}}, + ) + assert post.status_code == 200 + + verify = c.get("/api/mo/uni/tn-ANSIBLE-SMOKE.json") + assert verify.status_code == 200 + assert verify.json()["totalCount"] == "1" + + +# --------------------------------------------------------------------------- +# FEATURE 1 — HTTP DELETE +# --------------------------------------------------------------------------- + + +def test_delete_existing_dn_removes_subtree(): + c = _logged_in_client() + c.post( + "/api/mo/uni/tn-DelT.json", + json={ + "fvTenant": { + "attributes": {"name": "DelT", "dn": "uni/tn-DelT"}, + "children": [{"fvBD": {"attributes": {"name": "bd1"}}}], + } + }, + ) + assert c.get("/api/mo/uni/tn-DelT/BD-bd1.json").json()["totalCount"] == "1" + + resp = c.delete("/api/mo/uni/tn-DelT.json") + assert resp.status_code == 200 + data = resp.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0" + + # Subtree (the BD child) is gone too, not just the tenant itself. + assert c.get("/api/mo/uni/tn-DelT.json").json()["totalCount"] == "0" + assert c.get("/api/mo/uni/tn-DelT/BD-bd1.json").json()["totalCount"] == "0" + + +def test_delete_missing_dn_is_idempotent_200(): + c = _logged_in_client() + resp = c.delete("/api/mo/uni/tn-NeverExisted.json") + assert resp.status_code == 200 + data = resp.json() + assert data["imdata"] == [] + assert data["totalCount"] == "0" + + +def test_delete_without_auth_returns_403(): + c = _new_client() + resp = c.delete("/api/mo/uni/tn-Blocked.json") + assert resp.status_code == 403 + assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403" + + +# --------------------------------------------------------------------------- +# FEATURE 2 — status="deleted" cascade + status="created,modified" upsert +# --------------------------------------------------------------------------- + + +def test_status_deleted_via_post_removes_subtree(): + c = _logged_in_client() + c.post( + "/api/mo/uni/tn-StT.json", + json={ + "fvTenant": { + "attributes": {"name": "StT", "dn": "uni/tn-StT"}, + "children": [{"fvBD": {"attributes": {"name": "bd1"}}}], + } + }, + ) + assert c.get("/api/mo/uni/tn-StT/BD-bd1.json").json()["totalCount"] == "1" + + resp = c.post( + "/api/mo/uni/tn-StT.json", + json={"fvTenant": {"attributes": {"name": "StT", "dn": "uni/tn-StT", "status": "deleted"}}}, + ) + assert resp.status_code == 200 + assert resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "deleted" + + # The whole subtree, including the never-mentioned-in-this-POST child, is gone. + assert c.get("/api/mo/uni/tn-StT.json").json()["totalCount"] == "0" + assert c.get("/api/mo/uni/tn-StT/BD-bd1.json").json()["totalCount"] == "0" + + +def test_status_created_modified_is_treated_as_upsert(): + # cisco.aci does not actually send this on the wire (module_utils/aci.py + # never sets a "status" key in `proposed`), but real APIC accepts the + # value gracefully as a normal upsert rather than erroring — verified via + # this sim's own status=="deleted" special-case being the ONLY branch + # that diverts from upsert. Guard against a regression that special-cases + # any other status string. + c = _logged_in_client() + resp = c.post( + "/api/mo/uni/tn-CmT.json", + json={ + "fvTenant": { + "attributes": {"name": "CmT", "dn": "uni/tn-CmT", "status": "created,modified"}, + } + }, + ) + assert resp.status_code == 200 + assert resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created" + + get = c.get("/api/mo/uni/tn-CmT.json") + assert get.status_code == 200 + assert get.json()["totalCount"] == "1" + attrs = get.json()["imdata"][0]["fvTenant"]["attributes"] + assert attrs["name"] == "CmT" + # The literal "created,modified" string must not have been stored as a + # real store attribute in a way that later confuses upsert/delete logic — + # a second write with status="deleted" must still remove it. + resp2 = c.post( + "/api/mo/uni/tn-CmT.json", + json={"fvTenant": {"attributes": {"name": "CmT", "dn": "uni/tn-CmT", "status": "deleted"}}}, + ) + assert resp2.status_code == 200 + assert c.get("/api/mo/uni/tn-CmT.json").json()["totalCount"] == "0" + + +# --------------------------------------------------------------------------- +# FEATURE 3 — annotation / nameAlias / descr passthrough +# --------------------------------------------------------------------------- + + +def test_annotation_roundtrips_on_mo_get_and_class_query(): + c = _logged_in_client() + resp = c.post( + "/api/mo/uni/tn-AnnT.json", + json={ + "fvTenant": { + "attributes": { + "name": "AnnT", + "dn": "uni/tn-AnnT", + "annotation": "orchestrator:ansible", + "nameAlias": "friendly-name", + "descr": "managed by ansible", + } + } + }, + ) + assert resp.status_code == 200 + + mo = c.get("/api/mo/uni/tn-AnnT.json") + assert mo.status_code == 200 + attrs = mo.json()["imdata"][0]["fvTenant"]["attributes"] + assert attrs["annotation"] == "orchestrator:ansible" + assert attrs["nameAlias"] == "friendly-name" + assert attrs["descr"] == "managed by ansible" + + cls = c.get('/api/class/fvTenant.json?query-target-filter=eq(fvTenant.name,"AnnT")') + assert cls.status_code == 200 + rows = cls.json()["imdata"] + assert len(rows) == 1 + cls_attrs = rows[0]["fvTenant"]["attributes"] + assert cls_attrs["annotation"] == "orchestrator:ansible" + assert cls_attrs["nameAlias"] == "friendly-name" + + +# --------------------------------------------------------------------------- +# FEATURE 4 — certificate signature auth (accept-mode) +# --------------------------------------------------------------------------- + + +def _cert_cookies(user: str = "admin", certname: str = "ansible") -> dict[str, str]: + return { + "APIC-Certificate-Algorithm": "v1.0", + "APIC-Certificate-Fingerprint": "fingerprint", + "APIC-Certificate-DN": f"uni/userext/user-{user}/usercert-{certname}", + "APIC-Request-Signature": base64.b64encode(b"not-a-real-signature").decode(), + } + + +def test_cert_cookie_authenticates_without_prior_login(): + c = _new_client() + for name, value in _cert_cookies().items(): + c.cookies.set(name, value) + + resp = c.get("/api/class/fabricNode.json") + assert resp.status_code == 200 + assert int(resp.json()["totalCount"]) > 0 + + +def test_cert_cookie_write_and_delete_work_without_prior_login(): + c = _new_client() + for name, value in _cert_cookies().items(): + c.cookies.set(name, value) + + post = c.post( + "/api/mo/uni/tn-CertT.json", + json={"fvTenant": {"attributes": {"name": "CertT", "dn": "uni/tn-CertT"}}}, + ) + assert post.status_code == 200 + assert c.get("/api/mo/uni/tn-CertT.json").json()["totalCount"] == "1" + + delete = c.delete("/api/mo/uni/tn-CertT.json") + assert delete.status_code == 200 + assert c.get("/api/mo/uni/tn-CertT.json").json()["totalCount"] == "0" + + +def test_cert_cookie_wrong_user_returns_403(): + c = _new_client() + for name, value in _cert_cookies(user="notadmin").items(): + c.cookies.set(name, value) + + resp = c.get("/api/class/fabricNode.json") + assert resp.status_code == 403 + assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403" + + +def test_cert_cookie_missing_one_field_returns_403(): + c = _new_client() + cookies = _cert_cookies() + del cookies["APIC-Request-Signature"] + for name, value in cookies.items(): + c.cookies.set(name, value) + + resp = c.get("/api/class/fabricNode.json") + assert resp.status_code == 403 + + +def test_cert_cookie_malformed_dn_returns_403(): + c = _new_client() + cookies = _cert_cookies() + cookies["APIC-Certificate-DN"] = "not-a-valid-cert-dn" + for name, value in cookies.items(): + c.cookies.set(name, value) + + resp = c.get("/api/class/fabricNode.json") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# FEATURE 5 — firmwareCtrlrRunning +# --------------------------------------------------------------------------- + + +def test_firmware_ctrlr_running_seeded_if_implemented(): + c = _logged_in_client() + resp = c.get("/api/class/firmwareCtrlrRunning.json") + assert resp.status_code == 200 + data = resp.json() + if int(data["totalCount"]) == 0: + pytest.skip("firmwareCtrlrRunning not seeded in this build") + for item in data["imdata"]: + assert "version" in item["firmwareCtrlrRunning"]["attributes"] diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..ef9b0ed --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,613 @@ +"""Tests for query/ — filters and engine.""" + +import pytest +from aci_sim.mit.mo import MO +from aci_sim.mit.store import MITStore +from aci_sim.query.filters import FilterParseError, parse_filter +from aci_sim.query.engine import ( + QueryParams, + params_from_dict, + run_class_query, + run_mo_query, + run_node_scoped, +) + + +# --------------------------------------------------------------------------- +# Shared fixture +# --------------------------------------------------------------------------- + + +def _make_store() -> MITStore: + """Minimal but representative store covering all query shapes.""" + s = MITStore() + + # Tenants + for name in ["T1", "T2", "T3"]: + s.add(MO("fvTenant", dn=f"uni/tn-{name}", name=name)) + + # BDs + subnets under T1 + for bd_name in ["bd1", "bd2"]: + s.add(MO("fvBD", dn=f"uni/tn-T1/BD-{bd_name}", name=bd_name)) + s.add(MO( + "fvSubnet", + dn=f"uni/tn-T1/BD-{bd_name}/subnet-[10.0.0.1/24]", + ip="10.0.0.1/24", + )) + + # Fabric nodes + for node_id, role in [("101", "spine"), ("102", "spine"), ("103", "leaf"), ("104", "leaf")]: + s.add(MO( + "fabricNode", + dn=f"topology/pod-1/node-{node_id}", + id=node_id, + role=role, + name=f"node-{node_id}", + )) + + # Node-local faults + s.add(MO( + "faultInst", + dn="topology/pod-1/node-103/local/svc-x/fault-F0001", + severity="critical", + code="F0001", + )) + s.add(MO( + "faultInst", + dn="topology/pod-1/node-104/local/svc-y/fault-F0002", + severity="major", + code="F0002", + )) + + return s + + +# --------------------------------------------------------------------------- +# filters.py +# --------------------------------------------------------------------------- + + +class TestFilters: + def setup_method(self): + self.s = _make_store() + + # -- eq -- + + def test_eq_spine(self): + pred = parse_filter('eq(fabricNode.role,"spine")') + spines = [mo for mo in self.s.by_class("fabricNode") if pred(mo)] + assert len(spines) == 2 + assert all(mo.attrs["role"] == "spine" for mo in spines) + + def test_eq_no_match(self): + pred = parse_filter('eq(fabricNode.role,"controller")') + assert [mo for mo in self.s.by_class("fabricNode") if pred(mo)] == [] + + def test_eq_dn(self): + pred = parse_filter('eq(fvTenant.dn,"uni/tn-T1")') + result = [mo for mo in self.s.by_class("fvTenant") if pred(mo)] + assert len(result) == 1 + assert result[0].dn == "uni/tn-T1" + + # -- wcard -- + + def test_wcard_dn_substring(self): + pred = parse_filter('wcard(fvBD.dn,"tn-T1/")') + bds = [mo for mo in self.s.by_class("fvBD") if pred(mo)] + assert len(bds) == 2 + + def test_wcard_no_match(self): + pred = parse_filter('wcard(fvBD.dn,"tn-T2/")') + assert [mo for mo in self.s.by_class("fvBD") if pred(mo)] == [] + + # -- and -- + + def test_and_filter(self): + pred = parse_filter('and(eq(fvBD.name,"bd1"),wcard(fvBD.dn,"tn-T1/"))') + bds = [mo for mo in self.s.by_class("fvBD") if pred(mo)] + assert len(bds) == 1 + assert bds[0].attrs["name"] == "bd1" + + # -- or -- + + def test_or_filter(self): + pred = parse_filter( + 'or(eq(faultInst.severity,"critical"),eq(faultInst.severity,"major"))' + ) + faults = [mo for mo in self.s.by_class("faultInst") if pred(mo)] + assert len(faults) == 2 + + # -- nested -- + + def test_nested_and_or(self): + pred = parse_filter( + 'and(' + ' or(eq(fabricNode.role,"spine"),eq(fabricNode.role,"leaf")),' + ' eq(fabricNode.id,"101")' + ')' + ) + nodes = [mo for mo in self.s.by_class("fabricNode") if pred(mo)] + assert len(nodes) == 1 + assert nodes[0].attrs["id"] == "101" + + def test_deeply_nested_or_inside_and(self): + # Exactly mirrors the APIC example wcard(fvCtx.dn,"tn-Tenant1/") + pred = parse_filter('wcard(fvTenant.dn,"tn-T")') + all_tenants = self.s.by_class("fvTenant") + result = [mo for mo in all_tenants if pred(mo)] + assert len(result) == 3 # T1, T2, T3 all contain "tn-T" + + # -- edge cases -- + + def test_unknown_attr_is_false(self): + pred = parse_filter('eq(fvBD.nonexistent,"x")') + assert not any(pred(mo) for mo in self.s.by_class("fvBD")) + + def test_empty_expr_matches_all(self): + pred = parse_filter("") + mo = self.s.get("uni/tn-T1") + assert pred(mo) is True + + def test_none_expr_matches_all(self): + pred = parse_filter(None) + mo = self.s.get("uni/tn-T1") + assert pred(mo) is True + + +# --------------------------------------------------------------------------- +# engine.py — QueryParams / params_from_dict +# --------------------------------------------------------------------------- + + +class TestParamsFromDict: + def test_basic_filter_and_page(self): + p = params_from_dict({ + "query-target-filter": 'eq(fabricNode.role,"spine")', + "page-size": "100", + "page": "2", + }) + assert p.filter_expr == 'eq(fabricNode.role,"spine")' + assert p.page_size == 100 + assert p.page == 2 + + def test_rsp_subtree_class_split(self): + p = params_from_dict({ + "rsp-subtree": "children", + "rsp-subtree-class": "fvBD,fvSubnet", + }) + assert p.rsp_subtree == "children" + assert p.rsp_subtree_class == ["fvBD", "fvSubnet"] + + def test_legacy_query_target_subtree(self): + p = params_from_dict({ + "query-target": "subtree", + "target-subtree-class": "bgpPeer", + }) + assert p.query_target == "subtree" + assert p.target_subtree_class == ["bgpPeer"] + + def test_page_size_clamped_to_5000(self): + p = params_from_dict({"page-size": "99999"}) + assert p.page_size == 5000 + + def test_defaults(self): + p = params_from_dict({}) + assert p.filter_expr is None + assert p.rsp_subtree is None + assert p.page == 0 + assert p.page_size == 500 + + # -- defensive page/page-size parsing (PR-1 F2 regression) -- + + def test_non_numeric_page_raises(self): + with pytest.raises(FilterParseError): + params_from_dict({"page": "abc"}) + + def test_non_numeric_page_size_raises(self): + with pytest.raises(FilterParseError): + params_from_dict({"page-size": "abc"}) + + def test_negative_page_size_raises(self): + with pytest.raises(FilterParseError): + params_from_dict({"page-size": "-1"}) + + def test_zero_page_size_raises(self): + with pytest.raises(FilterParseError): + params_from_dict({"page-size": "0"}) + + def test_negative_page_raises(self): + with pytest.raises(FilterParseError): + params_from_dict({"page": "-1"}) + + +# --------------------------------------------------------------------------- +# engine.py — run_class_query +# --------------------------------------------------------------------------- + + +class TestRunClassQuery: + def setup_method(self): + self.s = _make_store() + + def test_basic_all_tenants(self): + imdata, total = run_class_query(self.s, "fvTenant", QueryParams()) + assert total == 3 + assert len(imdata) == 3 + assert all("fvTenant" in d for d in imdata) + + def test_with_eq_filter(self): + params = QueryParams(filter_expr='eq(fabricNode.role,"spine")') + imdata, total = run_class_query(self.s, "fabricNode", params) + assert total == 2 + assert len(imdata) == 2 + + def test_no_children_by_default(self): + imdata, _ = run_class_query(self.s, "fvBD", QueryParams()) + for item in imdata: + body = list(item.values())[0] + assert "children" not in body + + # -- pagination -- + + def test_pagination_page0(self): + params = QueryParams(page=0, page_size=2) + imdata, total = run_class_query(self.s, "fvTenant", params) + assert total == 3 + assert len(imdata) == 2 + + def test_pagination_page1(self): + params = QueryParams(page=1, page_size=2) + imdata, total = run_class_query(self.s, "fvTenant", params) + assert total == 3 + assert len(imdata) == 1 # 3 total → page 1 has 1 item + + def test_pagination_beyond_end(self): + params = QueryParams(page=5, page_size=10) + imdata, total = run_class_query(self.s, "fvTenant", params) + assert total == 3 + assert imdata == [] + + def test_total_unaffected_by_page(self): + """total is always pre-pagination count.""" + p0 = QueryParams(page=0, page_size=1) + p1 = QueryParams(page=1, page_size=1) + _, total0 = run_class_query(self.s, "fvTenant", p0) + _, total1 = run_class_query(self.s, "fvTenant", p1) + assert total0 == total1 == 3 + + # -- subtree -- + + def test_rsp_subtree_children_attaches_descendants(self): + params = QueryParams(rsp_subtree="children") + imdata, total = run_class_query(self.s, "fvBD", params) + assert total == 2 + for item in imdata: + body = list(item.values())[0] + assert "children" in body + child_classes = {list(c.keys())[0] for c in body["children"]} + assert "fvSubnet" in child_classes + + def test_rsp_subtree_class_filters_descendants(self): + params = QueryParams(rsp_subtree="full", rsp_subtree_class=["fvSubnet"]) + imdata, _ = run_class_query(self.s, "fvBD", params) + for item in imdata: + body = list(item.values())[0] + children = body.get("children", []) + # Every attached descendant must be fvSubnet + for c in children: + assert list(c.keys())[0] == "fvSubnet" + + def test_rsp_subtree_no_match_class_no_children_key(self): + """When rsp-subtree-class matches nothing, 'children' key absent.""" + params = QueryParams(rsp_subtree="full", rsp_subtree_class=["fvAp"]) + imdata, _ = run_class_query(self.s, "fvBD", params) + for item in imdata: + body = list(item.values())[0] + assert "children" not in body + + # -- envelope shape -- + + def test_envelope_shape(self): + imdata, total = run_class_query(self.s, "fabricNode", QueryParams()) + for item in imdata: + assert len(item) == 1 + cls = list(item.keys())[0] + assert cls == "fabricNode" + body = item[cls] + assert "attributes" in body + assert "dn" in body["attributes"] + + +# --------------------------------------------------------------------------- +# engine.py — run_mo_query +# --------------------------------------------------------------------------- + + +class TestRunMoQuery: + def setup_method(self): + self.s = _make_store() + + def test_get_single_mo(self): + imdata, total = run_mo_query(self.s, "uni/tn-T1", QueryParams()) + assert total == 1 + assert len(imdata) == 1 + assert "fvTenant" in imdata[0] + assert imdata[0]["fvTenant"]["attributes"]["dn"] == "uni/tn-T1" + + def test_not_found_returns_empty(self): + imdata, total = run_mo_query(self.s, "uni/tn-MISSING", QueryParams()) + assert total == 0 + assert imdata == [] + + def test_subtree_returns_descendants_flat(self): + # legacy query-target=subtree → FLAT top-level imdata (root + all descendants), + # NOT nested under the root (matches real APIC + autoACI's top-level reads). + params = QueryParams(query_target="subtree") + imdata, total = run_mo_query(self.s, "uni/tn-T1", params) + classes = [next(iter(e)) for e in imdata] + assert total == 5 # fvTenant + 2 fvBD + 2 fvSubnet + assert classes.count("fvTenant") == 1 + assert classes.count("fvBD") == 2 + assert classes.count("fvSubnet") == 2 + for e in imdata: # flat, never nested + assert "children" not in next(iter(e.values())) + + def test_subtree_class_filter_flat(self): + params = QueryParams(query_target="subtree", target_subtree_class=["fvBD"]) + imdata, total = run_mo_query(self.s, "uni/tn-T1", params) + classes = {next(iter(e)) for e in imdata} + assert classes == {"fvBD"} # root + subnets excluded by the class filter + assert total == 2 + + def test_subtree_class_filter_deep(self): + """Class filter on legacy subtree reaches deeply-nested descendants, flat at top level.""" + params = QueryParams(query_target="subtree", target_subtree_class=["fvSubnet"]) + imdata, total = run_mo_query(self.s, "uni/tn-T1", params) + classes = [next(iter(e)) for e in imdata] + assert total == 2 + assert all(c == "fvSubnet" for c in classes) + + def test_bracket_dn(self): + imdata, total = run_mo_query( + self.s, "uni/tn-T1/BD-bd1/subnet-[10.0.0.1/24]", QueryParams() + ) + assert total == 1 + assert "fvSubnet" in imdata[0] + + +# --------------------------------------------------------------------------- +# engine.py — run_node_scoped +# --------------------------------------------------------------------------- + + +class TestRunNodeScoped: + def setup_method(self): + self.s = _make_store() + + def test_returns_faults_under_node(self): + imdata, total = run_node_scoped( + self.s, "topology/pod-1/node-103", "faultInst", QueryParams() + ) + assert total == 1 + assert imdata[0]["faultInst"]["attributes"]["code"] == "F0001" + + def test_no_match_different_node(self): + imdata, total = run_node_scoped( + self.s, "topology/pod-1/node-101", "faultInst", QueryParams() + ) + assert total == 0 + assert imdata == [] + + def test_with_eq_filter(self): + params = QueryParams(filter_expr='eq(faultInst.severity,"major")') + imdata, total = run_node_scoped( + self.s, "topology/pod-1/node-104", "faultInst", params + ) + assert total == 1 + assert imdata[0]["faultInst"]["attributes"]["severity"] == "major" + + def test_filter_excludes_non_matching(self): + params = QueryParams(filter_expr='eq(faultInst.severity,"critical")') + imdata, total = run_node_scoped( + self.s, "topology/pod-1/node-104", "faultInst", params + ) + assert total == 0 + + def test_envelope_shape(self): + imdata, _ = run_node_scoped( + self.s, "topology/pod-1/node-103", "faultInst", QueryParams() + ) + item = imdata[0] + assert "faultInst" in item + assert "attributes" in item["faultInst"] + assert "dn" in item["faultInst"]["attributes"] + assert "children" not in item["faultInst"] # no subtree requested + + +# --------------------------------------------------------------------------- +# engine.py — rsp-subtree=full MUST preserve multi-level nesting (B1 regression) +# --------------------------------------------------------------------------- + + +class TestRspSubtreeFullNesting: + """rsp-subtree=full must keep grandchildren nested under their parent, not + hoist them flat onto the root. autoACI contract_map/access_policy read two + levels (vzBrCP.children→vzSubj, vzSubj.children→vzRsSubjFiltAtt); flattening + silently empties the filter/entry column.""" + + def _contract_store(self) -> MITStore: + s = MITStore() + s.add(MO("vzBrCP", dn="uni/tn-T1/brc-Web", name="Web")) + s.add(MO("vzSubj", dn="uni/tn-T1/brc-Web/subj-S1", name="S1")) + s.add(MO( + "vzRsSubjFiltAtt", + dn="uni/tn-T1/brc-Web/subj-S1/rssubjFiltAtt-http", + tnVzFilterName="http", + )) + s.add(MO( + "vzRsSubjFiltAtt", + dn="uni/tn-T1/brc-Web/subj-S1/rssubjFiltAtt-https", + tnVzFilterName="https", + )) + return s + + def test_full_with_class_filter_keeps_grandchildren_nested(self): + s = self._contract_store() + params = QueryParams( + rsp_subtree="full", + rsp_subtree_class=["vzSubj", "vzRsSubjFiltAtt"], + ) + imdata, total = run_class_query(s, "vzBrCP", params) + assert total == 1 + brc = imdata[0]["vzBrCP"] + # vzSubj is the ONLY direct child of vzBrCP (filter attrs never hoisted) + assert [next(iter(c)) for c in brc["children"]] == ["vzSubj"] + subj = brc["children"][0]["vzSubj"] + # the two filter bindings are nested UNDER vzSubj, not under vzBrCP + filt_classes = [next(iter(c)) for c in subj["children"]] + assert filt_classes == ["vzRsSubjFiltAtt", "vzRsSubjFiltAtt"] + + def test_full_no_class_filter_nests_every_level(self): + s = MITStore() + s.add(MO("fvTenant", dn="uni/tn-T1", name="T1")) + s.add(MO("fvBD", dn="uni/tn-T1/BD-bd1", name="bd1")) + s.add(MO("fvSubnet", dn="uni/tn-T1/BD-bd1/subnet-[10.0.0.1/24]", ip="10.0.0.1/24")) + imdata, _ = run_mo_query(s, "uni/tn-T1", QueryParams(rsp_subtree="full")) + tn = imdata[0]["fvTenant"] + assert next(iter(tn["children"][0])) == "fvBD" + bd = tn["children"][0]["fvBD"] + # grandchild nested under BD, not flattened onto the tenant + assert next(iter(bd["children"][0])) == "fvSubnet" + assert all(next(iter(c)) != "fvSubnet" for c in tn["children"]) + + +# --------------------------------------------------------------------------- +# filters.py — comparison operators + parse errors (B2 regression) +# --------------------------------------------------------------------------- + + +class TestComparisonFilters: + def setup_method(self): + self.s = _make_store() + + def _ids(self, expr: str) -> set[str]: + pred = parse_filter(expr) + return {mo.attrs["id"] for mo in self.s.by_class("fabricNode") if pred(mo)} + + def test_ne(self): + pred = parse_filter('ne(fvTenant.name,"T1")') + names = {mo.attrs["name"] for mo in self.s.by_class("fvTenant") if pred(mo)} + assert names == {"T2", "T3"} + + def test_gt_numeric(self): + assert self._ids('gt(fabricNode.id,"102")') == {"103", "104"} + + def test_lt_numeric(self): + assert self._ids('lt(fabricNode.id,"102")') == {"101"} + + def test_ge_numeric(self): + assert self._ids('ge(fabricNode.id,"103")') == {"103", "104"} + + def test_le_numeric(self): + assert self._ids('le(fabricNode.id,"102")') == {"101", "102"} + + def test_bw_inclusive_range(self): + assert self._ids('bw(fabricNode.id,"102","103")') == {"102", "103"} + + def test_compare_missing_attr_is_false(self): + assert self._ids('gt(fabricNode.nonexistent,"1")') == set() + + def test_ne_nested_in_and(self): + assert self._ids('and(ne(fabricNode.role,"spine"),gt(fabricNode.id,"100"))') == {"103", "104"} + + def test_string_fallback_when_non_numeric(self): + # role is not numeric → lexical compare still works, doesn't raise + pred = parse_filter('gt(fabricNode.role,"leaf")') + roles = {mo.attrs["role"] for mo in self.s.by_class("fabricNode") if pred(mo)} + assert roles == {"spine"} # "spine" > "leaf" lexically + + def test_unknown_operator_raises(self): + from aci_sim.query.filters import FilterParseError + with pytest.raises(FilterParseError): + parse_filter('frobnicate(fvTenant.name,"x")') + + def test_truncated_expression_raises(self): + from aci_sim.query.filters import FilterParseError + with pytest.raises(FilterParseError): + parse_filter('eq(fvTenant.name') + + # -- numeric comparison guard (kills lexical-only mutation) -- + + def test_bw_numeric_wide_range_matches_all_nodes(self): + # "150" < "99" lexically, so a lexical-only bw would empty the range; + # numerically 99 <= id <= 150 must match every node (101-104). + assert self._ids('bw(fabricNode.id,"99","150")') == {"101", "102", "103", "104"} + + def test_gt_numeric_low_bound_matches_all_nodes(self): + assert self._ids('gt(fabricNode.id,"99")') == {"101", "102", "103", "104"} + + # -- strict parsing: trailing tokens / zero-arg composites (regression) -- + + def test_comma_joined_double_filter_raises(self): + with pytest.raises(FilterParseError): + parse_filter('eq(a.b,"x"),eq(a.b,"y")') + + def test_unbalanced_trailing_paren_raises(self): + with pytest.raises(FilterParseError): + parse_filter('eq(a.b,"x"))') + + def test_zero_arg_or_raises(self): + with pytest.raises(FilterParseError): + parse_filter('or()') + + def test_zero_arg_and_raises(self): + with pytest.raises(FilterParseError): + parse_filter('and()') + + +# --------------------------------------------------------------------------- +# engine.py — subtree scope filtering (PR-1 F1 regression) +# --------------------------------------------------------------------------- + + +class TestSubtreeScopeFiltering: + """query-target=subtree + query-target-filter must filter the EXPANDED + subtree scope (root + descendants), not pre-filter the root before + expansion. A filter on a descendant-only attribute (e.g. faultInst.severity + under a topSystem root) must still reach matching descendants.""" + + def _make_topsystem_store(self) -> MITStore: + s = MITStore() + s.add(MO("topSystem", dn="topology/pod-1/node-101/sys", id="101", role="leaf")) + s.add(MO( + "faultInst", + dn="topology/pod-1/node-101/sys/faults/fault-F1", + severity="minor", + code="F1", + )) + s.add(MO( + "faultInst", + dn="topology/pod-1/node-101/sys/faults/fault-F2", + severity="major", + code="F2", + )) + return s + + def test_run_mo_query_subtree_filter_on_descendant_attr(self): + s = self._make_topsystem_store() + params = QueryParams( + query_target="subtree", + target_subtree_class=["faultInst"], + filter_expr='eq(faultInst.severity,"minor")', + ) + imdata, total = run_mo_query(s, "topology/pod-1/node-101/sys", params) + assert total == 1 + assert imdata[0]["faultInst"]["attributes"]["code"] == "F1" + assert imdata[0]["faultInst"]["attributes"]["severity"] == "minor" + + def test_run_class_query_subtree_filter_on_descendant_attr(self): + s = self._make_topsystem_store() + params = QueryParams(filter_expr='eq(faultInst.severity,"minor")') + imdata, total = run_class_query(s, "faultInst", params) + assert total == 1 + assert imdata[0]["faultInst"]["attributes"]["code"] == "F1" diff --git a/tests/test_query_numeric_semantics.py b/tests/test_query_numeric_semantics.py new file mode 100644 index 0000000..582ae43 --- /dev/null +++ b/tests/test_query_numeric_semantics.py @@ -0,0 +1,186 @@ +"""Mutation-killers for filters.py numeric-comparison semantics (finding #21). + +A prior audit replaced the numeric branch of gt/ge/lt/le/bw with pure-lexical +string comparison and the full suite stayed green — the numeric-vs-lexical +fork in ``_cmp_pred`` / ``_bw_pred`` was effectively untested. Every case +below is chosen so lexical and numeric ordering DISAGREE: if the numeric +branch were deleted (or the ``rn is not None and vn is not None`` guard were +forced to always fail), these assertions flip and the test fails. + +Covers both the parse_filter unit level (this file) and the HTTP +class-query path (tests/test_rest_aci.py::TestNumericSemanticsHttp-style +cases live alongside the existing REST fault/filter tests — see the +`test_bw_wide_numeric_range_matches_non_controller_nodes` / +`test_gt_numeric_filter_matches_all_non_controller_nodes` companions in +tests/test_rest_aci.py, exercised here again via the FastAPI TestClient +for the same fixture used by fabricNode ids 101-104). +""" + +from __future__ import annotations + +import copy + +import pytest +from fastapi.testclient import TestClient + +from aci_sim.build.orchestrator import build_site +from aci_sim.mit.mo import MO +from aci_sim.mit.store import MITStore +from aci_sim.query.filters import parse_filter +from aci_sim.rest_aci.app import ApicSiteState, make_apic_app +from aci_sim.topology.loader import load_topology + +TOPO_PATH = "topology.yaml" # run from project root + + +def _make_store() -> MITStore: + """Fabric nodes with three-digit ids 101-104 — same fixture shape as + tests/test_query.py::_make_store, so lexical "150" < "99" and + "101" < "99" traps are exercised identically.""" + s = MITStore() + for node_id, role in [("101", "spine"), ("102", "spine"), ("103", "leaf"), ("104", "leaf")]: + s.add(MO( + "fabricNode", + dn=f"topology/pod-1/node-{node_id}", + id=node_id, + role=role, + name=f"node-{node_id}", + )) + return s + + +# --------------------------------------------------------------------------- +# parse_filter unit level +# --------------------------------------------------------------------------- + + +class TestNumericSemanticsUnit: + def setup_method(self): + self.s = _make_store() + + def _ids(self, expr: str) -> set[str]: + pred = parse_filter(expr) + return {mo.attrs["id"] for mo in self.s.by_class("fabricNode") if pred(mo)} + + # -- bw: lexically "150" < "99" empties the range; numerically it must + # cover every 3-digit node id 101-104. -- + + def test_bw_lexically_inverted_bounds_matches_all_nodes(self): + assert self._ids('bw(fabricNode.id,"99","150")') == {"101", "102", "103", "104"} + + def test_bw_lexically_inverted_bounds_excludes_below_low(self): + # Numeric lower bound 99 excludes nothing here (all ids >= 101), but + # pin a case where the lexical trap would ALSO wrongly exclude if the + # numeric branch were dropped: bw with a tight numeric window that a + # lexical comparison would evaluate completely differently. + assert self._ids('bw(fabricNode.id,"101","102")') == {"101", "102"} + + # -- gt: "99" is lexically greater than "101".."104" (since "9" > "1"), + # so a lexical-only gt(id,"99") would match NOTHING; numerically it must + # match every node. -- + + def test_gt_low_numeric_bound_matches_all_nodes(self): + assert self._ids('gt(fabricNode.id,"99")') == {"101", "102", "103", "104"} + + def test_gt_three_digit_ids_numeric(self): + # "103" > "102" holds both lexically and numerically for same-width + # strings, so pair it with the mixed-width case below to force the + # numeric branch specifically. + assert self._ids('gt(fabricNode.id,"102")') == {"103", "104"} + + # -- ge: mirrors gt with mixed-width low bound. -- + + def test_ge_low_numeric_bound_matches_all_nodes(self): + assert self._ids('ge(fabricNode.id,"99")') == {"101", "102", "103", "104"} + + # -- lt / le: "9" is lexically greater than "101".."104", so a + # lexical-only lt(id,"9") would match every node (each id < "9" lexically + # is False since "1..." > "9" is False... actually "101" < "9" is True + # lexically because '1' < '9'). Use a case where lexical and numeric + # DISAGREE in direction: lt(id, "20") — lexically "101" < "20" is True + # (since '1' < '2'), but numerically 101 < 20 is False. -- + + def test_lt_mixed_width_bound_matches_nothing_numerically(self): + # Lexical comparison would wrongly match all nodes ("101" < "20", + # "102" < "20", ... all True as strings); numeric comparison must + # correctly match none (101-104 are not < 20). + assert self._ids('lt(fabricNode.id,"20")') == set() + + def test_le_mixed_width_bound_matches_nothing_numerically(self): + assert self._ids('le(fabricNode.id,"20")') == set() + + def test_lt_numeric_within_range(self): + assert self._ids('lt(fabricNode.id,"103")') == {"101", "102"} + + def test_le_numeric_within_range(self): + assert self._ids('le(fabricNode.id,"102")') == {"101", "102"} + + +# --------------------------------------------------------------------------- +# HTTP class-query path +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def client(): + 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) + login = c.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + assert login.status_code == 200 + return c + + +class TestNumericSemanticsHttp: + def test_bw_lexically_inverted_bounds_matches_all_non_controller_nodes(self, client): + # "150" < "99" lexically would empty the range entirely; numerically + # 99 <= id <= 150 must match all four LAB1 leaf/border-leaf nodes. + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=bw(fabricNode.id,"99","150")' + ) + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) == 4 + ids = {i["fabricNode"]["attributes"]["id"] for i in data["imdata"]} + assert ids == {"101", "102", "103", "104"} + + def test_gt_low_numeric_bound_matches_all_non_controller_nodes(self, client): + # Lexically "99" > "101".."104" (since '9' > '1'), so a lexical-only + # gt would match nothing; numerically every 3-digit node id passes. + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"99")' + ) + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) >= 4 + roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]] + assert "controller" not in roles + + def test_lt_mixed_width_bound_excludes_leaves_and_spines(self, client): + # Lexically "101".."104"/"201"/"202" are all < "50" (since '1'/'2' < + # '5'), so a lexical-only lt would wrongly match every leaf/spine; + # numerically none of 101-104/201/202 is < 50, so only the + # single-digit controllers (id 1-3) may pass. + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=lt(fabricNode.id,"50")' + ) + assert resp.status_code == 200 + data = resp.json() + roles = {i["fabricNode"]["attributes"]["role"] for i in data["imdata"]} + ids = {i["fabricNode"]["attributes"]["id"] for i in data["imdata"]} + assert roles <= {"controller"} + assert "leaf" not in roles + assert "spine" not in roles + assert ids <= {"1", "2", "3"} diff --git a/tests/test_rest_aci.py b/tests/test_rest_aci.py new file mode 100644 index 0000000..e615fa8 --- /dev/null +++ b/tests/test_rest_aci.py @@ -0,0 +1,717 @@ +"""Tests for the APIC REST simulation layer (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" # run from project root + + +@pytest.fixture(scope="module") +def client(): + 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) + return TestClient(app) + + +def test_login(client): + resp = client.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "aaaLogin" in data["imdata"][0] + token = data["imdata"][0]["aaaLogin"]["attributes"]["token"] + assert token + assert "APIC-cookie" in resp.cookies + + +def test_refresh(client): + resp = client.get("/api/aaaRefresh.json") + assert resp.status_code == 200 + data = resp.json() + assert "aaaLogin" in data["imdata"][0] + + +def test_class_fabric_node(client): + resp = client.get("/api/class/fabricNode.json") + assert resp.status_code == 200 + data = resp.json() + # 6 switches (2 spine + 2 leaf + 2 border-leaf) + 1 APIC controller (PR-21 single-APIC default) = 7 + assert data["totalCount"] == "7" + assert len(data["imdata"]) == 7 + roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]] + assert sum(r in ("spine", "leaf") for r in roles) == 6 + assert roles.count("controller") == 1 + + +def test_class_fault_filter(client): + resp = client.get( + '/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")' + ) + assert resp.status_code == 200 + data = resp.json() + # Topology seeds exactly one "minor" faultInst (F0532 on node-101) — a + # regression that silently empties imdata (e.g. 200-with-empty-imdata) + # must not pass silently. + assert int(data["totalCount"]) == 1 + assert len(data["imdata"]) == 1 + for item in data["imdata"]: + assert item["faultInst"]["attributes"]["severity"] == "minor" + + +def test_fault_inst_rows_carry_seeded_severities(client): + # Stable premise for the PR-1 subtree+filter regression tests: the + # topology seeds exactly two faultInst rows (severity=minor F0532 on + # node-101, severity=warning F0554 on node-102). Assert both are present + # with their seeded severity so a regression that drops/renames severity + # values is caught here rather than only in the filter-specific tests. + resp = client.get("/api/class/faultInst.json") + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) == 2 + assert len(data["imdata"]) == 2 + severities = {i["faultInst"]["attributes"]["severity"] for i in data["imdata"]} + assert severities == {"minor", "warning"} + codes = {i["faultInst"]["attributes"]["code"] for i in data["imdata"]} + assert codes == {"F0532", "F0554"} + + +def test_mo_subtree_flat(client): + resp = client.get( + "/api/mo/uni/tn-MS-TN1.json?query-target=subtree&target-subtree-class=fvBD" + ) + assert resp.status_code == 200 + data = resp.json() + # tn-MS-TN1 seeds at least one BD in the topology fixture; a regression + # returning 200-with-empty-imdata must fail loudly rather than pass this + # now-vacuous loop. + assert int(data["totalCount"]) > 0 + assert len(data["imdata"]) > 0 + for item in data["imdata"]: + assert "fvBD" in item + + +def test_bracket_dn(client): + resp = client.get("/api/class/fvSubnet.json") + assert resp.status_code == 200 + data = resp.json() + if not data["imdata"]: + pytest.skip("no subnets in topology") + dn = data["imdata"][0]["fvSubnet"]["attributes"]["dn"] + resp2 = client.get(f"/api/mo/{dn}.json") + assert resp2.status_code == 200 + d2 = resp2.json() + assert len(d2["imdata"]) == 1 + + +def test_node_scoped_fault(client): + resp = client.get("/api/node/class/topology/pod-1/node-101/faultInst.json") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data["imdata"], list) + assert isinstance(data["totalCount"], str) + + +def test_write_readback(client): + resp = client.post( + "/api/mo/uni/tn-NEW.json", + json={"fvTenant": {"attributes": {"name": "NEW", "dn": "uni/tn-NEW", "descr": "test"}}}, + ) + assert resp.status_code == 200 + resp2 = client.get("/api/class/fvTenant.json") + names = [item["fvTenant"]["attributes"]["name"] for item in resp2.json()["imdata"]] + assert "NEW" in names + + +def test_add_leaf_reaction(client): + resp = client.post( + "/api/mo/uni/controller/nodeidentpol/nodep-901.json", + json={ + "fabricNodeIdentP": { + "attributes": { + "dn": "uni/controller/nodeidentpol/nodep-901", + "name": "leaf-901", + "nodeType": "leaf", + } + } + }, + ) + assert resp.status_code == 200 + resp2 = client.get("/api/class/fabricNode.json") + ids = [item["fabricNode"]["attributes"]["id"] for item in resp2.json()["imdata"]] + assert "901" in ids + + +def test_missing_mo_returns_200_empty(client): + # PR-9 FEATURE 6: real APIC (and cisco.aci's GET-before-POST existence + # check) requires 200 + empty imdata for a well-formed-but-absent DN, not + # 404 — see tests/test_pr9_ansible.py for the verified module-source + # rationale. This was test_missing_mo_404 pre-PR-9. + resp = client.get("/api/mo/uni/tn-DOESNOTEXIST.json") + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "0" + assert data["imdata"] == [] + + +# -- B2 regression: comparison filters work, malformed filters are 400 not 500 -- + + +def test_ne_filter_returns_200_not_500(client): + # ne/gt/lt/ge/le/bw used to hit `raise ValueError` -> uncaught -> HTTP 500. + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=ne(fabricNode.role,"controller")' + ) + assert resp.status_code == 200 + roles = [i["fabricNode"]["attributes"]["role"] for i in resp.json()["imdata"]] + assert roles and "controller" not in roles + + +def test_gt_numeric_filter_returns_200(client): + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"200")' + ) + assert resp.status_code == 200 + ids = [int(i["fabricNode"]["attributes"]["id"]) for i in resp.json()["imdata"]] + assert ids and all(i > 200 for i in ids) + + +def test_malformed_filter_returns_apic_400_not_500(client): + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=frobnicate(fabricNode.id,"1")' + ) + assert resp.status_code == 400 + err = resp.json()["imdata"][0]["error"]["attributes"] + assert err["code"] == "107" + assert "frobnicate" in err["text"] + + +# -- PR-1 F1 regression: query-target=subtree + query-target-filter on a +# descendant-only attribute must filter the EXPANDED scope, not the root -- + + +def test_subtree_filter_on_descendant_attr_mo_query(client): + # topology/pod-1/node-101 has a seeded faultInst descendant (severity=minor) + # at .../local/svc-policyelem-id-0/uni/fault-F0532. Filtering on + # faultInst.severity used to pre-filter the fabricNode root (which lacks a + # "severity" attr → False) BEFORE subtree expansion, always returning empty + # imdata regardless of matching descendants. + resp = client.get( + '/api/mo/topology/pod-1/node-101.json' + '?query-target=subtree&target-subtree-class=faultInst' + '&query-target-filter=eq(faultInst.severity,"minor")' + ) + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) > 0 + for item in data["imdata"]: + assert "faultInst" in item + assert item["faultInst"]["attributes"]["severity"] == "minor" + + +def test_subtree_filter_on_descendant_attr_class_query(client): + resp = client.get( + '/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")' + ) + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) > 0 + for item in data["imdata"]: + assert item["faultInst"]["attributes"]["severity"] == "minor" + + +# -- PR-1 F2 regression: non-numeric / out-of-range page & page-size -> APIC 400 -- + + +@pytest.mark.parametrize( + "query", + [ + "page=abc", + "page-size=abc", + "page-size=-1", + "page-size=0", + ], +) +def test_bad_page_params_return_apic_400(client, query): + resp = client.get(f"/api/class/fabricNode.json?{query}") + assert resp.status_code == 400 + body = resp.json() + assert "imdata" in body + assert "detail" not in body + assert "error" in body["imdata"][0] + + +def test_bad_page_size_return_apic_400_on_mo_query(client): + resp = client.get("/api/mo/uni/tn-MS-TN1.json?page-size=-1") + assert resp.status_code == 400 + body = resp.json() + assert "imdata" in body + assert "detail" not in body + + +def test_bad_page_size_return_apic_400_on_node_scoped_query(client): + resp = client.get( + "/api/node/class/topology/pod-1/node-101/faultInst.json?page-size=abc" + ) + assert resp.status_code == 400 + body = resp.json() + assert "imdata" in body + assert "detail" not in body + + +# -- PR-1 F3 regression: strict filter parsing (trailing tokens, zero-arg composites) -- + + +@pytest.mark.parametrize( + "expr", + [ + 'eq(fabricNode.role,"spine"),eq(fabricNode.role,"leaf")', # comma-joined double filter + 'eq(fabricNode.role,"spine"))', # unbalanced trailing paren + "or()", # zero-arg or + "and()", # zero-arg and + ], +) +def test_strict_filter_parsing_returns_apic_400(client, expr): + resp = client.get(f"/api/class/fabricNode.json?query-target-filter={expr}") + assert resp.status_code == 400 + err = resp.json()["imdata"][0]["error"]["attributes"] + assert err["code"] == "107" + + +# -- PR-1 numeric comparison guard: kills lexical-only mutation on bw/gt -- + + +def test_bw_wide_numeric_range_matches_non_controller_nodes(client): + # "150" < "99" lexically, so a lexical-only bw would empty the range; + # numerically 99 <= id <= 150 must match all 4 LAB1 leaf/border-leaf nodes. + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=bw(fabricNode.id,"99","150")' + ) + assert resp.status_code == 200 + ids = {i["fabricNode"]["attributes"]["id"] for i in resp.json()["imdata"]} + assert ids == {"101", "102", "103", "104"} + + +def test_gt_numeric_filter_matches_all_non_controller_nodes(client): + resp = client.get( + '/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"99")' + ) + assert resp.status_code == 200 + data = resp.json() + # Controllers are id 1..N (<99); every id > 99 must be a non-controller node. + # (Don't assert an exact count here — the shared module-scoped `client` + # fixture may have accumulated extra nodes from earlier write tests.) + roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]] + assert roles + assert "controller" not in roles + + +# -- PR-2 R1: atomic validate-then-commit POST (no partial writes on 400) -- + + +def test_malformed_child_rejects_with_400_and_writes_nothing(client): + resp = client.post( + "/api/mo/uni/tn-AtomT.json", + json={ + "fvTenant": { + "attributes": {"name": "AtomT", "dn": "uni/tn-AtomT"}, + "children": ["not-a-dict"], + } + }, + ) + assert resp.status_code == 400 + err = resp.json()["imdata"][0]["error"]["attributes"] + assert err["code"] == "103" + + resp2 = client.get("/api/mo/uni/tn-AtomT.json") + assert resp2.status_code == 200 + assert resp2.json()["totalCount"] == "0" + + +def test_valid_first_child_then_malformed_second_child_rejects_everything(client): + resp = client.post( + "/api/mo/uni/tn-AtomT2.json", + json={ + "fvTenant": { + "attributes": {"name": "AtomT2", "dn": "uni/tn-AtomT2"}, + "children": [ + {"fvCtx": {"attributes": {"name": "goodctx"}}}, + ["not-a-dict"], + ], + } + }, + ) + assert resp.status_code == 400 + err = resp.json()["imdata"][0]["error"]["attributes"] + assert err["code"] == "103" + + # Neither the tenant nor the valid first child persisted. + resp2 = client.get("/api/mo/uni/tn-AtomT2.json") + assert resp2.status_code == 200 + assert resp2.json()["totalCount"] == "0" + resp3 = client.get("/api/mo/uni/tn-AtomT2/ctx-goodctx.json") + assert resp3.status_code == 200 + assert resp3.json()["totalCount"] == "0" + + +def test_malformed_grandchild_key_count_rejects_with_400_and_writes_nothing(client): + # A child_entry dict with != 1 key is also a shape violation. + resp = client.post( + "/api/mo/uni/tn-AtomT3.json", + json={ + "fvTenant": { + "attributes": {"name": "AtomT3", "dn": "uni/tn-AtomT3"}, + "children": [{"fvCtx": {"attributes": {"name": "c1"}}, "fvBD": {"attributes": {"name": "b1"}}}], + } + }, + ) + assert resp.status_code == 400 + resp2 = client.get("/api/mo/uni/tn-AtomT3.json") + assert resp2.status_code == 200 + assert resp2.json()["totalCount"] == "0" + + +# -- PR-2 R2: canonical RN synthesis for dn-less children -- + + +def test_child_bd_without_dn_lands_on_canonical_BD_dn(client): + resp = client.post( + "/api/mo/uni/tn-RnT.json", + json={ + "fvTenant": { + "attributes": {"name": "RnT", "dn": "uni/tn-RnT"}, + "children": [{"fvBD": {"attributes": {"name": "bd1"}}}], + } + }, + ) + assert resp.status_code == 200 + + ok = client.get("/api/mo/uni/tn-RnT/BD-bd1.json") + assert ok.status_code == 200 + assert ok.json()["imdata"][0]["fvBD"]["attributes"]["name"] == "bd1" + + # The old '{cls}-{name}' heuristic must NOT have been used. + stale = client.get("/api/mo/uni/tn-RnT/fvBD-bd1.json") + assert stale.status_code == 200 + assert stale.json()["totalCount"] == "0" + + +def test_nested_ap_epg_without_dn_land_on_canonical_dns(client): + resp = client.post( + "/api/mo/uni/tn-RnT2.json", + json={ + "fvTenant": { + "attributes": {"name": "RnT2", "dn": "uni/tn-RnT2"}, + "children": [ + { + "fvAp": { + "attributes": {"name": "ap1"}, + "children": [{"fvAEPg": {"attributes": {"name": "epg1"}}}], + } + } + ], + } + }, + ) + assert resp.status_code == 200 + + ap = client.get("/api/mo/uni/tn-RnT2/ap-ap1.json") + assert ap.status_code == 200 + + epg = client.get("/api/mo/uni/tn-RnT2/ap-ap1/epg-epg1.json") + assert epg.status_code == 200 + assert epg.json()["imdata"][0]["fvAEPg"]["attributes"]["name"] == "epg1" + + # Old heuristics must not have created a shadow object. + stale_ap = client.get("/api/mo/uni/tn-RnT2/fvAp-ap1.json") + assert stale_ap.status_code == 200 + assert stale_ap.json()["totalCount"] == "0" + stale_epg = client.get("/api/mo/uni/tn-RnT2/ap-ap1/fvAEPg-epg1.json") + assert stale_epg.status_code == 200 + assert stale_epg.json()["totalCount"] == "0" + + +def test_subnet_without_dn_uses_bracketed_ip_rn(client): + resp = client.post( + "/api/mo/uni/tn-RnT3.json", + json={ + "fvTenant": { + "attributes": {"name": "RnT3", "dn": "uni/tn-RnT3"}, + "children": [ + { + "fvBD": { + "attributes": {"name": "bd1"}, + "children": [ + {"fvSubnet": {"attributes": {"ip": "10.20.30.1/24"}}} + ], + } + } + ], + } + }, + ) + assert resp.status_code == 200 + + subnet = client.get("/api/mo/uni/tn-RnT3/BD-bd1/subnet-[10.20.30.1/24].json") + assert subnet.status_code == 200 + assert subnet.json()["imdata"][0]["fvSubnet"]["attributes"]["ip"] == "10.20.30.1/24" + + +def test_dnless_child_then_explicit_canonical_dn_does_not_duplicate(client): + # First POST without dn (synthesizes canonical dn), then POST again with + # the explicit canonical dn — must upsert the SAME object, not duplicate. + resp1 = client.post( + "/api/mo/uni/tn-RnT4.json", + json={ + "fvTenant": { + "attributes": {"name": "RnT4", "dn": "uni/tn-RnT4"}, + "children": [{"fvBD": {"attributes": {"name": "bd1"}}}], + } + }, + ) + assert resp1.status_code == 200 + + resp2 = client.post( + "/api/mo/uni/tn-RnT4/BD-bd1.json", + json={ + "fvBD": { + "attributes": { + "dn": "uni/tn-RnT4/BD-bd1", + "name": "bd1", + "descr": "explicit dn write", + } + } + }, + ) + assert resp2.status_code == 200 + + cls_resp = client.get('/api/class/fvBD.json?query-target-filter=eq(fvBD.name,"bd1")') + assert cls_resp.status_code == 200 + matches = [ + i for i in cls_resp.json()["imdata"] if i["fvBD"]["attributes"]["dn"] == "uni/tn-RnT4/BD-bd1" + ] + assert len(matches) == 1 + assert matches[0]["fvBD"]["attributes"]["descr"] == "explicit dn write" + + +# -- PR-2: valid multi-level write still works end-to-end -- + + +def test_valid_multilevel_write_reads_back_every_level(client): + resp = client.post( + "/api/mo/uni/tn-FullT.json", + json={ + "fvTenant": { + "attributes": {"name": "FullT", "dn": "uni/tn-FullT"}, + "children": [ + { + "fvCtx": { + "attributes": {"name": "vrf1"}, + } + }, + { + "fvBD": { + "attributes": {"name": "bd1"}, + "children": [ + {"fvRsCtx": {"attributes": {"tnFvCtxName": "vrf1"}}}, + {"fvSubnet": {"attributes": {"ip": "192.168.1.1/24"}}}, + ], + } + }, + { + "fvAp": { + "attributes": {"name": "ap1"}, + "children": [ + { + "fvAEPg": { + "attributes": {"name": "epg1"}, + "children": [ + {"fvRsBd": {"attributes": {"tnFvBDName": "bd1"}}} + ], + } + } + ], + } + }, + ], + } + }, + ) + assert resp.status_code == 200 + + tenant = client.get("/api/mo/uni/tn-FullT.json") + assert tenant.status_code == 200 + + ctx = client.get("/api/mo/uni/tn-FullT/ctx-vrf1.json") + assert ctx.status_code == 200 + assert ctx.json()["imdata"][0]["fvCtx"]["attributes"]["name"] == "vrf1" + + bd = client.get("/api/mo/uni/tn-FullT/BD-bd1.json") + assert bd.status_code == 200 + + rsctx = client.get("/api/mo/uni/tn-FullT/BD-bd1/rsctx.json") + assert rsctx.status_code == 200 + assert rsctx.json()["imdata"][0]["fvRsCtx"]["attributes"]["tnFvCtxName"] == "vrf1" + + subnet = client.get("/api/mo/uni/tn-FullT/BD-bd1/subnet-[192.168.1.1/24].json") + assert subnet.status_code == 200 + + ap = client.get("/api/mo/uni/tn-FullT/ap-ap1.json") + assert ap.status_code == 200 + + epg = client.get("/api/mo/uni/tn-FullT/ap-ap1/epg-epg1.json") + assert epg.status_code == 200 + + rsbd = client.get("/api/mo/uni/tn-FullT/ap-ap1/epg-epg1/rsbd.json") + assert rsbd.status_code == 200 + assert rsbd.json()["imdata"][0]["fvRsBd"]["attributes"]["tnFvBDName"] == "bd1" + + +# -- fvBD create-time defaults (fidelity fix: real APIC fills object defaults +# -- on CREATE so a sparse POST still reads back full BD-detail attributes) -- + + +def test_posted_bd_gets_create_time_defaults(client): + resp = client.post( + "/api/mo/uni/tn-BdDefT.json", + json={ + "fvTenant": { + "attributes": {"name": "BdDefT", "dn": "uni/tn-BdDefT"}, + "children": [ + { + "fvBD": { + "attributes": { + "name": "bd1", + "unicastRoute": "yes", + "unkMacUcastAct": "proxy", + } + } + } + ], + } + }, + ) + assert resp.status_code == 200 + + bd = client.get("/api/mo/uni/tn-BdDefT/BD-bd1.json") + assert bd.status_code == 200 + attrs = bd.json()["imdata"][0]["fvBD"]["attributes"] + assert attrs["epMoveDetectMode"] == "" + assert attrs["hostBasedRouting"] == "no" + assert attrs["arpFlood"] == "no" + assert attrs["unkMcastAct"] == "flood" + assert attrs["v6unkMcastAct"] == "nd" + assert attrs["multiDstPktAct"] == "bd-flood" + assert attrs["limitIpLearnToSubnets"] == "yes" + assert attrs["ipLearning"] == "yes" + assert attrs["mac"] == "00:22:BD:F8:19:FF" + assert attrs["type"] == "regular" + # Posted attrs still present alongside the filled-in defaults. + assert attrs["unicastRoute"] == "yes" + assert attrs["unkMacUcastAct"] == "proxy" + + +def test_posted_bd_explicit_attr_wins_over_default(client): + resp = client.post( + "/api/mo/uni/tn-BdDefT2.json", + json={ + "fvTenant": { + "attributes": {"name": "BdDefT2", "dn": "uni/tn-BdDefT2"}, + "children": [ + {"fvBD": {"attributes": {"name": "bd1", "arpFlood": "yes"}}} + ], + } + }, + ) + assert resp.status_code == 200 + + bd = client.get("/api/mo/uni/tn-BdDefT2/BD-bd1.json") + assert bd.status_code == 200 + assert bd.json()["imdata"][0]["fvBD"]["attributes"]["arpFlood"] == "yes" + + +def test_bd_partial_update_does_not_reset_to_defaults(client): + # Create with an explicit non-default arpFlood. + resp1 = client.post( + "/api/mo/uni/tn-BdDefT3.json", + json={ + "fvTenant": { + "attributes": {"name": "BdDefT3", "dn": "uni/tn-BdDefT3"}, + "children": [ + {"fvBD": {"attributes": {"name": "bd1", "arpFlood": "yes"}}} + ], + } + }, + ) + assert resp1.status_code == 200 + + # Re-POST the SAME dn without arpFlood — a partial update that changes + # only descr. Real APIC preserves the prior arpFlood="yes"; it must NOT + # be reset to the create-time default "no". + resp2 = client.post( + "/api/mo/uni/tn-BdDefT3/BD-bd1.json", + json={ + "fvBD": { + "attributes": { + "dn": "uni/tn-BdDefT3/BD-bd1", + "name": "bd1", + "descr": "partial update", + } + } + }, + ) + assert resp2.status_code == 200 + + bd = client.get("/api/mo/uni/tn-BdDefT3/BD-bd1.json") + assert bd.status_code == 200 + attrs = bd.json()["imdata"][0]["fvBD"]["attributes"] + assert attrs["arpFlood"] == "yes" + assert attrs["descr"] == "partial update" + + +def test_deleted_bd_is_removed_not_resurrected_with_defaults(client): + resp1 = client.post( + "/api/mo/uni/tn-BdDefT4.json", + json={ + "fvTenant": { + "attributes": {"name": "BdDefT4", "dn": "uni/tn-BdDefT4"}, + "children": [{"fvBD": {"attributes": {"name": "bd1"}}}], + } + }, + ) + assert resp1.status_code == 200 + + resp2 = client.post( + "/api/mo/uni/tn-BdDefT4/BD-bd1.json", + json={ + "fvBD": { + "attributes": { + "dn": "uni/tn-BdDefT4/BD-bd1", + "status": "deleted", + } + } + }, + ) + assert resp2.status_code == 200 + + gone = client.get("/api/mo/uni/tn-BdDefT4/BD-bd1.json") + assert gone.status_code == 200 + assert gone.json()["totalCount"] == "0" diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py new file mode 100644 index 0000000..0ce0595 --- /dev/null +++ b/tests/test_subscriptions.py @@ -0,0 +1,285 @@ +"""Tests for APIC query subscriptions + websocket push-on-change. + +Covers: + 1. `?subscription=yes` on the class-query route returns a top-level + `subscriptionId`; the SAME query WITHOUT that param is byte-identical + to the pre-subscription response (backward compat — critical). + 2. Websocket `/socket` push for created/modified/deleted events on a + subscribed class. + 3. A change to an UNsubscribed class does not push anything. + 4. `GET /api/subscriptionRefresh.json?id=` returns 200. + 5. An unknown-token websocket is rejected (closed before any message). + 6. Disconnecting a websocket cleans up its subscriptions (a later change no + longer has anywhere to push, and does not error). + +This starlette version's WebSocketTestSession.receive_json() has no built-in +timeout, so every websocket receive here goes through a thread+Future-bounded +wrapper (`_receive_json_with_timeout` / `_assert_no_event` below) so a +missing/never-arriving event fails fast instead of hanging the suite. +""" + +from __future__ import annotations + +import copy +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError + +import pytest +from fastapi.testclient import TestClient + +from aci_sim.build.orchestrator import build_site +from aci_sim.rest_aci import subscriptions as subs +from aci_sim.rest_aci.app import ApicSiteState, make_apic_app +from aci_sim.topology.loader import load_topology + +TOPO_PATH = "topology.yaml" # run from project root + + +def _receive_json_with_timeout(ws, timeout: float = 5.0): + """Bounded-wait wrapper around WebSocketTestSession.receive_json(). + + This starlette version's TestClient websocket session has no built-in + receive timeout (`receive()` is a blocking `anyio` portal call with no + deadline) — an event that never arrives due to a regression would hang + the whole test suite instead of failing fast. Run the blocking call on a + worker thread and bound it with `Future.result(timeout=...)` so a missing + push surfaces as a prompt, clear test failure. + + Deliberately does NOT use the ThreadPoolExecutor context manager: on a + timeout the worker thread is still blocked inside `receive()` (there is + no way to cancel it), and `__exit__`'s default `shutdown(wait=True)` + would hang waiting for it — defeating the entire point of this helper. + The pool (and its one leaked, permanently-blocked thread) is simply + abandoned; harmless for a short-lived test process. + """ + pool = ThreadPoolExecutor(max_workers=1) + future = pool.submit(ws.receive_json) + try: + return future.result(timeout=timeout) + except FutureTimeoutError: + pytest.fail(f"no websocket event received within {timeout}s") + + +def _assert_no_event(ws, timeout: float = 2.0) -> None: + """Assert NO event arrives within *timeout* (the negative-case counterpart). + + `pytest.fail` (used by `_receive_json_with_timeout` above) raises + `_pytest.outcomes.Failed`, which subclasses `BaseException` — NOT + `Exception` — specifically so a real test failure can never be + accidentally swallowed by a broad `except Exception`/`pytest.raises + (Exception)`. That means this helper must check for the timeout directly + rather than wrapping `_receive_json_with_timeout` in `pytest.raises`. + """ + pool = ThreadPoolExecutor(max_workers=1) + future = pool.submit(ws.receive_json) + try: + event = future.result(timeout=timeout) + except FutureTimeoutError: + return # correct: no event arrived + pytest.fail(f"unexpected websocket event arrived: {event!r}") + + +@pytest.fixture() +def client(): + """A fresh app/store/client per test, plus a clean subscriptions registry. + + Unlike test_rest_aci.py's module-scoped fixture, subscription tests need + per-test isolation: the subscriptions module holds process-wide module + state (subscriptionId counter, connections, live subs) that must not leak + between tests — a stale connection/subscription from a previous test + could cause a false-positive push or mask a missing one. + """ + subs.reset() + 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) + with TestClient(app) as c: + yield c + subs.reset() + + +@pytest.fixture() +def token(client): + """Log in and return the APIC-cookie token (also left set on the client).""" + resp = client.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + assert resp.status_code == 200 + return resp.cookies["APIC-cookie"] + + +# --------------------------------------------------------------------------- +# Subscribe (opt-in query param) + backward compat +# --------------------------------------------------------------------------- + + +def test_subscribe_returns_subscription_id(client, token): + resp = client.get("/api/class/fvTenant.json?subscription=yes") + assert resp.status_code == 200 + data = resp.json() + assert "subscriptionId" in data + assert isinstance(data["subscriptionId"], str) + assert data["subscriptionId"] + # Normal query fields must still be present and correct. + assert "imdata" in data + assert "totalCount" in data + + +def test_plain_query_byte_identical_without_subscription_param(client, token): + """The critical backward-compat guarantee: no ?subscription=yes => no + subscriptionId field, no behavior change at all vs. pre-feature shape.""" + resp = client.get("/api/class/fvTenant.json") + assert resp.status_code == 200 + data = resp.json() + assert "subscriptionId" not in data + assert set(data.keys()) == {"imdata", "totalCount"} + + +def test_mo_query_subscribe_returns_subscription_id(client, token): + resp = client.get("/api/mo/uni/tn-mgmt.json?subscription=yes") + assert resp.status_code == 200 + data = resp.json() + assert "subscriptionId" in data + + +def test_node_class_fabric_wide_subscribe_returns_subscription_id(client, token): + resp = client.get("/api/node/class/fvTenant.json?subscription=yes") + assert resp.status_code == 200 + data = resp.json() + assert "subscriptionId" in data + + +# --------------------------------------------------------------------------- +# Subscription refresh +# --------------------------------------------------------------------------- + + +def test_subscription_refresh_returns_200(client, token): + resp = client.get("/api/class/fvTenant.json?subscription=yes") + sid = resp.json()["subscriptionId"] + refresh_resp = client.get(f"/api/subscriptionRefresh.json?id={sid}") + assert refresh_resp.status_code == 200 + + +def test_subscription_refresh_unknown_id_still_200(client, token): + # Real APIC does not error a stale/unknown refresh id. + resp = client.get("/api/subscriptionRefresh.json?id=999999") + assert resp.status_code == 200 + + +def test_subscription_refresh_requires_auth(client): + resp = client.get("/api/subscriptionRefresh.json?id=1") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Websocket push +# --------------------------------------------------------------------------- + + +def test_websocket_rejects_unknown_token(client): + with pytest.raises(Exception): + with client.websocket_connect("/socket-not-a-real-token") as ws: + _receive_json_with_timeout(ws, timeout=2) + + +def test_websocket_push_created_modified_deleted(client, token): + # Subscribe to fvTenant BEFORE opening the websocket (matches the + # acceptance flow: subscribe via query, then open the socket). + sub_resp = client.get("/api/class/fvTenant.json?subscription=yes") + assert sub_resp.status_code == 200 + subscription_id = sub_resp.json()["subscriptionId"] + + with client.websocket_connect(f"/socket{token}") as ws: + # (c) POST a new fvTenant. + post_resp = client.post( + "/api/mo/uni/tn-SubTest.json", + json={"fvTenant": {"attributes": {"name": "SubTest"}}}, + ) + assert post_resp.status_code == 200 + + # (d) assert a created event is pushed with the new tenant's dn. + event = _receive_json_with_timeout(ws, timeout=5) + assert subscription_id in event["subscriptionId"] + assert len(event["imdata"]) == 1 + tenant_body = event["imdata"][0]["fvTenant"] + assert tenant_body["attributes"]["dn"] == "uni/tn-SubTest" + assert tenant_body["attributes"]["status"] == "created" + + # Modify it (POST again on the same DN => pre-existing => "modified"). + mod_resp = client.post( + "/api/mo/uni/tn-SubTest.json", + json={"fvTenant": {"attributes": {"name": "SubTest", "descr": "updated"}}}, + ) + assert mod_resp.status_code == 200 + mod_event = _receive_json_with_timeout(ws, timeout=5) + assert mod_event["imdata"][0]["fvTenant"]["attributes"]["status"] == "modified" + assert mod_event["imdata"][0]["fvTenant"]["attributes"]["dn"] == "uni/tn-SubTest" + + # (e) DELETE it => assert a status=deleted event. + del_resp = client.delete("/api/mo/uni/tn-SubTest.json") + assert del_resp.status_code == 200 + del_event = _receive_json_with_timeout(ws, timeout=5) + assert subscription_id in del_event["subscriptionId"] + del_tenant = del_event["imdata"][0]["fvTenant"] + assert del_tenant["attributes"]["status"] == "deleted" + assert del_tenant["attributes"]["dn"] == "uni/tn-SubTest" + + +def test_unsubscribed_class_does_not_push(client, token): + # Subscribe only to fvTenant. + client.get("/api/class/fvTenant.json?subscription=yes") + + with client.websocket_connect(f"/socket{token}") as ws: + # Change an entirely different, unsubscribed class. + resp = client.post( + "/api/mo/uni/tn-mgmt/mgmtp-default.json", + json={"mgmtMgmtP": {"attributes": {"descr": "no-op change"}}}, + ) + assert resp.status_code == 200 + + # No event should arrive for this — bounded wait, not a hang. + _assert_no_event(ws, timeout=2) + + +def test_dn_scoped_subscription_matches_subtree_change(client, token): + # Subscribe via the MO/DN query shape on a parent DN. + sub_resp = client.get("/api/mo/uni/tn-mgmt.json?subscription=yes") + subscription_id = sub_resp.json()["subscriptionId"] + + with client.websocket_connect(f"/socket{token}") as ws: + # A write to a *child* DN under uni/tn-mgmt should still notify the + # parent-DN subscriber (subtree match). + resp = client.post( + "/api/mo/uni/tn-mgmt/ap-testap.json", + json={"fvAp": {"attributes": {"name": "testap"}}}, + ) + assert resp.status_code == 200 + event = _receive_json_with_timeout(ws, timeout=5) + assert subscription_id in event["subscriptionId"] + assert event["imdata"][0]["fvAp"]["attributes"]["dn"] == "uni/tn-mgmt/ap-testap" + + +def test_disconnect_cleans_up_subscriptions(client, token): + sub_resp = client.get("/api/class/fvTenant.json?subscription=yes") + assert sub_resp.status_code == 200 + + with client.websocket_connect(f"/socket{token}") as ws: + pass # connect then immediately disconnect (context manager exit) + + # After disconnect, a change to fvTenant must not raise/error even though + # the (now-dead) subscription still matches the scope — notify() must + # silently skip a token with no live connection. + resp = client.post( + "/api/mo/uni/tn-AfterDisconnect.json", + json={"fvTenant": {"attributes": {"name": "AfterDisconnect"}}}, + ) + assert resp.status_code == 200 diff --git a/tests/test_topology.py b/tests/test_topology.py new file mode 100644 index 0000000..201a0c3 --- /dev/null +++ b/tests/test_topology.py @@ -0,0 +1,620 @@ +"""Tests for topology/schema.py and topology/loader.py (Phase 2). + +All tests load the default topology.yaml at the repo root. One negative test +builds a minimal bad topology inline to verify cross-reference validation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from aci_sim.topology.loader import load_topology +from aci_sim.topology.schema import Node, Topology + +TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def get_topo() -> Topology: + return load_topology(TOPOLOGY_YAML) + + +# --------------------------------------------------------------------------- +# Positive tests against default topology.yaml +# --------------------------------------------------------------------------- + + +class TestDefaultTopologyLoad: + def test_has_two_sites(self) -> None: + topo = get_topo() + assert len(topo.sites) == 2 + + def test_site_names(self) -> None: + topo = get_topo() + names = [s.name for s in topo.sites] + assert "LAB1" in names + assert "LAB2" in names + + def test_site_a_spine_count(self) -> None: + topo = get_topo() + site_a = topo.site_by_name("LAB1") + assert len(site_a.spine_nodes()) == 2 + + def test_site_a_leaf_count(self) -> None: + topo = get_topo() + site_a = topo.site_by_name("LAB1") + assert len(site_a.leaf_nodes()) == 2 + + def test_site_a_border_leaf_count(self) -> None: + topo = get_topo() + site_a = topo.site_by_name("LAB1") + assert len(site_a.border_leaves) == 2 + + def test_site_b_spine_count(self) -> None: + topo = get_topo() + site_b = topo.site_by_name("LAB2") + assert len(site_b.spine_nodes()) == 2 + + def test_site_b_leaf_count(self) -> None: + topo = get_topo() + site_b = topo.site_by_name("LAB2") + assert len(site_b.leaf_nodes()) == 2 + + def test_site_b_border_leaf_count(self) -> None: + topo = get_topo() + site_b = topo.site_by_name("LAB2") + assert len(site_b.border_leaves) == 2 + + +class TestNodeIdExpansion: + """Int-count → Node expansion produces deterministic, collision-free IDs.""" + + def test_site_a_spine_ids(self) -> None: + # site_index=0: spine_id = 201 + 0*200 + i → 201, 202 + topo = get_topo() + ids = sorted(n.id for n in topo.site_by_name("LAB1").spine_nodes()) + assert ids == [201, 202] + + def test_site_a_leaf_ids(self) -> None: + # site_index=0: leaf_id = 101 + 0*200 + i → 101, 102 + topo = get_topo() + ids = sorted(n.id for n in topo.site_by_name("LAB1").leaf_nodes()) + assert ids == [101, 102] + + def test_site_b_spine_ids(self) -> None: + # site_index=1: spine_id = 201 + 1*200 + i → 401, 402 + topo = get_topo() + ids = sorted(n.id for n in topo.site_by_name("LAB2").spine_nodes()) + assert ids == [401, 402] + + def test_site_b_leaf_ids(self) -> None: + # site_index=1: leaf_id = 101 + 1*200 + i → 301, 302 + topo = get_topo() + ids = sorted(n.id for n in topo.site_by_name("LAB2").leaf_nodes()) + assert ids == [301, 302] + + def test_no_collisions_across_sites(self) -> None: + topo = get_topo() + all_ids: list[int] = [] + for site in topo.sites: + all_ids.extend(n.id for n in site.all_nodes()) + + duplicates = [nid for nid in set(all_ids) if all_ids.count(nid) > 1] + assert duplicates == [], f"Duplicate node IDs across sites: {duplicates}" + + def test_spine_names_deterministic(self) -> None: + topo = get_topo() + site_a = topo.site_by_name("LAB1") + names = sorted(n.name for n in site_a.spine_nodes()) + assert names == ["LAB1-ACI-SP01", "LAB1-ACI-SP02"] + + def test_leaf_names_deterministic(self) -> None: + topo = get_topo() + site_b = topo.site_by_name("LAB2") + names = sorted(n.name for n in site_b.leaf_nodes()) + assert names == ["LAB2-ACI-LF301", "LAB2-ACI-LF302"] + + +class TestStretchedTenant: + def test_stretched_tenant_exists(self) -> None: + topo = get_topo() + stretched = [t for t in topo.tenants if t.stretch] + assert len(stretched) >= 1, "No stretched tenant found in topology" + + def test_stretched_tenant_in_both_sites(self) -> None: + topo = get_topo() + for tenant in topo.tenants: + if tenant.stretch: + assert "LAB1" in tenant.sites, ( + f"Stretched tenant '{tenant.name}' missing LAB1" + ) + assert "LAB2" in tenant.sites, ( + f"Stretched tenant '{tenant.name}' missing LAB2" + ) + + def test_corp_tenant_vrfs_bds_aps(self) -> None: + topo = get_topo() + corp = next(t for t in topo.tenants if t.name == "MS-TN1") + assert len(corp.vrfs) >= 1 + assert len(corp.bds) >= 1 + assert len(corp.aps) >= 1 + + def test_corp_contracts_non_empty(self) -> None: + topo = get_topo() + corp = next(t for t in topo.tenants if t.name == "MS-TN1") + assert len(corp.contracts) >= 1 + + def test_corp_l3outs_per_site(self) -> None: + topo = get_topo() + corp = next(t for t in topo.tenants if t.name == "MS-TN1") + assert len(corp.l3outs) >= 2, "MS-TN1 tenant should have ≥1 L3Out per site" + + +class TestL3OutVrfResolves: + def test_l3out_vrfs_valid(self) -> None: + topo = get_topo() + for tenant in topo.tenants: + vrf_names = {v.name for v in tenant.vrfs} + for l3out in tenant.l3outs: + assert l3out.vrf in vrf_names, ( + f"Tenant '{tenant.name}', L3Out '{l3out.name}' references " + f"VRF '{l3out.vrf}' not in {vrf_names}" + ) + + +class TestHelpers: + def test_site_by_name_found(self) -> None: + topo = get_topo() + site = topo.site_by_name("LAB1") + assert site.name == "LAB1" + + def test_site_by_name_missing_raises(self) -> None: + topo = get_topo() + with pytest.raises(KeyError): + topo.site_by_name("Nonexistent") + + def test_other_site_from_a_returns_b(self) -> None: + topo = get_topo() + other = topo.other_site("LAB1") + assert other.name == "LAB2" + + def test_other_site_from_b_returns_a(self) -> None: + topo = get_topo() + other = topo.other_site("LAB2") + assert other.name == "LAB1" + + def test_other_site_gives_remote_asn(self) -> None: + """other_site() should let builders look up the remote fabric ASN for ISN.""" + topo = get_topo() + # LAB1's ISN peer is LAB2's ASN + remote_asn = topo.other_site("LAB1").asn + assert remote_asn == topo.site_by_name("LAB2").asn + + def test_all_nodes_count(self) -> None: + topo = get_topo() + site_a = topo.site_by_name("LAB1") + # 2 spines + 2 leaves + 2 border leaves = 6 + assert len(site_a.all_nodes()) == 6 + + +class TestISN: + def test_isn_enabled(self) -> None: + topo = get_topo() + assert topo.isn.enabled is True + + def test_isn_peer_mesh_full(self) -> None: + topo = get_topo() + assert topo.isn.peer_mesh == "full" + + +class TestFaultsAndEndpoints: + def test_fault_seeds_present(self) -> None: + topo = get_topo() + assert len(topo.faults.seed) >= 1 + + def test_health_defaults(self) -> None: + topo = get_topo() + assert topo.faults.health_defaults.node > 0 + assert topo.faults.health_defaults.tenant > 0 + + def test_endpoints_per_epg(self) -> None: + topo = get_topo() + assert topo.endpoints.per_epg >= 1 + + +class TestNewBuilderFields: + """Fields added for the Phase 3/4 builders (version, l3 domains, addr pools).""" + + def test_node_has_version_default(self) -> None: + topo = get_topo() + for node in topo.site_by_name("LAB1").all_nodes(): + assert node.version, f"Node {node.id} missing version" + + def test_explicit_node_version_override(self) -> None: + n = Node(id=201, name="spine-201", version="n9000-16.0(1)") + assert n.version == "n9000-16.0(1)" + + def test_access_has_l3_domain(self) -> None: + topo = get_topo() + assert len(topo.access.l3_domains) >= 1 + assert topo.access.l3_domains[0].name + + def test_fabric_address_pools(self) -> None: + topo = get_topo() + assert topo.fabric.loopback_pool + assert topo.fabric.oob_pool + + def test_l3out_site_association(self) -> None: + topo = get_topo() + corp = next(t for t in topo.tenants if t.name == "MS-TN1") + by_name = {lo.name: lo for lo in corp.l3outs} + assert by_name["l3o-bgp-Core_LAB1"].site == "LAB1" + assert by_name["l3o-bgp-Core_LAB2"].site == "LAB2" + + def test_l3out_border_leaves_are_real(self) -> None: + topo = get_topo() + all_border_ids = { + bl.id for site in topo.sites for bl in site.border_leaves + } + for tenant in topo.tenants: + for l3out in tenant.l3outs: + for bl_id in l3out.border_leaves: + assert bl_id in all_border_ids + + def test_fault_seed_nodes_are_real(self) -> None: + topo = get_topo() + real_ids = {n.id for site in topo.sites for n in site.all_nodes()} + for fseed in topo.faults.seed: + assert fseed.node in real_ids + + +# --------------------------------------------------------------------------- +# Negative test — bad cross-reference raises ValidationError +# --------------------------------------------------------------------------- + + +class TestValidationFailures: + def _load_bad(self, yaml_text: str) -> None: + data = yaml.safe_load(yaml_text) + Topology.model_validate(data) + + def test_epg_references_missing_bd_raises(self) -> None: + """EPG.bd that does not exist in tenant.bds must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto +isn: + enabled: false +tenants: + - name: BadTenant + stretch: false + sites: [SiteA] + vrfs: + - name: vrf1 + bds: + - name: real-bd + vrf: vrf1 + subnets: [] + aps: + - name: App + epgs: + - name: bad-epg + bd: nonexistent-bd + contracts: {} + contracts: [] + l3outs: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_bd_references_missing_vrf_raises(self) -> None: + """BD.vrf that does not exist in tenant.vrfs must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto +isn: + enabled: false +tenants: + - name: BadTenant + stretch: false + sites: [SiteA] + vrfs: + - name: vrf1 + bds: + - name: bd1 + vrf: ghost-vrf + subnets: [] + aps: [] + contracts: [] + l3outs: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_stretched_tenant_with_one_site_raises(self) -> None: + """stretch:true but only one site listed must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto + - name: SiteB + id: "2" + apic_host: "127.0.0.1:8444" + asn: 65002 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto +isn: + enabled: true + peer_mesh: full +tenants: + - name: BadStretch + stretch: true + sites: [SiteA] + vrfs: + - name: vrf1 + bds: [] + aps: [] + contracts: [] + l3outs: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_tenant_references_unknown_site_raises(self) -> None: + """tenant.sites that names a non-existent site must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto +isn: + enabled: false +tenants: + - name: BadTenant + stretch: false + sites: [SiteX] + vrfs: [] + bds: [] + aps: [] + contracts: [] + l3outs: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_l3out_references_missing_vrf_raises(self) -> None: + """L3Out.vrf that does not exist in tenant.vrfs must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + border_leaves: + - id: 103 + name: leaf-103 + vpc_domain: vpcdom-siteA + csw: {name: CSW-A, asn: 65100, peer_ip: 10.100.0.1, local_ip: 10.100.0.2, vlan: 100} + cabling: auto +isn: + enabled: false +tenants: + - name: BadTenant + stretch: false + sites: [SiteA] + vrfs: + - name: vrf1 + bds: [] + aps: [] + contracts: [] + l3outs: + - name: bad-l3out + vrf: ghost + border_leaves: [103] + ext_epgs: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_isn_full_mesh_single_site_raises(self) -> None: + """isn.peer_mesh='full' with fewer than 2 sites must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto +isn: + enabled: true + peer_mesh: full +tenants: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_l3out_references_missing_border_leaf_raises(self) -> None: + """L3Out.border_leaves id not present in any site's border_leaves must raise.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + border_leaves: + - id: 103 + name: leaf-103 + vpc_domain: vpcdom-siteA + csw: {name: CSW-A, asn: 65100, peer_ip: 10.100.0.1, local_ip: 10.100.0.2, vlan: 100} + cabling: auto +isn: + enabled: false +tenants: + - name: T + stretch: false + sites: [SiteA] + vrfs: + - name: vrf1 + bds: [] + aps: [] + contracts: [] + l3outs: + - name: l3o + vrf: vrf1 + border_leaves: [999] + ext_epgs: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_fault_seed_unknown_node_raises(self) -> None: + """faults.seed[i].node that is not a real node id must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto +isn: + enabled: false +tenants: [] +faults: + seed: + - severity: minor + code: "F0532" + node: 9999 + descr: "fault on a non-existent node" +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_epg_references_missing_contract_raises(self) -> None: + """EPG contract ref not present in tenant.contracts must raise ValidationError.""" + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 1 + leaves: 1 + cabling: auto +isn: + enabled: false +tenants: + - name: T + stretch: false + sites: [SiteA] + vrfs: + - name: vrf1 + bds: + - name: bd1 + vrf: vrf1 + subnets: [] + aps: + - name: App + epgs: + - name: epg1 + bd: bd1 + contracts: + provide: [ghost-contract] + consume: [] + contracts: [] + l3outs: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) + + def test_border_leaf_id_collision_raises(self) -> None: + """A border-leaf id colliding with an auto-generated leaf id must raise.""" + # SiteA auto leaves are 101,102; giving a border leaf id=101 collides. + bad_yaml = """ +fabric: + name: bad-topo +sites: + - name: SiteA + id: "1" + apic_host: "127.0.0.1:8443" + asn: 65001 + pod: 1 + spines: 2 + leaves: 2 + border_leaves: + - id: 101 + name: leaf-101-dup + vpc_domain: vpcdom-siteA + csw: {name: CSW-A, asn: 65100, peer_ip: 10.100.0.1, local_ip: 10.100.0.2, vlan: 100} + cabling: auto +isn: + enabled: false +tenants: [] +""" + with pytest.raises(ValidationError): + self._load_bad(bad_yaml) diff --git a/tests/verify_autoaci.py b/tests/verify_autoaci.py new file mode 100644 index 0000000..fe44dc7 --- /dev/null +++ b/tests/verify_autoaci.py @@ -0,0 +1,668 @@ +""" +verify_autoaci.py — end-to-end verification harness for aci-sim. + +Dual mode: + pytest tests/verify_autoaci.py -v (Tier 1 = must-pass, Tier 2 = best-effort) + python tests/verify_autoaci.py (standalone — prints coverage table) + +Architecture: + • Module-level setup builds the topology, per-site MITStores, and NdoState once. + • SimConnector / SimNDOConnector wrap FastAPI TestClient with the same async + interface as the real ACI/NDO connectors, so autoACI plugins run unmodified. + • _run() bridges async plugin coroutines into the synchronous pytest context. + • Tier 1 (17 tests) must all pass — they cover the APIC REST surface, the /_sim + control API, and the NDO plane. + • Tier 2 (plugin smoke tests) are best-effort; SKIP if the autoACI repo is + unavailable. +""" + +from __future__ import annotations + +import asyncio +import copy +import sys +import traceback +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +# ─── Path setup (needed for standalone / different cwd) ────────────────────── +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +# ─── Sim imports ───────────────────────────────────────────────────────────── +from aci_sim.topology.loader import load_topology +from aci_sim.build.orchestrator import build_all, build_site +from aci_sim.ndo.model import build_ndo_model +from aci_sim.rest_aci.app import ApicSiteState, make_apic_app +from aci_sim.ndo.app import make_ndo_app + +# ─── Module-level setup (runs once per pytest session) ─────────────────────── +TOPO_PATH = PROJECT_ROOT / "topology.yaml" +_topo = load_topology(TOPO_PATH) +_stores = build_all(_topo) # {site_name: MITStore} + + +def _fresh_apic_client(site_name: str = "LAB1") -> tuple[ApicSiteState, TestClient]: + """Return a *fresh* (deep-copied store) APIC state + TestClient pair. + + The client is pre-authenticated (aaaLogin admin/cisco) so its cookie jar + already carries a valid APIC-cookie — every data route now requires one. + """ + site = next(s for s in _topo.sites if s.name == site_name) + store = copy.deepcopy(_stores[site_name]) + state = ApicSiteState( + name=site.name, + site=site, + topo=_topo, + store=store, + baseline=copy.deepcopy(store), + ) + client = TestClient(make_apic_app(state)) + login = client.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + assert login.status_code == 200, f"pre-auth login failed: {login.status_code} {login.text}" + return state, client + + +def _fresh_ndo_client() -> TestClient: + """Return a fresh NDO TestClient backed by a newly derived NdoState.""" + ndo_state = build_ndo_model(_topo) + return TestClient(make_ndo_app(ndo_state)) + + +# Shared read-only clients (tests that only read use these for speed) +_state_a, _apic_a = _fresh_apic_client("LAB1") +_ndo_client = _fresh_ndo_client() + + +# ─── SimConnector ───────────────────────────────────────────────────────────── +class SimConnector: + """Async interface mirroring the real ACI connector, backed by TestClient. + + URL-building matches aci_connector.py exactly so autoACI plugins work + without any modification. + """ + + def __init__(self, client: TestClient, *, site_name: str = "sim"): + self._c = client + self.site_name = site_name + + async def query_class( + self, + class_name: str, + filter_expr: str = "", + subtree_class: str = "", + query_target: str = "", + subtree: str = "", + page: int = 0, + page_size: int = 500, + ) -> dict: + path = f"/api/class/{class_name}.json?" + params = [f"page-size={page_size}", f"page={page}"] + if filter_expr: + params.append(f"query-target-filter={filter_expr}") + if subtree: + params.append(f"rsp-subtree={subtree}") + if subtree_class: + params.append(f"rsp-subtree-class={subtree_class}") + else: + if subtree_class: + params.append(f"target-subtree-class={subtree_class}") + if not query_target: + query_target = "subtree" + if query_target: + params.append(f"query-target={query_target}") + path += "&".join(params) + return self._c.get(path).json() + + async def query_dn( + self, dn: str, subtree: bool = False, subtree_class: str = "" + ) -> dict: + path = f"/api/mo/{dn}.json" + params: list[str] = [] + if subtree: + params.append("query-target=subtree") + if subtree_class: + params.append(f"target-subtree-class={subtree_class}") + if not subtree: + params.append("query-target=subtree") + if params: + path += "?" + "&".join(params) + return self._c.get(path).json() + + async def query_class_scoped( + self, dn: str, class_name: str, filter_expr: str = "" + ) -> dict: + path = f"/api/node/class/{dn}/{class_name}.json" + if filter_expr: + path += f"?query-target-filter={filter_expr}" + return self._c.get(path).json() + + async def _raw_get(self, path: str) -> dict: + return self._c.get(path).json() + + +# ─── SimNDOConnector ───────────────────────────────────────────────────────── +class SimNDOConnector: + """Async interface mirroring the real NDO connector, backed by TestClient.""" + + def __init__(self, client: TestClient): + self._c = client + + async def _get(self, path: str) -> dict: + return self._c.get(path).json() + + async def get_sites(self) -> list: + data = await self._get("/mso/api/v1/sites") + return data.get("sites", []) + + async def get_tenants(self) -> list: + data = await self._get("/mso/api/v1/tenants") + return data.get("tenants", []) + + async def get_schemas(self) -> list: + data = await self._get("/mso/api/v1/schemas") + return data.get("schemas", []) + + async def get_schema(self, schema_id: str) -> dict: + return await self._get(f"/mso/api/v1/schemas/{schema_id}") + + async def get_dhcp_policy_map(self) -> dict: + raw = await self._get("/mso/api/v1/templates/summaries") + summaries = raw if isinstance(raw, list) else raw.get("templates", []) + dhcp_map: dict[str, str] = {} + for s in summaries: + if s.get("templateType") != "tenantPolicy": + continue + tmpl = await self._get(f"/mso/api/v1/templates/{s['templateId']}") + inner = tmpl.get("tenantPolicyTemplate", {}).get("template", {}) + for p in inner.get("dhcpRelayPolicies", []): + if p.get("uuid") and p.get("name"): + dhcp_map[p["uuid"]] = p["name"] + return dhcp_map + + +def _run(coro): + """Run a coroutine in a fresh event loop (sync bridge for Tier 2 + standalone).""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# ============================================================================= +# Tier 1 — MUST PASS +# ============================================================================= + +# ── APIC auth ───────────────────────────────────────────────────────────────── + +def test_t1_apic_login(): + """APIC aaaLogin returns a token and sets APIC-cookie.""" + resp = _apic_a.post( + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "aaaLogin" in data["imdata"][0] + token = data["imdata"][0]["aaaLogin"]["attributes"]["token"] + assert token, "token must be non-empty" + assert "APIC-cookie" in resp.cookies + + +def test_t1_apic_fabric_nodes_count(): + """SiteA fabricNodes: 6 switches (2 spine + 2 leaf + 2 border-leaf) + 1 APIC controller + (PR-21 single-APIC default) = 7.""" + resp = _apic_a.get("/api/class/fabricNode.json") + assert resp.status_code == 200 + data = resp.json() + roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]] + switches = [r for r in roles if r in ("spine", "leaf")] + controllers = [r for r in roles if r == "controller"] + assert len(switches) == 6, f"Expected 6 switch nodes, got {len(switches)} ({roles})" + assert len(controllers) == 1, f"Expected 1 controller node, got {len(controllers)} ({roles})" + assert data["totalCount"] == "7", f"Expected totalCount='7', got {data['totalCount']!r}" + + +def test_t1_apic_tenant_mo_query(): + """GET /api/mo/uni/tn-MS-TN1.json returns the MS-TN1 fvTenant MO.""" + resp = _apic_a.get("/api/mo/uni/tn-MS-TN1.json") + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) >= 1 + assert "fvTenant" in data["imdata"][0] + assert data["imdata"][0]["fvTenant"]["attributes"]["name"] == "MS-TN1" + + +def test_t1_apic_missing_mo_returns_200_empty(): + """A non-existent DN returns HTTP 200 + empty imdata (PR-9 FEATURE 6). + + Real APIC (and cisco.aci's GET-before-POST existence check in + module_utils/aci.py, which treats any non-200 as fatal) requires this — + not 404. See tests/test_pr9_ansible.py for the verified rationale. + """ + resp = _apic_a.get("/api/mo/uni/tn-NOSUCHTENANTXYZ.json") + assert resp.status_code == 200 + data = resp.json() + assert data["totalCount"] == "0" + assert data["imdata"] == [] + + +def test_t1_apic_fault_severity_filter(): + """Filter by severity=minor returns only minor-severity faultInst objects.""" + resp = _apic_a.get( + '/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")' + ) + assert resp.status_code == 200 + data = resp.json() + # topology.yaml seeds F0532/minor on node-101 + assert int(data["totalCount"]) >= 1, "Expected at least one seeded minor fault" + for item in data["imdata"]: + assert item["faultInst"]["attributes"]["severity"] == "minor", ( + f"Non-minor fault slipped through filter: {item}" + ) + + +def test_t1_apic_node_scoped_fault_on_101(): + """Node-scoped fault query on node-101 returns the seeded fault. + + topology.yaml seeds ``code: "F0532"`` on node 101. The materialized faultInst + must carry that exact, valid ACI fault code (guards the double-prefix regression). + """ + resp = _apic_a.get( + "/api/node/class/topology/pod-1/node-101/faultInst.json" + ) + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) >= 1, "node-101 must carry the seeded fault" + codes = [ + item["faultInst"]["attributes"].get("code", "") + for item in data["imdata"] + ] + assert "F0532" in codes, ( + f"Expected exact fault code 'F0532' on node-101; found: {codes}" + ) + + +def test_t1_apic_mo_subtree_flat_bd(): + """query-target=subtree + target-subtree-class=fvBD returns BDs at imdata top level.""" + resp = _apic_a.get( + "/api/mo/uni/tn-MS-TN1.json" + "?query-target=subtree&target-subtree-class=fvBD" + ) + assert resp.status_code == 200 + data = resp.json() + assert int(data["totalCount"]) >= 1, "MS-TN1 must have at least one BD" + for item in data["imdata"]: + assert "fvBD" in item, ( + f"Non-fvBD entry in subtree response: {list(item.keys())}" + ) + + +def test_t1_apic_write_and_readback(): + """POST a new fvTenant MO and verify it appears in /api/class/fvTenant.json.""" + _, wc = _fresh_apic_client("LAB1") + resp = wc.post( + "/api/mo/uni/tn-VERIFY.json", + json={"fvTenant": {"attributes": {"name": "VERIFY", "dn": "uni/tn-VERIFY"}}}, + ) + assert resp.status_code == 200 + + names = [ + item["fvTenant"]["attributes"]["name"] + for item in wc.get("/api/class/fvTenant.json").json()["imdata"] + ] + assert "VERIFY" in names, f"VERIFY tenant not found after write; list: {names}" + + +def test_t1_apic_fabric_node_reaction(): + """POST fabricNodeIdentP causes a fabricNode MO to materialize at topology/.../node-901.""" + _, wc = _fresh_apic_client("LAB1") + resp = wc.post( + "/api/mo/uni/controller/nodeidentpol/nodep-901.json", + json={ + "fabricNodeIdentP": { + "attributes": { + "dn": "uni/controller/nodeidentpol/nodep-901", + "name": "leaf-901", + "nodeType": "leaf", + } + } + }, + ) + assert resp.status_code == 200 + + ids = [ + item["fabricNode"]["attributes"]["id"] + for item in wc.get("/api/class/fabricNode.json").json()["imdata"] + ] + assert "901" in ids, ( + f"fabricNode 901 not materialized after fabricNodeIdentP write; ids: {ids}" + ) + + +# ── Control API ─────────────────────────────────────────────────────────────── + +def test_t1_sim_reset(): + """POST /_sim/reset restores the store to baseline; a written tenant disappears.""" + _, cc = _fresh_apic_client("LAB1") + + # Write a sentinel tenant + cc.post( + "/api/mo/uni/tn-RESETME.json", + json={"fvTenant": {"attributes": {"name": "RESETME", "dn": "uni/tn-RESETME"}}}, + ) + before = [ + item["fvTenant"]["attributes"]["name"] + for item in cc.get("/api/class/fvTenant.json").json()["imdata"] + ] + assert "RESETME" in before, "Sentinel write must succeed before reset" + + # Reset + resp = cc.post("/_sim/reset") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + # Sentinel must be gone + after = [ + item["fvTenant"]["attributes"]["name"] + for item in cc.get("/api/class/fvTenant.json").json()["imdata"] + ] + assert "RESETME" not in after, ( + "RESETME tenant must disappear after /_sim/reset" + ) + + +def test_t1_sim_snapshot_restore(): + """/_sim/snapshot + /_sim/restore round-trip preserves exact store state.""" + _, cc = _fresh_apic_client("LAB1") + + # Snapshot of clean state + resp = cc.post("/_sim/snapshot/checkpoint-1") + assert resp.status_code == 200 + + # Dirty the store + cc.post( + "/api/mo/uni/tn-SNAP.json", + json={"fvTenant": {"attributes": {"name": "SNAP", "dn": "uni/tn-SNAP"}}}, + ) + mid = [ + item["fvTenant"]["attributes"]["name"] + for item in cc.get("/api/class/fvTenant.json").json()["imdata"] + ] + assert "SNAP" in mid, "Write must succeed before restore" + + # Restore snapshot + resp = cc.post("/_sim/restore/checkpoint-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + # Dirty tenant must be gone + after = [ + item["fvTenant"]["attributes"]["name"] + for item in cc.get("/api/class/fvTenant.json").json()["imdata"] + ] + assert "SNAP" not in after, ( + "SNAP tenant must disappear after /_sim/restore/checkpoint-1" + ) + + +# ── NDO ─────────────────────────────────────────────────────────────────────── + +def test_t1_ndo_login(): + """NDO /api/v1/auth/login returns a non-empty bearer token.""" + resp = _ndo_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}" + assert data["token"], "NDO token must be non-empty" + + +def test_t1_ndo_sites_match_topology(): + """NDO /mso/api/v1/sites returns one entry per topology site with correct names.""" + resp = _ndo_client.get("/mso/api/v1/sites") + assert resp.status_code == 200 + sites = resp.json()["sites"] + assert len(sites) == len(_topo.sites), ( + f"Expected {len(_topo.sites)} NDO sites, got {len(sites)}" + ) + topo_names = {s.name for s in _topo.sites} + ndo_names = {s["name"] for s in sites} + assert topo_names == ndo_names, ( + f"NDO site names {ndo_names} differ from topology {topo_names}" + ) + + +def test_t1_ndo_tenants_match_topology(): + """NDO /mso/api/v1/tenants returns one entry per topology tenant.""" + resp = _ndo_client.get("/mso/api/v1/tenants") + assert resp.status_code == 200 + tenants = resp.json()["tenants"] + assert len(tenants) == len(_topo.tenants), ( + f"Expected {len(_topo.tenants)} tenants, got {len(tenants)}" + ) + topo_names = {t.name for t in _topo.tenants} + ndo_names = {t["name"] for t in tenants} + assert topo_names == ndo_names + + +def test_t1_ndo_schemas_list(): + """NDO /mso/api/v1/schemas returns at least one schema per topology tenant.""" + resp = _ndo_client.get("/mso/api/v1/schemas") + assert resp.status_code == 200 + schemas = resp.json()["schemas"] + assert len(schemas) >= len(_topo.tenants), ( + f"Expected >= {len(_topo.tenants)} schemas, got {len(schemas)}" + ) + for s in schemas: + assert "id" in s, f"Schema entry missing 'id': {s}" + assert "name" in s, f"Schema entry missing 'name': {s}" + + +def test_t1_ndo_corp_schema_vrf_bd_match(): + """MS-TN1 schema detail: VRF and BD names match topology exactly.""" + schemas = _ndo_client.get("/mso/api/v1/schemas").json()["schemas"] + corp_summary = next(s for s in schemas if "MS-TN1" in s["name"]) + detail = _ndo_client.get( + f"/mso/api/v1/schemas/{corp_summary['id']}" + ).json() + + tmpl = detail["templates"][0] + corp_topo = next(t for t in _topo.tenants if t.name == "MS-TN1") + + topo_vrfs = {v.name for v in corp_topo.vrfs} + ndo_vrfs = {v["name"] for v in tmpl["vrfs"]} + assert topo_vrfs == ndo_vrfs, f"VRF mismatch: topo={topo_vrfs} ndo={ndo_vrfs}" + + topo_bds = {b.name for b in corp_topo.bds} + ndo_bds = {b["name"] for b in tmpl["bds"]} + assert topo_bds == ndo_bds, f"BD mismatch: topo={topo_bds} ndo={ndo_bds}" + + +def test_t1_ndo_post_schema_readback(): + """POST /mso/api/v1/schemas creates a schema that appears in the list.""" + fc = _fresh_ndo_client() + resp = fc.post( + "/mso/api/v1/schemas", + json={ + "displayName": "verify-schema", + "name": "verify-schema", + "templates": [{"name": "t1", "templateType": "application"}], + }, + ) + assert resp.status_code == 200 + created = resp.json() + assert "id" in created, f"POST /schemas response missing 'id': {created}" + assert created.get("status") == "active", ( + f"Expected status='active', got {created.get('status')!r}" + ) + + schema_id = created["id"] + list_ids = {s["id"] for s in fc.get("/mso/api/v1/schemas").json()["schemas"]} + assert schema_id in list_ids, ( + f"Schema {schema_id!r} not found in list after POST" + ) + + +# ============================================================================= +# Tier 2 — best-effort plugin smoke tests +# ============================================================================= + +_AUTOACI_BACKEND = PROJECT_ROOT.parent / "autoACI" / "backend" + + +def _import_plugin(module: str, class_name: str): + """Try to import a plugin class from the autoACI backend; return (cls, None) or (None, reason).""" + backend = _AUTOACI_BACKEND + if not backend.exists(): + return None, f"autoACI backend not found at {backend}" + if str(backend) not in sys.path: + sys.path.insert(0, str(backend)) + try: + mod = __import__(module, fromlist=[class_name]) + return getattr(mod, class_name), None + except Exception as exc: + return None, str(exc) + + +def _run_plugin(plugin_cls, connector, params: dict) -> str: + """Execute a plugin and return a status string.""" + try: + result = _run(plugin_cls().execute(connector, params)) + return f"OK ({len(result.rows)} rows)" + except Exception as exc: + return f"FAIL ({exc})" + + +def _tier2_epg_detail() -> str: + cls, err = _import_plugin("plugins.epg_detail", "EpgDetail") + if cls is None: + return f"SKIP ({err})" + _, client = _fresh_apic_client("LAB1") + connector = SimConnector(client, site_name="LAB1") + return _run_plugin(cls, connector, {"tenant_name": "MS-TN1"}) + + +def _tier2_l3out_detail() -> str: + cls, err = _import_plugin("plugins.l3out_detail", "L3OutDetail") + if cls is None: + return f"SKIP ({err})" + _, client = _fresh_apic_client("LAB1") + connector = SimConnector(client, site_name="LAB1") + return _run_plugin(cls, connector, {"tenant_name": "MS-TN1"}) + + +# ============================================================================= +# Coverage table +# ============================================================================= + +_TIER1_TESTS: list[tuple[str, str, Any]] = [ + ("T1-01", "APIC login (token + cookie)", test_t1_apic_login), + ("T1-02", "APIC fabricNode count = 6 (LAB1)", test_t1_apic_fabric_nodes_count), + ("T1-03", "APIC MO query — MS-TN1 fvTenant", test_t1_apic_tenant_mo_query), + ("T1-04", "APIC 200-empty for unknown DN", test_t1_apic_missing_mo_returns_200_empty), + ("T1-05", "APIC fault severity filter (minor)", test_t1_apic_fault_severity_filter), + ("T1-06", "APIC node-scoped fault node-101 F0532",test_t1_apic_node_scoped_fault_on_101), + ("T1-07", "APIC subtree flat BD query", test_t1_apic_mo_subtree_flat_bd), + ("T1-08", "APIC write + readback (fvTenant)", test_t1_apic_write_and_readback), + ("T1-09", "APIC fabricNodeIdentP → fabricNode", test_t1_apic_fabric_node_reaction), + ("T1-10", "/_sim/reset clears written MOs", test_t1_sim_reset), + ("T1-11", "/_sim/snapshot + restore round-trip", test_t1_sim_snapshot_restore), + ("T1-12", "NDO login → bearer token", test_t1_ndo_login), + ("T1-13", "NDO sites match topology", test_t1_ndo_sites_match_topology), + ("T1-14", "NDO tenants match topology", test_t1_ndo_tenants_match_topology), + ("T1-15", "NDO schemas list (>= 1 per tenant)", test_t1_ndo_schemas_list), + ("T1-16", "NDO MS-TN1 schema VRF+BD names match", test_t1_ndo_corp_schema_vrf_bd_match), + ("T1-17", "NDO POST schema + readback", test_t1_ndo_post_schema_readback), +] + +_TIER2_TESTS: list[tuple[str, str, Any]] = [ + ("T2-01", "epg_detail plugin smoke (MS-TN1, LAB1)", _tier2_epg_detail), + ("T2-02", "l3out_detail plugin smoke (MS-TN1, LAB1)", _tier2_l3out_detail), +] + + +def _print_coverage(results: list[tuple[str, str, str]]) -> None: + W_ID, W_DESC, W_RES = 6, 42, 24 + sep = f"+{'-'*(W_ID+2)}+{'-'*(W_DESC+2)}+{'-'*(W_RES+2)}+" + print() + print("=" * (W_ID + W_DESC + W_RES + 10)) + print(" aci-sim · autoACI Verification Coverage") + print("=" * (W_ID + W_DESC + W_RES + 10)) + print(sep) + print(f"| {'ID':<{W_ID}} | {'Description':<{W_DESC}} | {'Result':<{W_RES}} |") + print(sep) + + tier1_pass = tier1_fail = tier2_ok = tier2_skip = tier2_fail = 0 + for id_, desc, result in results: + print(f"| {id_:<{W_ID}} | {desc:<{W_DESC}} | {result[:W_RES]:<{W_RES}} |") + if id_.startswith("T1"): + if result.startswith("PASS"): + tier1_pass += 1 + else: + tier1_fail += 1 + else: + if result.startswith("OK"): + tier2_ok += 1 + elif result.startswith("SKIP"): + tier2_skip += 1 + else: + tier2_fail += 1 + + print(sep) + print(f" Tier 1: {tier1_pass}/{tier1_pass + tier1_fail} passed" + + (f", {tier1_fail} FAILED" if tier1_fail else "")) + print(f" Tier 2: {tier2_ok} OK, {tier2_skip} skipped, {tier2_fail} failed") + print() + + +# ============================================================================= +# Standalone entry-point +# ============================================================================= + +if __name__ == "__main__": + results: list[tuple[str, str, str]] = [] + all_pass = True + + print("\nTier 1 — must-pass tests") + print("-" * 52) + for id_, desc, fn in _TIER1_TESTS: + try: + fn() + status = "PASS" + print(f" {id_} PASS {desc}") + except Exception as exc: + status = f"FAIL ({exc})" + print(f" {id_} FAIL {desc}") + print(f" {exc}") + all_pass = False + results.append((id_, desc, status)) + + print("\nTier 2 — plugin smoke tests (best-effort)") + print("-" * 52) + for id_, desc, fn in _TIER2_TESTS: + status = fn() + print(f" {id_} {status} {desc}") + results.append((id_, desc, status)) + + _print_coverage(results) + + if all_pass: + print("All Tier 1 tests passed.") + sys.exit(0) + else: + t1_fails = sum( + 1 for id_, _, r in results + if id_.startswith("T1") and not r.startswith("PASS") + ) + print(f"FAILED: {t1_fails} Tier 1 test(s) did not pass.") + sys.exit(1) diff --git a/topology.yaml b/topology.yaml new file mode 100644 index 0000000..fa280f8 --- /dev/null +++ b/topology.yaml @@ -0,0 +1,330 @@ +# aci-sim — 2-site ACI lab topology +# +# Object names follow a GENERICISED ACI naming convention (derived from a real +# NIP/LLD, with all customer/site-specific tokens stripped): +# region/site codes: LAB0 = stretched (multi-site) LAB1 = site-1 fabric LAB2 = site-2 fabric +# tenant: MS- (multi-site/NDO) SF- (single-fabric) pattern = TN1 (no-FW) +# vrf: vrf-L3_ / vrf-L2_ +# bd: bd-BD_ (BD3+ = stretched, BD1/BD2 = local, L2 = L2-only) +# ap: app-Stretched__ / app-Local__ +# epg: epg-APP_Stretched_BD / epg-APP_Local_BD +# l3out: l3o-bgp-Core_ ext-epg: xepg-All_ +# contract: con-_ filter: flt-_ +# domain: phy- / l3d- / vmm-vmw- vlan pool: vlp- aaep: aep- +# switch: -ACI-SP (spine) / -ACI-LF (leaf) / -ACI-APIC / -ACI-CSW +# +# APIC LAB1 → 127.0.0.1:8443 (sandbox 10.192.0.11:443, subnet 10.192.0.0/25) +# APIC LAB2 → 127.0.0.1:8444 (sandbox 10.192.128.11:443, subnet 10.192.128.0/25) +# NDO → 127.0.0.1:8445 (sandbox 10.192.0.10:443) +# +# Node-ID scheme (unchanged): LAB1 leaves 101-102 / spines 201-202 / border 103-104; +# LAB2 leaves 301-302 / spines 401-402 / border 303-304. + +fabric: + name: LAB-IT-ACI + evpn_rr: spine # spines act as BGP-EVPN route-reflectors; leaves are clients + loopback_pool: "10.0.0.0/16" + oob_pool: "192.168.0.0/16" + ndo_mgmt_ip: "10.192.0.10" # NDO/ND (in LAB1's mgmt subnet) + # ---- Tier-1 fabric params (PR-18) — all optional, shown here at their + # schema defaults (uncomment/edit to override; omitting them entirely is + # equivalent to the values below, so existing files need no changes): + # tep_pool: "10.0.0.0/16" # infra TEP address space (store+validate only; see docs/DESIGN.md PR-18 note) + # infra_vlan: 3967 # fabric infrastructure VLAN, 1-4094 (store+validate only) + # gipo_pool: "225.0.0.0/15" # multicast GIPo pool (store+validate only) + # ---- Tier-3 fine-tuning params (PR-20) — all optional, shown here at + # their schema defaults (uncomment/edit to override; omitting them is + # equivalent to the values below, so existing files need no changes): + # oob_subnet: "192.168.0.0/16" # explicit OOB mgmt subnet (store+validate only; must fall within 192.168.0.0/16) + # inb_subnet: "10.100.0.0/16" # in-band mgmt subnet (store+validate only — no mgmtInB MO built by this sim) + # default_bd_mac: "00:22:BD:F8:19:FF" # fabric-wide default BD gateway MAC -> fvBD.mac (real ACI default) + # default_apic_version: "4.2(7f)" # fabric-wide APIC controller version override; wins over each site's own apic_version + # ---- Single-APIC-per-site by design (PR-21) — see README "Configuration" + # section. APIC cluster size does not affect Ansible playbook deployment + # testing (a playbook targets ONE APIC endpoint); cluster size only matters + # for cluster-health/appliance-vector tooling (e.g. autoACI's health_score.py). + # Each site below omits `controllers`, so it defaults to 1 (single APIC). + # To exercise multi-controller/cluster-health semantics on a site, add: + # controllers: 3 # 1-5, real ACI's min cluster size is 3 + # controller_ips: ["10.192.0.11", "10.192.0.12", "10.192.0.13"] # optional; omit to auto-derive sequentially from mgmt_ip + +# --------------------------------------------------------------------------- +# Sites +# --------------------------------------------------------------------------- +sites: + - name: LAB1 + id: "1" + apic_host: "127.0.0.1:8443" + mgmt_ip: "10.192.0.11" # sandbox: LAB1 APIC (subnet 10.192.0.0/25, served on :443) + fabric_name: "LAB1-IT-ACI" # topSystem.fabricDomain → autoACI site grouping/colour + asn: 65001 + pod: 1 + # controllers: 1 # APIC cluster size (Tier-1, PR-18; single-APIC default, PR-21); default 1, shown here at its default + # oob_gateway: "10.192.0.1" # OOB mgmt default gateway (OOB-gateway PR); optional, default + # None (omitted) — when set, lands on the real mgmt-tenant MO + # uni/tn-mgmt/mgmtp-default/oob-default/rsooBStNode-[]'s `gw` + # attribute for every node at this site (build/mgmt.py); when unset, + # that same MO still builds, just with an empty `gw` (no gateway + # invented/derived). + spines: + - {id: 201, name: "LAB1-ACI-SP01"} + - {id: 202, name: "LAB1-ACI-SP02"} + leaves: + - {id: 101, name: "LAB1-ACI-LF101"} + - {id: 102, name: "LAB1-ACI-LF102"} + border_leaves: + # Two border leaves sharing vpc_domain form a vPC pair + - id: 103 + name: "LAB1-ACI-LF103" + vpc_domain: vpcdom-LAB1 + csw: + name: "LAB1-ACI-CSW01" + asn: 65100 # shared enterprise-core AS — BOTH sites' CSW use it (≠ fabric AS 65001/65002) + peer_ip: 10.100.0.1 # CSW IP on the peering sub-interface + local_ip: 10.100.0.2 # LAB1-ACI-LF103's own IP on the same sub-interface + vlan: 100 + - id: 104 + name: "LAB1-ACI-LF104" + vpc_domain: vpcdom-LAB1 + csw: + name: "LAB1-ACI-CSW01" + asn: 65100 + peer_ip: 10.100.0.1 + local_ip: 10.100.0.3 # LAB1-ACI-LF104's own IP (different from LF103's) + vlan: 100 + cabling: auto # auto = full spine×leaf mesh + border-leaf uplinks + + - name: LAB2 + id: "2" + apic_host: "127.0.0.1:8444" + mgmt_ip: "10.192.128.11" # sandbox: LAB2 APIC (subnet 10.192.128.0/25, served on :443) + fabric_name: "LAB2-IT-ACI" # topSystem.fabricDomain → autoACI site grouping/colour + asn: 65002 + pod: 1 + spines: + - {id: 401, name: "LAB2-ACI-SP01"} + - {id: 402, name: "LAB2-ACI-SP02"} + leaves: + - {id: 301, name: "LAB2-ACI-LF301"} + - {id: 302, name: "LAB2-ACI-LF302"} + border_leaves: + - id: 303 + name: "LAB2-ACI-LF303" + vpc_domain: vpcdom-LAB2 + csw: + name: "LAB2-ACI-CSW01" + asn: 65100 # same enterprise-core AS as LAB1's CSW (one external AS, two edge routers) + peer_ip: 10.200.0.1 + local_ip: 10.200.0.2 + vlan: 200 + - id: 304 + name: "LAB2-ACI-LF304" + vpc_domain: vpcdom-LAB2 + csw: + name: "LAB2-ACI-CSW01" + asn: 65100 + peer_ip: 10.200.0.1 + local_ip: 10.200.0.3 + vlan: 200 + cabling: auto + +# --------------------------------------------------------------------------- +# Inter-Site Network (ISN) +# --------------------------------------------------------------------------- +isn: + enabled: true + peer_mesh: full # every spine in LAB1 BGP-EVPN peers every spine in LAB2 + # ---- Tier-2 ISN params (PR-19) — optional, shown here at their schema + # defaults (uncomment/edit to override; omitting them is equivalent): + # ospf_area: "0.0.0.0" # OSPF area for the spine<->IPN/ISN uplink (ospfIf.area); "0" also accepted + # mtu: 9150 # ISN/IPN inter-site uplink MTU (l1PhysIf.mtu on the dedicated ISN port) + # ---- Tier-3 ISN timer params (PR-20) — optional, shown at their schema + # defaults (real ACI defaults): + # ospf_hello: 10 # OSPF hello timer, seconds -> ospfIf.helloIntvl + # ospf_dead: 40 # OSPF dead timer, seconds -> ospfIf.deadIntvl (must exceed ospf_hello) + # bfd_min_rx: 50 # BFD min-rx timer, ms (store+validate only — no bfdIfP MO built by this sim) + # bfd_min_tx: 50 # BFD min-tx timer, ms (store+validate only) + # bfd_multiplier: 3 # BFD detect multiplier, 1-50 (store+validate only) + +# --------------------------------------------------------------------------- +# Tenants +# --------------------------------------------------------------------------- +tenants: + + # ------------------------------------------------------------------------- + # MS-TN1 — multi-site (stretched, NDO-managed) no-firewall tenant, region LAB0 + # ------------------------------------------------------------------------- + - name: MS-TN1 + stretch: true + sites: [LAB1, LAB2] + + vrfs: + - name: vrf-L3_LAB0 + + bds: + # Application tier: 10.1.1.0/24, stretched L2 + - name: bd-BD3_LAB0 + vrf: vrf-L3_LAB0 + subnets: ["10.1.1.0/24"] + l2stretch: true + # mac: "AA:BB:CC:00:00:01" # Tier-3 (PR-20), optional: per-BD gateway MAC override -> fvBD.mac + # # (default: fabric.default_bd_mac, itself the real ACI default 00:22:BD:F8:19:FF) + # Database tier: 10.1.2.0/24, stretched L2 + - name: bd-BD4_LAB0 + vrf: vrf-L3_LAB0 + subnets: ["10.1.2.0/24"] + l2stretch: true + + aps: + - name: app-Stretched_Apps_LAB0 + epgs: + - name: epg-APP3_Stretched_BD + bd: bd-BD3_LAB0 + contracts: + provide: [con-Web_to_DB_LAB0] + consume: [] + - name: epg-APP4_Stretched_BD + bd: bd-BD4_LAB0 + contracts: + provide: [] + consume: [con-Web_to_DB_LAB0] + + contracts: + - name: con-Web_to_DB_LAB0 + scope: context + filters: + - name: flt-http_LAB0 + entries: + - proto: tcp + from_port: "80" + to_port: "80" + ether_type: ip + - name: flt-https_LAB0 + entries: + - proto: tcp + from_port: "443" + to_port: "443" + ether_type: ip + + l3outs: + # LAB1's L3Out → LAB1-ACI-CSW01 via border leaves 103 + 104 + - name: l3o-bgp-Core_LAB1 + vrf: vrf-L3_LAB0 + site: LAB1 + border_leaves: [103, 104] + csw_peer: + name: "LAB1-ACI-CSW01" + asn: 65100 + peer_ip: 10.100.0.1 + # keepalive: 60 # Tier-3 (PR-20), optional: eBGP keepalive timer, seconds -> bgpPeerP.keepAliveIntvl (default 60, real ACI default) + # hold: 180 # Tier-3 (PR-20), optional: eBGP hold timer, seconds -> bgpPeerP.holdIntvl (default 180; must exceed keepalive) + ext_epgs: + - name: xepg-All_LAB1 + subnets: ["0.0.0.0/0"] + # LAB2's L3Out → LAB2-ACI-CSW01 via border leaves 303 + 304 + - name: l3o-bgp-Core_LAB2 + vrf: vrf-L3_LAB0 + site: LAB2 + border_leaves: [303, 304] + csw_peer: + name: "LAB2-ACI-CSW01" + asn: 65100 + peer_ip: 10.200.0.1 + ext_epgs: + - name: xepg-All_LAB2 + subnets: ["0.0.0.0/0"] + + # ------------------------------------------------------------------------- + # SF-TN1-LAB1 — single-fabric (site-local) no-firewall tenant, LAB1 only + # ------------------------------------------------------------------------- + - name: SF-TN1-LAB1 + stretch: false + sites: [LAB1] + vrfs: + - name: vrf-L3_LAB1 + bds: + - name: bd-BD1_LAB1 + vrf: vrf-L3_LAB1 + subnets: ["192.168.1.0/24"] + aps: + - name: app-Local_Apps_LAB1 + epgs: + - name: epg-APP2_Local_BD + bd: bd-BD1_LAB1 + contracts: + provide: [] + consume: [] + contracts: [] + l3outs: [] + + # ------------------------------------------------------------------------- + # SF-TN1-LAB2 — single-fabric (site-local) no-firewall tenant, LAB2 only + # ------------------------------------------------------------------------- + - name: SF-TN1-LAB2 + stretch: false + sites: [LAB2] + vrfs: + - name: vrf-L3_LAB2 + bds: + - name: bd-BD1_LAB2 + vrf: vrf-L3_LAB2 + subnets: ["192.168.2.0/24"] + aps: + - name: app-Local_Apps_LAB2 + epgs: + - name: epg-APP2_Local_BD + bd: bd-BD1_LAB2 + contracts: + provide: [] + consume: [] + contracts: [] + l3outs: [] + +# --------------------------------------------------------------------------- +# Endpoint generation knobs +# --------------------------------------------------------------------------- +endpoints: + per_epg: 3 # endpoints to generate per EPG per site + ip_pool_from_subnet: true # pick IPs sequentially from BD subnets + +# --------------------------------------------------------------------------- +# Fault seeds + health defaults +# --------------------------------------------------------------------------- +faults: + seed: + - severity: minor + code: "F0532" + node: 101 + descr: "Link flap detected on LAB1-ACI-LF101 eth1/1" + - severity: warning + code: "F0554" + node: 102 + descr: "Interface operational speed mismatch on LAB1-ACI-LF102 eth1/3" + health_defaults: + node: 95 + tenant: 98 + +# --------------------------------------------------------------------------- +# Access policy (VLAN pools, physical domains, AAEPs) +# --------------------------------------------------------------------------- +access: + vlan_pools: + - name: vlp-general + from_vlan: 100 + to_vlan: 200 + phys_domains: + - name: phy-general + l3_domains: + - name: l3d-l3out # bound by l3extRsL3DomAtt in the l3out builder + aaeps: + - name: aep-general + # ---- Tier-2 VMM domains (PR-19) — optional, defaults to an empty list + # (no existing builder/production-chain reads a vmmDomP/vmmCtrlrP object + # today; see docs/DESIGN.md PR-19 section). Uncomment to add a + # vCenter-backed VMM domain: + # vmm_domains: + # - name: vmm-vmw-lab1 + # vcenter_ip: "10.50.1.10" # -> vmmCtrlrP.hostOrIp + # vcenter_dvs: "DVS-LAB1" # -> vmmCtrlrP.dvsName + # datacenter: "DC-LAB1" # -> vmmCtrlrP.rootContName + # vlan_pool: vlp-general # must reference an access.vlan_pools[] name above