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,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"
|
||||
Reference in New Issue
Block a user