Files
aci-sim/CHANGELOG.md
T
dtzp555-max c926702a29 fix(isn): flip sub-interface naming to real-gear conventions (v0.19.1)
Owner review of the v0.19.0 demo + Cisco docs/real spine CLI verification:
ACI spines name the VLAN-4 sub-if after the PORT (eth1/49.49, cf.
Eth1/29.29-style in show ip ospf neighbors vrf overlay-1); the ISN Nexus
names its dot1q-4 sub-if after the VLAN (Ethernet1/1.4). v0.19.0 had the
two conventions swapped. 908 tests green.
2026-07-07 08:55:16 +10:00

66 KiB
Raw Blame History

Changelog

All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning (pre-1.0: minor bumps may include breaking changes to the sim's behavior).

[0.19.1] - 2026-07-07

Fixed

  • ISN sub-interface naming conventions flipped to match real gear (owner review of the v0.19.0 demo, verified against Cisco docs/real spine CLI): the ACI spine names its VLAN-4 sub-if after the PORT (eth1/49.49, cf. Eth1/29.29-style output in show ip ospf neighbors vrf overlay-1), while the ISN Nexus names its dot1q-4 sub-if after the VLAN (Ethernet1/1.4). v0.19.0 had it backwards (spine .4, CSW bare physical): ospfIf/ospfAdjEp ids/dns now use the port-named form and the ISN-CSW lldpAdjEp.portIdV gained the .4 suffix.

[0.19.0] - 2026-07-07

Changed

  • Spine ISN-facing OSPF interface is now a routed VLAN-4 sub-interface (build/underlay.py) — real ACI multi-site spines carry the OSPF address toward the IPN/ISN on a routed sub-interface (e.g. eth1/49.4), not the bare physical port. ospfIf.id/dn now end in .4 and carry a new addr attribute (172.16.{site.id}.{si+1}/24, same /24 as the existing 172.16.{site.id}.254 peer); ospfAdjEp nests under the new sub-if segment. The underlying physical port (eth1/{49+si}) and its l1PhysIf/ethpmPhysIf are unchanged — LLDP/physical state stays there, matching real hardware.
  • ISN CSW LLDP neighbor (build/fabric.py) — each spine's ISN uplink physical port now gets a simulated LLDP neighbor (lldpAdjEp/cdpAdjEp, via the shared add_adjacency helper) representing the inter-site network switch: sysName=ISN-CSW{site.id}, portIdV=Ethernet1/{si+1}, model N9K-C9364C. Together with the sub-interface change above, this closes the gap where autoACI's Topology "Fabric (physical)" view showed Local IP=-/Neighbor Port=- for spine ISN uplinks — it now derives a complete row from ospfIf (port + local IP), ospfAdjEp (remote IP), and this lldpAdjEp (CSW-side port). Multi-site fabrics only; single-site fabrics build none of this (unchanged: no OSPF/LLDP toward an ISN that doesn't exist). New/updated assertions in tests/test_pr19_tier2.py, tests/test_pr3b_batch1.py, tests/test_pr5_topology_consistency.py.

[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} <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 omittedpackages = ["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 (bdsbdRef, anpsanpRef, epgsepgRef, contractscontractRef; _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_tenantcreate_bdcreate_applicationbind_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. fvBDBD-{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.