Files
aci-sim/CHANGELOG.md
T
taodengandClaude Fable 5 7e9a175ce6 Initial public release — aci-sim v0.16.0
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:06:36 +10:00

998 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(pre-1.0: minor bumps may include breaking changes to the sim's behavior).
## [0.16.0] - 2026-07-05
### Changed
- **Project renamed `aci-fabric-sim` -> `aci-sim`.** Import package
`aci_fabric_sim` -> `aci_sim`, PyPI distribution name, GitHub repo, and the
`aci-sim` CLI entry point are now all unified under one name (previously
the package and CLI were `aci-sim`/`aci_sim` while the repo/distribution
metadata still said `aci-fabric-sim` in places). No behavior change —
every module path under `aci_sim/` and every documented command are
unchanged; this is a naming-consistency cleanup ahead of the repo going
public.
## [0.15.0] - 2026-07-05
### Added
- **NDO -> APIC deploy mirror.** `POST /mso/api/v1/task` (the deploy/undeploy
request `cisco.mso.ndo_schema_template_deploy` sends) was a pure
acknowledgment no-op — it never materialized the deployed schema
template's VRFs/BDs/ANPs/EPGs/binds into the target sites' APIC stores, so
a multi-site tenant created purely through NDO never appeared on the APIC
side. `mirror_template_to_sites()` (`aci_sim/ndo/deploy_mirror.py`) closes
this gap: given the NDO state, a template name, and a site_id ->
ApicSiteState map, it walks the owning schema's template plus each
associated SITE entry (host-based-routing overlay, domain associations,
static ports) and upserts the equivalent `fvCtx`/`fvBD`/`fvAp`/`fvAEPg` (+
children) MOs into every target site's `MITStore`, reusing the same
DN/attribute conventions the boot-time builders and the direct POST-write
path already use. Also creates a per-site `fvTenant` shadow so the tenant
itself — not just its child objects — is visible on each target site's
APIC after an NDO-driven deploy (previously only the site passed via
`--apic` in ad hoc testing got the tenant; the other site's `fvTenant` was
missing entirely).
- **`fvBD` create-time default attributes.** A POSTed BD now carries the
same default attribute set a real APIC assigns at creation
(`epMoveDetectMode="garp"`, `hostBasedRouting="no"`, `mtu="9000"`, …)
instead of only the attributes the caller explicitly set — so
host-based-routing and EP-move-detection now show up correctly in the
APIC BD view for a BD created via a plain POST, matching real-APIC
behavior.
## [0.14.0] - 2026-07-05
### Added
- **File-backed state persistence.** New `POST /_sim/save/{name}` and
`POST /_sim/load/{name}` routes on both the APIC plane
(`aci_sim/control/admin.py`) and the NDO plane (`aci_sim/ndo/app.py`)
serialize/restore each plane's store to/from disk under `SIM_STATE_DIR`
(default `~/.aci-sim/state`) — unlike the existing in-memory
`snapshot`/`restore`, this state survives a full sim restart.
`scripts/sim-state.sh {save|restore} <name>` is the whole-fabric wrapper:
it derives each plane's address the same way `scripts/sandbox-up.sh` does
and hits every plane's endpoint (both APIC sites + NDO) in one command.
New module `aci_sim/control/persist.py` implements the serialization: a
`MITStore` flattens to a JSON list of `{class, attrs}` dicts (MOs stored
in the MIT are always childless — the parent/child hierarchy lives only
in the store's own index — so replaying them through `MITStore.add`
faithfully rebuilds it); `NdoState`'s persisted fields are deep-copied to
a JSON-friendly dict and reapplied onto the same live object via
`setattr` (the running NDO app closes over that object's identity, so a
reload must mutate in place rather than swap in a new instance).
## [0.13.2] - 2026-07-04
### Fixed
- **NDO `serviceNodeRef` resolved to a string ref (was a dict).** The
service-graph -> contract bind path stored `serviceNodeRef` as a dict-form
ref while every other mirror/lookup path expects the canonical NDO string
ref shape (`/schemas/{id}/templates/{tmpl}/{category}/{name}`); re-running
the bind against an already-bound contract was not idempotent because the
two representations never compared equal. Fixed to resolve/store the
string form, matching the rest of the NDO ref conventions.
## [0.13.1] - 2026-07-04
### Fixed
- **`pip install` was completely broken — now fixed (critical, packaging).** A
from-scratch clean-venv E2E revealed that `pip install aci-sim` / `pip
install .` produced an unusable package: (1) **zero runtime dependencies** were
declared to pip — `pyproject.toml` had `dynamic = ["dependencies"]` with no
`[tool.setuptools.dynamic]` source, so fastapi/uvicorn/pydantic/pyyaml were
never installed (the CLI crashed on the first third-party import, `import
yaml`); and (2) **every subpackage was omitted**`packages =
["aci_sim"]` shipped only the top-level module, dropping
`topology`/`build`/`rest_aci`/`ndo`/`mit`/`runtime`/`query`/`control`. The dev
workflow (`pip install -e .` + `pip install -r requirements.txt`) masked both.
Fix: static `[project.dependencies]` (fastapi/uvicorn[standard]/pydantic/pyyaml),
a `[project.optional-dependencies].dev` extra (httpx/pytest/pytest-asyncio), and
`[tool.setuptools.packages.find] include = ["aci_sim*"]` to ship every
subpackage. Verified end-to-end: a fresh venv `pip install .` now yields a
working `aci-sim` CLI + importable package.
## [0.13.0] - 2026-07-04
### Added
- **Admin account in the setup wizard + `aci-sim run` credential enforcement.**
`aci-sim init`'s new **Step 0: Admin account** prompts for the fabric admin
username/password (default `admin`/`cisco`) and whether NDO shares the APIC
account (default yes), writing them into a new topology.yaml `auth:` section
(`schema.Auth`: `username`/`password` + optional `ndo_username`/`ndo_password`).
`aci-sim run` resolves these into `SIM_USERNAME`/`SIM_PASSWORD` for the
supervisor via the new `cli.resolve_admin_credentials()`, so the APIC plane
then ENFORCES them (`aaaLogin` returns 401 on a username/password mismatch).
**Precedence** (highest first): an explicit `SIM_USERNAME`/`SIM_PASSWORD`
already in the environment wins (the CI/override escape hatch), else the
topology `auth:` section, else `auth.py`'s own `admin`/`cisco` default.
`aci-sim run` prints a one-line auth-source banner at startup (the username +
where it came from, never the password).
- The NDO plane stays **lenient by design**`/login` and `/api/v1/auth/login`
accept any credential and never validate the bearer token (cisco.mso/aci-py
client compatibility). The shared account is recorded/reported but not
enforced there; a split `ndo_username`/`ndo_password` is informational.
### Notes
- Purely additive / backward-compatible: a topology.yaml with **no** `auth:`
section still validates (`Topology.auth: Auth | None = None`) and `aci-sim
run` injects nothing — behavior is identical to 0.12.0.
- 24 new tests (`tests/test_admin_account.py`) cover the wizard's `auth:`
output (defaults / custom / split-NDO), `resolve_admin_credentials`
precedence, `build_run_env_argv` injection + filesystem purity, end-to-end
`aaaLogin` enforcement of a rotated password (incl. the stale default now
failing) with NDO still lenient, and no-`auth:` backward-compat.
## [0.12.0] - 2026-07-04
### Added
- **LLDP/CDP neighbor fidelity — enriched adjacencies + materialized container MOs + CLI.**
`lldpAdjEp`/`cdpAdjEp` previously carried only `sysName`/`mgmtIp`; both are now enriched with
real-APIC-shaped fields — `lldpAdjEp` gains `portIdV` (the NEIGHBOR's own port), `portIdT`,
`chassisIdT`, `chassisIdV` (a deterministic per-node MAC via new `build/neighbors.py:node_mac`),
`sysDesc` (`"{model} running {version}"` for a switch neighbor, `"Cisco APIC"` for a controller),
`capability`, `id`, `ttl`; `cdpAdjEp` gains `devId` (== `sysName`), `portId`, `platId`, `ver`,
`cap`. Purely additive — `sysName`/`mgmtIp` are unchanged, so every pre-existing consumer
(autoACI, this repo's own tests) keeps working untouched.
New container classes `lldpInst`/`lldpIf`/`cdpInst`/`cdpIf` are now materialized (one `*Inst`
per switch node with any adjacency, one `*If` per adjacency-bearing local port), fixing a bug
where a deep-root subtree query (`GET /api/mo/topology/pod-1/node-101/sys/lldp/inst.json?
query-target=subtree&target-subtree-class=lldpAdjEp`) returned `totalCount=0` — the query
engine's `run_mo_query` short-circuits to `([], 0)` when the root DN has no MO of its own, and
`.../sys/lldp/inst` previously had none. `/api/class/lldpInst.json`/`lldpIf.json`/`cdpInst.json`/
`cdpIf.json` now also return real results.
All three adjacency-construction sites (`build/cabling.py` spine<->leaf links, `build/fabric.py`
APIC<->leaf attachment, `rest_aci/writes.py`'s dynamic node-registration reaction) now share one
helper module, `build/neighbors.py` (`add_adjacency`/`add_switch_adjacency` +
`ensure_lldp_containers`/`ensure_cdp_containers`), so a dynamically-registered node (via POST
`fabricNodeIdentP` or `/_sim/add-leaf`) gets the identical enriched/materialized treatment a
boot-time-seeded node gets — verified by `tests/test_lldp_cdp_fidelity.py`'s dynamic-path tests.
New CLI subcommand `aci-sim lldp [topology] [--site NAME] [--node ID] [--cdp] [--json]` prints a
`show lldp neighbors`-style grouped table (or CDP with `--cdp`, or structured JSON with `--json`).
`tests/test_lldp_cdp_fidelity.py` (20 tests): container materialization, the subtree-query
regression fix, bidirectional `portIdV` consistency across a spine<->leaf link, `cdpAdjEp.devId
== sysName`, backward-compat field presence, the dynamic-registration path, and the CLI (text/
`--json`/`--cdp`/a graceful non-existent node id).
## [0.11.0] - 2026-07-04
### Added
- **APIC query subscriptions + websocket push-on-change.** Real APIC's subscription mechanism,
the piece the "Monitoring/observability integration" use case (README §1) needed to move past
polling-only. `GET /api/class/{cls}.json`, `GET /api/mo/{dn}.json` (+ `/api/node/mo/{dn}` alias),
and `GET /api/node/class/{...}` all now accept `?subscription=yes`, returning the *unchanged*
query result plus a top-level `subscriptionId` string (opt-in only — a plain query with no
`?subscription=yes` is byte-identical to before this feature, no key added, no behavior change).
`GET /socket<token>` (real APIC's literal path shape, `<token>` = the `APIC-cookie` session
token) opens a websocket; when a subscribed class or DN/subtree changes via `POST`/`DELETE`,
a `{"subscriptionId":[...],"imdata":[{<class>:{"attributes":{...,"status":"created|modified|
deleted","dn":...}}}]}` event pushes over that connection. `GET /api/subscriptionRefresh.json?
id=<id>` keeps a subscription alive past its 90s TTL (matching real APIC), always 200 even for
an unknown id. Disconnecting the websocket drops that connection's subscriptions. New module
`aci_sim/rest_aci/subscriptions.py` (in-memory registry + notify — module-level state,
same pattern as `auth.py`'s session store); write handlers stay fully synchronous and simply
call `notify()`, which enqueues onto each live connection's own `asyncio.Queue` — the websocket
route drains that queue independently, so a write request never awaits network I/O on some
other client's socket. `tests/test_subscriptions.py` (12 tests): subscribe returns
`subscriptionId`; a plain query has zero shape change; websocket created/modified/deleted push
for a subscribed class; an unsubscribed class does not push; DN-scoped subscriptions match
subtree writes; refresh returns 200 (known and unknown id); an unknown-token websocket is
rejected; disconnect cleans up subscriptions. See README §10a and `docs/CONTRACT.md` §2a for
the full contract.
## [0.10.0] - 2026-07-04
### Added
- **OOB management gateway — a real MO instead of a discarded value.** Every node already got
`topSystem.oobMgmtAddr` (the OOB IP), but no gateway was modeled anywhere; `aci-sim init`'s wizard
even asked for one per site, but discarded the answer (no schema field, no MO — just an
informational summary line). Real APIC models the OOB address + gateway in the special `mgmt`
tenant (`uni/tn-mgmt/mgmtp-default/oob-default`, a `mgmtOoB` EPG) with one `mgmtRsOoBStNode` child
per node. New `Site.oob_gateway: Optional[str]` schema field (optional, default `None`, validated
as a real IP address) plus a new builder `aci_sim/build/mgmt.py` (registered in
`build/orchestrator.py`) that emits the minimal faithful scaffolding for every site:
`fvTenant`(mgmt) → `mgmtMgmtP`(default) → `mgmtOoB`(default) → one `mgmtRsOoBStNode`(tDn,addr,gw)
per node (switches AND controllers), `addr` = the node's OOB IP + mask (mask from
`fabric.oob_subnet` if set, else `/24` by default — deliberately not `fabric.oob_pool`'s wider
`/16`, which spans multiple pods/sites), `gw` = `site.oob_gateway` (empty string, never an invented
default, when unset).
- `aci-sim init`'s wizard now WRITES each site's answered OOB gateway into
`topology_dict["sites"][i]["oob_gateway"]` instead of discarding it; `aci-sim show` surfaces
`oob_gateway=<value|->` per site.
- `tests/test_oob_gateway.py` (25 tests): schema default/validation, builder MO shape (mgmtRsOoBStNode
per node, addr/gw correctness, mask derivation, empty-gw-when-unset), wizard write-through
(defaults run, custom answers, multi-site per-site gateways), and backward compatibility (repo
`topology.yaml` builds unchanged, existing `topSystem`/`fabricNode` attrs untouched, new
mgmt-tenant scaffolding is purely additive).
### Changed
- `docs/CONTRACT.md`: new "Mgmt tenant — OOB management" entry cataloging `fvTenant`(mgmt)/
`mgmtMgmtP`/`mgmtOoB`/`mgmtRsOoBStNode`.
- `README.md` §6c (Tier-3 config reference) documents `site.oob_gateway`; §5 `aci-sim show`/
`aci-sim init` sample output updated to include `oob_gateway=...`.
## [0.9.0] - 2026-07-04
### Added
- **`aci-sim graph` — self-contained SVG/HTML topology diagram.** New `aci_sim/graph.py`
renders the *built* fabric (spines, leaves, border leaves with vPC-pair grouping, per-site
controllers, and — for multi-site topologies with `isn.enabled: true` — a single ISN cloud node
with spine→ISN uplinks) as hand-generated, dependency-free SVG: no CDN, no local server, no npm
build step. `-o FILE.html` wraps the SVG in a minimal inline-CSS HTML shell (double-click to open
in any browser); `-o FILE.svg` writes raw SVG. Layout is a hand-computed tiered ACI hierarchy
(spine row → leaf row → border-leaf row → controller row per site, sites placed side by side, ISN
cloud centered above/between them for multi-site) plus a role→color legend and a title bar
(fabric name, site count, node count). Purely additive: no change to `topology/schema.py` or any
`build/*.py` builder — `graph.py` only reads the validated `Topology` model and re-derives the
same spine×leaf mesh `build/cabling.py::cabling_links()` produces, so the diagram always matches
the fabric that would actually be built.
- **Visual language matched to autoACI's topology view** (studied read-only on autoACI's
`frontend/src/composables/useTopologyStyles.js` + `backend/routers/topology.py`; NOT imported —
autoACI's implementation is Vue+cytoscape, this is a standalone Python reimplementation of the
same colors/hierarchy): spine = `#2563eb` blue, leaf/border-leaf = `#16a34a` green, controller =
`#d97706` amber, ISN cloud = `#475569` slate; rounded-rect nodes with a subtle drop shadow;
cabling links solid gray, ISN links dashed orange, vPC-peer links dotted slate.
- Complements `aci-sim show` (text inventory) with a visual counterpart — `show` for machine/human
text detail, `graph` for at-a-glance topology shape.
- `tests/test_graph.py` (18 tests): node/link counts against the repo's default 2-site topology,
ISN-cloud presence for multi-site and absence for a generated 1-site topology, a generated 3-site
topology rendering all 3 sites, role-color presence, well-formed-XML checks via `xml.etree`, and
`aci-sim graph` CLI coverage (HTML/SVG output, default paths, missing-file and bad-extension
errors) — no browser required anywhere in the suite.
## [0.8.1] - 2026-07-04
### Added
- **CI gate example + Use Cases docs.** New `examples/ci/`: `assert_mit.py` (a tiny MIT-end-state
assertion helper — `class:`/`mo:`/`absent:`/`attr:` checks, exit code = failed-check count, works
against the sim OR a real APIC) and `github-actions-aci-gate.yml` (copy-pasteable start-sim →
run-automation → assert workflow). README §1 gains a "More applications" table (SDK/provider
testing, change pre-flight, REST-contract testing, multi-tool interop, monitoring, scale) with
honest ready/partial/extension status, plus an explicit out-of-scope note.
## [0.8.0] - 2026-07-04
### Added
- **`aci-sim init` — interactive APIC-setup-style topology wizard (PR-22).** Modeled on the real APIC
first-boot setup dialog: prompts each field with a suggested default shown in `[brackets]` (ENTER
accepts it, or type a custom value), then writes a `topology.yaml` and auto-runs `aci-sim validate` on
the result. Flow: Step 0 deployment type (`1) Single fabric` / `2) Multi-site` — real APIC's "multi-pod"
choice is deliberately NOT offered, since this sim builds no true multi-pod fabric); Step 1 per-site
fields (name, site ID, BGP AS, pod ID, controller count 1-5, APIC OOB mgmt IP, then — if
controllers > 1 — one prompt per additional controller each suggesting the previous IP + 1 via
`ipaddress.ip_address(prev) + 1`, mapping to PR-21's `Site.controller_ips`; OOB gateway asked once per
site; TEP pool/infra VLAN/GIPo pool/OOB subnet/spine-leaf-border counts); Step 2 multi-site-only
(NDO mgmt IP, ISN OSPF area/MTU); Step 3 summary + confirm + write + auto-validate. Reuses
`generate_topology()` — the SAME generator `aci-sim new` calls — as a thin interactive front-end; no
topology-emission logic is duplicated.
- **Non-interactive wizard modes**: `--defaults` accepts every suggested default without prompting, and is
also auto-enabled whenever stdin is not a TTY (so `aci-sim init` never hangs in CI/scripts even without
the flag). `--answers FILE` accepts a YAML or JSON file of pre-filled field-name -> value answers;
listed fields are used verbatim, unlisted fields still prompt/default as usual — enables fully scripted,
reproducible topology generation (e.g. `site1_controllers: "3"` to script a specific cluster size).
- **`generate_topology()` widened (PR-22, additive, backward compatible)**: new optional `site_overrides`
parameter — a `list[dict]` (one entry per site) shallow-merged onto each auto-derived site definition
(name/id/apic_host/mgmt_ip/fabric_name/asn/pod/controllers/controller_ips) — lets a caller (the wizard)
express per-site heterogeneity, including PR-21's `controller_ips`, which the existing flat
`--controllers`/`--apic-ip-base` flags cannot express per-site. `leaves_per_site`/`spines_per_site`/
`border_pairs` now also accept a `list[int]` (one count per site) in addition to the pre-existing
single `int` applied uniformly to every site — needed because the wizard asks spine/leaf/border-pair
counts per site. Both widenings are fully backward compatible: every pre-PR-22 caller (including
`aci-sim new` and every existing test) passes a plain `int`/omits `site_overrides`, and gets
byte-identical output to before.
### Changed
- CLI help/argparse: new `init` subcommand registered alongside `validate`/`show`/`run`/`new`.
## [0.7.0] - 2026-07-04
### Changed
- **BREAKING (node counts): `Site.controllers` default 3 → 1 — single-APIC-per-site by design (PR-21).**
APIC cluster size is irrelevant to Ansible playbook *deployment* testing (a playbook targets exactly one
APIC endpoint); cluster size only matters for cluster-health/appliance-vector tooling such as autoACI's
`health_score.py`. `controllers` remains fully configurable, now range-validated **1-5**
(`Topology.normalize_and_validate`) — set `controllers: 3` on a site to restore the old default /
exercise multi-controller `infraWiNode`/cluster-health semantics. This changes every site's generated
`fabricNode`/`topSystem` count (2 per site at the new default vs. the old one), `fabricLink`/
`lldpAdjEp`/`cdpAdjEp` counts for the APIC↔leaf attachment, and `infraWiNode` count (`controllers²`).
All existing test assertions and e2e scripts encoding the old counts were recomputed and updated — see
`docs/DESIGN.md`'s "PR-21" section for the full before/after accounting.
- `aci-sim new`'s `--controllers` flag and `generate_topology()`'s `controllers` parameter both default to
`1` now (was `3`), matching the new `Site.controllers` schema default.
- The shipped root `topology.yaml`'s commented `# controllers: N` example line now shows the current
default (`1`) instead of the pre-PR-21 default (`3`); a new commented block shows the
`controllers: 3` + `controller_ips: [...]` opt-in shape for multi-controller topologies.
### Added
- **`Site.controller_ips: Optional[list[str]]` — explicit per-controller OOB mgmt IPs (PR-21).** When
set, `controller_ips[i]` is controller `i+1`'s `topSystem.oobMgmtAddr` verbatim (length must equal
`controllers`, each entry a valid IP — validated). When unset (default), OOB IPs are auto-derived
**sequentially from `site.mgmt_ip`**: controller 1 = `mgmt_ip`, controller 2 = `mgmt_ip`+1, controller 3
= `mgmt_ip`+2, etc., via the new `Site.controller_oob_ips()` helper (`topology/schema.py`). Falls back
to the legacy per-node `oob_ip(pod, cid)` scheme when `mgmt_ip` is also unset, so a topology setting
neither field keeps byte-identical controller OOB addresses to pre-PR-21. Wired into
`build/fabric.py`'s controller `topSystem.oobMgmtAddr` and the leaf-side `lldpAdjEp`/`cdpAdjEp.mgmtIp`
(kept consistent — both read the same per-call derived list). Surfaced in `aci-sim show`/`show --json`
as `controller_ips`. The sim's REST server still binds to exactly one address per site (`mgmt_ip`,
controller-1 / the cluster VIP) regardless of `controllers` — it never runs N separate APIC processes;
`controllers`/`controller_ips` only affect the simulated fabricNode/topSystem/infraWiNode data.
- README "Configuration" section (§6a) now documents the single-APIC design intent explicitly, and a new
`docs/DESIGN.md` "PR-21" section covers the full before/after node-count accounting and verification.
## [0.6.0] - 2026-07-04
### Added
- **Tier-3 fine-tuning parameters (PR-20) — the last planned param tier.**
New optional `topology.yaml` fields — all defaulted to pre-existing
hardcoded behavior (or the real ACI factory default where no prior
hardcoded value existed), so the existing repo `topology.yaml` validates
and builds unchanged:
- `bd.mac` (per-BD, optional) and `fabric.default_bd_mac` (fabric-wide,
default `"00:22:BD:F8:19:FF"` — the exact literal `build/tenants.py`
already hardcoded for every BD pre-PR-20) — now threaded into
`fvBD.mac`, replacing that hardcode. Resolution order: `bd.mac or
fabric.default_bd_mac`. Byte-identical output when both are unset.
- `l3out.csw_peer.keepalive`/`.hold` (defaults `60`/`180`, real ACI
defaults) — threaded into `build/l3out.py`'s `bgpPeerP.keepAliveIntvl`/
`holdIntvl` (previously not carried on that MO at all). `hold` must
exceed `keepalive`.
- `isn.ospf_hello`/`.ospf_dead` (defaults `10`/`40`, real ACI defaults) —
threaded into `build/underlay.py`'s `ospfIf.helloIntvl`/`deadIntvl`
(previously not carried on that MO at all). `ospf_dead` must exceed
`ospf_hello`.
- `isn.bfd_min_rx`/`.bfd_min_tx`/`.bfd_multiplier` (defaults `50`/`50`/
`3`, real ACI defaults) — store + range-validate + surface only. This
sim builds no `bfdIfP`-style MO anywhere (grepped `build/*.py`: zero
matches for "bfd"), so these are explicitly **store-only**, documented
as such in `aci-sim show`'s own output.
- `fabric.oob_subnet` (validate-only; must fall within
`192.168.0.0/16`) and `fabric.inb_subnet` (store-only; must fall
within a private RFC1918 range) — `oob_ip()` still derives every
node's OOB address from `(pod, node_id)` only (documented pre-existing
limitation, unchanged); this sim has no in-band mgmt MO at all, so
`inb_subnet` is explicitly store-only.
- `fabric.default_apic_version` (fabric-wide override; default `None` =
unchanged behavior) — takes precedence over each site's own
`site.apic_version` (PR-9/PR-18 lineage, already fully wired and
already independent of switch-node `Node.version`) in
`build/fabric.py`'s controller `fabricNode`/`topSystem`/
`firmwareCtrlrRunning`. **Finding while implementing this PR:**
`site.apic_version` already drove those MOs independently of switch
version before this PR — the actual gap closed here is (1) a
fabric-wide override knob and (2) `aci-sim show` never surfaced
`apic_version` at all pre-PR-20 (now shown per-site as the resolved
effective value).
- `aci-sim new` gained `--isn-ospf-hello`, `--isn-ospf-dead`,
`--isn-bfd-min-rx`, `--isn-bfd-min-tx`, `--isn-bfd-multiplier`,
`--default-bd-mac` flags; `aci-sim show` now prints
`oob_subnet`/`inb_subnet` (labeled store-only)/`default_bd_mac`,
`default_apic_version` (when set), ISN `ospf_hello`/`ospf_dead`, ISN
BFD timers (labeled store-only), and each site's effective
`apic_version`.
- New tests: `tests/test_pr20_tier3.py` (41 tests). Full suite: 713
passed (672 pre-existing + 41 new), zero regressions.
- See `docs/DESIGN.md`'s "PR-20 — Tier-3 fine-tuning parameters" section
for the full design rationale and an explicit statement of which
backward-compat verification was performed in this sandboxed
development environment vs. deferred to the main thread's independent
production-chain re-run (no SSH access to the hardware test host from
this environment).
## [0.5.0] - 2026-07-04
### Added
- **Tier-2 topology-elasticity parameters (PR-19).** New optional
`topology.yaml` fields — all defaulted to pre-existing hardcoded behavior,
so the existing repo `topology.yaml` validates and builds unchanged:
- `isn.ospf_area` (default `"0.0.0.0"`, accepts dotted-decimal or
decimal-integer notation) and `isn.mtu` (default `9150`, range
576-9216) — now threaded into `build/underlay.py`'s `ospfIf`/
`ospfAdjEp` `area` and `build/interfaces.py`'s dedicated ISN/IPN spine
uplink `l1PhysIf.mtu`, replacing hardcoded `"0.0.0.0"`/`9216` literals
at those specific call sites. Ordinary fabric ports keep their existing
9216 MTU.
- `access.vmm_domains[]` (default: empty list) — a VMM domain model
(`vcenter_ip`, `vcenter_dvs`, `vlan_pool`, `datacenter`) that builds a
`vmmDomP` (+ `vmmCtrlrP`/`vmmUsrAccP` when `vcenter_ip` is set, +
`infraRsVlanNs` when `vlan_pool` is set) in `build/access.py`. Verified
(read-only, hardware test host) that this is a **pure addition**: the real
aci-py `bind_epg_to_vmm_domain` MS-TN1 chain writes an NDO schema
`domainAssociations` entry referencing the domain by DN string only —
no existing builder or production chain reads a `vmmDomP`/`vmmCtrlrP`
object today, hence the empty-list default (unlike `phys_domains`/
`l3_domains`/`aaeps`, which each seed one entry). See `docs/DESIGN.md`
PR-19 section for the full verification trail.
- **Leaf/spine count elasticity bug fix**: `aci-sim new`'s node-ID
generator (`generate_topology()`) had no guard against
`--leaves-per-site`/`--spines-per-site` exceeding the node-ID scheme's
documented ~100-per-site ceiling, or `--border-pairs` pushing the
border block past the next site's leaf range — all three previously
produced a `topology.yaml` dict that failed deep inside
`Topology.normalize_and_validate` with a generic "node ids collide"
error. Now rejected at generation time with a clear message naming the
offending flag. Verified: `--leaves-per-site 8 --spines-per-site 4`
(and `--border-pairs` > 1) validate and build clean; the exact boundary
(100 leaves/spines, 49 border-pairs at default counts) still works;
101/50 respectively are cleanly rejected.
- `isn.peer_mesh: partial` re-examined and kept as a documented
validate-and-reject (unchanged decision from PR-17/18) — no sensible
default partition exists without additional per-site config the schema
doesn't carry.
- `aci-sim new` gained `--isn-ospf-area`, `--isn-mtu` flags; `aci-sim
show` now prints ISN enabled/peer_mesh/ospf_area/mtu and any configured
VMM domains.
- New tests: `tests/test_pr19_tier2.py` (37 tests) + 6 additions to
`tests/test_cli.py`.
## [0.4.0] - 2026-07-04
### Added
- **Tier-1 fabric configuration parameters (PR-18).** New optional
`topology.yaml` fields — all defaulted to the pre-existing hardcoded
behavior, so the existing repo `topology.yaml` validates and builds
unchanged:
- `fabric.tep_pool` (default `"10.0.0.0/16"`), `fabric.infra_vlan`
(default `3967`, range 1-4094), `fabric.gipo_pool` (default
`"225.0.0.0/15"`) — stored, CIDR/range-validated, and surfaced via
`aci-sim show`/`aci-sim new`. No builder currently derives a per-node
address or VLAN attribute from these (see `docs/DESIGN.md` PR-18
section) — store-only until a future PR wires them into a builder.
- `site.controllers` (APIC cluster size, default 3) and `site.pod`
(default 1) already existed and were already fully wired through every
relevant builder (`fabricNode`/`topSystem`/`infraWiNode`/
`firmwareCtrlrRunning`/health/endpoints); PR-18 verified this end-to-end
and added `aci-sim new --controllers N`.
- Deterministic non-empty node serials: `build/fabric.py`'s new
`default_serial(site_id, node_id)` (`f"SAL{site_id}{node_id:04d}"`)
replaces the previous inline `SIM{node.id:08d}` fallback used whenever
an auto-generated `Node.serial` is empty, so `fabricNode.serial` /
`fabricNodeIdentP.serial` always carry a real, deterministic serial for
`cisco.aci.aci_fabric_node` to register against. Explicit YAML
`serial:` values still override.
- `aci-sim new` gained `--controllers`, `--tep-pool`, `--infra-vlan`,
`--gipo-pool` flags; `aci-sim show` now prints each node's resolved
serial and each site's controller count, plus the fabric-wide
tep_pool/infra_vlan/gipo_pool.
- Verified N-site behavior: a 3-site scaffold (`aci-sim new --sites 3`)
builds with zero node-ID collisions and per-site `infraWiNode` counts
matching the configured controller count. **Documented limit**: ISN
inter-site peering and stretched-tenant HOME/REMOTE endpoint placement
still rely on `Topology.other_site()`, which is 2-site-only semantics
(pre-existing, not introduced by this PR) — with 3+ sites, ISN is not a
true full mesh. See README §6a / §8 and `docs/DESIGN.md` PR-18 section.
- New tests: `tests/test_pr18_tier1.py`.
## [0.3.0] - 2026-07-04
### Added
- **`aci-sim` topology CLI (PR-17).** A new console script
(`aci_sim/cli.py`, `[project.scripts] aci-sim`) that makes
`topology.yaml` easier to author, validate, preview, and run — purely
additive: it does not change the topology schema, builders, or NDO/REST
code, and `topology.yaml` remains the single source of truth. Four
subcommands:
- `aci-sim validate [TOPOLOGY]` — runs the same `load_topology()`/Pydantic
validation the sim already performs at startup, printing a concise
pass/fail summary (sites, leaves/spines/border-leaves per site, tenant
count) instead of a raw traceback. This is the CI gate.
- `aci-sim show [TOPOLOGY]` — prints the **derived** inventory (not just
an echo of the YAML): per-site APIC host/mgmt IP/ASN/pod/fabric name, a
table of every node with its derived loopback/OOB IP (via
`build/fabric.py`'s `loopback_ip`/`oob_ip`), the NDO mgmt IP, and the
actual host:port each controller will listen on (port mode or sandbox
mode, read from `runtime/config.py`). `--json` for machine-readable
output.
- `aci-sim run [TOPOLOGY]` — a thin wrapper around `python -m
aci_sim.runtime.supervisor`: validates the topology up front,
generates self-signed certs on first run (mirrors
`scripts/gen_certs.sh`), sets `TOPOLOGY_PATH`, passes through
`SIM_BIND`/`SIM_SANDBOX`/etc. from the environment, and execs the
supervisor — `scripts/run.sh` is unchanged and keeps working.
- `aci-sim new` — scaffolds a fresh, minimal, valid `topology.yaml` (to
stdout or `-o FILE`) from flags (`--sites`, `--leaves-per-site`,
`--spines-per-site`, `--border-pairs`, `--asn`, `--fabric-name-prefix`,
`--ndo-ip`, `--apic-ip-base`), using the same node-ID scheme documented
in `topology/schema.py` (leaves base 101 +200/site, spines base 201
+200/site, border leaves placed outside both ranges in vPC pairs). The
generated file always passes `aci-sim validate` and boots.
- New `tests/test_cli.py` (20 tests): validate positive/negative
(duplicate node id, bad CIDR), show text + `--json` asserting derived
IPs for a known node, `new --sites 3 --leaves-per-site 4` round-tripped
through the real Pydantic model, and `run`'s env/argv construction via
a pure `build_run_env_argv` helper (no live socket binding in tests).
- README gets a new "## 5. CLI" section (subsequent sections renumbered
6-11); `deploy/README.md`/Quickstart note `aci-sim run` as the
preferred entrypoint alongside `scripts/run.sh`.
## [0.2.7] - 2026-07-04
### Fixed
- **NDO site-local BD duplication — mirror vs site-module dedup (PR-16).**
`aci_sim/ndo/patch.py::_mirror_template_bd_to_sites` (PR-15)
auto-creates a site-local BD shadow with a full-path STRING `bdRef` the
moment a template BD is added. Separately, `mso_schema_site_bd.py`'s
"BD does not exist yet" branch PATCHes `add /sites/{siteId}-{template}/
bds/-` (the RFC-6902 *append* form) with a DICT-shaped `bdRef`. The
append branch of `apply_json_patch` never consulted `_find_by_name`/ref
resolution before appending, and `_find_by_name`'s own ref-lookup only
recognized the string ref shape — so the two writers never converged on
one entry, and `sites[].bds[]` ended up with two rows for the same
template BD (one empty mirror, one carrying the real subnet) —
confirmed against a reference NDO schema (`App1-Net-LAB0`, Acme
App1-Net tenant) after `create_tenant`+`create_bd`.
Fixed by deduping a **nameless site-local shadow** strictly by its
single IDENTITY ref (`bds`→`bdRef`, `anps`→`anpRef`, `epgs`→`epgRef`,
`contracts`→`contractRef`; `_IDENTITY_REF_BY_COLLECTION`), resolved in
both string and dict shapes via a new `_bare_ref_name()` helper. A new
`_find_shadow_by_identity()` is the append-path dedup; `_find_by_name`'s
ref-lookup now fires only for nameless items and only over identity
refs. A NAMED object (a template-level BD add) is matched only by
name/displayName and always appends.
IMPORTANT — the first cut of this fix iterated **all** `*Ref` keys
(including `vrfRef`), which collapsed every template's BDs into a single
entry because distinct BDs share one `vrfRef` (`vrf-L3_LAB0`); an
independent full-chain Acme run caught it (`create_bd` failed
`"Provided BD 'bd-App2-Net-...' does not exist"`). The shipped fix
restricts ref-matching to identity refs of nameless shadows only —
guarded by `test_distinct_template_bds_sharing_vrfref_do_not_collapse`.
Verified end-to-end on real ACI hardware: Acme App1-Net
`create_tenant.yml`/`create_bd.yml` both `failed=0`; live NDO schema
`App1-Net-LAB0` shows template BD counts `LAB1-LAB2=6`, `LAB1=14`,
`LAB2=10` (30, no collapse) and exactly one site-local BD entry per
template BD (no duplicate). Covers the analogous ANP/contract
site-mirror paths too — no regression against the PR-12/PR-13 mirror
suites. 9 tests in `tests/test_pr16_bd_mirror_dedup.py`.
## [0.2.6] - 2026-07-04
### Added
- **Domain-qualified `aaaLogin` name + template-BD site mirroring for
aci-py (PR-15).** Two confirmed sim gaps from a real `aci-py` (pure-Python
Ansible-to-ACI compiler) run against the hardware sandbox:
1. **APIC login rejects a domain-qualified login name.** `aci-py` posts
`POST /api/aaaLogin.json` with `aaaUser.attributes.name =
"apic:local\admin"` — a valid real-APIC login format
(`apic:<loginDomain>\<username>`). The sim's `aaaLogin` handler
(`aci_sim/rest_aci/auth.py`) exact-matched the raw `name`
attribute against `SIM_USERNAME`, so every domain-qualified login
401'd even with the correct password — confirmed live against the
sandbox (`name="apic:local\admin"` -> 401, `name="admin"` -> 200
before this fix). Added `_bare_username()`, which strips an optional
`apic:<domain>\` / `<domain>\` prefix before the credential compare,
so both the plain and domain-qualified forms authenticate identically;
a wrong username in either form still 401s.
2. **NDO schema PATCH 400 mid `create_tenant`/`create_bd`.** `aci-py`'s
`mso_schema_site_bd` shim always PATCHes a site-local BD shadow with
`op: replace` (never `add`), because real NDO 4.x auto-creates that
shadow ("BDDelta") the moment a template-level BD is added to a
template that already has sites attached — a fresh `add` there 409s
on real hardware, so the shim does a GET-then-replace instead (its own
docstring documents this real-hardware behavior). The sim never
mirrored a template BD `add` into `sites[].bds[]` (unlike ANPs/EPGs
since PR-12 and contracts since PR-13 — the same "auto-mirror on
template add" pattern, just missing the BD collection), so the
site-BD `replace` 400'd with `"replace: 'bd-FW_LAB0' not found at
'/sites/1-LAB1-LAB2/bds/bd-FW_LAB0'"` — confirmed verbatim against a
real hardware aci-py MS-TN2 run before this fix. Added
`_mirror_template_bd_to_sites` (`aci_sim/ndo/patch.py`,
PATCH-time, parallel to `_mirror_template_anp_to_sites` /
`_mirror_template_contract_to_sites`) and the equivalent boot-time
mirror in `build_ndo_model` (`aci_sim/ndo/model.py`), so a
topology.yaml tenant that ships with BDs already configured also
boots with the site shadow pre-mirrored.
- VERDICT: both gaps are genuine sim fidelity gaps, not aci-py bugs —
aci-py's domain-qualified login name and its GET-then-replace
site-BD shim both mirror documented real-APIC/real-NDO behavior.
- Confirmed end-to-end on real ACI hardware: aci-py's `live_chain.py`
driver against `environments/lab-multisite-sim` now reaches
**MS-TN2: 13/13 steps rc=0** (was 9 ok / 4 fail before this PR — the 4
failures were exactly the login 401s and the `replace: 'bd-*' not
found` 400) and **MS-TN1: 11/11 rc=0** (1 skip, no regression).
- 13 new tests in `tests/test_pr15_acipy_gaps.py` covering both plain and
domain-qualified (`apic:local\admin` and `local\admin`) aaaLogin forms,
a domain-qualified wrong-user/wrong-password still-401 check, the
template-BD-add-mirrors-into-site PATCH behavior (single site, multiple
sites, idempotency, non-associated-site isolation), the full
add-then-replace sequence aci-py actually emits, and the boot-time
mirror for topology.yaml tenants that ship with BDs already configured.
## [0.2.5] - 2026-07-04
### Added
- **Classic ND bare `/login` + `/logout` endpoints (aci-py, cisco.nd)
(PR-14).** aci-py's NDO connector and the `cisco.nd` httpapi plugin POST
to the classic Nexus Dashboard bare `/login` (body `{userName,
userPasswd, domain}`, JWT in `token`/`jwttoken`) alongside the newer
`/api/v1/auth/login`. The sim only had the latter, so aci-py 404'd on
every NDO login. Mirrored the existing auth handler at bare `/login` +
`/logout`.
## [0.2.4] - 2026-07-04
### Added
- **NDO tenant-policy-template DHCP surface + schema serviceGraphs for
cisco.mso (PR-13).** Two confirmed sim gaps from a real aci-ansible run
against ACI hardware, both the last NDO write surfaces blocking the multi-site
(MS) suite:
1. **Tenant-policy-template CREATE flow.** `prereq_tenantpol.yml`'s
`cisco.mso.ndo_template` task (`delegate_to: localhost`) 404'd on
`GET /api/v1/templates/summaries` — a BARE path with no `/mso` prefix.
Root cause: `delegate_to: localhost` forces `ansible-mso`'s
`MSOModule` down its direct-HTTP branch instead of the persistent
`ansible.netcommon.httpapi` connection every other task on the same
playbook host uses, and that branch never adds the `/mso` prefix.
Added a full generalized template store (`POST`/`GET`/`PATCH`/`DELETE
/templates{,/summaries,/objects,/{id}}`, both bare and `/mso`-prefixed)
so `ndo_template` can create a tenant-policy template that persists,
`cisco.mso.ndo_dhcp_relay_policy` (`create_dhcp_relay`) can add a DHCP
relay policy against it (schema-template EPGs now carry a `uuid` field
for provider refs — previously absent), and
`cisco.mso.ndo_schema_template_bd_dhcp_policy` (`bind_dhcp_relay_to_bd`)
can resolve/bind it to a BD via the new `templates/objects?type=
dhcpRelay&{uuid|name}=...` cross-template lookup route. Confirmed
end-to-end on a real hardware run: MS-TN1/MS-TN2 `create_dhcp_relay` +
`bind_dhcp_relay_to_bd` now reach `failed=0`.
2. **Schema serviceGraphs surface.** The mso-model role's "Create service
graph" task (`custom_mso_schema_service_graph.py`, a legacy
pre-`MSOTemplate`/`MSOSchema` module) bare-subscripts
`schema_obj.get('sites')[site_idx]['serviceGraphs']` — the
TEMPLATE-level `serviceGraphs` bare-subscript was already covered by
PR-11, but the SITE-level sibling was not, hitting `KeyError:
'serviceGraphs'` on a real hardware MS-TN2 `create_tenant` pass-2 run
(`-e automate_contract_graph=true`). Added `SITE_TOP_LEVEL_DEFAULTS`
(`aci_sim/ndo/patch.py`) to backfill `serviceGraphs` (and
`contracts`, see below) on every schema `sites[]` entry; `GET
/mso/api/v1/schemas` now returns full nested schema detail (matching
real NDO) instead of a trimmed `{name}`-only summary, since this same
legacy module bare-subscripts straight into that response; added
`GET schemas/service-node-types` and a `tenantId` field on every
schema template (both required by the same module). Chasing this to
green on real ACI hardware surfaced a DEEPER gap: once the
`serviceGraphs` KeyError was fixed, the mso-model role's raw
`cisco.mso.mso_rest`-driven "Atomic PATCH — bind service-graph
redirect on ALL fabrics" task addresses a SITE-LOCAL contract by bare
name (`/sites/{siteId}-{t}/contracts/{c}/serviceGraphRelationship`) —
the sim never mirrored template-level contracts into
`sites[].contracts[]` at all (unlike ANPs/EPGs, mirrored since PR-12),
hitting `PatchError: path segment 'con-Firewall_LAB0' not found in
list`. Added `_mirror_template_contract_to_sites` (PATCH-time) and the
equivalent boot-time mirror in `build_ndo_model`. Confirmed end-to-end
on a real hardware run: MS-TN2 `create_tenant` pass-2 (01b) now reaches
`failed=0`.
- 26 new tests in `tests/test_pr13_ndo_dhcp_svcgraph.py` covering the
bare/mso-prefixed template routes, the full `ndo_template` create→lookup
round trip, DHCP relay policy add/read via `templates/objects`,
`bind_dhcp_relay_to_bd`'s full PATCH sequence, site-level `serviceGraphs`
normalization, the full schema-detail `GET /schemas` shape, and the
site-local contracts mirroring + atomic redirect PATCH.
## [0.2.3] - 2026-07-03
### Added
- **NDO site-local ANP/EPG + static-port schema surface.** PR-11 closed the
schema write round-trip for template-level objects but left the last
MS-TN1 gap: `bind_epg_to_static_port`
(`cisco.mso.mso_schema_site_anp_epg_staticport`) crashed client-side with
`'NoneType' object has no attribute 'details'`. Two compounding root
causes, both fixed:
1. Real NDO stores a self-referencing `anpRef` string on every
template-level ANP object itself (and `epgRef` on every EPG) —
confirmed because `ansible-mso`'s `mso_schema_template_anp.py`/
`_anp_epg.py` explicitly strip it client-side before an idempotency
comparison (`if "anpRef" in mso.previous: del mso.previous["anpRef"]`
— dead code unless the server sends one back). `MSOSchema.
set_site_anp()`/`set_site_anp_epg()` (`ansible-mso`'s
`module_utils/schema.py`) key their site-local lookup off exactly
this field; without it the lookup always compares against `None`.
`normalize_template()` (`aci_sim/ndo/patch.py`) now backfills
`anpRef`/`epgRef` on every `anps[]`/`epgs[]` entry (takes an optional
`schema_id` param to build them); `build_ndo_model()`
(`aci_sim/ndo/model.py`) calls it at boot too.
2. Real NDO 4.x auto-populates a schema's site-local
`sites[].anps[].epgs[]` the moment a template-level ANP/EPG is added
to a template that already has a site attached — the sim never did
this mirroring, so `set_site_anp()`/`set_site_anp_epg()` always
returned `None` and the module's own dead-on-4.x fallback path (its
own source comment: *"Coverage misses this two conditionals when
testing on 4.x and above"*) crashed instead. `apply_json_patch` now
auto-mirrors: `add /templates/{t}/anps/-` creates a matching
`{anpRef, epgs:[]}` site-anp in every associated site; `add
/templates/{t}/anps/{a}/epgs/-` creates a matching **full-shaped**
site-EPG `{epgRef, subnets:[], staticPorts:[], staticLeafs:[],
domainAssociations:[]}` under it. The full child-collection shape is
mandatory — every sibling `mso_schema_site_anp_epg_*` module
bare-subscripts its own array (a missing key is a hard `KeyError`,
not a 4xx): `_domain` reads `domainAssociations`, `_staticleaf` reads
`staticLeafs`, `_staticport` reads `staticPorts`, `_subnet` reads
`subnets`. (An earlier iteration that omitted `domainAssociations`
regressed `bind_epg_to_physical_domain`/`_vmm_domain` on ACI hardware with
`KeyError: 'domainAssociations'`.) Single source of truth for the
shape is `SITE_OBJECT_DEFAULTS["epgs"]`, consumed by `_new_site_epg()`
(mirror path) and `normalize_site()` (backfill path).
`build_ndo_model()` performs the same mirroring at boot for
topology-seeded schemas. Both idempotent.
`epgRef`'s canonical string form uniquely nests two name segments
(`/schemas/{id}/templates/{t}/anps/{a}/epgs/{e}`, confirmed against
`ansible-mso`'s `module_utils/mso.py` `epg_ref()`) — handled by a
dedicated `_stringify_refs` branch since the existing single-category
`_REF_NAME_FIELDS` table only fits one name field. `_REF_LOOKUP_KEYS`
gained `epgRef` so a site-epg (ref-only, no `name` of its own) resolves
by its bare EPG name the same way site-local bds/anps already did
(PR-11). Verified end-to-end against a real-hardware multi-site E2E run:
the full MS-TN1 chain (`create_tenant``create_bd`
`create_application``bind_epg_to_static_port`) now reaches `failed=0`
on every host, closing the CONTRACT.md §7a "known gap" note. New
`tests/test_pr12_site_local.py`.
## [0.2.2] - 2026-07-03
### Added
- **NDO schema write round-trip (POST/PATCH).** PR-10's schema surface was
read-only (a static per-tenant seed plus a POST that stored a schema no
PATCH could reach). PR-11 makes the schema store fully writable, matching
the exact request sequence every `cisco.mso` schema-object module
performs (verified against `ansible-mso`'s `plugins/module_utils/mso.py` +
`schema.py` and confirmed on a real multi-site aci-ansible E2E run against
ACI hardware): `GET schemas/list-identity` (displayName→id, already existed) →
`GET schemas/{id}` (already existed) → `PATCH schemas/{id}` with a
JSON-Patch-shaped op list (`{"op":"add"/"replace"/"remove","path":...,
"value":...}`). New `aci_sim/ndo/patch.py` implements a general
applier (`apply_json_patch`) supporting three path-addressing conventions
cisco.mso modules use: numeric index / `"-"` append (RFC 6902), a *named*
segment resolved against a `name`/`displayName` field
(`/templates/LAB1/bds/bd-App1_LAB1`), a *synthetic composite* key
`"{siteId}-{templateName}"` unique to the top-level `sites[]` array
(`/sites/1-LAB1/bds/-` — every `mso_schema_site_*` module builds this
literally), and a *`*Ref`-name* lookup for site-local child objects that
carry no `name` of their own (`/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-`
resolves `bd-App1_LAB1` against the site-local BD's `bdRef` string).
`normalize_template()`/`normalize_site()` backfill every collection-default
key (`vrfs`/`bds`/`anps.epgs`/`contracts`/`filters`/`externalEpgs`/
`intersiteL3outs`/`serviceGraphs`/`subnets`/...) real NDO defaults
server-side but several `cisco.mso` create/add payloads omit — a missing
key there is a hard crash client-side (`'NoneType' object is not
iterable`, bare `KeyError`, or `TypeError: sequence item 0: expected str
instance, dict found` when a sibling module `", ".join()`s a `*Ref` field
the sim had stored as a dict instead of NDO's canonical string form
`/schemas/{id}/templates/{tmpl}/{category}/{name}` — all three confirmed
on real hardware create_tenant/create_bd runs before the fix).
- **`GET /api/config/class/remoteusers` + `/localusers`.** `cisco.mso.mso_
tenant`'s tenant-create flow always resolves the tenant's `users` list via
`lookup_users()`, which on ND platforms falls back to these legacy
`/api/config/class/*` routes when the modern `/nexus/infra/api/aaa/v4/*`
endpoints aren't available. The sim only served the modern route's absence
as a 404, so `nd_request()` had no `"code"`/`"messages"` in the error body
and aborted with `"ND Error: Unknown error no error code in decoded
payload"` — verified on a real hardware create_tenant run. Now seeded with a
local `admin` user in ND's `{"items":[{"spec":{...}}]}` aggregate shape.
- **Bare `GET /api/v1/sites`.** `cisco.mso.ndo_template`'s TenantPol prereq
lookup hits this un-prefixed path (no `/mso`); the sim only served the
`/mso/api/v1/sites` form. Same data, added alongside PR-10's bare
`/api/v1/tenants`.
- **`PUT`/`POST /mso/api/v1/tenants{,/{id}}`.** `cisco.mso.mso_tenant`
updates a tenant in place (`PUT .../tenants/{id}`) whenever the tenant
already exists in `GET /mso/api/v1/tenants` — true for every tenant this
sim seeds from the topology. Previously unimplemented (404).
- **`GET /mso/api/v1/schemas/{id}/validate`, `execute|status/schema/{id}/
template/{name}`, `POST /mso/api/v1/task`.** The full schema-template
deploy surface: `cisco.mso.mso_schema_template_deploy` (legacy) validates
then calls `GET execute|status/schema/.../template/...`; the module
aci-ansible's `mso-model` role actually invokes,
`cisco.mso.ndo_schema_template_deploy`, instead validates then `POST
/mso/api/v1/task` with `{schemaId, templateName, isRedeploy}` — confirmed
against the collection installed on real ACI hardware (differs from the upstream
`master`-branch module source). All three were 404 before this PR.
### Fixed
- `GET /mso/api/v1/schemas` / `list-identity` no longer double-lists (or
silently drops) a schema after it's mutated by `PATCH` — the summary
projection is now kept in sync in place rather than appended as a
duplicate "extra" entry.
### Verified against a real-hardware E2E run
MS-TN1 (multi-site tenant) now reaches `failed=0` for `create_tenant`,
`create_bd`, `create_application`, `bind_epg_to_physical_domain`,
`bind_epg_to_vmm_domain`, and `bind_epg_to_secure_application`. Remaining
known gap: `bind_epg_to_static_port` needs site-local ANP/EPG object
creation (`mso_schema_site_anp`/`_anp_epg`), not implemented in this PR —
tracked for PR-12 (see docs/CONTRACT.md §7 for the precise failure
signature).
### Tests
- New `tests/test_pr11_ndo_write.py` (31 tests): `apply_json_patch` op
semantics (add/replace/remove, index/name/composite-key/ref-name
resolution, auto-vivification safety), `normalize_template`/
`normalize_site` collection-default backfilling, `*Ref` dict→string
normalization, and full schema create→PATCH→GET round-trips through the
FastAPI routes (including the exact site-local BD + BD-subnet sequence a
real E2E run exercises).
## [0.2.1] - 2026-07-03
### Fixed
- **`/api/node/mo/{dn}` alias (11 E2E failures).** Real APIC serves
`/api/node/mo/{dn}.json` as a full equivalent of `/api/mo/{dn}.json` (the
form the APIC GUI's API Inspector and some `cisco.aci` call paths emit).
The sim only registered `/api/mo/*`, so an alias request fell through to
FastAPI's default 404 `{"detail":"Not Found"}` — a shape `cisco.aci`
cannot parse (surfaces as `"APIC Error None: None"`, `status=-1`). GET,
POST, and DELETE are now all registered on both paths via stacked route
decorators sharing one handler, so behavior can never drift between the
canonical and alias forms.
- **APIC-shaped 400 for unmatched `/api/*` paths.** Any request under
`/api/*` that doesn't match a real route now returns an APIC error
envelope (`imdata`/`error`/`code`) instead of FastAPI's `{"detail":...}` —
real APIC never emits that shape. Implemented as a Starlette 404 exception
handler scoped to the `/api/*` prefix, so it only fires once routing has
already failed and can never shadow a real route.
- **NDO site names now use the fabric name, not the short topology name.**
`GET /mso/api/v1/sites` previously reported the topology's internal short
site name (`"LAB1"`/`"LAB2"`); real NDO registers sites under their
fabric name (`"LAB1-IT-ACI"`/`"LAB2-IT-ACI"`), and `cisco.mso.mso_tenant`
failed with `"Site 'LAB1-IT-ACI' is not a valid site name"` against the
short form. Verified against a real-gear `cisco.mso.mso_tenant` run.
`aci_sim/ndo/model.py`'s site builder now exposes
`Site.fabric_name` (falling back to the short name if unset); internal
site-id joins (`tenant.sites`, schema site associations) are unaffected
since they key off the topology's short name, never the NDO-facing
display name.
- **NDO schema `list-identity` + bare `/api/v1/tenants`.** Added
`GET /mso/api/v1/schemas/list-identity` — the lightweight
schema-enumeration endpoint every `cisco.mso` schema module calls first to
resolve a schema `displayName` to its `id` (verified against
`ansible-mso`'s `plugins/module_utils/mso.py`:
`query_objs("schemas/list-identity", key="schemas", displayName=schema)`,
which reads `json["schemas"][]` entries with at least `id` + `displayName`).
Declared *before* the parameterized `GET /mso/api/v1/schemas/{schema_id}`
route so `"list-identity"` is never matched as a schema id (previously
would 404 with `{"detail":"Schema 'list-identity' not found"}`). Also
added `GET /api/v1/tenants`, backing `cisco.mso.ndo_template`'s tenant
prereq lookup, returning the same tenant list as the existing
`/mso/api/v1/tenants`. Both are read-only additions — the schema/template
*write* surface (`PATCH` deploy) is an intentional follow-up.
- New regression suite `tests/test_pr10_ansible_gaps.py` (9 tests) covers
all of the above; `tests/test_ndo.py`'s site-name assertions updated to
join by fabric name.
## [0.2.0] - 2026-07-03
### Added
- Public-release preparation: rewritten README (purpose, architecture, both
run modes, configuration reference, error-handling contract, known
limitations), MIT `LICENSE`, this `CHANGELOG.md`, GitHub Actions CI
(`ci.yml`), a validated `examples/ansible/` playbook set (smoke +
teardown), and a project-root `CLAUDE.md` release-kit overlay.
### Changed
- Hygiene pass: removed a real internal hostname/username pair from
`docs/DESIGN.md`'s batch-2/3 verification note and genericized an example
path in `deploy/aci-sim.service`; no functional/behavioral change.
## [0.1.12] - 2026-07-03
### Added
- Ansible `cisco.aci` fidelity: `DELETE /api/mo/{dn}.json` (`state=absent`,
server-side idempotent), `GET` on a nonexistent-but-well-formed DN now
returns 200-empty instead of 404 (unblocks `state=present` GET-before-write
playbooks), `status="deleted"` cascade regression coverage,
`annotation`/`ownerKey`/`ownerTag`/`nameAlias` passthrough regression
coverage, and certificate-signature auth in accept-mode (claimed-identity
trust, no signature verification — documented trust-model caveat).
## [0.1.11] - 2026-07-03
### Added
- Default loopback bind (`SIM_BIND`, defaults to `127.0.0.1`) so the
unauthenticated `/_sim` control plane is never LAN-exposed by accident.
- Linux sandbox-mode support (`ip addr add/del … dev lo`) alongside the
existing macOS `lo0 alias` path in `sandbox-up.sh`/`sandbox-down.sh`.
- Effective-port environment overrides and topology config validation.
- systemd `--user` unit and launchd plist deployment docs (`deploy/`).
## [0.1.10] - 2026-07-03
### Added
- Mutation-killing numeric-comparison test coverage and minimum-row
assertions on HTTP fault-path tests, closing gaps a mutation-testing pass
identified in the query engine's comparison operators.
## [0.1.9] - 2026-07-03
### Fixed
- `/_sim/reload` now rebuilds against the freshly-loaded `Site` object
(matched by id, falling back to name) instead of a stale pre-reload
reference, so topology edits actually take effect.
- The `fabricNodeIdentP` node-registration reaction now materializes an
enriched MO set (fabricNode + topSystem + healthInst + fabricLink/
lldpAdjEp/cdpAdjEp cabling + port inventory), not just a bare fabricNode.
- `GET /api/node/class/{class}.json` (no DN prefix) now correctly resolves
as the fabric-wide equivalent of `/api/class/{class}.json`.
## [0.1.8] - 2026-07-03
### Added
- Seeded batch-2/3 contract classes: port-channel/vPC member relations
(`pcAggrIf`, `pcRsMbrIfs`), transceiver/optics inventory (`ethpmFcot`),
APIC cluster health (`infraWiNode`), zoning-rule/pcTag plumbing
(`actrlRule`, `fvEpP`), EVPN VPN routes (`bgpVpnRoute`, `bgpPath`), and
boot-seeded node registration (`fabricNodeIdentP`). Deliberately did NOT
build `infraNodeIdentP` (no real consumer; a different concept from
`fabricNodeIdentP` despite the similar name).
## [0.1.7] - 2026-07-03
### Added
- Seeded batch-1 previously-catalogued-but-unbuilt contract classes:
access-policy cluster (`infraAccPortP`/`infraHPortS`/`infraPortBlk`/
`infraRsAccBaseGrp`/`infraAccBndlGrp`), route-control cluster
(`rtctrlProfile`/`rtctrlCtxP`/`rtctrlSubjP`/`rtctrlMatchRtDest`/
`rtctrlSetComm`/`rtctrlSetPref`), static routes (`ipRouteP`/`ipNexthopP`),
RIB/ARP (`uribv4Route`/`uribv4Nexthop`/`arpAdjEp`), `ospfIf`, and static
bindings (`fvRsPathAtt`, `vzRsSubjGraphAtt`).
## [0.1.6] - 2026-07-03
### Fixed
- Topology-consistency findings: real ports built for L3Out sub-interfaces
and spine OSPF/ISN uplinks (previously referenced but never built),
endpoint/APIC port separation on shared leaves, correct multi-site
endpoint semantics (home-site front-panel attachment vs. remote tunnel-
learned), BD `unicastRoute` now reflects actual subnet presence, and
controller health (`healthInst`) seeded for every controller node.
## [0.1.5] - 2026-07-03
### Fixed
- Auth is now enforced end-to-end: token validation, credential checking
against `SIM_USERNAME`/`SIM_PASSWORD`, session expiry
(`refreshTimeoutSeconds`), and APIC error envelopes (401/403) on every
query/write route instead of accepting any request.
## [0.1.4] - 2026-07-03
### Fixed
- Valid IPv4 derivation for node IDs greater than 255 (previous scheme
overflowed the last octet). Seeded the `userprofile`/`aaaUserDomain`
login-probe MO so the CONTRACT §1 subtree query resolves.
## [0.1.3] - 2026-07-03
### Fixed
- Write path is now atomic (validate-then-commit): a malformed node
anywhere in a POST body's tree rejects the whole write with a 400
envelope and leaves the store untouched. Canonical RN synthesis for
dn-less children (e.g. `fvBD` → `BD-{name}`) instead of a generic
`{class}-{name}` guess, avoiding orphan duplicate DNs.
## [0.1.2] - 2026-07-03
### Fixed
- Query engine: subtree-scope filtering now applies `query-target-filter`
to the whole scoped set (root + descendants) rather than pre-filtering
the root out of scope; `page`/`page-size` parameter validation (400 on
non-numeric/out-of-range values instead of silent truncation); stricter
filter-expression parsing (malformed/truncated expressions → 400 instead
of a 500 or silent no-op).
## [0.1.1] - 2026-07-02
### Fixed
- `rsp-subtree=full` nesting (recursive children, not one level).
- Comparison filter operators (`gt`/`lt`/`ge`/`le`/`bw`) with numeric-vs-
lexical comparison semantics.
## [0.1.0] - 2026-07-01
### Added
- Initial project scaffold: MIT store + query engine (P1/P2), topology
schema + default `topology.yaml`, fabric/tenant builders (P3/P4), the APIC
REST app + runtime supervisor + `/_sim` control API (P5), the NDO plane
(P6), and an end-to-end verification harness (P7) — see `docs/CONTRACT.md`
and `docs/DESIGN.md` for the full design record.