Initial public release — aci-sim v0.16.0

aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

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

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
"""PR-12 — NDO site-local ANP/EPG + static-port schema surface.
Closes the last MS-TN1 gap noted in docs/CONTRACT.md §7a: `bind_epg_to_
static_port` (`cisco.mso.mso_schema_site_anp_epg_staticport`) crashed
client-side with `'NoneType' object has no attribute 'details'` because the
sim never mirrored a template-level ANP/EPG add into the schema's site-local
`sites[].anps[].epgs[]` structure — so `MSOSchema.set_site_anp()`/
`set_site_anp_epg()` always found nothing (`None`) for a site that already
had the template attached, exactly the site-local surface a real NDO 4.x
controller auto-populates when a template ANP/EPG is added to a template
that already has sites associated (verified against `ansible-mso`'s
`plugins/module_utils/schema.py` `set_site_anp()`/`set_site_anp_epg()` and
`plugins/modules/mso_schema_site_anp_epg_staticport.py`'s literal source,
and confirmed end-to-end on a real-hardware multi-site E2E run).
These tests exercise `apply_json_patch`'s new auto-mirror behavior directly
(unit level) and via the full FastAPI PATCH/GET round trip (integration
level), replicating the exact real-playbook request sequence:
1. `mso_schema_template_anp` PATCHes `add /templates/{t}/anps/-`.
2. `mso_schema_template_anp_epg` PATCHes `add /templates/{t}/anps/{a}/epgs/-`.
3. `mso_schema_site_anp_epg_staticport` (having found a non-None site_anp/
site_anp_epg — the crash never triggers) PATCHes
`add /sites/{siteId}-{t}/anps/{a}/epgs/{e}/staticPorts/-`.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.ndo.patch import apply_json_patch, normalize_site, normalize_template
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
@pytest.fixture()
def topo():
return load_topology(TOPOLOGY_YAML)
@pytest.fixture()
def client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# normalize_template — template-level self-referencing anpRef/epgRef
# ---------------------------------------------------------------------------
def test_normalize_template_backfills_self_referencing_anp_ref():
"""Real NDO stores a self-referencing `anpRef` on every template-level
ANP object itself — confirmed by `ansible-mso`'s
`mso_schema_template_anp.py` explicitly stripping it client-side before
an idempotency comparison (`if "anpRef" in mso.previous: del
mso.previous["anpRef"]` — dead code unless the server sends one back).
`MSOSchema.set_site_anp()` keys its site-local lookup off exactly this
field (`template_anp.details.get("anpRef")`); without it that lookup
always compares against `None` and never matches, crashing
`mso_schema_site_anp_epg_staticport.py` (confirmed on a real
MS-TN1 bind_epg_to_static_port run on ACI hardware before this fix)."""
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": []}]}
normalize_template(tmpl, "schema-abc")
assert tmpl["anps"][0]["anpRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1"
def test_normalize_template_backfills_self_referencing_epg_ref():
"""Same rationale as the ANP case, one level down: `set_site_anp_epg()`
keys off `template_anp_epg.details.get("epgRef")`."""
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}
normalize_template(tmpl, "schema-abc")
epg = tmpl["anps"][0]["epgs"][0]
assert epg["epgRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
def test_normalize_template_self_ref_backfill_is_idempotent():
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}
normalize_template(tmpl, "schema-abc")
first_anp_ref = tmpl["anps"][0]["anpRef"]
first_epg_ref = tmpl["anps"][0]["epgs"][0]["epgRef"]
normalize_template(tmpl, "schema-abc")
assert tmpl["anps"][0]["anpRef"] == first_anp_ref
assert tmpl["anps"][0]["epgs"][0]["epgRef"] == first_epg_ref
# ---------------------------------------------------------------------------
# apply_json_patch — auto-mirror unit tests
# ---------------------------------------------------------------------------
def test_template_anp_add_mirrors_into_associated_site():
"""Adding a template-level ANP to a template that already has a site
attached must auto-create a matching site-local anp entry (anpRef
pointing back at the template ANP, epgs starting empty) — real NDO 4.x
behavior; without it `set_site_anp()` always returns None."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": []}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
ops = [
{
"op": "add",
"path": "/templates/LAB1/anps/-",
"value": {"name": "app-Web_LAB1", "displayName": "app-Web_LAB1", "epgs": []},
}
]
apply_json_patch(doc, ops)
site = doc["sites"][0]
assert site["anps"][0]["anpRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1"
assert site["anps"][0]["epgs"] == []
def test_template_anp_add_does_not_mirror_into_unrelated_template_site():
"""A site associated with a DIFFERENT template must be untouched."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": []}, {"name": "LAB2", "anps": []}],
"sites": [
{"siteId": "1", "templateName": "LAB1"},
{"siteId": "2", "templateName": "LAB2"},
],
}
ops = [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}]
apply_json_patch(doc, ops)
assert doc["sites"][0]["anps"][0]["anpRef"].endswith("/anps/app-Web_LAB1")
assert doc["sites"][1].get("anps", []) == []
def test_template_epg_add_mirrors_into_site_anp_epgs():
"""Adding a template-level EPG under an existing ANP must auto-create a
matching site-local epg entry under the already-mirrored site-anp
(epgRef pointing back at the template EPG, staticPorts starting empty)
— without it `set_site_anp_epg()` always returns None."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": []}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}],
)
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}],
)
site_anp = doc["sites"][0]["anps"][0]
site_epg = site_anp["epgs"][0]
assert site_epg["epgRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
# A mirrored site-EPG must carry the FULL child-collection shape every
# sibling `mso_schema_site_anp_epg_*` module bare-subscripts — a missing
# key is a hard KeyError, not a 4xx (see SITE_OBJECT_DEFAULTS["epgs"]).
assert site_epg["staticPorts"] == []
assert site_epg["subnets"] == []
assert site_epg["staticLeafs"] == []
assert site_epg["domainAssociations"] == []
def test_mirror_is_idempotent():
"""Re-running the same template ANP/EPG add ops (e.g. a retried
playbook task) must not duplicate the mirrored site-local entries."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": []}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
anp_ops = [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}]
epg_ops = [{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}]
apply_json_patch(doc, anp_ops)
apply_json_patch(doc, epg_ops)
apply_json_patch(doc, anp_ops)
apply_json_patch(doc, epg_ops)
site = doc["sites"][0]
assert len(site["anps"]) == 1
assert len(site["anps"][0]["epgs"]) == 1
def test_site_anp_epg_static_port_add_by_ref_name_resolution():
"""Full replica of the real bind_epg_to_static_port sequence tail: once
the site-anp/site-epg are mirrored (ref-only, no name), a static port is
addressed by the ANP/EPG's bare template names —
`/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-` — exactly
what `mso_schema_site_anp_epg_staticport.py` sends once `site_anp`/
`site_anp_epg` are found (the crash path this PR eliminates)."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}])
apply_json_patch(
doc, [{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}]
)
static_port_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-",
"value": {
"path": "topology/pod-1/protpaths-101-102/pathep-[ipg-vpc-LegacySW01]",
"portEncapVlan": 100,
"mode": "regular",
"deploymentImmediacy": "immediate",
"type": "vpc",
},
}
]
apply_json_patch(doc, static_port_ops)
static_ports = doc["sites"][0]["anps"][0]["epgs"][0]["staticPorts"]
assert len(static_ports) == 1
assert static_ports[0]["portEncapVlan"] == 100
def test_site_anp_epg_domain_association_add_by_ref_name_resolution():
"""Full replica of the real bind_epg_to_physical_domain / _vmm_domain
sequence tail (`mso_schema_site_anp_epg_domain.py`): once the site-anp/
site-epg are mirrored, a domain association is added at
`/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/domainAssociations/-`.
The module reads that array with a BARE subscript
(`domains = [dom.get("dn") for dom in ...["epgs"][epg_idx]
["domainAssociations"]]`), so a missing key is a hard `KeyError:
'domainAssociations'` — a real MS-TN1 hardware run regressed here after
PR-12 started auto-creating the site-EPG (pre-PR-12 no site-EPG existed
so the module took a non-crashing branch)."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}])
apply_json_patch(
doc, [{"op": "add", "path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-", "value": {"name": "epg-web"}}]
)
# The mirrored site-EPG must already carry an (empty) domainAssociations
# array — the module bare-subscripts it before deciding to append.
assert doc["sites"][0]["anps"][0]["epgs"][0]["domainAssociations"] == []
domain_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/domainAssociations/-",
"value": {
"dn": "uni/phys-phy-general",
"domainType": "physicalDomain",
"deployImmediacy": "immediate",
"resolutionImmediacy": "immediate",
},
}
]
apply_json_patch(doc, domain_ops)
domains = doc["sites"][0]["anps"][0]["epgs"][0]["domainAssociations"]
assert len(domains) == 1
assert domains[0]["dn"] == "uni/phys-phy-general"
assert domains[0]["domainType"] == "physicalDomain"
def test_epg_ref_dict_stringified_to_canonical_two_segment_form():
"""`epgRef` is the one `*Ref` field whose canonical string form nests
TWO name segments under the template (anps/{anp}/epgs/{epg}), not one —
confirmed against ansible-mso's module_utils/mso.py `epg_ref()`. A dict
payload embedding it (as `mso_schema_site_anp_epg_staticport.py`'s
create-site-anp fallback sends) must be stored in that exact form."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1", "anps": []}]}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/-",
"value": {
"epgRef": {
"schemaId": "schema-abc",
"templateName": "LAB1",
"anpName": "app-Web_LAB1",
"epgName": "epg-web",
}
},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["anps"][0]["epgRef"] == "/schemas/schema-abc/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
def test_normalize_site_backfills_all_epg_child_collections():
"""normalize_site() must backfill the FULL site-EPG child-collection
shape (subnets/staticPorts/staticLeafs/domainAssociations) on a
ref-only site-epg — each is bare-subscripted by a sibling
mso_schema_site_anp_epg_* module."""
site = {
"siteId": "1",
"templateName": "LAB1",
"anps": [{"anpRef": "/schemas/s/templates/LAB1/anps/app-Web_LAB1", "epgs": [{"epgRef": "/schemas/s/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"}]}],
}
normalize_site(site)
epg = site["anps"][0]["epgs"][0]
assert epg["subnets"] == []
assert epg["staticPorts"] == []
assert epg["staticLeafs"] == []
assert epg["domainAssociations"] == []
# ---------------------------------------------------------------------------
# End-to-end FastAPI round trip — full real-playbook sequence
# ---------------------------------------------------------------------------
def test_schema_patch_site_local_anp_epg_staticport_end_to_end(client):
"""Full replica of the real MS-TN1 create_application + bind_epg_to_
static_port sequence: schema already has a site attached to the
template (mso_schema_site.py, PR-11's create_tenant flow), then
mso_schema_template_anp / _anp_epg add the template ANP/EPG (already
supported since PR-11), and the site-local mirrors must already exist
by the time mso_schema_site_anp_epg_staticport PATCHes the static port
— matching real NDO 4.x, where the crash this PR fixes never triggers."""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "t1", "anps": []}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
anp_ops = [
{
"op": "add",
"path": "/templates/LAB1/anps/-",
"value": {"name": "app-Web_LAB1", "displayName": "app-Web_LAB1", "epgs": []},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=anp_ops)
assert resp.status_code == 200
site_anp = resp.json()["sites"][0]["anps"][0]
assert site_anp["anpRef"] == f"/schemas/{schema_id}/templates/LAB1/anps/app-Web_LAB1"
epg_ops = [
{
"op": "add",
"path": "/templates/LAB1/anps/app-Web_LAB1/epgs/-",
"value": {"name": "epg-web", "displayName": "epg-web"},
}
]
resp2 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=epg_ops)
assert resp2.status_code == 200
site_epg = resp2.json()["sites"][0]["anps"][0]["epgs"][0]
assert site_epg["epgRef"] == f"/schemas/{schema_id}/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web"
assert site_epg["staticPorts"] == []
assert site_epg["domainAssociations"] == []
static_port_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-",
"value": {
"path": "topology/pod-1/protpaths-101-102/pathep-[ipg-vpc-LegacySW01]",
"portEncapVlan": 100,
"mode": "regular",
"deploymentImmediacy": "immediate",
"type": "vpc",
},
}
]
resp3 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=static_port_ops)
assert resp3.status_code == 200
static_ports = resp3.json()["sites"][0]["anps"][0]["epgs"][0]["staticPorts"]
assert len(static_ports) == 1
assert static_ports[0]["portEncapVlan"] == 100
# Independent GET after the PATCH must reflect the same mutation
# (get_site_anp-equivalent lookup: a fresh GET's sites[].anps[].epgs[]
# must resolve non-None for the static-port module's own lookup chain).
detail = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
got_site = detail["sites"][0]
got_anp = got_site["anps"][0]
got_epg = got_anp["epgs"][0]
assert got_anp["anpRef"].endswith("/anps/app-Web_LAB1")
assert got_epg["epgRef"].endswith("/epgs/epg-web")
assert got_epg["staticPorts"][0]["portEncapVlan"] == 100
def test_schema_patch_site_local_anp_epg_survives_repeated_get(client):
"""Regression guard: a plain GET (no PATCH in between) must keep
reporting the mirrored site-anp/site-epg — i.e. normalization isn't a
PATCH-only side effect that a bare GET-only reader would miss."""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "anps": []}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
client.patch(
f"/mso/api/v1/schemas/{schema_id}",
json=[{"op": "add", "path": "/templates/LAB1/anps/-", "value": {"name": "app-Web_LAB1", "epgs": []}}],
)
first = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
second = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
assert first["sites"][0]["anps"][0]["anpRef"] == second["sites"][0]["anps"][0]["anpRef"]
# ---------------------------------------------------------------------------
# build_ndo_model — boot-time seed already has the site-local mirror
# ---------------------------------------------------------------------------
def test_build_ndo_model_seeds_site_local_anp_epg_for_topology_tenants(topo):
"""A topology.yaml tenant that already ships with ANPs/EPGs configured
(not built up via PATCH at runtime) must boot with its site-local
sites[].anps[].epgs[] already mirrored too — otherwise a fresh sim
instance would hit the same set_site_anp()-returns-None crash on its
very first bind_epg_to_static_port run, before any PATCH ever ran."""
state = build_ndo_model(topo)
found_any_site_anp = False
for detail in state.schema_details.values():
for tmpl in detail.get("templates", []):
if not tmpl.get("anps"):
continue
# Every template-level anp/epg must carry its self-referencing ref.
for anp in tmpl["anps"]:
assert anp.get("anpRef", "").endswith(f"/anps/{anp['name']}")
for epg in anp.get("epgs", []):
assert epg.get("epgRef", "").endswith(f"/epgs/{epg['name']}")
for site in detail.get("sites", []):
if site.get("templateName") != tmpl["name"]:
continue
for site_anp in site.get("anps", []):
found_any_site_anp = True
assert site_anp.get("anpRef")
for site_epg in site_anp.get("epgs", []):
assert site_epg.get("epgRef")
assert site_epg.get("staticPorts") == []
assert site_epg.get("domainAssociations") == []
assert found_any_site_anp, "expected at least one topology tenant with a mirrored site-local anp"