# 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.18.1] - 2026-07-07 ### Changed - **License: MIT -> PolyForm Noncommercial 1.0.0** — aci-sim stays free for personal, lab, research, and other noncommercial use; commercial use now requires the author's permission. (`LICENSE`, `pyproject.toml` SPDX field, README badge + License section.) - README: added a Buy-Me-a-Coffee badge/link. ## [0.18.0] - 2026-07-06 ### Added - **Runtime tenant-L3Out eBGP session materialization** — a tenant L3Out pushed live via `POST /api/mo` (cisco.aci playbooks / aci-py) lands `bgpPeerP` (config) through the generic write path, which never produced the operational session MOs. `writes.py` now reacts to every runtime `bgpPeerP` by materializing `bgpPeer`/`bgpPeerEntry`(`operSt=established`)/ `bgpPeerAfEntry` under a `dom-{tenant}:{vrf}` BGP domain on the L3Out's own node — the peer ASN derived from the nested `bgpAsP`, the VRF from the L3Out's `l3extRsEctx.tnFvCtxName` (skips cleanly when no VRF is bound, or on `status=deleted`). This mirrors what `build/l3out.py` already does for topology-defined L3Outs, and makes per-tenant L3Out BGP session views in API clients (e.g. autoACI's "L3Out BGP" topology table) populate for runtime-pushed tenants. New regression tests in `tests/test_l3out_bgp_session.py` (903 tests total). ## [0.17.0] - 2026-07-05 ### Added - **`aci-sim query CLASS`** — a zero-friction, stdlib-only authenticated MIT query helper (`aci_sim/cli.py`'s `cmd_query`), closing a clean-room onboarding gap: the README's smoke test (`curl .../class/fabricNode.json`) returned an APIC 403 error envelope, not the fabric, because — like real APIC — the sim requires `aaaLogin` before any query. `aci-sim query` does the `aaaLogin` -> `APIC-cookie` -> `GET /api/class|mo/...` dance for you (via `urllib.request`/`ssl`/`http.cookies`, no new dependency — httpx stays dev-only) and prints a compact table (`--json` for the raw envelope). Supports `--dn DN` for a single-MO query, `--host`/`--user`/ `--password` overrides, and a friendly hint ("is the sim running? start it with `aci-sim run`") on a connection error instead of a traceback. ### Fixed - **`scripts/run.sh` failed with `ModuleNotFoundError: uvicorn` outside an activated venv.** It invoked bare `python` (whatever that resolves to on `$PATH`), unlike `scripts/sandbox-up.sh` which already resolves `./.venv/bin/python`. Now `scripts/run.sh` uses the same `PY="./.venv/bin/python"` (falling back to `python3`) pattern, so `bash scripts/run.sh` works from a fresh, non-activated shell right after `pip install -e '.[dev]'` — no `source .venv/bin/activate` required. - **README quickstart + smoke test now copy-paste-correct.** Added a 60-second quickstart (`pip install` -> `aci-sim run` -> `aci-sim query fabricNode`) verified end-to-end in a clean-room test; the old smoke test (`curl` with no login) is replaced with `aci-sim query fabricNode` (or the full authenticated curl, shown as an alternative). Also embeds a rendered `aci-sim graph` topology screenshot (`docs/images/topology.png`) near the top and in the `aci-sim graph` CLI section, and documents `aci-sim query` in §5 with a live sample transcript. ## [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.