mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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:
@@ -0,0 +1,325 @@
|
||||
"""PR-15 — gaps exposed by a real `aci-py` (pure-Python Ansible-to-ACI
|
||||
compiler) run against the sandbox sim on real ACI hardware.
|
||||
|
||||
GAP 1 — APIC login rejects a domain-qualified login name. `aci-py` posts
|
||||
`POST /api/aaaLogin.json` with `aaaUser.attributes.name =
|
||||
"apic:local\\admin"` — a valid real-APIC login format
|
||||
(`apic:<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 hardware sandbox
|
||||
(`name="apic:local\\admin"` -> 401, `name="admin"` -> 200 before this fix).
|
||||
This PR strips an optional `apic:<domain>\\` / `<domain>\\` prefix before
|
||||
comparing the bare username (`_bare_username()`), so both forms authenticate
|
||||
identically; a wrong username in EITHER form still 401s.
|
||||
|
||||
GAP 2 — NDO schema PATCH 400 mid `create_tenant`/`create_bd`. `aci-py`'s
|
||||
`mso_schema_site_bd` shim (`shims/mso_mso_bd_vrf.py`) always PATCHes a
|
||||
site-local BD shadow with `op: replace` (never `add`), because real NDO 4.x
|
||||
auto-creates that shadow (the "BDDelta") the moment a template-level BD is
|
||||
added to a template that already has sites attached — a fresh `add` there
|
||||
409s on real hardware ("Multiple BDDelta entries"), so the shim's own
|
||||
docstring documents doing a GET-then-replace instead. The sim never mirrored
|
||||
a template BD `add` into `sites[].bds[]` (unlike ANPs/EPGs since PR-12 and
|
||||
contracts since PR-13 — the exact same "auto-mirror on template add" pattern,
|
||||
just missing one collection), so the site-BD `replace` 400'd with
|
||||
`"replace: 'bd-FW_LAB0' not found at
|
||||
'/sites/1-LAB1-LAB2/bds/bd-FW_LAB0'"` — confirmed verbatim against a real
|
||||
hardware aci-py MS-TN2 `create_tenant`/`create_bd` run before this fix (the
|
||||
sim's own log + the NdoError body). This PR adds
|
||||
`_mirror_template_bd_to_sites` (PATCH-time, parallel to
|
||||
`_mirror_template_anp_to_sites`/`_mirror_template_contract_to_sites`) plus
|
||||
the equivalent boot-time mirror in `build_ndo_model`, closing this fully —
|
||||
a real hardware MS-TN2 run now reaches 13/13 steps rc=0 (was 9 ok / 4 fail
|
||||
before this PR; the 4 failures were the `replace: 'bd-*' not found` 400 in
|
||||
`create_tenant` pass1, `create_bd`, `create_tenant` pass2, and
|
||||
`migrate_existing_vlan_gateway`), and MS-TN1 stays 11/11 rc=0 (1 skip, no
|
||||
regression).
|
||||
|
||||
VERDICT: both gaps are genuine SIM FIDELITY gaps, not aci-py bugs — aci-py's
|
||||
domain-qualified login name and its GET-then-replace site-BD shim both mirror
|
||||
documented real-APIC/real-NDO behavior (the latter cited verbatim in aci-py's
|
||||
own shim docstring, matching the exact rationale this codebase already
|
||||
accepted for ANPs/EPGs/contracts in PR-11/12/13).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aci_sim.build.orchestrator import build_site
|
||||
from aci_sim.ndo.app import make_ndo_app
|
||||
from aci_sim.ndo.model import build_ndo_model
|
||||
from aci_sim.ndo.patch import apply_json_patch
|
||||
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
||||
from aci_sim.topology.loader import load_topology
|
||||
|
||||
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GAP 1 — domain-qualified aaaLogin name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _apic_client() -> TestClient:
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
site = topo.sites[0]
|
||||
store = build_site(topo, site)
|
||||
state = ApicSiteState(name=site.name, site=site, topo=topo, store=store,
|
||||
baseline=copy.deepcopy(store))
|
||||
return TestClient(make_apic_app(state))
|
||||
|
||||
|
||||
def _login(client: TestClient, name: str, pwd: str = "cisco"):
|
||||
return client.post(
|
||||
"/api/aaaLogin.json",
|
||||
json={"aaaUser": {"attributes": {"name": name, "pwd": pwd}}},
|
||||
)
|
||||
|
||||
|
||||
def test_plain_username_login_still_works():
|
||||
c = _apic_client()
|
||||
resp = _login(c, "admin")
|
||||
assert resp.status_code == 200
|
||||
assert "APIC-cookie" in resp.cookies
|
||||
|
||||
|
||||
def test_domain_qualified_apic_prefixed_login_works():
|
||||
"""`apic:local\\admin` — the exact shape aci-py's APIC connector sends."""
|
||||
c = _apic_client()
|
||||
resp = _login(c, "apic:local\\admin")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
|
||||
assert token
|
||||
assert "APIC-cookie" in resp.cookies
|
||||
|
||||
|
||||
def test_domain_qualified_bare_domain_login_works():
|
||||
"""`local\\admin` (no `apic:` scheme prefix) — the other real-APIC shape."""
|
||||
c = _apic_client()
|
||||
resp = _login(c, "local\\admin")
|
||||
assert resp.status_code == 200
|
||||
assert "APIC-cookie" in resp.cookies
|
||||
|
||||
|
||||
def test_domain_qualified_wrong_user_still_401s():
|
||||
c = _apic_client()
|
||||
resp = _login(c, "apic:local\\bob")
|
||||
assert resp.status_code == 401
|
||||
data = resp.json()
|
||||
assert "imdata" in data
|
||||
assert data["imdata"][0]["error"]["attributes"]["code"] == "401"
|
||||
|
||||
|
||||
def test_domain_qualified_correct_user_wrong_password_still_401s():
|
||||
c = _apic_client()
|
||||
resp = _login(c, "apic:local\\admin", pwd="wrong")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_domain_qualified_login_session_authenticates_subsequent_queries():
|
||||
"""The minted session must work exactly like a plain-username session —
|
||||
no lingering domain-qualified string leaking into the session's user."""
|
||||
c = _apic_client()
|
||||
resp = _login(c, "apic:local\\admin")
|
||||
assert resp.status_code == 200
|
||||
q = c.get("/api/class/fvTenant.json")
|
||||
assert q.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GAP 2 — template BD add mirrors into every associated site (PATCH-time)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_template_bd_add_mirrors_into_associated_site():
|
||||
"""Adding a template-level BD to a template that already has a site
|
||||
attached must auto-create a matching site-local BD shadow — mirrors
|
||||
`test_template_contract_add_mirrors_into_associated_site` (PR-13) /
|
||||
`test_template_anp_add_mirrors_into_associated_site` (PR-12) for bds[]."""
|
||||
doc = {
|
||||
"id": "schema-abc",
|
||||
"templates": [{"name": "LAB1-LAB2", "bds": []}],
|
||||
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
|
||||
}
|
||||
ops = [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/templates/LAB1-LAB2/bds/-",
|
||||
"value": {"name": "bd-FW_LAB0", "displayName": "FW_LAB0"},
|
||||
}
|
||||
]
|
||||
apply_json_patch(doc, ops)
|
||||
site = doc["sites"][0]
|
||||
assert len(site["bds"]) == 1
|
||||
mirrored = site["bds"][0]
|
||||
assert mirrored["bdRef"] == "/schemas/schema-abc/templates/LAB1-LAB2/bds/bd-FW_LAB0"
|
||||
# Real NDO's auto-created shadow defaults hostBasedRouting=False and
|
||||
# carries the full child-collection shape (mso_schema_site_bd_subnet.py
|
||||
# bare-subscripts `subnets`).
|
||||
assert mirrored["hostBasedRouting"] is False
|
||||
assert mirrored["subnets"] == []
|
||||
|
||||
|
||||
def test_template_bd_add_mirror_is_idempotent():
|
||||
doc = {
|
||||
"id": "schema-abc",
|
||||
"templates": [{"name": "LAB1-LAB2", "bds": []}],
|
||||
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
|
||||
}
|
||||
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/bds/-", "value": {"name": "bd-FW_LAB0"}}]
|
||||
apply_json_patch(doc, ops)
|
||||
apply_json_patch(doc, ops)
|
||||
assert len(doc["sites"][0]["bds"]) == 1
|
||||
|
||||
|
||||
def test_template_bd_add_mirrors_into_multiple_sites():
|
||||
doc = {
|
||||
"id": "schema-abc",
|
||||
"templates": [{"name": "LAB1-LAB2", "bds": []}],
|
||||
"sites": [
|
||||
{"siteId": "1", "templateName": "LAB1-LAB2"},
|
||||
{"siteId": "2", "templateName": "LAB1-LAB2"},
|
||||
],
|
||||
}
|
||||
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/bds/-", "value": {"name": "bd-App1_LAB0"}}]
|
||||
apply_json_patch(doc, ops)
|
||||
for site in doc["sites"]:
|
||||
assert len(site["bds"]) == 1
|
||||
assert site["bds"][0]["bdRef"].endswith("/bds/bd-App1_LAB0")
|
||||
|
||||
|
||||
def test_template_bd_add_does_not_mirror_into_unassociated_site():
|
||||
doc = {
|
||||
"id": "schema-abc",
|
||||
"templates": [{"name": "LAB1-LAB2", "bds": []}, {"name": "OtherTpl", "bds": []}],
|
||||
"sites": [{"siteId": "1", "templateName": "OtherTpl"}],
|
||||
}
|
||||
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/bds/-", "value": {"name": "bd-FW_LAB0"}}]
|
||||
apply_json_patch(doc, ops)
|
||||
assert doc["sites"][0].get("bds", []) == []
|
||||
|
||||
|
||||
def test_site_bd_replace_succeeds_after_template_bd_add():
|
||||
"""The end-to-end sequence aci-py actually emits: template BD `add`
|
||||
(mso_schema_template_bd) followed by a site-local BD `replace`
|
||||
(mso_schema_site_bd) in a LATER, separate PATCH call — exactly
|
||||
reproducing the real hardware 400 this PR fixes, then proving it is gone."""
|
||||
doc = {
|
||||
"id": "schema-abc",
|
||||
"templates": [{"name": "LAB1-LAB2", "bds": []}],
|
||||
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
|
||||
}
|
||||
add_ops = [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/templates/LAB1-LAB2/bds/-",
|
||||
"value": {"name": "bd-FW_LAB0", "displayName": "FW_LAB0"},
|
||||
}
|
||||
]
|
||||
apply_json_patch(doc, add_ops)
|
||||
|
||||
# Site-BD replace, exactly as `mso_schema_site_bd.py`/aci-py's shim sends
|
||||
# it: {"op": "replace", "path": "/sites/{siteId}-{tpl}/bds/{bd}", ...}.
|
||||
replace_ops = [
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/sites/1-LAB1-LAB2/bds/bd-FW_LAB0",
|
||||
"value": {
|
||||
"bdRef": {
|
||||
"schemaId": "schema-abc",
|
||||
"templateName": "LAB1-LAB2",
|
||||
"bdName": "bd-FW_LAB0",
|
||||
},
|
||||
"hostBasedRouting": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
# Before this PR: PatchError("replace: 'bd-FW_LAB0' not found at
|
||||
# '/sites/1-LAB1-LAB2/bds/bd-FW_LAB0'") — the site shadow never existed.
|
||||
apply_json_patch(doc, replace_ops)
|
||||
site = doc["sites"][0]
|
||||
assert len(site["bds"]) == 1
|
||||
assert site["bds"][0]["bdRef"] == "/schemas/schema-abc/templates/LAB1-LAB2/bds/bd-FW_LAB0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GAP 2 — boot-time mirror (topology.yaml tenants that ship with BDs already)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_ndo_model_seeds_site_local_bds_for_topology_tenants():
|
||||
"""A topology.yaml tenant that already ships with BDs configured must
|
||||
boot with its site-local `sites[].bds[]` already mirrored — otherwise
|
||||
the FIRST site-BD PATCH (`replace`) against a freshly-booted sim would
|
||||
hit the same 'not found in list' 400 this PR fixes for the PATCH-built
|
||||
case. Mirrors `test_build_ndo_model_seeds_site_local_contracts_for_topology_tenants`
|
||||
(PR-13)."""
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
state = build_ndo_model(topo, sandbox=True)
|
||||
found_any = False
|
||||
for detail in state.schema_details.values():
|
||||
for tmpl in detail.get("templates", []):
|
||||
if not tmpl.get("bds"):
|
||||
continue
|
||||
for site in detail.get("sites", []):
|
||||
if site.get("templateName") != tmpl["name"]:
|
||||
continue
|
||||
assert "bds" in site
|
||||
for bd in tmpl["bds"]:
|
||||
matches = [b for b in site["bds"] if b.get("bdRef", "").endswith(f"/bds/{bd['name']}")]
|
||||
assert matches, f"site missing mirrored bdRef for {bd['name']}"
|
||||
found_any = True
|
||||
assert found_any, "expected at least one topology tenant with a mirrored site-local BD"
|
||||
|
||||
|
||||
def test_boot_seeded_site_bd_replace_end_to_end():
|
||||
"""Full HTTP round trip: boot the NDO app from topology.yaml, find a
|
||||
schema/template/site that already has a BD, then PATCH a `replace` on
|
||||
the site-local shadow exactly like `mso_schema_site_bd.py` would on a
|
||||
re-run — must succeed against the BOOT-seeded schema, not just one built
|
||||
up entirely via runtime PATCH ops."""
|
||||
topo = load_topology(TOPOLOGY_YAML)
|
||||
state = build_ndo_model(topo, sandbox=True)
|
||||
app = make_ndo_app(state)
|
||||
with TestClient(app) as client:
|
||||
schema_id, tmpl_name, site_id, bd_name = None, None, None, None
|
||||
for sid, detail in state.schema_details.items():
|
||||
for tmpl in detail.get("templates", []):
|
||||
if not tmpl.get("bds"):
|
||||
continue
|
||||
for site in detail.get("sites", []):
|
||||
if site.get("templateName") == tmpl["name"] and site.get("bds"):
|
||||
schema_id = sid
|
||||
tmpl_name = tmpl["name"]
|
||||
site_id = site["siteId"]
|
||||
bd_name = tmpl["bds"][0]["name"]
|
||||
break
|
||||
if schema_id:
|
||||
break
|
||||
if schema_id:
|
||||
break
|
||||
assert schema_id is not None, "expected a boot-seeded schema/template/site with a BD"
|
||||
|
||||
site_seg = f"{site_id}-{tmpl_name}"
|
||||
replace_ops = [
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/sites/{site_seg}/bds/{bd_name}",
|
||||
"value": {
|
||||
"bdRef": {"schemaId": schema_id, "templateName": tmpl_name, "bdName": bd_name},
|
||||
"hostBasedRouting": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=replace_ops)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
fresh = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
|
||||
site = next(s for s in fresh["sites"] if s.get("templateName") == tmpl_name and s.get("siteId") == site_id)
|
||||
matches = [b for b in site["bds"] if b.get("bdRef", "").endswith(f"/bds/{bd_name}")]
|
||||
assert matches
|
||||
Reference in New Issue
Block a user