mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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>
448 lines
20 KiB
Python
448 lines
20 KiB
Python
"""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"
|