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
View File
+372
View File
@@ -0,0 +1,372 @@
"""Tests for the Admin account wizard step + `aci-sim run` credential enforcement.
Covers:
1. `run_wizard`/`cmd_init` write a topology `auth:` section (custom
username/password, and the `ndo_same_account=no` split-account path),
round-tripping through `Topology.model_validate`/`load_topology`.
2. Defaults (no answers / non-tty `--defaults`) still produce a valid
topology with `auth: {username: admin, password: cisco}`.
3. `resolve_admin_credentials` — the pure credential-resolution function
`cmd_run` uses — is unit-tested directly for all three precedence cases:
topology-only, env-override-wins, neither-present.
4. `build_run_env_argv` injects SIM_USERNAME/SIM_PASSWORD only when both
admin_username/admin_password are given, and never touches the
filesystem (pure function, per its own docstring contract).
5. End-to-end enforcement: a FastAPI APIC app backed by `auth.py`'s real
`aaaLogin` route 401s on the wrong password and 200s on the right one,
for a NON-default (custom) SIM_USERNAME/SIM_PASSWORD pair — proving the
enforcement is real, not just resolved-and-ignored. Since auth.py binds
SIM_USERNAME/SIM_PASSWORD as module-level constants at import time, this
test monkeypatches the module's attributes directly (the aaaLogin route
closure reads them as module globals at request time, not at def time —
verified by this file's own tests) rather than fighting import-time
binding with `importlib.reload`.
6. Backward compat: an existing topology.yaml with NO `auth:` section still
loads (`topo.auth is None`) and `resolve_admin_credentials`/
`build_run_env_argv` inject nothing — `rest_aci/auth.py` keeps its own
env-or-admin/cisco default, unchanged.
Does NOT weaken any pre-existing auth test — this file only adds new
coverage; `tests/test_pr9_ansible.py`'s cert-auth/aaaLogin tests are
untouched.
"""
from __future__ import annotations
import copy
import io
import os
from pathlib import Path
import pytest
import yaml
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.cli import build_run_env_argv, resolve_admin_credentials, run_wizard
from aci_sim.rest_aci import auth as auth_module
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Auth, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
# ---------------------------------------------------------------------------
# 1 + 2. Wizard writes an `auth:` section (defaults + custom + split NDO).
# ---------------------------------------------------------------------------
class TestWizardAuthSection:
def test_defaults_produce_admin_cisco_auth(self) -> None:
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=True)
assert topo["auth"] == {"username": "admin", "password": "cisco"}
validated = Topology.model_validate(topo)
assert validated.auth == Auth(username="admin", password="cisco")
def test_custom_admin_credentials_via_answers(self) -> None:
answers = {
"admin_username": "netops",
"admin_password": "s3cr3t!",
"deployment_type": "1",
}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
assert topo["auth"] == {"username": "netops", "password": "s3cr3t!"}
validated = Topology.model_validate(topo)
assert validated.auth.username == "netops"
assert validated.auth.password == "s3cr3t!"
assert validated.auth.ndo_username is None
assert validated.auth.ndo_password is None
def test_custom_admin_credentials_round_trip_through_yaml(self, tmp_path: Path) -> None:
answers = {"admin_username": "netops", "admin_password": "s3cr3t!", "deployment_type": "1"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
out_file = tmp_path / "auth.topology.yaml"
out_file.write_text(yaml.dump(topo, sort_keys=False), encoding="utf-8")
loaded = load_topology(out_file)
assert loaded.auth.username == "netops"
assert loaded.auth.password == "s3cr3t!"
def test_ndo_same_account_no_stores_split_ndo_credentials(self) -> None:
answers = {
"deployment_type": "1",
"admin_username": "apicadmin",
"admin_password": "apicpass",
"ndo_same_account": "no",
"ndo_username": "ndoadmin",
"ndo_password": "ndopass",
}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
assert topo["auth"] == {
"username": "apicadmin",
"password": "apicpass",
"ndo_username": "ndoadmin",
"ndo_password": "ndopass",
}
validated = Topology.model_validate(topo)
assert validated.auth.ndo_username == "ndoadmin"
assert validated.auth.ndo_password == "ndopass"
def test_ndo_same_account_no_defaults_ndo_fields_to_admin_account(self) -> None:
"""ndo_same_account=no but ndo_username/ndo_password not separately
answered -> the prompt defaults (admin_username/admin_password) are used."""
answers = {"deployment_type": "1", "ndo_same_account": "no"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
assert topo["auth"]["ndo_username"] == "admin"
assert topo["auth"]["ndo_password"] == "cisco"
def test_scripted_stdin_admin_account_step(self) -> None:
stdin_script = (
"svcaccount\n" # admin username
"hunter2\n" # admin password
"no\n" # ndo_same_account -> no
"ndosvc\n" # ndo username
"ndopass\n" # ndo password
"1\n" # deployment type -> single fabric
)
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False)
assert topo["auth"] == {
"username": "svcaccount",
"password": "hunter2",
"ndo_username": "ndosvc",
"ndo_password": "ndopass",
}
def test_summary_shows_username_but_masks_password(self) -> None:
answers = {"deployment_type": "1", "admin_username": "netops", "admin_password": "supersecret"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
from aci_sim.cli import _print_summary
summary_out = io.StringIO()
_print_summary(topo, _gw, summary_out)
text = summary_out.getvalue()
assert "netops" in text
assert "supersecret" not in text
assert "Admin account" in text
# ---------------------------------------------------------------------------
# 3. resolve_admin_credentials — the pure precedence function cmd_run uses.
# ---------------------------------------------------------------------------
class TestResolveAdminCredentialsPrecedence:
def test_topology_auth_present_no_env_override(self) -> None:
auth = Auth(username="netops", password="s3cr3t!")
username, password, source = resolve_admin_credentials({}, auth)
assert (username, password, source) == ("netops", "s3cr3t!", "topology.yaml")
def test_env_override_wins_even_with_topology_auth_present(self) -> None:
auth = Auth(username="netops", password="s3cr3t!")
base_env = {"SIM_USERNAME": "envuser", "SIM_PASSWORD": "envpass"}
username, password, source = resolve_admin_credentials(base_env, auth)
assert (username, password, source) == (None, None, "env override")
def test_neither_present_injects_nothing(self) -> None:
username, password, source = resolve_admin_credentials({}, None)
assert (username, password, source) == (None, None, "built-in default")
def test_env_override_wins_even_without_topology_auth(self) -> None:
base_env = {"SIM_USERNAME": "envuser"}
username, password, source = resolve_admin_credentials(base_env, None)
assert (username, password, source) == (None, None, "env override")
def test_empty_string_sim_username_in_env_does_not_count_as_override(self) -> None:
"""An empty-string SIM_USERNAME (e.g. `SIM_USERNAME= aci-sim run`) is
falsy — falls through to topology.yaml/default, matching os.environ's
own treatment of unset-vs-empty as not meaningfully "set"."""
auth = Auth(username="netops", password="s3cr3t!")
username, password, source = resolve_admin_credentials({"SIM_USERNAME": ""}, auth)
assert (username, password, source) == ("netops", "s3cr3t!", "topology.yaml")
def test_sim_password_only_in_env_is_an_atomic_override(self) -> None:
"""Regression (reviewer Finding 1): a caller who sets ONLY SIM_PASSWORD
(rotating just the password, keeping the default username) must NOT have
it silently discarded in favor of the topology password. Setting EITHER
credential var counts as an env override, so topology.auth is ignored
entirely — the override is atomic, the caller owns both vars."""
auth = Auth(username="topo_user", password="topo_pass")
username, password, source = resolve_admin_credentials({"SIM_PASSWORD": "caller_pw"}, auth)
assert (username, password, source) == (None, None, "env override")
def test_sim_password_only_no_topology_is_env_override(self) -> None:
username, password, source = resolve_admin_credentials({"SIM_PASSWORD": "caller_pw"}, None)
assert (username, password, source) == (None, None, "env override")
def test_caller_sim_password_survives_into_exec_env(self) -> None:
"""End of the Finding-1 chain: with the atomic override, the caller's
SIM_PASSWORD passes through to the exec'd env untouched (never clobbered
by the topology password); the unset SIM_USERNAME is left for auth.py to
fill with its own admin default."""
base = {"SIM_PASSWORD": "caller_pw"}
user, pwd, _src = resolve_admin_credentials(base, Auth(username="topo_user", password="topo_pass"))
env, _argv = build_run_env_argv("topology.yaml", base_env=base, admin_username=user, admin_password=pwd)
assert env["SIM_PASSWORD"] == "caller_pw" # survived — not clobbered
assert "SIM_USERNAME" not in env # left unset -> auth.py fills with admin default
# ---------------------------------------------------------------------------
# 4. build_run_env_argv — env injection + filesystem purity.
# ---------------------------------------------------------------------------
class TestBuildRunEnvArgvCredentialInjection:
def test_injects_both_vars_when_both_given(self) -> None:
env, argv = build_run_env_argv(
"topology.yaml", base_env={"PATH": "/usr/bin"}, admin_username="netops", admin_password="s3cr3t!"
)
assert env["SIM_USERNAME"] == "netops"
assert env["SIM_PASSWORD"] == "s3cr3t!"
assert env["TOPOLOGY_PATH"] == "topology.yaml"
def test_injects_nothing_when_neither_given(self) -> None:
env, argv = build_run_env_argv("topology.yaml", base_env={"PATH": "/usr/bin"})
assert "SIM_USERNAME" not in env
assert "SIM_PASSWORD" not in env
def test_base_env_untouched_by_reference(self) -> None:
"""build_run_env_argv deepcopies base_env — the caller's dict must
not be mutated (pure-function contract in its docstring)."""
base_env = {"PATH": "/usr/bin"}
build_run_env_argv("topology.yaml", base_env=base_env, admin_username="netops", admin_password="s3cr3t!")
assert "SIM_USERNAME" not in base_env
assert "TOPOLOGY_PATH" not in base_env
def test_does_not_touch_filesystem(self, tmp_path: Path) -> None:
"""Passing a nonexistent topology path must not raise / must not stat
the file — build_run_env_argv is filesystem-pure per its docstring."""
nonexistent = tmp_path / "does-not-exist.yaml"
env, argv = build_run_env_argv(str(nonexistent))
assert env["TOPOLOGY_PATH"] == str(nonexistent)
# ---------------------------------------------------------------------------
# 5. End-to-end: aaaLogin actually enforces a custom password.
# ---------------------------------------------------------------------------
def _new_client(topo) -> TestClient:
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))
class TestEndToEndEnforcement:
@pytest.fixture(autouse=True)
def _restore_auth_module_globals(self):
"""auth.py binds SIM_USERNAME/SIM_PASSWORD as module-level constants
at import time; this fixture monkeypatches the module's OWN attributes
(which aaaLogin's closure reads as module globals at request time, not
at def time) and restores the originals after each test, so these
tests never leak a custom credential into any other test in the
suite."""
orig_username = auth_module.SIM_USERNAME
orig_password = auth_module.SIM_PASSWORD
yield
auth_module.SIM_USERNAME = orig_username
auth_module.SIM_PASSWORD = orig_password
def test_aaalogin_succeeds_with_custom_password(self) -> None:
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
client = _new_client(topo)
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "customadmin", "pwd": "custompass123"}}},
)
assert resp.status_code == 200
def test_aaalogin_401s_with_wrong_password(self) -> None:
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
client = _new_client(topo)
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "customadmin", "pwd": "totally-wrong"}}},
)
assert resp.status_code == 401
def test_aaalogin_401s_with_stale_default_password_after_rotation(self) -> None:
"""The previous default (admin/cisco) must stop working once a
topology-driven credential rotation has taken effect — proves this
isn't just "any password works", it's a real replacement."""
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
client = _new_client(topo)
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 401
def test_ndo_remains_lenient_regardless_of_apic_credential_rotation(self) -> None:
"""NDO must keep accepting any credential even while the APIC plane
enforces a rotated (non-default) password — proves this feature does
NOT change NDO's deliberate leniency."""
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
auth_module.SIM_USERNAME = "customadmin"
auth_module.SIM_PASSWORD = "custompass123"
topo = load_topology(TOPOLOGY_YAML)
ndo_state = build_ndo_model(topo)
ndo_client = TestClient(make_ndo_app(ndo_state))
resp = ndo_client.post(
"/api/v1/auth/login",
json={"userName": "anyone", "userPasswd": "anything-at-all"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# 6. Backward compat: no `auth:` section -> no injection, no regression.
# ---------------------------------------------------------------------------
class TestBackwardCompatNoAuthSection:
def test_repo_topology_has_no_auth_section(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
assert topo.auth is None
def test_resolve_admin_credentials_injects_nothing_for_repo_topology(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
username, password, source = resolve_admin_credentials({}, topo.auth)
assert (username, password, source) == (None, None, "built-in default")
def test_build_run_env_argv_injects_nothing_for_repo_topology(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
username, password, _source = resolve_admin_credentials(dict(os.environ), topo.auth)
env, _argv = build_run_env_argv(
str(TOPOLOGY_YAML), base_env={"PATH": "/usr/bin"}, admin_username=username, admin_password=password
)
assert "SIM_USERNAME" not in env
assert "SIM_PASSWORD" not in env
def test_topology_dict_without_auth_key_still_validates(self) -> None:
"""A hand-authored topology dict that never mentions `auth` at all
(pre-dating this feature) must still validate — Topology.auth is
Optional[Auth] = None."""
from aci_sim.cli import generate_topology
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=1)
assert "auth" not in topo_dict
validated = Topology.model_validate(topo_dict)
assert validated.auth is None
+273
View File
@@ -0,0 +1,273 @@
"""Regression tests for PR-4 auth fidelity.
Covers: unauthenticated/garbage/expired-cookie query rejection, credential
checking on aaaLogin, session expiry + aaaRefresh renewal, aaaLogout
invalidation, and the 422-leak fix for malformed aaaLogin bodies. See
docs/CONTRACT.md §1 (login) and §5 (error shapes) for the contract this
enforces.
"""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
import aci_sim.rest_aci.auth as auth_module
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
def _new_client() -> TestClient:
"""Fresh, unauthenticated TestClient backed by its own store/state."""
topo = load_topology(TOPO_PATH)
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, *, user: str = "admin", pwd: str = "cisco"):
return client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": user, "pwd": pwd}}},
)
def _assert_apic_error_envelope(resp, code: str) -> None:
data = resp.json()
assert "detail" not in data, f"FastAPI detail leak: {data}"
assert "imdata" in data
assert data["imdata"][0]["error"]["attributes"]["code"] == code
# ---------------------------------------------------------------------------
# No-cookie / garbage-cookie / expired-cookie query rejection
# ---------------------------------------------------------------------------
def test_no_cookie_query_returns_403():
c = _new_client()
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_garbage_cookie_query_returns_403():
c = _new_client()
c.cookies.set("APIC-cookie", "not-a-real-token")
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_expired_token_query_returns_403():
c = _new_client()
login = _login(c)
assert login.status_code == 200
token = c.cookies.get("APIC-cookie")
assert token in auth_module._sessions
# Force expiry without sleeping — patch the session's expires_at directly.
auth_module._sessions[token].expires_at = auth_module._now() - 1
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_no_cookie_mo_query_returns_403():
c = _new_client()
resp = c.get("/api/mo/uni/tn-MS-TN1.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_no_cookie_node_scoped_query_returns_403():
c = _new_client()
resp = c.get("/api/node/class/topology/pod-1/node-101/faultInst.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_no_cookie_write_returns_403():
c = _new_client()
resp = c.post(
"/api/mo/uni/tn-Blocked.json",
json={"fvTenant": {"attributes": {"name": "Blocked", "dn": "uni/tn-Blocked"}}},
)
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
# And the write must not have landed.
c2 = _new_client()
_login(c2)
names = [
i["fvTenant"]["attributes"]["name"]
for i in c2.get("/api/class/fvTenant.json").json()["imdata"]
]
assert "Blocked" not in names
# ---------------------------------------------------------------------------
# Credential checking
# ---------------------------------------------------------------------------
def test_wrong_password_returns_401_envelope():
c = _new_client()
resp = _login(c, pwd="wrong-password")
assert resp.status_code == 401
_assert_apic_error_envelope(resp, "401")
assert "APIC-cookie" not in resp.cookies
def test_wrong_username_returns_401_envelope():
c = _new_client()
resp = _login(c, user="notadmin")
assert resp.status_code == 401
_assert_apic_error_envelope(resp, "401")
def test_correct_credentials_return_200_with_token():
c = _new_client()
resp = _login(c)
assert resp.status_code == 200
data = resp.json()
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
assert token
assert "APIC-cookie" in resp.cookies
# ---------------------------------------------------------------------------
# Malformed aaaLogin body -> 400 imdata envelope (never FastAPI {"detail":...})
# ---------------------------------------------------------------------------
def test_malformed_login_body_non_dict_top_level():
c = _new_client()
resp = c.post("/api/aaaLogin.json", content=b'"just a string"',
headers={"Content-Type": "application/json"})
assert resp.status_code == 400
data = resp.json()
assert "imdata" in data
assert "detail" not in data
def test_malformed_login_body_missing_aaauser():
c = _new_client()
resp = c.post("/api/aaaLogin.json", json={"wrongKey": {}})
assert resp.status_code == 400
data = resp.json()
assert "imdata" in data
assert "detail" not in data
def test_malformed_login_body_non_json():
c = _new_client()
resp = c.post(
"/api/aaaLogin.json",
content=b"not json at all {{{",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
data = resp.json()
assert "imdata" in data
assert "detail" not in data
# ---------------------------------------------------------------------------
# aaaRefresh
# ---------------------------------------------------------------------------
def test_refresh_without_login_returns_403():
c = _new_client()
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_refresh_with_garbage_cookie_returns_403():
c = _new_client()
c.cookies.set("APIC-cookie", "garbage")
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
def test_refresh_extends_expiry():
c = _new_client()
_login(c)
token = c.cookies.get("APIC-cookie")
sess = auth_module._sessions[token]
# Move expiry to the near future (still valid) and confirm refresh pushes
# it back out to a full lifetime.
sess.expires_at = auth_module._now() + 1
old_expiry = sess.expires_at
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 200
data = resp.json()
assert "aaaLogin" in data["imdata"][0]
new_token = c.cookies.get("APIC-cookie")
assert new_token in auth_module._sessions
assert auth_module._sessions[new_token].expires_at > old_expiry
# And a subsequent query with the refreshed cookie still works.
q = c.get("/api/class/fabricNode.json")
assert q.status_code == 200
def test_refresh_on_expired_token_returns_403():
c = _new_client()
_login(c)
token = c.cookies.get("APIC-cookie")
auth_module._sessions[token].expires_at = auth_module._now() - 1
resp = c.get("/api/aaaRefresh.json")
assert resp.status_code == 403
_assert_apic_error_envelope(resp, "403")
# ---------------------------------------------------------------------------
# aaaLogout
# ---------------------------------------------------------------------------
def test_logout_then_query_returns_403():
c = _new_client()
_login(c)
token = c.cookies.get("APIC-cookie")
assert token in auth_module._sessions
resp = c.post("/api/aaaLogout.json")
assert resp.status_code == 200
assert token not in auth_module._sessions
q = c.get("/api/class/fabricNode.json")
assert q.status_code == 403
_assert_apic_error_envelope(q, "403")
# ---------------------------------------------------------------------------
# /_sim control plane must remain unauthenticated
# ---------------------------------------------------------------------------
def test_sim_reset_works_without_auth():
c = _new_client()
resp = c.post("/_sim/reset")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
+288
View File
@@ -0,0 +1,288 @@
"""Phase 3 — fabric builder tests.
Loads the default topology, builds SiteA into a fresh MITStore, and asserts:
- 2 spines + 2 leaves + 2 border-leaves present as fabricNode
- Every fabricLink n1/n2 references a real fabricNode
- lldpAdjEp + cdpAdjEp neighbor set matches the cabling graph
- Each spine's sys/bgp/inst subtree has ISN bgpPeer with /32 addr + remote ASN
- healthInst per node present with correct DN
- Seeded faultInst under node-local DNs (contain /node-{id}/)
- topSystem has the correct loopback address formula
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
from aci_sim.mit.store import MITStore
from aci_sim.build import fabric, cabling, underlay, overlay, interfaces, health_faults
from aci_sim.build.cabling import cabling_links
from aci_sim.build.fabric import loopback_ip, oob_ip
TOPO_YAML = Path(__file__).parent.parent / "topology.yaml"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo() -> Topology:
return load_topology(TOPO_YAML)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def store(topo, site_a) -> MITStore:
"""Build SiteA into a fresh store using all Phase-3 builders."""
s = MITStore()
fabric.build(topo, site_a, s)
cabling.build(topo, site_a, s)
underlay.build(topo, site_a, s)
overlay.build(topo, site_a, s)
interfaces.build(topo, site_a, s)
health_faults.build(topo, site_a, s)
return s
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_attrs(store: MITStore, cls: str) -> list[dict]:
return [mo.attrs for mo in store.by_class(cls)]
# ---------------------------------------------------------------------------
# Test: node counts
# ---------------------------------------------------------------------------
def test_node_counts(store, site_a):
"""SiteA default: 2 spines + 2 leaves + 2 border-leaves = 6 switches, plus a
1-node APIC cluster (role=controller, PR-21 single-APIC default) → 7 fabricNodes."""
nodes = get_attrs(store, "fabricNode")
spines = [n for n in nodes if n["role"] == "spine"]
leaves = [n for n in nodes if n["role"] == "leaf"]
controllers = [n for n in nodes if n["role"] == "controller"]
assert len(spines) == 2, f"Expected 2 spines, got {len(spines)}"
# Leaves include regular leaves (2) + border-leaves (2) — all role="leaf"
assert len(leaves) == 4, f"Expected 4 leaf-role nodes, got {len(leaves)}"
assert len(controllers) == 1, f"Expected 1 controller, got {len(controllers)}"
assert len(nodes) == 7, f"Expected 7 fabricNodes (6 switch + 1 APIC), got {len(nodes)}: {[n['name'] for n in nodes]}"
# ---------------------------------------------------------------------------
# Test: fabricLink n1/n2 reference real nodes
# ---------------------------------------------------------------------------
def test_fabric_links_reference_real_nodes(store, site_a):
"""Every fabricLink n1 and n2 must be a real fabricNode id in this site."""
node_ids = {mo.attrs["id"] for mo in store.by_class("fabricNode")}
links = store.by_class("fabricLink")
assert len(links) > 0, "No fabricLinks found"
for link in links:
n1 = link.attrs.get("n1", "")
n2 = link.attrs.get("n2", "")
assert n1 in node_ids, f"fabricLink n1={n1!r} not in fabricNodes {node_ids}"
assert n2 in node_ids, f"fabricLink n2={n2!r} not in fabricNodes {node_ids}"
# Expected link count: (2 spines) × (2 leaves + 2 border-leaves) = 8
# 8 spine↔leaf (2 spines × 4 downlinks) + 2 APIC↔leaf (1 controller × 2 leaves, PR-21 default)
assert len(links) == 10, f"Expected 10 fabricLinks (8 fabric + 2 APIC), got {len(links)}"
# ---------------------------------------------------------------------------
# Test: LLDP/CDP neighbor set matches cabling graph
# ---------------------------------------------------------------------------
def test_lldp_cdp_matches_cabling(store, site_a):
"""lldpAdjEp sysName values on each node match what the cabling graph predicts."""
pod = site_a.pod
node_map = {n.id: n for n in site_a.all_nodes()}
# Build expected neighbor pairs from cabling_links
# For each link (n1, s1, p1, n2, s2, p2):
# node n1 expects neighbor n2.name on port eth{s1}/{p1}
# node n2 expects neighbor n1.name on port eth{s2}/{p2}
expected_lldp: set[tuple[int, str]] = set() # (node_id, neighbor_name)
for n1, s1, p1, n2, s2, p2 in cabling_links(site_a):
expected_lldp.add((n1, node_map[n2].name))
expected_lldp.add((n2, node_map[n1].name))
# APIC↔leaf: the first two leaves each see every APIC (leaf-side lldpAdjEp)
for cid in range(1, site_a.controllers + 1):
for leaf in site_a.leaf_nodes()[:2]:
expected_lldp.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
actual_lldp: set[tuple[int, str]] = set()
for mo in store.by_class("lldpAdjEp"):
dn = mo.attrs["dn"]
m = re.search(r"/node-(\d+)/", dn)
if m:
actual_lldp.add((int(m.group(1)), mo.attrs.get("sysName", "")))
assert expected_lldp == actual_lldp, (
f"LLDP mismatch\n missing: {expected_lldp - actual_lldp}\n"
f" extra: {actual_lldp - expected_lldp}"
)
# CDP must be symmetric
cdp_pairs: set[tuple[int, str]] = set()
for mo in store.by_class("cdpAdjEp"):
dn = mo.attrs["dn"]
m = re.search(r"/node-(\d+)/", dn)
if m:
cdp_pairs.add((int(m.group(1)), mo.attrs.get("sysName", "")))
assert expected_lldp == cdp_pairs, "CDP neighbor set does not match cabling"
# ---------------------------------------------------------------------------
# Test: ISN bgpPeer — /32 addr + remote ASN on each spine
# ---------------------------------------------------------------------------
def test_isn_bgp_peers(store, topo, site_a):
"""Each SiteA spine's bgp/inst subtree must contain ISN bgpPeers:
- addr ends in /32
- asn == remote site's ASN (not the local site's ASN)
"""
pod = site_a.pod
local_asn = str(site_a.asn)
remote_site = topo.other_site(site_a.name)
remote_asn = str(remote_site.asn)
remote_spines = remote_site.spine_nodes()
remote_pod = remote_site.pod
for spine in site_a.spine_nodes():
inst_dn = f"topology/pod-{pod}/node-{spine.id}/sys/bgp/inst"
# Collect all bgpPeer descendants under this inst
bgp_peers = store.subtree(inst_dn, {"bgpPeer"})
assert bgp_peers, f"No bgpPeer found under {inst_dn}"
isn_peers = [
p for p in bgp_peers
if "/32" in p.attrs.get("addr", "")
and p.attrs.get("asn", "") != local_asn
]
assert isn_peers, (
f"No ISN bgpPeer (/32 + remote ASN) found on spine {spine.id}. "
f"Peers found: {[(p.attrs.get('addr'), p.attrs.get('asn')) for p in bgp_peers]}"
)
# Verify one ISN peer per remote spine
isn_addrs = {p.attrs["addr"] for p in isn_peers}
for rs in remote_spines:
expected_addr = f"{loopback_ip(remote_pod, rs.id)}/32"
assert expected_addr in isn_addrs, (
f"Missing ISN peer to remote spine {rs.id} ({expected_addr}) "
f"on spine {spine.id}. Found: {isn_addrs}"
)
# Verify the remote ASN
for p in isn_peers:
if p.attrs["addr"] == expected_addr:
assert p.attrs["asn"] == remote_asn, (
f"ISN peer {expected_addr} has asn={p.attrs['asn']!r}, "
f"expected {remote_asn!r}"
)
# ---------------------------------------------------------------------------
# Test: healthInst per node
# ---------------------------------------------------------------------------
def test_health_inst_per_node(store, site_a, topo):
"""Every node in SiteA must have a healthInst at topology/.../sys/health."""
pod = site_a.pod
hd = topo.faults.health_defaults
for node in site_a.all_nodes():
dn = f"topology/pod-{pod}/node-{node.id}/sys/health"
mo = store.get(dn)
assert mo is not None, f"healthInst missing for node {node.id} at {dn}"
assert mo.class_name == "healthInst"
assert mo.attrs["cur"] == str(hd.node), (
f"healthInst cur={mo.attrs['cur']!r} != {hd.node!r} for node {node.id}"
)
# ---------------------------------------------------------------------------
# Test: seeded faultInst under node-local DNs
# ---------------------------------------------------------------------------
def test_seeded_fault_insts(store, site_a, topo):
"""Seeded faultInst MOs must exist under node-local DNs (contain /node-{id}/)."""
site_node_ids = {n.id for n in site_a.all_nodes()}
for seed in topo.faults.seed:
if seed.node not in site_node_ids:
continue
# Find a faultInst with the right code under the right node
fault_dn_pattern = re.compile(rf"/node-{seed.node}/")
found = [
mo for mo in store.by_class("faultInst")
if fault_dn_pattern.search(mo.attrs.get("dn", ""))
and seed.severity == mo.attrs.get("severity", "")
]
assert found, (
f"No faultInst found for seed code={seed.code} node={seed.node} "
f"severity={seed.severity}"
)
# Verify the DN contains /node-{id}/ (required for topology.py grouping)
for mo in found:
assert f"/node-{seed.node}/" in mo.attrs["dn"], (
f"faultInst DN {mo.attrs['dn']!r} does not contain /node-{seed.node}/"
)
# ---------------------------------------------------------------------------
# Test: topSystem loopback address
# ---------------------------------------------------------------------------
def test_top_system_loopback(store, site_a):
"""topSystem.address must follow the formula 10.<pod>.<node_id>.1"""
pod = site_a.pod
for node in site_a.all_nodes():
ts_dn = f"topology/pod-{pod}/node-{node.id}/sys"
mo = store.get(ts_dn)
assert mo is not None, f"topSystem missing for node {node.id} at {ts_dn}"
expected_lb = loopback_ip(pod, node.id)
assert mo.attrs["address"] == expected_lb, (
f"topSystem.address={mo.attrs['address']!r} != {expected_lb!r} "
f"for node {node.id}"
)
# ---------------------------------------------------------------------------
# Test: fabricLink DN format parseable by topology.py
# ---------------------------------------------------------------------------
def test_fabric_link_dn_format(store, site_a):
"""fabricLink DNs must be parseable by topology.py's port-extraction logic."""
pod = site_a.pod
for mo in store.by_class("fabricLink"):
dn = mo.attrs["dn"]
source_port = target_port = ""
for seg in dn.split("/"):
if seg.startswith("lnk-"):
lnk_part = seg[4:]
if "-to-" in lnk_part:
sp, dp = lnk_part.split("-to-", 1)
spc = sp.split("-")
dpc = dp.split("-")
if len(spc) >= 3:
source_port = f"eth{spc[1]}/{spc[2]}"
if len(dpc) >= 3:
target_port = f"eth{dpc[1]}/{dpc[2]}"
assert source_port, f"Could not parse source_port from fabricLink DN: {dn}"
assert target_port, f"Could not parse target_port from fabricLink DN: {dn}"
# Ports must follow ethN/M format
assert re.match(r"eth\d+/\d+", source_port), f"Bad source_port {source_port!r}"
assert re.match(r"eth\d+/\d+", target_port), f"Bad target_port {target_port!r}"
+219
View File
@@ -0,0 +1,219 @@
"""Tests for Phase 4 tenant-side builders.
Load topology.yaml, build SiteA into a fresh MITStore, then assert:
1. Tenant / VRF / BD / EPG presence; SiteB-Local absent; SiteA-Local present.
2. Every fvRsBd.tnFvBDName references a real fvBD.
3. Endpoints: parent EPG real; fvIp inside BD subnet; fabricPathDn leaf in SiteA.
4. L3Out SiteA present, SiteB absent; bgpPeerP and bgpPeerEntry established.
5. Contracts/filters cross-refs valid; each vzFilter has ≥1 vzEntry.
"""
from __future__ import annotations
import ipaddress
import re
from pathlib import Path
import pytest
from aci_sim.build import access, endpoints, l3out, tenants
from aci_sim.mit.dn import parent_dn
from aci_sim.mit.store import MITStore
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
@pytest.fixture(scope="module")
def built():
topo = load_topology(TOPOLOGY_YAML)
site_a = topo.site_by_name("LAB1")
store = MITStore()
tenants.build(topo, site_a, store)
access.build(topo, site_a, store)
l3out.build(topo, site_a, store)
endpoints.build(topo, site_a, store)
return store, topo, site_a
# ---------------------------------------------------------------------------
# 1. Tenant / VRF / BD / EPG presence
# ---------------------------------------------------------------------------
class TestTenantPresence:
def test_corp_tenant_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1") is not None
def test_corp_vrf_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/ctx-vrf-L3_LAB0") is not None
def test_corp_bd_app_bd_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/BD-bd-BD3_LAB0") is not None
def test_corp_bd_db_bd_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/BD-bd-BD4_LAB0") is not None
def test_corp_epg_web_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/ap-app-Stretched_Apps_LAB0/epg-epg-APP3_Stretched_BD") is not None
def test_corp_epg_db_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/ap-app-Stretched_Apps_LAB0/epg-epg-APP4_Stretched_BD") is not None
def test_siteb_local_absent(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-SF-TN1-LAB2") is None
def test_sitea_local_present(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-SF-TN1-LAB1") is not None
# ---------------------------------------------------------------------------
# 2. fvRsBd cross-references
# ---------------------------------------------------------------------------
class TestFvRsBdRefs:
def test_all_rsbds_reference_real_bds(self, built) -> None:
store, _, _ = built
bd_names = {mo.attrs["name"] for mo in store.by_class("fvBD")}
for rsbd in store.by_class("fvRsBd"):
assert rsbd.attrs["tnFvBDName"] in bd_names, (
f"fvRsBd {rsbd.dn!r} references BD "
f"{rsbd.attrs['tnFvBDName']!r} not in store"
)
# ---------------------------------------------------------------------------
# 3. Endpoints
# ---------------------------------------------------------------------------
class TestEndpoints:
def test_at_least_one_fvcep(self, built) -> None:
store, _, _ = built
assert len(store.by_class("fvCEp")) >= 1
def test_cep_parent_is_real_epg(self, built) -> None:
store, _, _ = built
for cep in store.by_class("fvCEp"):
p_dn = parent_dn(cep.dn)
assert store.get(p_dn) is not None, (
f"fvCEp {cep.dn!r} parent {p_dn!r} not in store"
)
def test_cep_has_fvip_inside_bd_subnet(self, built) -> None:
store, _, _ = built
for cep in store.by_class("fvCEp"):
fvips = store.children(cep.dn, {"fvIp"})
assert fvips, f"fvCEp {cep.dn!r} has no fvIp child"
epg_dn = parent_dn(cep.dn)
rsbds = store.children(epg_dn, {"fvRsBd"})
assert rsbds, f"EPG {epg_dn!r} has no fvRsBd"
bd_name = rsbds[0].attrs.get("tnFvBDName", "")
bd_mo = next(
(m for m in store.by_class("fvBD") if m.attrs.get("name") == bd_name),
None,
)
assert bd_mo is not None, f"BD {bd_name!r} not found for {cep.dn!r}"
subnet_mos = store.children(bd_mo.dn, {"fvSubnet"})
assert subnet_mos, f"BD {bd_mo.dn!r} has no fvSubnet"
for fvip in fvips:
ip_str = fvip.attrs.get("addr", "")
in_net = any(
ipaddress.ip_address(ip_str)
in ipaddress.ip_network(sm.attrs.get("ip", "0.0.0.0/0"), strict=False)
for sm in subnet_mos
)
assert in_net, (
f"fvIp {ip_str!r} not inside any BD subnet for {cep.dn!r}"
)
def test_cep_fabricpathdn_uses_sitea_leaf(self, built) -> None:
"""Every fvCEp's fabricPathDn must reference a node that really exists
in SiteA: either a real leaf (HOME endpoint, front-panel port) or a
real spine (REMOTE/stretched endpoint, learned via the multi-site
overlay tunnel that terminates on a local spine — see FIX 4 /
docs/DESIGN.md). It must never reference a node id from the OTHER
site (that was the finding #16 bug: identical local-looking fvCEp on
both sites)."""
store, _, site_a = built
leaf_ids = {n.id for n in site_a.leaf_nodes()}
spine_ids = {n.id for n in site_a.spine_nodes()}
for cep in store.by_class("fvCEp"):
path = cep.attrs.get("fabricPathDn", "")
m = re.search(r"paths-(\d+)", path)
assert m is not None, f"Cannot parse leafId from {path!r}"
nid = int(m.group(1))
assert nid in leaf_ids or nid in spine_ids, (
f"fvCEp {cep.dn!r} uses node {nid} not in SiteA "
f"leaves={leaf_ids} spines={spine_ids}"
)
# ---------------------------------------------------------------------------
# 4. L3Out
# ---------------------------------------------------------------------------
class TestL3Out:
def test_l3out_sitea_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/out-l3o-bgp-Core_LAB1") is not None
def test_l3out_siteb_absent(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/out-l3o-bgp-Core_LAB2") is None
def test_bgp_peer_p_with_csw_a_ip(self, built) -> None:
store, _, _ = built
peers = store.by_class("bgpPeerP")
assert any(p.attrs.get("addr") == "10.100.0.1" for p in peers), (
"No bgpPeerP with addr=10.100.0.1 found"
)
def test_bgp_peer_entry_established_on_border_leaf(self, built) -> None:
store, _, _ = built
entries = store.by_class("bgpPeerEntry")
matching = [
e for e in entries
if e.attrs.get("operSt") == "established"
and e.attrs.get("addr") == "10.100.0.1"
and ("node-103" in e.dn or "node-104" in e.dn)
]
assert matching, (
"No bgpPeerEntry with operSt=established, addr=10.100.0.1 on node-103/104"
)
# ---------------------------------------------------------------------------
# 5. Contracts / Filters
# ---------------------------------------------------------------------------
class TestContractsFilters:
def test_vzbrcp_web_to_db_exists(self, built) -> None:
store, _, _ = built
assert store.get("uni/tn-MS-TN1/brc-con-Web_to_DB_LAB0") is not None
def test_rssubjfiltatt_refs_real_filter(self, built) -> None:
store, _, _ = built
flt_names = {mo.attrs["name"] for mo in store.by_class("vzFilter")}
for rs in store.by_class("vzRsSubjFiltAtt"):
assert rs.attrs["tnVzFilterName"] in flt_names, (
f"vzRsSubjFiltAtt {rs.dn!r} refs filter "
f"{rs.attrs['tnVzFilterName']!r} not in store"
)
def test_each_vzfilter_has_vzentry(self, built) -> None:
store, _, _ = built
filters = store.by_class("vzFilter")
assert filters, "No vzFilter MOs in store"
for flt in filters:
entries = store.children(flt.dn, {"vzEntry"})
assert entries, f"vzFilter {flt.dn!r} has no vzEntry children"
+461
View File
@@ -0,0 +1,461 @@
"""Tests for aci_sim/cli.py — the `aci-sim` topology CLI (PR-17).
Covers `validate` (positive + negative), `show` (text + --json, asserting
derived loopback/OOB IPs for a known node), `new` (scaffold generation +
re-validation of the output), and `run`'s env/argv construction (no live
socket binding — build_run_env_argv is a pure helper factored out of
cmd_run for exactly this reason).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
from aci_sim.cli import (
_ensure_certs,
build_parser,
build_run_env_argv,
generate_topology,
main,
)
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------
class TestValidate:
def test_validate_default_topology_ok(self, capsys) -> None:
rc = main(["validate", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
assert "OK:" in out
assert "2 site(s)" in out
assert "3 tenant(s)" in out
assert "LAB1" in out and "LAB2" in out # per-site breakdown lines
assert "leaves" in out and "spines" in out and "border leaves" in out
def test_validate_missing_file(self, capsys) -> None:
rc = main(["validate", "/nonexistent/path/topology.yaml"])
err = capsys.readouterr().err
assert rc != 0
assert "not found" in err.lower() or "ERROR" in err
def test_validate_duplicate_node_id(self, tmp_path, capsys) -> None:
bad = {
"fabric": {"name": "BAD-ACI"},
"sites": [
{
"name": "BADSITE",
"id": "1",
"apic_host": "127.0.0.1:8443",
"asn": 65001,
"spines": 2,
"leaves": [
{"id": 101, "name": "leaf-a"},
{"id": 101, "name": "leaf-b"}, # duplicate id -> validation error
],
}
],
}
bad_path = tmp_path / "bad_topology.yaml"
bad_path.write_text(yaml.dump(bad), encoding="utf-8")
rc = main(["validate", str(bad_path)])
err = capsys.readouterr().err
assert rc != 0
assert "collide" in err.lower() or "error" in err.lower()
def test_validate_bad_ip_pool(self, tmp_path, capsys) -> None:
bad = {
"fabric": {"name": "BAD-ACI", "loopback_pool": "not-a-cidr"},
"isn": {"enabled": False},
"sites": [
{
"name": "BADSITE",
"id": "1",
"apic_host": "127.0.0.1:8443",
"asn": 65001,
"spines": 1,
"leaves": 1,
}
],
}
bad_path = tmp_path / "bad_ip.yaml"
bad_path.write_text(yaml.dump(bad), encoding="utf-8")
rc = main(["validate", str(bad_path)])
err = capsys.readouterr().err
assert rc != 0
assert "loopback_pool" in err or "cidr" in err.lower()
def test_validate_default_arg_is_topology_yaml(self) -> None:
parser = build_parser()
args = parser.parse_args(["validate"])
assert args.topology == "topology.yaml"
# ---------------------------------------------------------------------------
# show
# ---------------------------------------------------------------------------
class TestShow:
def test_show_text_contains_known_node_derived_ips(self, capsys) -> None:
rc = main(["show", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
# Node 101 (LAB1-ACI-LF101, pod 1) -> loopback 10.1.101.1 / oob 192.168.1.101
assert "10.1.101.1" in out
assert "192.168.1.101" in out
assert "LAB1" in out
assert "LAB2" in out
def test_show_json_parses_and_has_known_node(self) -> None:
import io
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
rc = main(["show", str(TOPOLOGY_YAML), "--json"])
assert rc == 0
data = json.loads(buf.getvalue())
assert data["fabric_name"] == "LAB-IT-ACI"
site_names = {s["name"] for s in data["sites"]}
assert {"LAB1", "LAB2"} <= site_names
lab1 = next(s for s in data["sites"] if s["name"] == "LAB1")
node_101 = next(n for n in lab1["nodes"] if n["id"] == 101)
assert node_101["loopback_ip"] == "10.1.101.1"
assert node_101["oob_ip"] == "192.168.1.101"
assert node_101["role"] == "leaf"
# Port bindings: default (non-sandbox) mode -> SIM_BIND host + fixed ports.
roles = {b["role"] for b in data["ports"]}
assert "apic" in roles
assert "ndo" in roles
def test_show_missing_file(self, capsys) -> None:
rc = main(["show", "/nonexistent/path/topology.yaml"])
assert rc != 0
assert capsys.readouterr().err
# -- PR-18: Tier-1 fabric params surfaced in `show` --------------------
def test_show_json_has_tier1_fabric_fields(self) -> None:
import io
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
rc = main(["show", str(TOPOLOGY_YAML), "--json"])
assert rc == 0
data = json.loads(buf.getvalue())
assert data["tep_pool"] == "10.0.0.0/16"
assert data["infra_vlan"] == 3967
assert data["gipo_pool"] == "225.0.0.0/15"
lab1 = next(s for s in data["sites"] if s["name"] == "LAB1")
# PR-21: single-APIC-per-site is now the default (was 3 pre-PR-21).
assert lab1["controllers"] == 1
node_101 = next(n for n in lab1["nodes"] if n["id"] == 101)
assert node_101["serial"] == "SAL10101"
def test_show_text_contains_tier1_fields(self, capsys) -> None:
rc = main(["show", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
assert "tep_pool=10.0.0.0/16" in out
assert "infra_vlan=3967" in out
assert "gipo_pool=225.0.0.0/15" in out
assert "controllers=1" in out # PR-21: single-APIC-per-site default (was 3)
assert "SAL10101" in out
# -- PR-19: Tier-2 ISN params + VMM domains surfaced in `show` ----------
def test_show_json_has_tier2_isn_fields(self) -> None:
import io
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
rc = main(["show", str(TOPOLOGY_YAML), "--json"])
assert rc == 0
data = json.loads(buf.getvalue())
assert data["isn_enabled"] is True
assert data["isn_peer_mesh"] == "full"
assert data["isn_ospf_area"] == "0.0.0.0"
assert data["isn_mtu"] == 9150
assert data["vmm_domains"] == []
def test_show_text_contains_tier2_isn_fields(self, capsys) -> None:
rc = main(["show", str(TOPOLOGY_YAML)])
out = capsys.readouterr().out
assert rc == 0
assert "ospf_area=0.0.0.0" in out
assert "mtu=9150" in out
# ---------------------------------------------------------------------------
# new (scaffold)
# ---------------------------------------------------------------------------
class TestNew:
def test_generate_topology_default_shape(self) -> None:
topo_dict = generate_topology()
# Must validate cleanly via the real Pydantic model.
Topology.model_validate(topo_dict)
assert len(topo_dict["sites"]) == 2
def test_new_sites3_leaves4_stdout(self, capsys) -> None:
rc = main(["new", "--sites", "3", "--leaves-per-site", "4"])
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
assert len(parsed["sites"]) == 3
for site in parsed["sites"]:
assert len(site["leaves"]) == 4
# Re-validate through the full loader-equivalent path.
topo = Topology.model_validate(parsed)
assert len(topo.sites) == 3
for site in topo.sites:
assert len(site.leaf_nodes()) == 4
def test_new_node_id_offsets(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=4, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
expected_offsets = [0, 200, 400]
for site, offset in zip(topo.sites, expected_offsets, strict=True):
leaf_ids = sorted(n.id for n in site.leaf_nodes())
assert leaf_ids == [101 + offset + i for i in range(4)]
spine_ids = sorted(n.id for n in site.spine_nodes())
assert spine_ids == [201 + offset + i for i in range(2)]
# Border leaves must land outside both the leaf (101-104+offset)
# and spine (201-202+offset) ranges -- no fixed 103/104 assumption
# here since that collides once leaves_per_site > 2 (see
# generate_topology's border_base logic).
border_ids = sorted(bl.id for bl in site.border_leaves)
assert len(border_ids) == 2
assert all(bid not in leaf_ids and bid not in spine_ids for bid in border_ids)
assert border_ids[1] == border_ids[0] + 1 # a vPC pair, contiguous ids
def test_new_default_shape_matches_documented_border_ids(self) -> None:
# The default 2-leaf/2-spine shape should reproduce the exact
# documented example from topology/schema.py's module docstring
# (site 0: border 103,104; site 1: border 303,304).
topo_dict = generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
site0_border = sorted(bl.id for bl in topo.sites[0].border_leaves)
site1_border = sorted(bl.id for bl in topo.sites[1].border_leaves)
assert site0_border == [103, 104]
assert site1_border == [303, 304]
def test_new_writes_to_file(self, tmp_path) -> None:
out_path = tmp_path / "scaffold.yaml"
rc = main(["new", "--sites", "1", "--leaves-per-site", "2", "-o", str(out_path)])
assert rc == 0
assert out_path.exists()
# Must pass the same load_topology() path `aci-sim validate` uses.
topo = load_topology(out_path)
assert len(topo.sites) == 1
assert len(topo.sites[0].leaf_nodes()) == 2
# And `aci-sim validate` itself must accept it end-to-end.
rc2 = main(["validate", str(out_path)])
assert rc2 == 0
def test_new_custom_asn_count_mismatch_errors(self, capsys) -> None:
rc = main(["new", "--sites", "2", "--asn", "65001"]) # only 1 value for 2 sites
err = capsys.readouterr().err
assert rc != 0
assert "asn" in err.lower()
def test_new_single_site_disables_full_isn_mesh(self) -> None:
# ISN peer_mesh:full requires >=2 sites (schema.py); scaffolding a
# single-site topology must not trip that validator.
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 1
assert topo.isn.enabled is False
# -- PR-18: Tier-1 fabric params in `new` scaffold ----------------------
def test_new_controllers_flag_stdout(self, capsys) -> None:
rc = main(["new", "--sites", "2", "--controllers", "5"])
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
for site in parsed["sites"]:
assert site["controllers"] == 5
topo = Topology.model_validate(parsed)
for site in topo.sites:
assert site.controllers == 5
def test_new_tier1_fabric_flags_stdout(self, capsys) -> None:
rc = main(
[
"new",
"--sites",
"1",
"--leaves-per-site",
"1",
"--spines-per-site",
"1",
"--border-pairs",
"0",
"--tep-pool",
"172.16.0.0/16",
"--infra-vlan",
"100",
"--gipo-pool",
"226.0.0.0/15",
]
)
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
assert parsed["fabric"]["tep_pool"] == "172.16.0.0/16"
assert parsed["fabric"]["infra_vlan"] == 100
assert parsed["fabric"]["gipo_pool"] == "226.0.0.0/15"
topo = Topology.model_validate(parsed)
assert topo.fabric.tep_pool == "172.16.0.0/16"
assert topo.fabric.infra_vlan == 100
assert topo.fabric.gipo_pool == "226.0.0.0/15"
def test_new_default_shape_has_default_tier1_values(self) -> None:
topo_dict = generate_topology()
assert topo_dict["fabric"]["tep_pool"] == "10.0.0.0/16"
assert topo_dict["fabric"]["infra_vlan"] == 3967
assert topo_dict["fabric"]["gipo_pool"] == "225.0.0.0/15"
for site in topo_dict["sites"]:
# PR-21: single-APIC-per-site is now the default (was 3 pre-PR-21).
assert site["controllers"] == 1
# -- PR-19: Tier-2 ISN flags + leaf/spine count elasticity --------------
def test_new_isn_flags_stdout(self, capsys) -> None:
rc = main(
[
"new",
"--sites", "2",
"--leaves-per-site", "1",
"--spines-per-site", "1",
"--border-pairs", "0",
"--isn-ospf-area", "10.0.0.0",
"--isn-mtu", "9000",
]
)
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
assert parsed["isn"]["ospf_area"] == "10.0.0.0"
assert parsed["isn"]["mtu"] == 9000
topo = Topology.model_validate(parsed)
assert topo.isn.ospf_area == "10.0.0.0"
assert topo.isn.mtu == 9000
def test_new_default_shape_has_default_tier2_isn_values(self) -> None:
topo_dict = generate_topology()
assert topo_dict["isn"]["ospf_area"] == "0.0.0.0"
assert topo_dict["isn"]["mtu"] == 9150
def test_new_8_leaves_4_spines_via_cli(self, capsys) -> None:
rc = main(
["new", "--sites", "2", "--leaves-per-site", "8", "--spines-per-site", "4"]
)
out = capsys.readouterr().out
assert rc == 0
parsed = yaml.safe_load(out)
topo = Topology.model_validate(parsed)
for site in topo.sites:
assert len(site.leaf_nodes()) == 8
assert len(site.spine_nodes()) == 4
def test_new_leaves_per_site_overrun_errors_cleanly(self, capsys) -> None:
rc = main(
["new", "--sites", "2", "--leaves-per-site", "150", "--spines-per-site", "4"]
)
err = capsys.readouterr().err
assert rc != 0
assert "leaves-per-site" in err
# ---------------------------------------------------------------------------
# run — env/argv construction only, no live boot
# ---------------------------------------------------------------------------
class TestRunEnvArgv:
def test_build_run_env_argv_sets_topology_path(self) -> None:
env, argv = build_run_env_argv("custom/topology.yaml", base_env={"PATH": "/usr/bin"})
assert env["TOPOLOGY_PATH"] == "custom/topology.yaml"
assert env["PATH"] == "/usr/bin" # base env preserved
assert argv == [sys.executable, "-m", "aci_sim.runtime.supervisor"]
def test_build_run_env_argv_passthrough_vars_preserved(self) -> None:
base_env = {"SIM_BIND": "0.0.0.0", "SIM_SANDBOX": "1", "UNRELATED": "x"}
env, _argv = build_run_env_argv("topology.yaml", base_env=base_env)
assert env["SIM_BIND"] == "0.0.0.0"
assert env["SIM_SANDBOX"] == "1"
assert env["UNRELATED"] == "x"
assert env["TOPOLOGY_PATH"] == "topology.yaml"
def test_run_missing_topology_errors_without_exec(self, capsys) -> None:
rc = main(["run", "/nonexistent/path/topology.yaml"])
err = capsys.readouterr().err
assert rc != 0
assert "not found" in err.lower()
def test_run_invalid_topology_errors_without_exec(self, tmp_path, capsys) -> None:
bad_path = tmp_path / "bad.yaml"
bad_path.write_text(
yaml.dump(
{
"fabric": {"name": "BAD"},
"sites": [
{
"name": "S",
"id": "1",
"apic_host": "h",
"asn": 1,
"leaves": [{"id": 1, "name": "a"}, {"id": 1, "name": "b"}],
}
],
}
),
encoding="utf-8",
)
rc = main(["run", str(bad_path)])
err = capsys.readouterr().err
assert rc != 0
assert err
def test_ensure_certs_noop_when_already_present(self, tmp_path) -> None:
cert = tmp_path / "sim.crt"
key = tmp_path / "sim.key"
cert.write_text("existing-cert", encoding="utf-8")
key.write_text("existing-key", encoding="utf-8")
# Must not touch (or attempt to regenerate) files that already exist.
_ensure_certs(str(cert), str(key))
assert cert.read_text(encoding="utf-8") == "existing-cert"
assert key.read_text(encoding="utf-8") == "existing-key"
+74
View File
@@ -0,0 +1,74 @@
"""Tests for the /_sim control-plane admin endpoints (Phase 5)."""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
@pytest.fixture
def client_and_state():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
c = TestClient(app)
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c, state
def test_reset_removes_posted_tenant(client_and_state):
client, state = client_and_state
# POST a new tenant
client.post(
"/api/mo/uni/tn-TEMP.json",
json={"fvTenant": {"attributes": {"name": "TEMP", "dn": "uni/tn-TEMP"}}},
)
# Verify it's there
resp = client.get("/api/class/fvTenant.json")
names = [i["fvTenant"]["attributes"]["name"] for i in resp.json()["imdata"]]
assert "TEMP" in names
# Reset
r = client.post("/_sim/reset")
assert r.status_code == 200
# Verify it's gone
resp2 = client.get("/api/class/fvTenant.json")
names2 = [i["fvTenant"]["attributes"]["name"] for i in resp2.json()["imdata"]]
assert "TEMP" not in names2
def test_snapshot_restore(client_and_state):
client, state = client_and_state
# POST a tenant
client.post(
"/api/mo/uni/tn-SNAP.json",
json={"fvTenant": {"attributes": {"name": "SNAP", "dn": "uni/tn-SNAP"}}},
)
# Snapshot
client.post("/_sim/snapshot/s1")
# Reset to baseline (removes SNAP)
client.post("/_sim/reset")
# Restore
client.post("/_sim/restore/s1")
# Verify SNAP is back
resp = client.get("/api/class/fvTenant.json")
names = [i["fvTenant"]["attributes"]["name"] for i in resp.json()["imdata"]]
assert "SNAP" in names
+496
View File
@@ -0,0 +1,496 @@
"""Tests for the NDO -> APIC deploy mirror (aci_sim/ndo/deploy_mirror.py).
Confirmed SIM GAP: `POST /mso/api/v1/task` used to be a pure ack no-op —
multi-site EPGs/BDs/VRFs deployed via NDO never appeared on the target
sites' APIC MITStores. These tests cover the pure `mirror_template_to_sites`
function directly (unit + idempotency + undeploy) and the app-level wiring
in `POST /mso/api/v1/task` (with and without `apic_states`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
import pytest
from fastapi.testclient import TestClient
from aci_sim.mit.store import MITStore
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.deploy_mirror import _basename, mirror_template_to_sites
from aci_sim.ndo.model import NdoState
@dataclass
class _FakeApicState:
"""Minimal stand-in for rest_aci.app.ApicSiteState — the mirror only
ever touches `.store`, so a full ApicSiteState (name/site/topo/baseline)
is unnecessary scaffolding for these unit tests."""
store: MITStore = field(default_factory=MITStore)
TENANT_ID = "tenant-ms-tn1-id"
TENANT_NAME = "ANS-MS_TN1"
SCHEMA_ID = "schema-1"
TEMPLATE_NAME = "MS-TN1-template"
def _build_state(*, site1_host_based_routing: bool = True) -> NdoState:
"""A minimal NdoState: one schema, one template (1 vrf, 1 bd with
subnets, 1 anp with 1 epg providing a contract), and one site entry
(siteId '1') associated with the template."""
template = {
"name": TEMPLATE_NAME,
"tenantId": TENANT_ID,
"vrfs": [{"name": "VRF1"}],
"bds": [
{
"name": "App1",
"vrfRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/vrfs/VRF1",
"epMoveDetectMode": "garp",
"arpFlood": True,
"l2UnknownUnicast": "flood",
"unicastRouting": True,
"l2Stretch": True,
"intersiteBumTrafficAllow": True,
"subnets": [{"ip": "10.10.10.1/24", "scope": "public,shared", "primary": True}],
}
],
"anps": [
{
"name": "AP1",
"epgs": [
{
"name": "Web",
"bdRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/bds/App1",
"preferredGroup": False,
"subnets": [],
"contractRelationships": [
{
"relationshipType": "provider",
"contractRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/contracts/Web-Con",
}
],
}
],
}
],
"contracts": [{"name": "Web-Con"}],
}
schema_detail = {
"id": SCHEMA_ID,
"templates": [template],
"sites": [
{
"siteId": "1",
"templateName": TEMPLATE_NAME,
"bds": [
{
"bdRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/bds/App1",
"hostBasedRouting": site1_host_based_routing,
}
],
"anps": [
{
"anpRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/anps/AP1",
"epgs": [
{
"epgRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/anps/AP1/epgs/Web",
"domainAssociations": [],
"staticPorts": [],
"staticLeafs": [],
"subnets": [],
}
],
}
],
# site '2' deliberately omitted — no association at all.
}
],
}
return NdoState(
sites=[],
tenants=[{"id": TENANT_ID, "name": TENANT_NAME}],
schemas=[],
schema_details={SCHEMA_ID: schema_detail},
template_summaries=[],
tenant_policy_templates={},
fabric_connectivity={},
policy_states={},
audit_records=[],
)
# ---------------------------------------------------------------------------
# Unit tests — pure mirror_template_to_sites()
# ---------------------------------------------------------------------------
def test_mirror_materializes_vrf_bd_anp_epg():
state = _build_state(site1_host_based_routing=True)
apic_states = {"1": _FakeApicState(), "2": _FakeApicState()}
written = mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert written > 0
store1 = apic_states["1"].store
ctx = store1.get(f"uni/tn-{TENANT_NAME}/ctx-VRF1")
assert ctx is not None
assert ctx.class_name == "fvCtx"
bd = store1.get(f"uni/tn-{TENANT_NAME}/BD-App1")
assert bd is not None
assert bd.class_name == "fvBD"
assert bd.attrs["hostBasedRouting"] == "yes"
assert bd.attrs["epMoveDetectMode"] == "garp"
assert bd.attrs["unkMacUcastAct"] == "flood"
assert bd.attrs["unicastRoute"] == "yes"
# _CLASS_DEFAULTS-filled attrs present (not overridden by the template)
assert bd.attrs["mac"] == "00:22:BD:F8:19:FF"
assert bd.attrs["type"] == "regular"
assert bd.attrs["limitIpLearnToSubnets"] == "yes"
rsctx = store1.get(f"uni/tn-{TENANT_NAME}/BD-App1/rsctx")
assert rsctx is not None
assert rsctx.attrs["tnFvCtxName"] == "VRF1"
subnet = store1.get(f"uni/tn-{TENANT_NAME}/BD-App1/subnet-[10.10.10.1/24]")
assert subnet is not None
ap = store1.get(f"uni/tn-{TENANT_NAME}/ap-AP1")
assert ap is not None
assert ap.class_name == "fvAp"
epg_dn = f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web"
epg = store1.get(epg_dn)
assert epg is not None
assert epg.class_name == "fvAEPg"
rsbd = store1.get(f"{epg_dn}/rsbd")
assert rsbd is not None
assert rsbd.attrs["tnFvBDName"] == "App1"
rsprov = store1.get(f"{epg_dn}/rsprov-Web-Con")
assert rsprov is not None
assert rsprov.attrs["tnVzBrCPName"] == "Web-Con"
# class query surface works too
epgs_by_class = store1.by_class("fvAEPg")
assert any(e.dn == epg_dn for e in epgs_by_class)
# The tenant SHADOW MO must exist on the site so a subtree/mo query on
# uni/tn-<T> resolves the mirrored children (F1 root cause: without the
# fvTenant root MO, the subtree root is empty and the query returns 0 even
# though the mirrored VRF/BD/EPG children are present).
tenant_mo = store1.get(f"uni/tn-{TENANT_NAME}")
assert tenant_mo is not None and tenant_mo.class_name == "fvTenant"
# Site '2' was never associated with this template's schema-sites entry,
# and is absent from the schema — its store must stay completely empty.
store2 = apic_states["2"].store
assert store2.all() == []
def test_mirror_skips_sites_not_in_apic_states():
state = _build_state()
# Only site '1' has a backing APIC store; a real deployment might target
# other sites this sim process doesn't happen to be running.
apic_states = {"1": _FakeApicState()}
written = mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert written > 0
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/BD-App1") is not None
def test_mirror_unknown_template_is_safe_noop():
state = _build_state()
apic_states = {"1": _FakeApicState()}
written = mirror_template_to_sites(state, "does-not-exist", apic_states)
assert written == 0
assert apic_states["1"].store.all() == []
# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------
def test_mirror_twice_is_idempotent():
state = _build_state()
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
count_after_first = len(apic_states["1"].store.all())
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
count_after_second = len(apic_states["1"].store.all())
assert count_after_first == count_after_second
epg = apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web")
assert epg is not None
assert epg.attrs["name"] == "Web"
# ---------------------------------------------------------------------------
# Undeploy
# ---------------------------------------------------------------------------
def test_undeploy_removes_template_objects():
state = _build_state()
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is not None
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states, undeploy=True)
store1 = apic_states["1"].store
assert store1.get(f"uni/tn-{TENANT_NAME}/BD-App1") is None
assert store1.get(f"uni/tn-{TENANT_NAME}/ap-AP1") is None
assert store1.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is None
assert store1.get(f"uni/tn-{TENANT_NAME}/ctx-VRF1") is None
# ---------------------------------------------------------------------------
# Schema-collision regression (schema_id threading)
# ---------------------------------------------------------------------------
def test_schema_id_scopes_template_resolution_to_the_right_tenant():
"""Two DIFFERENT schemas each have a template named 'LAB1-LAB2' (a real
collision: every multi-site tenant's schema commonly reuses these
generic template names), but belong to different tenants. Passing
schema_id must resolve ONLY the targeted schema's tenant — the other
tenant's DNs must never appear in any site's store."""
collide_name = "LAB1-LAB2"
schema_a_id = "schema-a"
schema_b_id = "schema-b"
tenant_a_id, tenant_a_name = "tenant-a-id", "Tenant-A"
tenant_b_id, tenant_b_name = "tenant-b-id", "Tenant-B"
def _make_schema(schema_id: str, tenant_id: str) -> dict:
template = {
"name": collide_name,
"tenantId": tenant_id,
"vrfs": [{"name": "VRF1"}],
"bds": [],
"anps": [],
}
return {
"id": schema_id,
"templates": [template],
"sites": [
{
"siteId": "1",
"templateName": collide_name,
"bds": [],
"anps": [],
}
],
}
state = NdoState(
sites=[],
tenants=[
{"id": tenant_a_id, "name": tenant_a_name},
{"id": tenant_b_id, "name": tenant_b_name},
],
schemas=[],
schema_details={
schema_a_id: _make_schema(schema_a_id, tenant_a_id),
schema_b_id: _make_schema(schema_b_id, tenant_b_id),
},
template_summaries=[],
tenant_policy_templates={},
fabric_connectivity={},
policy_states={},
audit_records=[],
)
apic_states = {"1": _FakeApicState()}
written = mirror_template_to_sites(
state, collide_name, apic_states, schema_id=schema_b_id
)
assert written > 0
store1 = apic_states["1"].store
assert store1.get(f"uni/tn-{tenant_b_name}/ctx-VRF1") is not None
# Tenant A's DNs must be completely absent — no cross-schema bleed.
assert store1.get(f"uni/tn-{tenant_a_name}/ctx-VRF1") is None
# ---------------------------------------------------------------------------
# fvRsPathAtt encap key regression
# ---------------------------------------------------------------------------
def test_static_port_encap_uses_encap_key_not_deployment_immediacy():
state = _build_state()
schema_detail = state.schema_details[SCHEMA_ID]
site_epg = schema_detail["sites"][0]["anps"][0]["epgs"][0]
site_epg["staticPorts"] = [
{
"path": "topology/pod-1/paths-101/pathep-[eth1/1]",
"encap": "vlan-101",
"deploymentImmediacy": "immediate",
}
]
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
epg_dn = f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web"
path_dn = "topology/pod-1/paths-101/pathep-[eth1/1]"
rspath = apic_states["1"].store.get(f"{epg_dn}/rspathAtt-[{path_dn}]")
assert rspath is not None
assert rspath.attrs["encap"] == "vlan-101"
assert rspath.attrs["instrImedcy"] == "immediate"
# ---------------------------------------------------------------------------
# Redeploy prune — stale EPG children must not survive a merge-upsert
# ---------------------------------------------------------------------------
def test_redeploy_prunes_removed_contract_binding():
state = _build_state()
template = state.schema_details[SCHEMA_ID]["templates"][0]
epg = template["anps"][0]["epgs"][0]
# Seed a SECOND provider contract relationship (C1) alongside the
# existing Web-Con one, deploy, then remove C1 from the template (as if
# NDO-side config removed it) and redeploy — C1's fvRsProv must be gone.
epg["contractRelationships"].append(
{
"relationshipType": "provider",
"contractRef": f"/schemas/{SCHEMA_ID}/templates/{TEMPLATE_NAME}/contracts/C1",
}
)
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
epg_dn = f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web"
store1 = apic_states["1"].store
assert store1.get(f"{epg_dn}/rsprov-C1") is not None
# Remove C1 from the template (mutate in place) and redeploy.
epg["contractRelationships"] = [
rel
for rel in epg["contractRelationships"]
if _basename_test_helper(rel["contractRef"]) != "C1"
]
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
assert store1.get(f"{epg_dn}/rsprov-C1") is None
# The surviving provider relationship must still be present.
assert store1.get(f"{epg_dn}/rsprov-Web-Con") is not None
def _basename_test_helper(ref: str) -> str:
return ref.rsplit("/", 1)[-1]
def test_basename_resolves_string_and_dict_refs():
# STRING ref -> last path segment.
assert _basename("/schemas/x/templates/LAB1-LAB2/bds/bd-App1_LAB0") == "bd-App1_LAB0"
# DICT refs -> the leaf object-name key (aci-ansible's cisco.mso store shape).
# Regression: the mirror used to AttributeError ('dict'.rsplit) on a redeploy
# of an aci-ansible multi-site tenant whose vrfRef/bdRef are dicts, 500-ing
# the NDO deploy.
assert _basename({"vrfName": "vrf-L3_LAB0", "schemaId": "s", "templateName": "t"}) == "vrf-L3_LAB0"
assert _basename({"bdName": "bd-App1_LAB0", "schemaId": "s", "templateName": "t"}) == "bd-App1_LAB0"
assert _basename({"contractName": "con-Firewall", "schemaId": "s", "templateName": "t"}) == "con-Firewall"
# epgRef carries BOTH anpName and epgName; the EPG's own name wins.
assert _basename({"epgName": "epg-web", "anpName": "app-App", "schemaId": "s"}) == "epg-web"
assert _basename(None) == ""
assert _basename({}) == ""
# ---------------------------------------------------------------------------
# Empty vrfRef — no fvRsCtx child
# ---------------------------------------------------------------------------
def test_bd_with_no_vrf_ref_emits_no_fvrsctx_child():
state = _build_state()
template = state.schema_details[SCHEMA_ID]["templates"][0]
bd = template["bds"][0]
bd["vrfRef"] = None
apic_states = {"1": _FakeApicState()}
mirror_template_to_sites(state, TEMPLATE_NAME, apic_states)
bd_dn = f"uni/tn-{TENANT_NAME}/BD-App1"
assert apic_states["1"].store.get(bd_dn) is not None
assert apic_states["1"].store.get(f"{bd_dn}/rsctx") is None
# ---------------------------------------------------------------------------
# App-level wiring — POST /mso/api/v1/task
# ---------------------------------------------------------------------------
def test_app_level_deploy_mirrors_into_apic_store():
state = _build_state()
apic_states = {"1": _FakeApicState()}
app = make_ndo_app(state, apic_states)
with TestClient(app) as client:
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": SCHEMA_ID, "templateName": TEMPLATE_NAME},
)
assert resp.status_code == 200
body = resp.json()
assert body["templateName"] == TEMPLATE_NAME
assert body["status"] == "success"
epg = apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web")
assert epg is not None
assert epg.class_name == "fvAEPg"
def test_app_level_without_apic_states_is_still_a_noop():
"""make_ndo_app(state) with NO apic_states (every pre-existing test /
call site) must keep behaving exactly as before — ack only, no mirror
attempted (and no crash from a missing apic_states arg)."""
state = _build_state()
app = make_ndo_app(state)
with TestClient(app) as client:
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": SCHEMA_ID, "templateName": TEMPLATE_NAME},
)
assert resp.status_code == 200
body = resp.json()
assert body["templateName"] == TEMPLATE_NAME
assert body["status"] == "success"
def test_app_level_undeploy_via_task_body():
state = _build_state()
apic_states = {"1": _FakeApicState()}
app = make_ndo_app(state, apic_states)
with TestClient(app) as client:
client.post(
"/mso/api/v1/task",
json={"schemaId": SCHEMA_ID, "templateName": TEMPLATE_NAME},
)
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is not None
resp = client.post(
"/mso/api/v1/task",
json={
"schemaId": SCHEMA_ID,
"templateName": TEMPLATE_NAME,
"undeploy": ["1"],
},
)
assert resp.status_code == 200
assert apic_states["1"].store.get(f"uni/tn-{TENANT_NAME}/ap-AP1/epg-Web") is None
+185
View File
@@ -0,0 +1,185 @@
"""Tests for aci_sim/graph.py — self-contained SVG/HTML topology diagram.
Covers `render_topology()` directly (node counts, ISN cloud presence/absence,
role colors, well-formed XML) plus the `aci-sim graph` CLI subcommand. No
browser is required anywhere — every assertion works on the raw string
output.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from aci_sim.cli import generate_topology, main
from aci_sim.graph import ROLE_COLORS, build_graph, render_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
def _new_topology(sites: int) -> Topology:
"""Build a fresh N-site Topology via cli.generate_topology() (returns a dict)."""
raw = generate_topology(sites=sites)
return Topology.model_validate(raw)
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
def _total_node_count(topo) -> int:
total = 0
for site in topo.sites:
total += len(site.spine_nodes()) + len(site.leaf_nodes()) + len(site.border_leaves)
total += site.controllers
return total
def _svg_fragment(html: str) -> str:
m = re.search(r"<svg.*</svg>", html, re.S)
assert m, "no <svg>...</svg> fragment found"
return m.group(0)
class TestRenderTopologyRepoDefault:
"""The repo's default topology.yaml is a 2-site, ISN-enabled fabric."""
def test_html_contains_svg(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
html = render_topology(topo, fmt="html")
assert "<svg" in html
assert html.startswith("<html")
def test_svg_format_starts_with_svg_tag(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
assert svg.startswith("<svg")
assert "</svg>" in svg
def test_node_rect_count_matches_topology(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
node_groups = re.findall(r'<g class="node node-[a-z-]+"', svg)
expected = _total_node_count(topo) # ISN cloud is the +1 (checked separately)
assert len(node_groups) == expected + 1 # +1 for the ISN cloud node
def test_isn_cloud_present_for_multisite(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
assert topo.isn.enabled and len(topo.sites) > 1
svg = render_topology(topo, fmt="svg")
assert 'class="node node-isn"' in svg
assert "ISN" in svg
def test_role_colors_present(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
for role in ("spine", "leaf", "border-leaf", "controller", "isn"):
fill, _border = ROLE_COLORS[role]
assert fill in svg, f"expected {role} fill color {fill} in output"
def test_output_is_well_formed_xml(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
ET.fromstring(svg) # raises if malformed
def test_html_output_is_well_formed_xml_fragment(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
html = render_topology(topo, fmt="html")
ET.fromstring(_svg_fragment(html))
def test_legend_present(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
assert 'class="legend"' in svg
assert "Spine" in svg and "Leaf" in svg and "Border Leaf" in svg
def test_title_contains_fabric_name_and_site_count(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
svg = render_topology(topo, fmt="svg")
assert topo.fabric.name in svg
assert f"{len(topo.sites)} site(s)" in svg
class TestSingleFabricNoIsn:
def test_single_site_omits_isn_cloud(self) -> None:
single = _new_topology(sites=1)
assert len(single.sites) == 1 # sanity: no ISN possible with 1 site
svg = render_topology(single, fmt="svg")
assert 'class="node node-isn"' not in svg
ET.fromstring(svg)
class TestThreeSiteTopology:
def test_three_sites_all_render(self) -> None:
topo = _new_topology(sites=3)
assert len(topo.sites) == 3
svg = render_topology(topo, fmt="svg")
ET.fromstring(svg)
for site in topo.sites:
assert site.name in svg
node_groups = re.findall(r'<g class="node node-[a-z-]+"', svg)
assert len(node_groups) == _total_node_count(topo) + 1 # +1 ISN cloud
assert 'class="node node-isn"' in svg
class TestBuildGraph:
def test_build_graph_node_and_link_counts(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
graph = build_graph(topo)
assert graph.node_count() == _total_node_count(topo) + 1 # + ISN cloud
assert graph.site_count == len(topo.sites)
# every link references a node that exists in the graph
node_ids = {n.id for n in graph.nodes}
for link in graph.links:
assert link.source in node_ids
assert link.target in node_ids
def test_vpc_pair_links_present(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
graph = build_graph(topo)
vpc_links = [link for link in graph.links if link.kind == "vpc"]
assert len(vpc_links) >= 1 # LAB1 + LAB2 each have one vPC pair
class TestGraphCLI:
def test_cli_graph_writes_html_file(self, tmp_path, capsys) -> None:
out_file = tmp_path / "g.html"
rc = main(["graph", str(TOPOLOGY_YAML), "-o", str(out_file)])
out = capsys.readouterr().out
assert rc == 0
assert out_file.exists()
content = out_file.read_text(encoding="utf-8")
assert content.startswith("<html")
assert "<svg" in content
assert str(out_file) in out
def test_cli_graph_writes_svg_file(self, tmp_path) -> None:
out_file = tmp_path / "g.svg"
rc = main(["graph", str(TOPOLOGY_YAML), "-o", str(out_file)])
assert rc == 0
content = out_file.read_text(encoding="utf-8")
assert content.startswith("<svg")
def test_cli_graph_default_topology_and_output(self, tmp_path, monkeypatch) -> None:
monkeypatch.chdir(REPO_ROOT)
out_file = REPO_ROOT / "topology.html"
try:
rc = main(["graph", "-o", str(tmp_path / "default_out.html")])
assert rc == 0
finally:
if out_file.exists():
out_file.unlink()
def test_cli_graph_unknown_extension_errors(self, tmp_path, capsys) -> None:
out_file = tmp_path / "g.png"
rc = main(["graph", str(TOPOLOGY_YAML), "-o", str(out_file)])
err = capsys.readouterr().err
assert rc != 0
assert "ERROR" in err
def test_cli_graph_missing_topology_errors(self, tmp_path, capsys) -> None:
rc = main(["graph", "/nonexistent/topology.yaml", "-o", str(tmp_path / "g.html")])
err = capsys.readouterr().err
assert rc != 0
assert "ERROR" in err
+456
View File
@@ -0,0 +1,456 @@
"""LLDP/CDP fidelity feature tests (v0.12.0):
1. lldpInst/cdpInst materialized (one per switch node with adjacencies);
lldpIf/cdpIf materialized per adjacency interface.
2. Regression fix: deep-root subtree query
(`GET /api/mo/.../sys/lldp/inst.json?query-target=subtree&
target-subtree-class=lldpAdjEp`) returns totalCount>0 (was 0 before
lldpInst/lldpIf were materialized) — plus /api/class/lldpInst.json and
/api/class/lldpIf.json both return >0.
3. lldpAdjEp bidirectional portIdV consistency (spine<->leaf link).
4. cdpAdjEp.devId == sysName, cdpAdjEp.portId non-empty.
5. Backward-compat: sysName + mgmtIp unchanged on both lldpAdjEp/cdpAdjEp.
6. Dynamic path: POSTing a fabricNodeIdentP (writes.py's
materialize_node_registration reaction) gets the same treatment.
7. CLI: `aci-sim lldp` — text, --json, --cdp, and a graceful non-existent
node id.
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim import cli
from aci_sim.build import cabling, fabric, health_faults, interfaces, overlay, underlay
from aci_sim.build.orchestrator import build_site
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
TOPO_YAML = Path(__file__).parent.parent / "topology.yaml"
TOPO_PATH_STR = "topology.yaml" # run from project root, matches other test modules
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo() -> Topology:
return load_topology(TOPO_YAML)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def store(topo, site_a) -> MITStore:
s = MITStore()
fabric.build(topo, site_a, s)
cabling.build(topo, site_a, s)
underlay.build(topo, site_a, s)
overlay.build(topo, site_a, s)
interfaces.build(topo, site_a, s)
health_faults.build(topo, site_a, s)
return s
def _login(client: TestClient) -> None:
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200, resp.text
def _fresh_client(site_name: str = "LAB1") -> tuple[ApicSiteState, TestClient]:
topo_obj = load_topology(TOPO_PATH_STR)
site = topo_obj.site_by_name(site_name)
built_store = build_site(topo_obj, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo_obj,
store=built_store,
baseline=copy.deepcopy(built_store),
)
client = TestClient(make_apic_app(state))
_login(client)
return state, client
# ---------------------------------------------------------------------------
# 1. Containers materialized
# ---------------------------------------------------------------------------
def test_lldp_inst_materialized_per_switch_node(store, site_a):
"""One lldpInst per switch node that has at least one lldpAdjEp."""
node_ids_with_adj = set()
for mo in store.by_class("lldpAdjEp"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
if m:
node_ids_with_adj.add(int(m.group(1)))
lldp_inst_node_ids = set()
for mo in store.by_class("lldpInst"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
assert m, f"lldpInst dn missing node id: {mo.attrs['dn']}"
lldp_inst_node_ids.add(int(m.group(1)))
assert node_ids_with_adj, "expected at least one node with an lldpAdjEp"
assert lldp_inst_node_ids == node_ids_with_adj, (
f"lldpInst node set mismatch: adj nodes={node_ids_with_adj} "
f"lldpInst nodes={lldp_inst_node_ids}"
)
inst = store.by_class("lldpInst")[0]
assert inst.attrs["adminSt"] == "enabled"
assert inst.attrs["name"] == "default"
assert inst.attrs["holdTime"] == "120"
assert inst.attrs["initDelayTime"] == "2"
assert inst.attrs["txFreq"] == "30"
assert inst.attrs["ctrl"] == "rx-en,tx-en"
assert "sys-name" in inst.attrs["optTlvSel"]
def test_cdp_inst_materialized_per_switch_node(store, site_a):
node_ids_with_adj = set()
for mo in store.by_class("cdpAdjEp"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
if m:
node_ids_with_adj.add(int(m.group(1)))
cdp_inst_node_ids = set()
for mo in store.by_class("cdpInst"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
assert m
cdp_inst_node_ids.add(int(m.group(1)))
assert cdp_inst_node_ids == node_ids_with_adj
inst = store.by_class("cdpInst")[0]
assert inst.attrs["adminSt"] == "enabled"
assert inst.attrs["name"] == "default"
assert inst.attrs["holdIntvl"] == "180"
assert inst.attrs["txFreq"] == "60"
assert inst.attrs["ver"] == "v2"
def test_lldp_if_cdp_if_materialized_per_adjacency_interface(store, site_a):
"""One lldpIf/cdpIf per (node, local_port) that has an adjacency."""
expected_if_dns = set()
for mo in store.by_class("lldpAdjEp"):
# .../sys/lldp/inst/if-[{port}]/adj-1 -> .../sys/lldp/inst/if-[{port}]
if_dn = mo.attrs["dn"].rsplit("/", 1)[0]
expected_if_dns.add(if_dn)
actual_if_dns = {mo.attrs["dn"] for mo in store.by_class("lldpIf")}
assert actual_if_dns == expected_if_dns
lldp_if = store.by_class("lldpIf")[0]
assert lldp_if.attrs["adminRxSt"] == "enabled"
assert lldp_if.attrs["adminTxSt"] == "enabled"
assert lldp_if.attrs["operRxSt"] == "up"
assert lldp_if.attrs["operTxSt"] == "up"
assert lldp_if.attrs["wiring"] == "valid"
assert re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", lldp_if.attrs["mac"])
expected_cdp_if_dns = set()
for mo in store.by_class("cdpAdjEp"):
expected_cdp_if_dns.add(mo.attrs["dn"].rsplit("/", 1)[0])
actual_cdp_if_dns = {mo.attrs["dn"] for mo in store.by_class("cdpIf")}
assert actual_cdp_if_dns == expected_cdp_if_dns
cdp_if = store.by_class("cdpIf")[0]
assert cdp_if.attrs["adminSt"] == "enabled"
assert cdp_if.attrs["operSt"] == "up"
# ---------------------------------------------------------------------------
# 2. Regression fix — deep-root subtree query + class queries
# ---------------------------------------------------------------------------
def test_deep_root_subtree_query_returns_neighbors():
"""The bug this feature fixes: GET .../sys/lldp/inst.json?query-target=
subtree&target-subtree-class=lldpAdjEp used to return totalCount=0
because the root DN had no materialized MO (run_mo_query short-circuits
to ([], 0) when store.get(dn) is None)."""
_, client = _fresh_client("LAB1")
resp = client.get(
"/api/mo/topology/pod-1/node-101/sys/lldp/inst.json"
"?query-target=subtree&target-subtree-class=lldpAdjEp"
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert int(data["totalCount"]) > 0, f"expected totalCount>0, got {data}"
for item in data["imdata"]:
assert "lldpAdjEp" in item
def test_deep_root_cdp_subtree_query_returns_neighbors():
_, client = _fresh_client("LAB1")
resp = client.get(
"/api/mo/topology/pod-1/node-101/sys/cdp/inst.json"
"?query-target=subtree&target-subtree-class=cdpAdjEp"
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert int(data["totalCount"]) > 0, f"expected totalCount>0, got {data}"
def test_lldp_inst_class_query_nonzero():
_, client = _fresh_client("LAB1")
resp = client.get("/api/class/lldpInst.json")
assert resp.status_code == 200, resp.text
assert int(resp.json()["totalCount"]) > 0
def test_lldp_if_class_query_nonzero():
_, client = _fresh_client("LAB1")
resp = client.get("/api/class/lldpIf.json")
assert resp.status_code == 200, resp.text
assert int(resp.json()["totalCount"]) > 0
def test_cdp_inst_and_if_class_query_nonzero():
_, client = _fresh_client("LAB1")
resp_inst = client.get("/api/class/cdpInst.json")
assert resp_inst.status_code == 200, resp_inst.text
assert int(resp_inst.json()["totalCount"]) > 0
resp_if = client.get("/api/class/cdpIf.json")
assert resp_if.status_code == 200, resp_if.text
assert int(resp_if.json()["totalCount"]) > 0
# ---------------------------------------------------------------------------
# 3. Bidirectional portIdV consistency (spine<->leaf link)
# ---------------------------------------------------------------------------
def test_lldp_adjep_bidirectional_portidv_consistency(store, site_a):
"""For a spine<->leaf link, the leaf-side adj's portIdV must equal the
spine's own local port, and vice-versa."""
from aci_sim.build.cabling import cabling_links
pod = site_a.pod
checked = 0
for n1, s1, p1, n2, s2, p2 in cabling_links(site_a):
port1 = f"eth{s1}/{p1}"
port2 = f"eth{s2}/{p2}"
adj_n1 = store.get(f"topology/pod-{pod}/node-{n1}/sys/lldp/inst/if-[{port1}]/adj-1")
adj_n2 = store.get(f"topology/pod-{pod}/node-{n2}/sys/lldp/inst/if-[{port2}]/adj-1")
assert adj_n1 is not None
assert adj_n2 is not None
# n1 sees n2 on port1; n2's own port is port2 -> n1's portIdV == port2
assert adj_n1.attrs["portIdV"] == port2
# n2 sees n1 on port2; n1's own port is port1 -> n2's portIdV == port1
assert adj_n2.attrs["portIdV"] == port1
checked += 1
assert checked > 0, "no cabling links found to check"
# ---------------------------------------------------------------------------
# 4. cdpAdjEp devId/portId
# ---------------------------------------------------------------------------
def test_cdp_adjep_devid_matches_sysname_and_portid_nonempty(store, site_a):
cdp_mos = store.by_class("cdpAdjEp")
assert cdp_mos, "expected at least one cdpAdjEp"
for mo in cdp_mos:
assert mo.attrs["devId"] == mo.attrs["sysName"]
assert mo.attrs["portId"], f"empty portId on {mo.attrs['dn']}"
# ---------------------------------------------------------------------------
# 5. Backward-compat: sysName + mgmtIp unchanged
# ---------------------------------------------------------------------------
def test_backward_compat_sysname_and_mgmtip_present(store, site_a):
for cls in ("lldpAdjEp", "cdpAdjEp"):
mos = store.by_class(cls)
assert mos, f"expected at least one {cls}"
for mo in mos:
assert mo.attrs.get("sysName"), f"{cls} missing sysName: {mo.attrs['dn']}"
assert mo.attrs.get("mgmtIp"), f"{cls} missing mgmtIp: {mo.attrs['dn']}"
def test_backward_compat_matches_cabling_graph(store, site_a):
"""Same assertion test_build_fabric.py's test_lldp_cdp_matches_cabling
already makes (sysName set matches the cabling graph) — re-verified here
to prove the enrichment didn't alter the pre-existing neighbor set."""
from aci_sim.build.cabling import cabling_links
node_map = {n.id: n for n in site_a.all_nodes()}
expected: set[tuple[int, str]] = set()
for n1, _s1, _p1, n2, _s2, _p2 in cabling_links(site_a):
expected.add((n1, node_map[n2].name))
expected.add((n2, node_map[n1].name))
for cid in range(1, site_a.controllers + 1):
for leaf in site_a.leaf_nodes()[:2]:
expected.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
actual: set[tuple[int, str]] = set()
for mo in store.by_class("lldpAdjEp"):
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
if m:
actual.add((int(m.group(1)), mo.attrs.get("sysName", "")))
assert expected == actual
# ---------------------------------------------------------------------------
# 6. Dynamic path — fabricNodeIdentP registration reaction
# ---------------------------------------------------------------------------
def test_dynamic_node_registration_materializes_lldp_containers():
"""POSTing a fabricNodeIdentP (writes.py's materialize_node_registration
reaction) must produce the same lldpInst/lldpIf materialization the
boot-time builders produce, so its subtree query also works."""
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-920.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-920",
"name": "leaf-920",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200, resp.text
node_dn = "topology/pod-1/node-920"
inst_resp = client.get(f"/api/mo/{node_dn}/sys/lldp/inst.json")
assert inst_resp.status_code == 200, inst_resp.text
assert int(inst_resp.json()["totalCount"]) > 0, "new node's lldpInst missing"
subtree_resp = client.get(
f"/api/mo/{node_dn}/sys/lldp/inst.json"
"?query-target=subtree&target-subtree-class=lldpAdjEp"
)
assert subtree_resp.status_code == 200, subtree_resp.text
subtree_data = subtree_resp.json()
assert int(subtree_data["totalCount"]) > 0, (
f"new node's lldpAdjEp subtree query returned 0: {subtree_data}"
)
# Also verify the enriched fields landed on the dynamic path (not just
# boot-time build) and the spine-side reciprocal adjacency was created too.
adj = subtree_data["imdata"][0]["lldpAdjEp"]["attributes"]
assert adj["sysName"]
assert adj["mgmtIp"]
assert adj["portIdV"]
assert adj["chassisIdV"]
links_resp = client.get("/api/class/fabricLink.json")
node_links = [
item["fabricLink"]["attributes"]
for item in links_resp.json()["imdata"]
if "920" in (item["fabricLink"]["attributes"].get("n1"), item["fabricLink"]["attributes"].get("n2"))
]
assert node_links, "expected a fabricLink for the newly registered node"
def test_dynamic_node_registration_cdp_also_materialized():
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-921.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-921",
"name": "leaf-921",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200, resp.text
node_dn = "topology/pod-1/node-921"
resp2 = client.get(
f"/api/mo/{node_dn}/sys/cdp/inst.json"
"?query-target=subtree&target-subtree-class=cdpAdjEp"
)
assert resp2.status_code == 200, resp2.text
data = resp2.json()
assert int(data["totalCount"]) > 0
adj = data["imdata"][0]["cdpAdjEp"]["attributes"]
assert adj["devId"] == adj["sysName"]
assert adj["portId"]
# ---------------------------------------------------------------------------
# 7. CLI: aci-sim lldp
# ---------------------------------------------------------------------------
def test_cli_lldp_text_shows_neighbor(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101"])
assert rc == 0
out = capsys.readouterr().out
assert "node-101" in out
assert "LAB1-ACI-SP01" in out or "LAB1-ACI-SP02" in out
def test_cli_lldp_json(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101", "--json"])
assert rc == 0
out = capsys.readouterr().out
data = json.loads(out)
assert isinstance(data, list)
assert len(data) > 0
row = data[0]
assert "local_port" in row
assert "neighbor" in row
assert "neighbor_port" in row
assert "mgmt_ip" in row
assert "platform" in row
def test_cli_lldp_cdp_flag(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101", "--cdp", "--json"])
assert rc == 0
out = capsys.readouterr().out
data = json.loads(out)
assert len(data) > 0
# cdp platId values are real model/platform strings (e.g. APIC-SERVER-M3
# or N9K-C9332C), not the lldp sysDesc free-text form.
assert any(row["platform"] for row in data)
def test_cli_lldp_nonexistent_node_is_graceful(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "999999"])
assert rc == 0
out = capsys.readouterr().out
assert "No neighbors found" in out
def test_cli_lldp_nonexistent_node_json_is_empty_list(monkeypatch, capsys):
monkeypatch.chdir(Path(__file__).parent.parent)
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "999999", "--json"])
assert rc == 0
out = capsys.readouterr().out
data = json.loads(out)
assert data == []
def test_cli_lldp_missing_topology_file_returns_1(tmp_path, capsys):
rc = cli.main(["lldp", str(tmp_path / "does-not-exist.yaml")])
assert rc == 1
+330
View File
@@ -0,0 +1,330 @@
"""Tests for mit/ — dn, mo, store."""
import copy
import pytest
from aci_sim.mit.dn import (
dn_split, parent_dn, rn_of, name_from_dn, pod_from_dn, dn_class_hint,
)
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
# ---------------------------------------------------------------------------
# dn.py
# ---------------------------------------------------------------------------
class TestDnSplit:
def test_simple_three_parts(self):
assert dn_split("uni/tn-T/BD-b") == ["uni", "tn-T", "BD-b"]
def test_subnet_bracket(self):
# CONTRACT example: slash inside brackets must NOT split
assert dn_split("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == [
"uni", "tn-T", "BD-b", "subnet-[10.0.0.1/24]"
]
def test_pathep_bracket(self):
# CONTRACT example
assert dn_split("uni/tn-T/pathep-[eth1/9]") == [
"uni", "tn-T", "pathep-[eth1/9]"
]
def test_rsdomatt_bracket(self):
# CONTRACT example: bracket value itself contains a slash
assert dn_split("uni/phys-X/rsdomAtt-[uni/phys-X]") == [
"uni", "phys-X", "rsdomAtt-[uni/phys-X]"
]
def test_nested_brackets(self):
# e.g. rslldpIfAtt-[pathep-[eth1/9]] — nested brackets, one slash inside
parts = dn_split("uni/infra/rslldpIfAtt-[pathep-[eth1/9]]")
assert parts == ["uni", "infra", "rslldpIfAtt-[pathep-[eth1/9]]"]
def test_single_component(self):
assert dn_split("uni") == ["uni"]
def test_empty(self):
assert dn_split("") == []
def test_no_brackets(self):
assert dn_split("a/b/c/d") == ["a", "b", "c", "d"]
class TestParentDn:
def test_three_deep(self):
assert parent_dn("uni/tn-T/BD-b") == "uni/tn-T"
def test_top_level(self):
assert parent_dn("uni") == ""
def test_bracket_rn(self):
# parent of a bracket RN is the surrounding path
assert parent_dn("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == "uni/tn-T/BD-b"
class TestRnOf:
def test_basic(self):
assert rn_of("uni/tn-T/BD-b") == "BD-b"
def test_bracket(self):
assert rn_of("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == "subnet-[10.0.0.1/24]"
def test_single(self):
assert rn_of("uni") == "uni"
class TestNameFromDn:
def test_plain(self):
assert name_from_dn("uni/tn-T") == "T"
def test_bd(self):
assert name_from_dn("uni/tn-T/BD-mybd") == "mybd"
def test_subnet_bracket(self):
assert name_from_dn("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == "10.0.0.1/24"
def test_pathep_bracket(self):
# pathep-[eth1/9] → eth1/9
assert name_from_dn("topology/pod-1/node-101/local/svc-x/pathep-[eth1/9]") == "eth1/9"
def test_rsdomatt_bracket(self):
assert name_from_dn("uni/phys-X/rsdomAtt-[uni/phys-X]") == "uni/phys-X"
def test_no_dash(self):
assert name_from_dn("uni") == "uni"
class TestPodFromDn:
def test_pod_1(self):
assert pod_from_dn("topology/pod-1/node-101") == "1"
def test_pod_2(self):
assert pod_from_dn("topology/pod-2/node-201/sys") == "2"
def test_no_pod_default_1(self):
assert pod_from_dn("uni/tn-T") == "1"
def test_pod_at_end(self):
assert pod_from_dn("topology/pod-3") == "3"
def test_pod_prefix_dn(self):
assert pod_from_dn("topology/pod-10/node-1001/sys/health") == "10"
class TestDnClassHint:
def test_tn(self):
assert dn_class_hint("tn-T") == "fvTenant"
def test_uni(self):
assert dn_class_hint("uni") == "polUni"
def test_unknown(self):
assert dn_class_hint("foo-bar") is None
# ---------------------------------------------------------------------------
# mo.py
# ---------------------------------------------------------------------------
class TestMO:
def test_construction(self):
mo = MO("fvTenant", dn="uni/tn-T", name="T")
assert mo.class_name == "fvTenant"
assert mo.dn == "uni/tn-T"
assert mo.attrs["name"] == "T"
def test_add_child(self):
parent = MO("fvTenant", dn="uni/tn-T")
child = MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1")
parent.add_child(child)
assert parent.children == [child]
def test_flat_depth_first(self):
root = MO("fvTenant", dn="uni/tn-T")
c1 = MO("fvCtx", dn="uni/tn-T/ctx-v1")
c2 = MO("fvBD", dn="uni/tn-T/BD-b")
c2a = MO("fvSubnet", dn="uni/tn-T/BD-b/subnet-[10.0.0.1/24]")
root.add_child(c1)
root.add_child(c2)
c2.add_child(c2a)
assert root.flat() == [root, c1, c2, c2a]
def test_to_imdata_no_children(self):
mo = MO("fvTenant", dn="uni/tn-T", name="T")
d = mo.to_imdata()
assert list(d.keys()) == ["fvTenant"]
assert "children" not in d["fvTenant"]
assert d["fvTenant"]["attributes"]["dn"] == "uni/tn-T"
def test_to_imdata_with_children(self):
parent = MO("fvTenant", dn="uni/tn-T")
child = MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1")
parent.add_child(child)
d = parent.to_imdata()
assert "children" in d["fvTenant"]
child_d = d["fvTenant"]["children"][0]
assert "fvCtx" in child_d
assert child_d["fvCtx"]["attributes"]["name"] == "v1"
def test_to_imdata_attrs_are_copy(self):
mo = MO("fvTenant", dn="uni/tn-T", name="T")
d = mo.to_imdata()
d["fvTenant"]["attributes"]["name"] = "mutated"
assert mo.attrs["name"] == "T" # original unchanged
# ---------------------------------------------------------------------------
# store.py
# ---------------------------------------------------------------------------
def _flat_store() -> MITStore:
"""Return a store populated with a small tree (all MOs added individually)."""
s = MITStore()
s.add(MO("fvTenant", dn="uni/tn-T", name="T"))
s.add(MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1"))
s.add(MO("fvBD", dn="uni/tn-T/BD-b", name="b"))
s.add(MO("fvSubnet", dn="uni/tn-T/BD-b/subnet-[10.0.0.1/24]", ip="10.0.0.1/24"))
return s
class TestMITStoreGet:
def test_get_existing(self):
s = _flat_store()
mo = s.get("uni/tn-T/ctx-v1")
assert mo is not None
assert mo.class_name == "fvCtx"
def test_get_missing(self):
assert _flat_store().get("uni/tn-X") is None
def test_get_bracket_dn(self):
s = _flat_store()
mo = s.get("uni/tn-T/BD-b/subnet-[10.0.0.1/24]")
assert mo is not None
assert mo.attrs["ip"] == "10.0.0.1/24"
class TestMITStoreChildren:
def test_children_of_tenant(self):
s = _flat_store()
kids = s.children("uni/tn-T")
dns = {mo.dn for mo in kids}
assert "uni/tn-T/ctx-v1" in dns
assert "uni/tn-T/BD-b" in dns
def test_children_class_filter(self):
s = _flat_store()
ctxs = s.children("uni/tn-T", classes={"fvCtx"})
assert len(ctxs) == 1
assert ctxs[0].class_name == "fvCtx"
def test_children_leaf_node_empty(self):
s = _flat_store()
assert s.children("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == []
class TestMITStoreSubtree:
def test_all_descendants(self):
s = _flat_store()
desc = s.subtree("uni/tn-T")
dns = {mo.dn for mo in desc}
assert "uni/tn-T/ctx-v1" in dns
assert "uni/tn-T/BD-b" in dns
assert "uni/tn-T/BD-b/subnet-[10.0.0.1/24]" in dns
# Root itself must NOT be included
assert "uni/tn-T" not in dns
def test_class_filter(self):
s = _flat_store()
subs = s.subtree("uni/tn-T", classes={"fvSubnet"})
assert len(subs) == 1
assert subs[0].class_name == "fvSubnet"
def test_class_filter_traverses_full_tree(self):
"""A class filter must still find deeply-nested matches."""
s = _flat_store()
# fvSubnet is 3 levels below uni/tn-T; asking for it from the top works
subs = s.subtree("uni/tn-T", classes={"fvSubnet"})
assert len(subs) == 1
def test_empty_subtree(self):
s = _flat_store()
assert s.subtree("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == []
class TestMITStoreByClass:
def test_by_class_present(self):
s = _flat_store()
bds = s.by_class("fvBD")
assert len(bds) == 1
assert bds[0].dn == "uni/tn-T/BD-b"
def test_by_class_absent(self):
assert _flat_store().by_class("fvAp") == []
class TestMITStoreAll:
def test_insertion_order(self):
s = _flat_store()
dns = [mo.dn for mo in s.all()]
assert dns == [
"uni/tn-T",
"uni/tn-T/ctx-v1",
"uni/tn-T/BD-b",
"uni/tn-T/BD-b/subnet-[10.0.0.1/24]",
]
class TestMITStoreUpsert:
def test_upsert_merges_attrs(self):
s = _flat_store()
s.upsert(MO("fvTenant", dn="uni/tn-T", name="T", descr="new-desc"))
mo = s.get("uni/tn-T")
assert mo.attrs["descr"] == "new-desc"
assert mo.attrs["name"] == "T" # original attr preserved
def test_upsert_inserts_new(self):
s = _flat_store()
s.upsert(MO("fvTenant", dn="uni/tn-T2", name="T2"))
assert s.get("uni/tn-T2") is not None
def test_upsert_recurses_children(self):
s = MITStore()
root = MO("fvTenant", dn="uni/tn-T")
child = MO("fvCtx", dn="uni/tn-T/ctx-v1", name="v1")
root.add_child(child)
s.upsert(root)
assert s.get("uni/tn-T") is not None
assert s.get("uni/tn-T/ctx-v1") is not None
def test_upsert_deleted_removes_dn(self):
s = _flat_store()
s.upsert(MO("fvBD", dn="uni/tn-T/BD-b", status="deleted"))
assert s.get("uni/tn-T/BD-b") is None
def test_upsert_deleted_removes_subtree(self):
s = _flat_store()
s.upsert(MO("fvBD", dn="uni/tn-T/BD-b", status="deleted"))
assert s.get("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") is None
def test_upsert_deleted_keeps_siblings(self):
s = _flat_store()
s.upsert(MO("fvBD", dn="uni/tn-T/BD-b", status="deleted"))
assert s.get("uni/tn-T") is not None
assert s.get("uni/tn-T/ctx-v1") is not None
class TestMITStoreDeepCopy:
def test_deepcopy_isolates(self):
s = _flat_store()
s2 = copy.deepcopy(s)
s.upsert(MO("fvTenant", dn="uni/tn-T", name="mutated"))
assert s2.get("uni/tn-T").attrs["name"] == "T"
def test_deepcopy_is_complete(self):
s = _flat_store()
s2 = copy.deepcopy(s)
assert {mo.dn for mo in s2.all()} == {mo.dn for mo in s.all()}
+666
View File
@@ -0,0 +1,666 @@
"""Phase 6 NDO tests — tests/test_ndo.py
All tests use FastAPI's synchronous TestClient (no real network).
The module-scoped client/state is shared across the whole module so that the
POST-schema write test can be followed by a GET read-back in the same session.
Key assertions:
- every §7 endpoint responds with the right shape
- VRF/BD/EPG/contract names in the schema exactly match the topology
- DHCP relay UUID resolves via the tenantPolicy template
- POST /schemas stores the schema; subsequent GET /schemas shows it
"""
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 (
DHCP_RELAY_UUID,
TENANT_POLICY_TEMPLATE_ID,
build_ndo_model,
)
from aci_sim.topology.loader import load_topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
# ---------------------------------------------------------------------------
# Module-scoped fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPOLOGY_YAML)
@pytest.fixture(scope="module")
def client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def token(client):
resp = client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
data = resp.json()
assert "token" in data, f"Login response missing token: {data}"
return data["token"]
# ---------------------------------------------------------------------------
# Auth + platform version
# ---------------------------------------------------------------------------
def test_login(client):
resp = client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
data = resp.json()
assert "token" in data
assert data["token"], "token must be non-empty"
def test_platform_version(client, token):
resp = client.get(
"/api/v1/platform/version",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["version"] == "4.2.2"
# ---------------------------------------------------------------------------
# Sites
# ---------------------------------------------------------------------------
def test_get_sites_matches_topology(client, topo, token):
"""PR-10: NDO site names must be the fabric name (e.g. "LAB1-IT-ACI"),
not the short topology-internal site name ("LAB1") — verified against a
real-gear cisco.mso.mso_tenant run where only the fabric-name form was
accepted (see docs/CONTRACT.md §7).
"""
resp = client.get(
"/mso/api/v1/sites",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
sites = resp.json()["sites"]
assert len(sites) == len(topo.sites)
site_names = {s["name"] for s in sites}
for topo_site in topo.sites:
assert (topo_site.fabric_name or topo_site.name) in site_names
for s in sites:
assert "id" in s
assert s["platform"] == "APIC"
assert isinstance(s["urls"], list) and len(s["urls"]) > 0
# URL must embed the site's apic_host
topo_site = next(
ts for ts in topo.sites if (ts.fabric_name or ts.name) == s["name"]
)
assert topo_site.apic_host in s["urls"][0]
# ---------------------------------------------------------------------------
# Tenants
# ---------------------------------------------------------------------------
def test_get_tenants_matches_topology(client, topo, token):
resp = client.get(
"/mso/api/v1/tenants",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
tenants = resp.json()["tenants"]
assert len(tenants) == len(topo.tenants)
ndo_names = {t["name"] for t in tenants}
for tt in topo.tenants:
assert tt.name in ndo_names
for t in tenants:
assert "id" in t
assert "siteAssociations" in t
assert isinstance(t["siteAssociations"], list)
def test_tenant_site_associations(client, topo, token):
"""Stretched tenant must be associated with the correct site IDs."""
sites_resp = client.get("/mso/api/v1/sites",
headers={"Authorization": f"Bearer {token}"})
# PR-10: /mso/api/v1/sites now reports fabric names, not the topology's
# short site names — join by fabric_name (falling back to the short
# name) to map short-name (as used in tenant.sites) → site id.
fabric_to_short = {
(ts.fabric_name or ts.name): ts.name for ts in topo.sites
}
site_id_map = {
fabric_to_short.get(s["name"], s["name"]): s["id"]
for s in sites_resp.json()["sites"]
}
tenants_resp = client.get("/mso/api/v1/tenants",
headers={"Authorization": f"Bearer {token}"})
ndo_tenants = {t["name"]: t for t in tenants_resp.json()["tenants"]}
for tt in topo.tenants:
ndo_t = ndo_tenants[tt.name]
ndo_site_ids = {sa["siteId"] for sa in ndo_t["siteAssociations"]}
expected_ids = {site_id_map[s] for s in tt.sites if s in site_id_map}
assert ndo_site_ids == expected_ids, (
f"Tenant {tt.name}: site-id mismatch ndo={ndo_site_ids} "
f"expected={expected_ids}"
)
# ---------------------------------------------------------------------------
# Schemas list
# ---------------------------------------------------------------------------
def test_get_schemas_list(client, topo, token):
resp = client.get(
"/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
schemas = resp.json()["schemas"]
# At least one schema per tenant
assert len(schemas) >= len(topo.tenants)
for s in schemas:
assert "id" in s
assert "displayName" in s
assert "name" in s
assert isinstance(s["templates"], list)
# ---------------------------------------------------------------------------
# Schema detail — cross-plane consistency
# ---------------------------------------------------------------------------
def _get_corp_schema_detail(client, token):
"""Helper: fetch the MS-TN1 schema detail (stretched tenant)."""
schemas = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
corp_summary = next(s for s in schemas if "MS-TN1" in s["name"])
resp = client.get(
f"/mso/api/v1/schemas/{corp_summary['id']}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
return resp.json(), corp_summary["id"]
def test_schema_detail_vrf_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
assert len(detail["templates"]) >= 1
tmpl = detail["templates"][0]
topo_vrf_names = {v.name for v in corp_tenant.vrfs}
ndo_vrf_names = {v["name"] for v in tmpl["vrfs"]}
assert topo_vrf_names == ndo_vrf_names, (
f"VRF name mismatch: topo={topo_vrf_names} ndo={ndo_vrf_names}"
)
def test_schema_detail_bd_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_bd_names = {b.name for b in corp_tenant.bds}
ndo_bd_names = {b["name"] for b in tmpl["bds"]}
assert topo_bd_names == ndo_bd_names, (
f"BD name mismatch: topo={topo_bd_names} ndo={ndo_bd_names}"
)
def test_schema_detail_bd_vrf_refs(client, topo, token):
"""Each BD's vrfRef must end with the correct VRF name."""
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_bd_vrf = {b.name: b.vrf for b in corp_tenant.bds}
for bd in tmpl["bds"]:
expected_vrf = topo_bd_vrf[bd["name"]]
assert bd["vrfRef"].endswith(f"/vrfs/{expected_vrf}"), (
f"BD {bd['name']}: vrfRef {bd['vrfRef']!r} "
f"should end /vrfs/{expected_vrf}"
)
assert bd["vrfRef"].startswith("/vrfs/")
def test_schema_detail_epg_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_epg_names: set[str] = {
epg.name for ap in corp_tenant.aps for epg in ap.epgs
}
ndo_epg_names: set[str] = {
epg["name"]
for anp in tmpl["anps"]
for epg in anp["epgs"]
}
assert topo_epg_names == ndo_epg_names, (
f"EPG name mismatch: topo={topo_epg_names} ndo={ndo_epg_names}"
)
def test_schema_detail_contract_names_match_topology(client, topo, token):
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_contract_names = {c.name for c in corp_tenant.contracts}
ndo_contract_names = {c["name"] for c in tmpl["contracts"]}
assert topo_contract_names == ndo_contract_names
def test_schema_detail_site_associations_match_topology(client, topo, token):
"""Stretched schema sites must exactly match the tenant's site IDs."""
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
sites_resp = client.get("/mso/api/v1/sites",
headers={"Authorization": f"Bearer {token}"})
# PR-10: join by fabric_name since /mso/api/v1/sites now reports fabric
# names — see test_tenant_site_associations above for the same pattern.
fabric_to_short = {
(ts.fabric_name or ts.name): ts.name for ts in topo.sites
}
site_id_map = {
fabric_to_short.get(s["name"], s["name"]): s["id"]
for s in sites_resp.json()["sites"]
}
expected_site_ids = {site_id_map[s] for s in corp_tenant.sites}
schema_site_ids = {sa["siteId"] for sa in detail.get("sites", [])}
assert expected_site_ids == schema_site_ids, (
f"Schema site-id mismatch: expected={expected_site_ids} "
f"got={schema_site_ids}"
)
def test_schema_detail_epg_bd_refs(client, topo, token):
"""Each EPG's bdRef must start /bds/ and match a real BD name."""
detail, _ = _get_corp_schema_detail(client, token)
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
tmpl = detail["templates"][0]
topo_bd_names = {b.name for b in corp_tenant.bds}
for anp in tmpl["anps"]:
for epg in anp["epgs"]:
bd_ref = epg["bdRef"]
assert bd_ref.startswith("/bds/"), (
f"EPG {epg['name']} bdRef {bd_ref!r} must start /bds/"
)
bd_name = bd_ref.split("/")[-1]
assert bd_name in topo_bd_names, (
f"EPG {epg['name']} bdRef references unknown BD {bd_name!r}"
)
def test_schema_detail_contract_filter_refs(client, topo, token):
"""Each contract's filterRelationships must reference real filter names."""
detail, _ = _get_corp_schema_detail(client, token)
tmpl = detail["templates"][0]
filter_names = {f["name"] for f in tmpl["filters"]}
for c in tmpl["contracts"]:
for fr in c.get("filterRelationships", []):
f_ref = fr["filterRef"]
assert f_ref.startswith("/filters/"), (
f"Contract {c['name']} filterRef {f_ref!r} must start /filters/"
)
f_name = f_ref.split("/")[-1]
assert f_name in filter_names, (
f"Contract {c['name']} references unknown filter {f_name!r}"
)
def test_schema_detail_external_epgs(client, topo, token):
"""External EPGs must have l3outRef, vrfRef, and subnets."""
detail, _ = _get_corp_schema_detail(client, token)
tmpl = detail["templates"][0]
# Corp has two l3outs each with one ext_epg → expect 2 externalEpgs
corp_tenant = next(t for t in topo.tenants if t.name == "MS-TN1")
expected_count = sum(
len(l3out.ext_epgs) for l3out in corp_tenant.l3outs
)
assert len(tmpl["externalEpgs"]) == expected_count
for xepg in tmpl["externalEpgs"]:
assert xepg["vrfRef"].startswith("/vrfs/")
assert xepg["l3outRef"].startswith("/l3outs/")
assert isinstance(xepg["subnets"], list)
# ---------------------------------------------------------------------------
# Fabric connectivity
# ---------------------------------------------------------------------------
def test_fabric_connectivity(client, topo, token):
resp = client.get(
"/mso/api/v1/sites/fabric-connectivity",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
# Accept either a list or {"sites": [...]}
fc_sites = (
data
if isinstance(data, list)
else data.get("sites", data.get("fabricConnectivity", []))
)
assert len(fc_sites) == len(topo.sites), (
f"Expected {len(topo.sites)} sites in fabric-connectivity, got {len(fc_sites)}"
)
for fc in fc_sites:
# At least one status field must be present
has_status = fc.get("status") or fc.get("connectivityStatus")
assert has_status, f"fabric-connectivity entry missing status: {fc}"
# ---------------------------------------------------------------------------
# Policy states
# ---------------------------------------------------------------------------
def test_policy_states_per_schema(client, token):
schemas = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
for schema_summary in schemas:
schema_id = schema_summary["id"]
resp = client.get(
f"/mso/api/v1/schemas/{schema_id}/policy-states",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
states = (
data
if isinstance(data, list)
else data.get("policyStates", data.get("states", []))
)
assert len(states) > 0, (
f"Schema {schema_id} returned empty policy-states"
)
for state in states:
assert "status" in state, f"policy-state entry missing 'status': {state}"
# ---------------------------------------------------------------------------
# Template summaries + tenantPolicy DHCP
# ---------------------------------------------------------------------------
def test_template_summaries(client, token):
resp = client.get(
"/mso/api/v1/templates/summaries",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
# Normalise: handle both list and {"templates": [...]}
summaries = data if isinstance(data, list) else data.get("templates", [])
assert len(summaries) > 0
types = [s.get("templateType") for s in summaries]
assert "tenantPolicy" in types, (
f"No tenantPolicy entry in template summaries: {types}"
)
for s in summaries:
assert "templateId" in s
assert "templateType" in s
def test_tenant_policy_template_dhcp_relay(client, token):
resp = client.get(
f"/mso/api/v1/templates/{TENANT_POLICY_TEMPLATE_ID}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
inner = (
resp.json()
.get("tenantPolicyTemplate", {})
.get("template", {})
)
relay = inner.get("dhcpRelayPolicies", [])
assert len(relay) > 0, "tenantPolicy template must have dhcpRelayPolicies"
for p in relay:
assert "uuid" in p
assert "name" in p
assert relay[0]["uuid"] == DHCP_RELAY_UUID
def test_bd_dhcp_labels_resolve_via_tenant_policy(client, token):
"""Every BD dhcpLabel ref must resolve in the tenantPolicy DHCP map."""
# Build uuid→name map exactly as ndo_connector.get_dhcp_policy_map() does
summaries_resp = client.get(
"/mso/api/v1/templates/summaries",
headers={"Authorization": f"Bearer {token}"},
)
summaries = summaries_resp.json()
if isinstance(summaries, dict):
summaries = summaries.get("templates", [])
dhcp_map: dict[str, str] = {}
for s in summaries:
if s.get("templateType") != "tenantPolicy":
continue
tmpl_resp = client.get(
f"/mso/api/v1/templates/{s['templateId']}",
headers={"Authorization": f"Bearer {token}"},
)
inner = (
tmpl_resp.json()
.get("tenantPolicyTemplate", {})
.get("template", {})
)
for p in inner.get("dhcpRelayPolicies", []):
if p.get("uuid") and p.get("name"):
dhcp_map[p["uuid"]] = p["name"]
for p in inner.get("dhcpOptionPolicies", []):
if p.get("uuid") and p.get("name"):
dhcp_map[p["uuid"]] = p["name"]
assert dhcp_map, "DHCP policy map must not be empty"
# Verify every BD dhcpLabel ref is resolvable
schemas = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"}).json()["schemas"]
found_any_label = False
for s in schemas:
detail = client.get(
f"/mso/api/v1/schemas/{s['id']}",
headers={"Authorization": f"Bearer {token}"},
).json()
for tmpl in detail.get("templates", []):
for bd in tmpl.get("bds", []):
for lbl in bd.get("dhcpLabels", []):
ref = lbl.get("ref", "")
assert ref in dhcp_map, (
f"BD {bd['name']} dhcpLabel ref {ref!r} "
f"not in DHCP policy map {list(dhcp_map)}"
)
found_any_label = True
assert found_any_label, "No BD with dhcpLabels found — test is vacuous"
# ---------------------------------------------------------------------------
# Audit records (both endpoints)
# ---------------------------------------------------------------------------
def test_audit_records_nonempty_primary(client, token):
resp = client.get(
"/api/v1/audit-records?count=50",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
records = (
data
if isinstance(data, list)
else data.get("auditRecords", data.get("records", []))
)
assert len(records) > 0, "Primary audit-records endpoint returned empty list"
def test_audit_records_fallback_endpoint(client, token):
resp = client.get(
"/mso/api/v1/audit-records?count=20",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
records = (
data
if isinstance(data, list)
else data.get("auditRecords", data.get("records", []))
)
assert len(records) > 0
def test_audit_record_fields(client, token):
resp = client.get("/api/v1/audit-records",
headers={"Authorization": f"Bearer {token}"})
records = resp.json().get("auditRecords", [])
for rec in records:
# ndo_audit_log reads these fields; all must be present
assert "timestamp" in rec or "date" in rec, f"No timestamp in {rec}"
assert "user" in rec or "userName" in rec, f"No user in {rec}"
assert "action" in rec or "type" in rec, f"No action in {rec}"
# ---------------------------------------------------------------------------
# POST schemas → write + read-back
# ---------------------------------------------------------------------------
def test_post_schema_and_readback(client, token):
"""POST a new schema and verify it appears in the schema list."""
new_schema = {
"displayName": "test-post-schema",
"name": "test-post-schema",
"templates": [
{
"name": "test-tmpl",
"templateType": "application",
"vrfs": [],
"bds": [],
"anps": [],
"contracts": [],
"filters": [],
"externalEpgs": [],
}
],
}
# Create
resp = client.post(
"/mso/api/v1/schemas",
json=new_schema,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
created = resp.json()
assert "id" in created, f"POST /schemas response missing id: {created}"
schema_id = created["id"]
assert created.get("status") == "active"
# Read back from list
list_resp = client.get("/mso/api/v1/schemas",
headers={"Authorization": f"Bearer {token}"})
ids_in_list = {s["id"] for s in list_resp.json()["schemas"]}
assert schema_id in ids_in_list, (
f"New schema {schema_id!r} not in GET /schemas after POST"
)
# Read back full detail
detail_resp = client.get(
f"/mso/api/v1/schemas/{schema_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert detail_resp.status_code == 200
# ---------------------------------------------------------------------------
# POST deploy
# ---------------------------------------------------------------------------
def test_post_deploy(client, token):
resp = client.post(
"/mso/api/v1/deploy",
json={"schemaId": "schema-123", "templateName": "tmpl"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "id" in data
assert data["status"] == "in-progress"
# ---------------------------------------------------------------------------
# Auth leniency — no bearer token still works
# ---------------------------------------------------------------------------
def test_auth_lenient_no_token(client):
"""Endpoints must respond even without a Bearer token (lenient auth)."""
assert client.get("/mso/api/v1/sites").status_code == 200
assert client.get("/mso/api/v1/tenants").status_code == 200
assert client.get("/mso/api/v1/schemas").status_code == 200
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
def test_unknown_schema_returns_404(client, token):
resp = client.get(
"/mso/api/v1/schemas/does-not-exist-xyzxyz",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
def test_unknown_template_returns_404(client, token):
resp = client.get(
"/mso/api/v1/templates/no-such-template-id",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
+276
View File
@@ -0,0 +1,276 @@
"""Tests for the OOB management gateway PR — a real MO instead of a discarded value.
Today, every node gets `topSystem.oobMgmtAddr` (the OOB IP) but no gateway is
modeled anywhere. Real APIC carries 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, carrying `addr` (node's OOB IP + mask)
and `gw` (the OOB default gateway). Prior to this PR, `aci-sim init`'s wizard
COLLECTED this same gateway value but discarded it (no schema field, no MO,
just an informational summary line) — see cli.py's `run_wizard` history.
Covers:
- `Site.oob_gateway`: defaults to `None`; IP-format validation (valid IPs
accepted, garbage rejected).
- `build/mgmt.py`: mgmtRsOoBStNode built per node (switches + controllers),
with `tDn`/`addr`/`gw` matching the real DN/address/gateway; addr mask
derived from `fabric.oob_subnet` if set, else defaults to /24; `gw` empty
when `site.oob_gateway` is unset (no invented default).
- wizard (`run_wizard`): the answered per-site OOB gateway is written into
`topology_dict["sites"][i]["oob_gateway"]` (both `--defaults` and
`--answers`-driven runs), not discarded.
- Backward compatibility: the repo's own topology.yaml (no `oob_gateway`
set anywhere) still builds without error, still produces exactly the
same `fabricNode`/`topSystem` MOs as before, and now ALSO builds the new
mgmt-tenant OOB scaffolding (additive — new MOs, no changed existing
ones) with an empty `gw` on every mgmtRsOoBStNode.
"""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import mgmt, orchestrator
from aci_sim.build.fabric import oob_ip
from aci_sim.cli import generate_topology, run_wizard
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
def _base_topo_dict(**kwargs) -> dict:
return generate_topology(sites=1, leaves_per_site=2, spines_per_site=2, border_pairs=1, **kwargs)
# ---------------------------------------------------------------------------
# Schema: Site.oob_gateway
# ---------------------------------------------------------------------------
class TestSchemaField:
def test_default_is_none(self) -> None:
topo = Topology.model_validate(_base_topo_dict())
for site in topo.sites:
assert site.oob_gateway is None
def test_repo_topology_has_no_oob_gateway_set(self) -> None:
"""The shipped topology.yaml predates this PR — no site sets it."""
topo = load_topology(TOPOLOGY_YAML)
for site in topo.sites:
assert site.oob_gateway is None
@pytest.mark.parametrize("gw", ["10.192.0.1", "192.168.1.1", "172.16.5.254", "::1", "2001:db8::1"])
def test_valid_ip_accepted(self, gw: str) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = gw
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].oob_gateway == gw
@pytest.mark.parametrize("gw", ["not-an-ip", "10.192.0.999", "", "10.192.0"])
def test_invalid_ip_rejected(self, gw: str) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = gw
with pytest.raises(ValidationError, match="oob_gateway"):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# Builder: mgmtRsOoBStNode
# ---------------------------------------------------------------------------
class TestMgmtBuilder:
def test_mgmt_tenant_scaffolding_present(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = "10.192.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
tenant_mo = store.get("uni/tn-mgmt")
assert tenant_mo is not None and tenant_mo.class_name == "fvTenant"
mgmtp_mo = store.get("uni/tn-mgmt/mgmtp-default")
assert mgmtp_mo is not None and mgmtp_mo.class_name == "mgmtMgmtP"
oob_mo = store.get("uni/tn-mgmt/mgmtp-default/oob-default")
assert oob_mo is not None and oob_mo.class_name == "mgmtOoB"
def test_one_mgmtrsoobstnode_per_node_including_controllers(self) -> None:
"""2 spines + 2 leaves + 2 border-leaves + 1 controller (default) = 7."""
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = "10.192.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
mos = store.by_class("mgmtRsOoBStNode")
assert len(mos) == 7, f"Expected 7 mgmtRsOoBStNode, got {len(mos)}"
# Every switch/controller node id must have exactly one entry, keyed
# by tDn == topology/pod-<pod>/node-<id>.
all_node_ids = {n.id for n in site.all_nodes()} | {1} # +1 controller
seen_tdns = {mo.attrs["tDn"] for mo in mos}
expected_tdns = {f"topology/pod-{site.pod}/node-{nid}" for nid in all_node_ids}
assert seen_tdns == expected_tdns
def test_addr_and_gw_correct_when_gateway_set(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["oob_gateway"] = "10.192.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
for node in site.all_nodes():
node_dn = f"topology/pod-{site.pod}/node-{node.id}"
mo = store.get(f"uni/tn-mgmt/mgmtp-default/oob-default/rsooBStNode-[{node_dn}]")
assert mo is not None, f"missing mgmtRsOoBStNode for {node_dn}"
assert mo.attrs["tDn"] == node_dn
assert mo.attrs["addr"] == f"{oob_ip(site.pod, node.id)}/24"
assert mo.attrs["gw"] == "10.192.0.1"
def test_gw_empty_when_gateway_unset(self) -> None:
"""No gateway configured -> gw is the empty string, never invented."""
topo = Topology.model_validate(_base_topo_dict())
site = topo.sites[0]
assert site.oob_gateway is None
store = orchestrator.build_site(topo, site)
mos = store.by_class("mgmtRsOoBStNode")
assert mos, "expected mgmtRsOoBStNode entries even with no gateway set"
for mo in mos:
assert mo.attrs["gw"] == ""
def test_mask_derived_from_oob_subnet_when_set(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["oob_subnet"] = "192.168.0.0/20"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
for mo in store.by_class("mgmtRsOoBStNode"):
assert mo.attrs["addr"].endswith("/20")
def test_mask_defaults_to_24_without_oob_subnet(self) -> None:
topo = Topology.model_validate(_base_topo_dict())
assert topo.fabric.oob_subnet is None
store = orchestrator.build_site(topo, topo.sites[0])
for mo in store.by_class("mgmtRsOoBStNode"):
assert mo.attrs["addr"].endswith("/24")
def test_controller_addr_matches_controller_oob_ip(self) -> None:
"""Controller's mgmtRsOoBStNode.addr must match its topSystem.oobMgmtAddr
(same address source: site.controller_oob_ips() / oob_ip fallback)."""
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.11"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrl_dn = f"topology/pod-{site.pod}/node-1"
top_sys = store.get(f"{ctrl_dn}/sys")
assert top_sys is not None
mgmt_mo = store.get(f"uni/tn-mgmt/mgmtp-default/oob-default/rsooBStNode-[{ctrl_dn}]")
assert mgmt_mo is not None
assert mgmt_mo.attrs["addr"].split("/")[0] == top_sys.attrs["oobMgmtAddr"]
# ---------------------------------------------------------------------------
# Wizard: writes oob_gateway instead of discarding it
# ---------------------------------------------------------------------------
class TestWizardWritesGateway:
def test_defaults_run_writes_oob_gateway_per_site(self) -> None:
out = io.StringIO()
topo_dict, gateway_note = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=True)
assert gateway_note # backward-compat return value still populated
for site in topo_dict["sites"]:
assert site.get("oob_gateway"), f"site {site['name']} missing oob_gateway"
# Result must still validate (schema field wired correctly end-to-end).
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
assert site.oob_gateway is not None
def test_custom_answers_gateway_flows_through(self) -> None:
answers = {
"deployment_type": "1",
"site1_gateway": "10.50.0.1",
}
out = io.StringIO()
topo_dict, gateway_note = run_wizard(
stream_in=io.StringIO(""), stream_out=out, accept_defaults=True, answers=answers
)
assert gateway_note == "10.50.0.1"
assert topo_dict["sites"][0]["oob_gateway"] == "10.50.0.1"
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].oob_gateway == "10.50.0.1"
def test_multi_site_each_site_gets_its_own_gateway(self) -> None:
answers = {
"deployment_type": "2",
"num_sites": "2",
"site1_gateway": "10.10.0.1",
"site2_gateway": "10.20.0.1",
}
out = io.StringIO()
topo_dict, _ = run_wizard(
stream_in=io.StringIO(""), stream_out=out, accept_defaults=True, answers=answers
)
assert topo_dict["sites"][0]["oob_gateway"] == "10.10.0.1"
assert topo_dict["sites"][1]["oob_gateway"] == "10.20.0.1"
# And the built store for each site carries its OWN gateway, not the
# other site's.
topo = Topology.model_validate(topo_dict)
store0 = orchestrator.build_site(topo, topo.sites[0])
store1 = orchestrator.build_site(topo, topo.sites[1])
gws0 = {mo.attrs["gw"] for mo in store0.by_class("mgmtRsOoBStNode")}
gws1 = {mo.attrs["gw"] for mo in store1.by_class("mgmtRsOoBStNode")}
assert gws0 == {"10.10.0.1"}
assert gws1 == {"10.20.0.1"}
# ---------------------------------------------------------------------------
# Backward compatibility
# ---------------------------------------------------------------------------
class TestBackwardCompat:
def test_repo_topology_builds_without_error(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
stores = orchestrator.build_all(topo)
assert set(stores.keys()) == {s.name for s in topo.sites}
def test_repo_topology_mgmt_scaffolding_builds_with_empty_gw(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
for site in topo.sites:
store = orchestrator.build_site(topo, site)
mos = store.by_class("mgmtRsOoBStNode")
assert mos, f"expected mgmtRsOoBStNode entries for site {site.name}"
for mo in mos:
assert mo.attrs["gw"] == ""
def test_repo_topology_existing_mos_unchanged(self) -> None:
"""fabricNode/topSystem must be byte-identical to pre-PR shape — this
PR is purely additive (new mgmt-tenant MOs), it must not touch any
existing MO's attributes."""
topo = load_topology(TOPOLOGY_YAML)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
for node in site.all_nodes():
ts = store.get(f"topology/pod-{site.pod}/node-{node.id}/sys")
assert ts is not None
assert ts.attrs["oobMgmtAddr"] == oob_ip(site.pod, node.id)
assert "gw" not in ts.attrs # topSystem never gains a gateway attr
def test_mgmt_module_registered_in_orchestrator(self) -> None:
assert mgmt in orchestrator._BUILDERS
+172
View File
@@ -0,0 +1,172 @@
"""Regression tests for P1 review findings F1 (tree-add children leak/dup) and
F2 (rsp-subtree=children must be one level, full must be recursive).
Builders add MOs as TREES (MO.add_child), the exact pattern the original
flat-only tests never exercised.
"""
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.engine import QueryParams, run_class_query, run_mo_query
def _tenant_tree() -> MO:
"""uni/tn-T ▷ BD-b ▷ subnet-[10.0.0.1/24] (a 3-level tree)."""
tn = MO("fvTenant", dn="uni/tn-T", name="T")
bd = MO("fvBD", dn="uni/tn-T/BD-b", name="b")
sn = MO("fvSubnet", dn="uni/tn-T/BD-b/subnet-[10.0.0.1/24]", ip="10.0.0.1/24")
bd.add_child(sn)
tn.add_child(bd)
return tn
# --- F1: adding a tree must not leak/duplicate children ---------------------
def test_tree_add_no_children_leak_on_nonsubtree_class_query():
s = MITStore()
s.add(_tenant_tree())
imdata, total = run_class_query(s, "fvBD", QueryParams()) # no subtree requested
assert total == 1
assert "children" not in imdata[0]["fvBD"], "children must be absent without a subtree mode (CONTRACT §2)"
def test_tree_add_no_children_leak_on_nonsubtree_mo_query():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_mo_query(s, "uni/tn-T", QueryParams())
assert "children" not in imdata[0]["fvTenant"]
def test_tree_add_subtree_query_lists_each_descendant_once():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_mo_query(s, "uni/tn-T", QueryParams(query_target="subtree"))
# Flatten every dn that appears anywhere in the imdata payload.
seen: list[str] = []
def walk(entry: dict):
for _cls, body in entry.items():
dn = body["attributes"].get("dn")
if dn:
seen.append(dn)
for child in body.get("children", []):
walk(child)
for entry in imdata:
walk(entry)
# root + bd + subnet, each exactly once
assert seen.count("uni/tn-T") == 1
assert seen.count("uni/tn-T/BD-b") == 1
assert seen.count("uni/tn-T/BD-b/subnet-[10.0.0.1/24]") == 1
# --- F2: children == one level, full == recursive ---------------------------
def _child_classes(imdata_entry: dict) -> set[str]:
body = next(iter(imdata_entry.values()))
return {next(iter(c)) for c in body.get("children", [])}
def test_rsp_subtree_children_is_one_level():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_class_query(s, "fvTenant", QueryParams(rsp_subtree="children"))
classes = _child_classes(imdata[0])
assert "fvBD" in classes
assert "fvSubnet" not in classes, "children mode is direct children only"
def test_rsp_subtree_full_is_recursive():
s = MITStore()
s.add(_tenant_tree())
imdata, _ = run_class_query(s, "fvTenant", QueryParams(rsp_subtree="full"))
tn = imdata[0]["fvTenant"]
# full mode returns the WHOLE subtree, and it stays NESTED — the grandchild
# (fvSubnet) hangs under its parent (fvBD), NOT flattened onto the tenant.
# (B1 regression: flattening silently emptied autoACI's two-level readers
# like contract_map vzBrCP→vzSubj→vzRsSubjFiltAtt.)
assert _child_classes(imdata[0]) == {"fvBD"}, "grandchildren must not be hoisted to the root"
bd = tn["children"][0]["fvBD"]
assert {next(iter(c)) for c in bd["children"]} == {"fvSubnet"}, "full mode is recursive"
# --- Legacy query-target=subtree must be FLAT top-level (P3 review Finding 1) ----
def _bgp_inst_tree() -> MO:
"""spine sys/bgp/inst ▷ dom-overlay-1 ▷ peer (ISN /32) — the real ISN shape."""
inst = MO("bgpInst", dn="topology/pod-1/node-201/sys/bgp/inst", asn="65001")
dom = MO("bgpDom", dn="topology/pod-1/node-201/sys/bgp/inst/dom-overlay-1", name="overlay-1")
peer = MO(
"bgpPeer",
dn="topology/pod-1/node-201/sys/bgp/inst/dom-overlay-1/peer-[10.1.401.1]",
addr="10.1.401.1/32",
asn="65002",
)
dom.add_child(peer)
inst.add_child(dom)
return inst
def test_legacy_subtree_returns_target_class_flat_at_top_level():
"""autoACI's query_dn(subtree=True, subtree_class="bgpPeer") reads item.get('bgpPeer')
at the TOP level of imdata — the ISN detection depends on this."""
s = MITStore()
s.add(_bgp_inst_tree())
imdata, total = run_mo_query(
s,
"topology/pod-1/node-201/sys/bgp/inst",
QueryParams(query_target="subtree", target_subtree_class=["bgpPeer"]),
)
assert total == 1
assert list(imdata[0].keys()) == ["bgpPeer"], "legacy subtree = class flat at top level, not nested"
assert imdata[0]["bgpPeer"]["attributes"]["addr"] == "10.1.401.1/32"
assert "children" not in imdata[0]["bgpPeer"]
assert all("bgpInst" not in e for e in imdata), "root excluded when it doesn't match the class filter"
def test_legacy_subtree_no_class_filter_is_flat_root_plus_descendants():
s = MITStore()
s.add(_bgp_inst_tree())
imdata, total = run_mo_query(
s,
"topology/pod-1/node-201/sys/bgp/inst",
QueryParams(query_target="subtree"),
)
classes = {next(iter(e)) for e in imdata}
assert classes == {"bgpInst", "bgpDom", "bgpPeer"}
assert total == 3
for e in imdata:
assert "children" not in next(iter(e.values())), "legacy subtree is flat, never nested"
# --- Store hardening: subtree traverses MO-less structural intermediates ------
# (ep_tracker queries epmMacEp via a node/sys subtree; the epm containers
# ctx-[]/bd-[]/db-ep have no MO of their own — reachability must still hold.)
def test_subtree_reaches_deep_mo_without_intermediate_mos():
s = MITStore()
node_sys = "topology/pod-1/node-101/sys"
s.add(MO("topSystem", dn=node_sys, role="leaf"))
deep = f"{node_sys}/ctx-[vxlan-2900001]/bd-[vxlan-15000001]/db-ep/mac-00:50:56:00:00:01"
s.add(MO("epmMacEp", dn=deep, addr="00:50:56:00:00:01"))
found = s.subtree(node_sys, classes={"epmMacEp"})
assert [m.dn for m in found] == [deep], "deep MO reachable via subtree despite MO-less containers"
# the MO-less containers are NOT surfaced as direct children
assert s.children(node_sys) == []
def test_legacy_subtree_reaches_deep_epm_via_engine():
s = MITStore()
node_sys = "topology/pod-1/node-101/sys"
s.add(MO("topSystem", dn=node_sys, role="leaf"))
deep = f"{node_sys}/ctx-[vxlan-2900001]/bd-[vxlan-15000001]/db-ep/mac-00:50:56:00:00:01"
s.add(MO("epmMacEp", dn=deep, addr="00:50:56:00:00:01"))
imdata, total = run_mo_query(
s, node_sys, QueryParams(query_target="subtree", target_subtree_class=["epmMacEp"])
)
assert total == 1
assert imdata[0]["epmMacEp"]["attributes"]["addr"] == "00:50:56:00:00:01"
+79
View File
@@ -0,0 +1,79 @@
"""Regression tests for the 6 Phase-5 cold-review findings.
P1-A empty-subtree-on-existing-MO -> 200; P1-B non-dict body -> 400 (not 500);
P2-A child without dn kept (no dn="" clobber); P2-B delete-with-children removes
subtree; P3 restore-unknown -> 404. (P4 single-site supervisor covered by inspection.)
PR-9 update: GET /api/mo/{dn}.json on a nonexistent DN now returns 200 + empty
imdata (not 404) — see tests/test_pr9_ansible.py for the full rationale
(cisco.aci's GET-before-POST existence check treats any non-200 as fatal).
test_p1a_nonexistent_mo_returns_404 renamed/updated accordingly.
"""
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
@pytest.fixture()
def client_and_store():
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))
c = TestClient(make_apic_app(state))
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c, store
def test_p1a_existing_mo_empty_subtree_returns_200(client_and_store):
c, store = client_and_store
node = next(m for m in store.by_class("fabricNode") if m.attrs.get("role") == "leaf")
r = c.get(f"/api/mo/{node.dn}.json", params={"query-target": "subtree", "target-subtree-class": "bgpVpnRoute"})
assert r.status_code == 200
assert r.json()["totalCount"] == "0"
def test_p1a_nonexistent_mo_returns_200_empty(client_and_store):
c, _ = client_and_store
r = c.get("/api/mo/uni/tn-GHOST.json")
assert r.status_code == 200
assert r.json()["totalCount"] == "0"
assert r.json()["imdata"] == []
@pytest.mark.parametrize("bad", ["str", None, 123, [1, 2]])
def test_p1b_non_dict_body_returns_400(client_and_store, bad):
c, _ = client_and_store
assert c.post("/api/mo/uni/tn-X.json", json={"fvTenant": bad}).status_code == 400
def test_p2a_children_without_dn_are_both_kept(client_and_store):
c, store = client_and_store
for nm in ("FIRST", "SECOND"):
c.post("/api/mo/uni/tn-CA.json", json={"fvTenant": {"attributes": {"name": "CA"}, "children": [{"fvBD": {"attributes": {"name": nm}}}]}})
r = c.get("/api/class/fvBD.json", params={"query-target-filter": 'or(eq(fvBD.name,"FIRST"),eq(fvBD.name,"SECOND"))'})
names = {i["fvBD"]["attributes"].get("name") for i in r.json()["imdata"]}
assert {"FIRST", "SECOND"} <= names
assert store.get("") is None
def test_p2b_delete_with_children_removes_subtree(client_and_store):
c, _ = client_and_store
c.post("/api/mo/uni/tn-DP.json", json={"fvTenant": {"attributes": {"name": "DP"}, "children": [{"fvBD": {"attributes": {"name": "b", "dn": "uni/tn-DP/BD-b"}}}]}})
c.post("/api/mo/uni/tn-DP.json", json={"fvTenant": {"attributes": {"name": "DP", "status": "deleted"}, "children": [{"fvBD": {"attributes": {"dn": "uni/tn-DP/BD-b"}}}]}})
r = c.get("/api/mo/uni/tn-DP/BD-b.json")
assert r.status_code == 200
assert r.json()["totalCount"] == "0"
def test_p3_restore_unknown_snapshot_returns_404(client_and_store):
c, _ = client_and_store
assert c.post("/_sim/restore/nope").status_code == 404
+284
View File
@@ -0,0 +1,284 @@
"""Tests for file-backed state persistence (aci_sim/control/persist.py).
Covers both planes:
(a) MITStore round-trip (APIC plane) — serialize_store/deserialize_store
(b) NdoState round-trip (NDO plane) — serialize_ndo/apply_ndo
(c) APIC app integration — /_sim/save + /_sim/load over the real REST API
(d) NDO app integration — same, on the NDO app
All tests set SIM_STATE_DIR to an isolated tmp_path so nothing is ever
written under the real ~/.aci-sim/state.
"""
from __future__ import annotations
import copy
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.control.persist import (
apply_ndo,
deserialize_store,
serialize_ndo,
serialize_store,
)
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# (a) MITStore round-trip
# ---------------------------------------------------------------------------
def test_store_round_trip_preserves_all_dns_and_subtree(monkeypatch, tmp_path):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
store = MITStore()
store.add(MO("fvTenant", dn="uni/tn-PERSIST", name="PERSIST"))
store.add(
MO(
"fvAp",
dn="uni/tn-PERSIST/ap-APP1",
name="APP1",
)
)
# Deep nested child MO under the tenant's subtree.
store.add(
MO(
"fvAEPg",
dn="uni/tn-PERSIST/ap-APP1/epg-WEB",
name="WEB",
)
)
before_dns = {mo.dn for mo in store.all()}
data = serialize_store(store)
restored = deserialize_store(data)
after_dns = {mo.dn for mo in restored.all()}
assert after_dns == before_dns
# Subtree query on the tenant must still return the deeply nested child.
subtree_dns = {mo.dn for mo in restored.subtree("uni/tn-PERSIST")}
assert "uni/tn-PERSIST/ap-APP1/epg-WEB" in subtree_dns
assert "uni/tn-PERSIST/ap-APP1" in subtree_dns
def test_store_round_trip_via_real_topology(monkeypatch, tmp_path):
"""Round-trip a store built from the real topology (not just a toy one)."""
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
before_dns = {mo.dn for mo in store.all()}
data = serialize_store(store)
restored = deserialize_store(data)
after_dns = {mo.dn for mo in restored.all()}
assert after_dns == before_dns
assert len(before_dns) > 0
# ---------------------------------------------------------------------------
# (b) NdoState round-trip
# ---------------------------------------------------------------------------
def test_ndo_state_round_trip_onto_different_state(monkeypatch, tmp_path):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
source_state = build_ndo_model(topo)
# Mutate tenants + schemas so the persisted snapshot differs from a fresh build.
source_state.tenants.append(
{"id": "tenant-extra", "name": "EXTRA", "displayName": "EXTRA", "siteAssociations": []}
)
source_state.schemas.append(
{"id": "schema-extra", "displayName": "EXTRA-schema", "name": "EXTRA-schema", "templates": []}
)
data = serialize_ndo(source_state)
# Apply onto a DIFFERENT fresh state object.
target_state = build_ndo_model(topo)
apply_ndo(target_state, data)
for f in (
"tenants",
"schemas",
"schema_details",
"template_summaries",
"tenant_policy_templates",
"policy_states",
"audit_records",
"local_users",
"remote_users",
"extra_schemas",
"sites",
"fabric_connectivity",
):
assert getattr(target_state, f) == getattr(source_state, f), f"field {f} mismatch"
# Confirm mutation actually landed (not just equal-because-untouched).
assert any(t["id"] == "tenant-extra" for t in target_state.tenants)
assert any(s["id"] == "schema-extra" for s in target_state.schemas)
def test_apply_ndo_mutates_in_place_not_a_new_object(monkeypatch, tmp_path):
"""apply_ndo must mutate the SAME object (setattr), since make_ndo_app
closes over the state object identity."""
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
topo = load_topology(TOPO_PATH)
state = build_ndo_model(topo)
state_id = id(state)
data = serialize_ndo(state)
data["tenants"] = data["tenants"] + [{"id": "t2", "name": "T2", "displayName": "T2", "siteAssociations": []}]
apply_ndo(state, data)
assert id(state) == state_id
assert any(t["id"] == "t2" for t in state.tenants)
# ---------------------------------------------------------------------------
# (c) APIC app integration
# ---------------------------------------------------------------------------
@pytest.fixture
def apic_client_and_state():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
c = TestClient(app)
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c, state
def test_apic_save_reset_load_round_trip(monkeypatch, tmp_path, apic_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, state = apic_client_and_state
# Create a new tenant via the REST API.
client.post(
"/api/mo/uni/tn-ITEST.json",
json={"fvTenant": {"attributes": {"name": "ITEST", "dn": "uni/tn-ITEST"}}},
)
resp = client.get("/api/class/fvTenant.json")
names = [i["fvTenant"]["attributes"]["name"] for i in resp.json()["imdata"]]
assert "ITEST" in names
# Save.
r = client.post("/_sim/save/itest")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["count"] > 0
saved_path = Path(body["file"])
assert saved_path.exists()
assert saved_path.parent == tmp_path
# Reset wipes the tenant.
client.post("/_sim/reset")
resp2 = client.get("/api/class/fvTenant.json")
names2 = [i["fvTenant"]["attributes"]["name"] for i in resp2.json()["imdata"]]
assert "ITEST" not in names2
# Load restores it.
r2 = client.post("/_sim/load/itest")
assert r2.status_code == 200
resp3 = client.get("/api/class/fvTenant.json")
names3 = [i["fvTenant"]["attributes"]["name"] for i in resp3.json()["imdata"]]
assert "ITEST" in names3
def test_apic_load_missing_state_returns_404(monkeypatch, tmp_path, apic_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, _state = apic_client_and_state
r = client.post("/_sim/load/does-not-exist")
assert r.status_code == 404
body = r.json()
assert body["imdata"][0]["error"]["attributes"]["text"]
# ---------------------------------------------------------------------------
# (d) NDO app integration
# ---------------------------------------------------------------------------
@pytest.fixture
def ndo_client_and_state():
topo = load_topology(TOPO_PATH)
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c, state
def test_ndo_save_load_round_trip(monkeypatch, tmp_path, ndo_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, state = ndo_client_and_state
# Create a new tenant via the REST API.
resp = client.post(
"/mso/api/v1/tenants",
json={"displayName": "NDO-ITEST", "name": "NDO-ITEST"},
)
assert resp.status_code == 200
new_id = resp.json()["id"]
r = client.post("/_sim/save/nditest")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
saved_path = Path(body["file"])
assert saved_path.exists()
assert saved_path.parent == tmp_path
# Mutate away the tenant to prove load actually restores state (no reset
# endpoint exists on the NDO app, so directly clear the list in place).
state.tenants.clear()
resp2 = client.get("/mso/api/v1/tenants")
ids2 = [t["id"] for t in resp2.json()["tenants"]]
assert new_id not in ids2
r2 = client.post("/_sim/load/nditest")
assert r2.status_code == 200
resp3 = client.get("/mso/api/v1/tenants")
ids3 = [t["id"] for t in resp3.json()["tenants"]]
assert new_id in ids3
def test_ndo_load_missing_state_returns_404(monkeypatch, tmp_path, ndo_client_and_state):
monkeypatch.setenv("SIM_STATE_DIR", str(tmp_path))
client, _state = ndo_client_and_state
r = client.post("/_sim/load/does-not-exist")
assert r.status_code == 404
assert "not found" in r.json()["detail"]
+277
View File
@@ -0,0 +1,277 @@
"""Regression tests for PR-10: cisco.aci/cisco.mso E2E-run gap fixes.
Triaged from a live aci-ansible E2E run whose failures were checked against
real-gear logs (see docs/CONTRACT.md §7/§11 and CHANGELOG 0.2.1). Covers:
1. GET/POST/DELETE via /api/node/mo/{dn} behave identically to the
canonical /api/mo/{dn} — real APIC serves /api/node/mo/{dn}.json as a
full alias (API Inspector / aci_rest form); the sim only registered
/api/mo/*, so cisco.aci calls hitting the alias got FastAPI's default
404 {"detail":"Not Found"} (11 E2E failures).
2. An unmatched /api/* path returns an APIC-shaped 400 error envelope
(imdata/error/code), never FastAPI's {"detail":...} shape.
3. NDO /mso/api/v1/sites site names equal the topology's fabric names
(e.g. "LAB1-IT-ACI"), not the short internal site name ("LAB1") —
verified against a real-gear cisco.mso.mso_tenant run that only
accepted the fabric-name form.
4. GET /mso/api/v1/schemas/list-identity returns every seeded schema with
id + displayName, and is NOT shadowed by the parameterized
/mso/api/v1/schemas/{schema_id} route.
5. GET /api/v1/tenants (cisco.mso.ndo_template's tenant prereq lookup)
returns the NDO tenants list.
"""
from __future__ import annotations
import copy
import pytest
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.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# APIC fixtures
# ---------------------------------------------------------------------------
def _new_apic_client() -> TestClient:
topo = load_topology(TOPO_PATH)
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 _logged_in_apic_client() -> TestClient:
c = _new_apic_client()
resp = c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
return c
# ---------------------------------------------------------------------------
# NDO fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture(scope="module")
def ndo_client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def ndo_token(ndo_client):
resp = ndo_client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
return resp.json()["token"]
# ---------------------------------------------------------------------------
# FIX A — /api/node/mo alias
# ---------------------------------------------------------------------------
def test_node_mo_alias_get_post_delete_round_trip():
"""POST/GET/DELETE via /api/node/mo/{dn} must behave identically to the
canonical /api/mo/{dn} route — create via the node-alias, read back via
the canonical path, then delete via the alias and confirm removal.
"""
c = _logged_in_apic_client()
# Create via the node-alias.
post_resp = c.post(
"/api/node/mo/uni/tn-PR10AliasTest.json",
json={
"fvTenant": {
"attributes": {"name": "PR10AliasTest", "dn": "uni/tn-PR10AliasTest"}
}
},
)
assert post_resp.status_code == 200, post_resp.text
assert post_resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created"
# Read back via the canonical path.
get_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
assert get_resp.status_code == 200
assert get_resp.json()["totalCount"] == "1"
assert (
get_resp.json()["imdata"][0]["fvTenant"]["attributes"]["name"]
== "PR10AliasTest"
)
# Also readable via the alias itself.
get_alias_resp = c.get("/api/node/mo/uni/tn-PR10AliasTest.json")
assert get_alias_resp.status_code == 200
assert get_alias_resp.json()["totalCount"] == "1"
# Delete via the alias.
del_resp = c.delete("/api/node/mo/uni/tn-PR10AliasTest.json")
assert del_resp.status_code == 200
assert del_resp.json()["totalCount"] == "0"
# Confirm removal via the canonical path.
confirm_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
assert confirm_resp.status_code == 200
assert confirm_resp.json()["totalCount"] == "0"
def test_node_mo_alias_requires_auth():
"""The alias must be gated by the same session check as /api/mo/*."""
c = _new_apic_client()
resp = c.get("/api/node/mo/uni/tn-Whatever.json")
assert resp.status_code == 403
assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403"
def test_node_mo_alias_does_not_shadow_node_class():
"""/api/node/mo/* and /api/node/class/* are distinct literal prefixes —
the fabric-wide node/class form must still resolve correctly.
"""
c = _logged_in_apic_client()
resp = c.get("/api/node/class/fabricNode.json")
assert resp.status_code == 200
assert int(resp.json()["totalCount"]) > 0
# ---------------------------------------------------------------------------
# FIX A — unmatched /api/* fallback
# ---------------------------------------------------------------------------
def test_unmatched_api_path_returns_apic_shaped_400():
c = _logged_in_apic_client()
resp = c.get("/api/bogus/nonexistent/path")
assert resp.status_code == 400
body = resp.json()
assert "detail" not in body
assert body["imdata"][0]["error"]["attributes"]["code"] == "400"
assert "Invalid request path" in body["imdata"][0]["error"]["attributes"]["text"]
def test_unmatched_non_api_path_unaffected():
"""Paths outside /api/* (e.g. control-plane) keep the default 404 shape —
the fallback is scoped to the APIC REST contract only.
"""
c = _logged_in_apic_client()
resp = c.get("/_sim/does-not-exist")
assert resp.status_code == 404
assert "detail" in resp.json()
# ---------------------------------------------------------------------------
# FIX B — NDO site names == fabric names
# ---------------------------------------------------------------------------
def test_ndo_sites_use_fabric_names(ndo_client, ndo_token, topo):
resp = ndo_client.get(
"/mso/api/v1/sites",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
sites = resp.json()["sites"]
reported_names = {s["name"] for s in sites}
expected_fabric_names = {(s.fabric_name or s.name) for s in topo.sites}
assert reported_names == expected_fabric_names, (
f"NDO site names {reported_names} must equal topology fabric names "
f"{expected_fabric_names}, per real-gear cisco.mso.mso_tenant "
f"acceptance (see docs/CONTRACT.md §7)."
)
# And explicitly NOT the short internal names (guards against a
# regression that silently reverts to site.name).
short_names = {s.name for s in topo.sites}
assert reported_names.isdisjoint(short_names - expected_fabric_names)
# ---------------------------------------------------------------------------
# FIX C — schema list-identity
# ---------------------------------------------------------------------------
def test_schema_list_identity_returns_all_seeded_schemas(ndo_client, ndo_token):
resp = ndo_client.get(
"/mso/api/v1/schemas/list-identity",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "schemas" in data
schemas = data["schemas"]
assert len(schemas) >= 3, "expected at least the 3 seeded schemas"
display_names = {s["displayName"] for s in schemas}
for expected in ("MS-TN1-schema", "SF-TN1-LAB1-schema", "SF-TN1-LAB2-schema"):
assert expected in display_names, f"{expected} missing from list-identity"
for s in schemas:
assert "id" in s
assert "displayName" in s
def test_schema_list_identity_not_shadowed_by_id_route(ndo_client, ndo_token):
"""Route-ordering regression guard: list-identity must be declared before
the parameterized /schemas/{schema_id} route, or FastAPI matches
"list-identity" as a schema_id and 404s with
{"detail": "Schema 'list-identity' not found"}.
"""
resp = ndo_client.get(
"/mso/api/v1/schemas/list-identity",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
assert "detail" not in resp.json()
assert "schemas" in resp.json()
# ---------------------------------------------------------------------------
# FIX C — /api/v1/tenants
# ---------------------------------------------------------------------------
def test_bare_api_v1_tenants_returns_tenant_list(ndo_client, ndo_token, topo):
resp = ndo_client.get(
"/api/v1/tenants",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "tenants" in data
tenants = data["tenants"]
assert len(tenants) == len(topo.tenants)
tenant_names = {t["name"] for t in tenants}
for tt in topo.tenants:
assert tt.name in tenant_names
for t in tenants:
assert "id" in t
assert "siteAssociations" in t
+554
View File
@@ -0,0 +1,554 @@
"""PR-11 — NDO write surface: schema POST/PATCH round-trip, ND user-class
fallback endpoints, bare /api/v1/sites.
These close the gap between PR-10 (site names, schema list-identity) and a
real multi-site aci-ansible E2E run: the tenant-region schema
(`<tenant>-<region>`, e.g. "MS-TN1-LAB0") that create_tenant's MSO play
creates via `mso_schema_template` must round-trip through
list-identity → GET-by-id → PATCH (add template/BD/ANP/EPG ops) → GET-by-id
reflecting the mutation, exactly the sequence cisco.mso's schema-object
modules perform (see docs/CONTRACT.md §7 and aci_sim/ndo/patch.py).
"""
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 (
PatchError,
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
# ---------------------------------------------------------------------------
# /api/config/class/remoteusers + localusers — ND fallback shape
# ---------------------------------------------------------------------------
def test_remoteusers_class_query_shape(client):
"""Must be a dict with an "items" list — get_user_from_list_of_users()
(ansible-mso module_utils/mso.py) only iterates users.get("items") when
given a dict; anything else (404, bare list) sends mso_tenant into the
"ND Error: Unknown error" abort observed on the real hardware run.
"""
resp = client.get("/api/config/class/remoteusers")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
assert "items" in body
assert isinstance(body["items"], list)
def test_localusers_class_query_shape_has_admin(client):
resp = client.get("/api/config/class/localusers")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
items = body["items"]
assert len(items) >= 1
admin = next(i for i in items if i["spec"]["loginID"] == "admin")
assert admin["spec"].get("id") or admin["spec"].get("userID")
# ---------------------------------------------------------------------------
# bare /api/v1/sites — ndo_template prereq lookup
# ---------------------------------------------------------------------------
def test_bare_sites_endpoint_matches_mso_prefixed(client):
"""cisco.mso.ndo_template's prereq TenantPol lookup hits the BARE
/api/v1/sites path (no /mso prefix) — confirmed 404 on a real hardware run
against the previously mso-only route.
"""
bare = client.get("/api/v1/sites")
prefixed = client.get("/mso/api/v1/sites")
assert bare.status_code == 200
assert bare.json() == prefixed.json()
# ---------------------------------------------------------------------------
# apply_json_patch — unit-level op semantics
# ---------------------------------------------------------------------------
def test_patch_add_append_to_list():
doc = {"templates": [{"name": "T1", "bds": []}]}
ops = [{"op": "add", "path": "/templates/T1/bds/-", "value": {"name": "bd1"}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["bds"] == [{"name": "bd1"}]
def test_patch_add_named_template_slot():
"""/templates/- appends a whole new template dict (mso_schema_template's
'template does not exist yet' branch)."""
doc = {"templates": []}
ops = [{"op": "add", "path": "/templates/-", "value": {"name": "T1", "bds": []}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["name"] == "T1"
def test_patch_replace_by_name_resolution():
doc = {"templates": [{"name": "T1", "displayName": "old"}]}
ops = [{"op": "replace", "path": "/templates/T1/displayName", "value": "new"}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["displayName"] == "new"
def test_patch_remove_by_name_resolution():
doc = {"templates": [{"name": "T1", "bds": [{"name": "bd1"}, {"name": "bd2"}]}]}
ops = [{"op": "remove", "path": "/templates/T1/bds/bd1"}]
apply_json_patch(doc, ops)
assert [b["name"] for b in doc["templates"][0]["bds"]] == ["bd2"]
def test_patch_stringifies_ref_dicts_on_add():
"""A `*Ref` field sent as a dict (schemaId/templateName/xName) must be
persisted as NDO's canonical string ref — real NDO's storage shape,
confirmed by a sibling module (custom_mso_schema_site_bd_subnet.py)
that string-compares/joins bdRef values and crashes on a dict."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1", "bds": []}]}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {"bdRef": {"schemaId": "schema-abc", "templateName": "LAB1", "bdName": "bd1"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"][0]["bdRef"] == "/schemas/schema-abc/templates/LAB1/bds/bd1"
def test_patch_leaves_already_string_refs_untouched():
doc = {"templates": [{"name": "T1", "bds": []}]}
ops = [{"op": "add", "path": "/templates/T1/bds/-", "value": {"name": "bd1", "vrfRef": "/vrfs/vrf1"}}]
apply_json_patch(doc, ops)
assert doc["templates"][0]["bds"][0]["vrfRef"] == "/vrfs/vrf1"
def test_patch_add_autovivifies_missing_collection_as_list():
"""Regression guard for a real bug this suite caught: if an
intermediate collection key is missing when a PATCH targets it with
"/-" (append), the resolver must create a LIST, not a dict — a dict
turns the "-" append marker into a literal key '-': {'-': value},
silently corrupting the document instead of appending."""
doc = {"sites": [{"siteId": "1", "templateName": "LAB1"}]} # no "bds" key at all
ops = [{"op": "add", "path": "/sites/1-LAB1/bds/-", "value": {"name": "bd1"}}]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"] == [{"name": "bd1"}]
def test_patch_unresolvable_path_raises():
doc = {"templates": []}
with pytest.raises(PatchError):
apply_json_patch(doc, [{"op": "replace", "path": "/templates/NoSuchTmpl/name", "value": "x"}])
# ---------------------------------------------------------------------------
# normalize_template — server-side collection-key defaulting
# ---------------------------------------------------------------------------
def test_normalize_template_backfills_bare_create_payload():
"""mso_schema_template.py's create payload is only {name, displayName,
tenantId} — every other collection key (vrfs/bds/anps/.../
intersiteL3outs/serviceGraphs) must come back as [] or MSOSchema's
`set_template_vrf()` etc. crash with 'NoneType' object is not iterable
(observed on a real hardware run's Create VRF task)."""
tmpl = {"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "abc"}
normalize_template(tmpl)
for key in ("vrfs", "bds", "anps", "contracts", "filters", "externalEpgs", "intersiteL3outs", "serviceGraphs"):
assert tmpl[key] == [], f"{key} not defaulted to []"
assert tmpl["templateID"], "templateID must be assigned"
def test_normalize_template_backfills_external_epg_subnets():
"""mso_schema_template_external_epg.py's add payload never sets
'subnets' — mso_schema_template_external_epg_subnet.py subscripts it
bare (`externalEpgs[idx]["subnets"]`), a real hardware run hit
KeyError: 'subnets' on exactly this path."""
tmpl = {
"name": "LAB1-LAB2",
"externalEpgs": [{"name": "xepg-All", "vrfRef": "/vrfs/v1", "l3outRef": "/l3outs/l1"}],
}
normalize_template(tmpl)
assert tmpl["externalEpgs"][0]["subnets"] == []
assert tmpl["externalEpgs"][0]["contractRelationships"] == []
def test_normalize_template_backfills_nested_epg_under_anp():
tmpl = {"name": "T1", "anps": [{"name": "AP1", "epgs": [{"name": "web"}]}]}
normalize_template(tmpl)
epg = tmpl["anps"][0]["epgs"][0]
assert epg["subnets"] == []
assert epg["contractRelationships"] == []
assert epg["staticPorts"] == []
def test_patch_add_site_bd_by_composite_key():
"""mso_schema_site_bd.py addresses the top-level `sites[]` array by a
synthetic "{siteId}-{templateName}" composite key, not by name/
displayName — confirmed on a real hardware create_bd run that hit
"path segment '1-LAB1' not found in list" before this resolution mode
was added (site_template = "{siteId}-{templateName}").
"""
doc = {
"sites": [
{"siteId": "1", "templateName": "LAB1", "bds": []},
{"siteId": "2", "templateName": "LAB1", "bds": []},
]
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {"bdRef": {"bdName": "bd-App1_LAB1"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"] == [{"bdRef": {"bdName": "bd-App1_LAB1"}, "hostBasedRouting": False}]
assert doc["sites"][1]["bds"] == []
def test_patch_add_site_bd_subnet_by_ref_name_resolution():
"""Site-local bds[] entries carry no `name` of their own — only a
bdRef string — yet mso_schema_site_bd_subnet.py addresses them by the
referenced BD's bare name: `/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-`.
A real hardware run 400'd with "path segment 'bd-App1_LAB1' not found in
list" before ref-name resolution was added.
"""
doc = {
"sites": [
{
"siteId": "1",
"templateName": "LAB1",
"bds": [
{
"bdRef": "/schemas/schema-abc/templates/LAB1/bds/bd-App1_LAB1",
"hostBasedRouting": False,
"subnets": [],
}
],
}
]
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"][0]["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
def test_normalize_site_backfills_bd_subnets():
"""mso_schema_site_bd.py's add payload is {bdRef, hostBasedRouting} — no
subnets — but mso_schema_site_bd_subnet.py reads
site_bd.details.get("subnets") and iterates it."""
site = {"siteId": "1", "templateName": "LAB1", "bds": [{"bdRef": {}, "hostBasedRouting": False}]}
normalize_site(site)
assert site["bds"][0]["subnets"] == []
def test_normalize_template_idempotent():
tmpl = {"name": "T1"}
normalize_template(tmpl)
first_id = tmpl["templateID"]
normalize_template(tmpl)
assert tmpl["templateID"] == first_id
assert tmpl["bds"] == []
# ---------------------------------------------------------------------------
# End-to-end schema write round trip via the FastAPI routes
# ---------------------------------------------------------------------------
def test_schema_create_list_identity_get_roundtrip(client):
"""POST creates a schema whose displayName is resolvable via
list-identity and whose full doc is servable by GET /schemas/{id}
mso_schema_template.py's flow when a tenant-region schema doesn't exist
yet (schema_path = "schemas"; POST {displayName, templates:[...], sites:[]}).
"""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [
{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "abc123", "bds": [], "anps": []}
],
"sites": [],
}
created = client.post("/mso/api/v1/schemas", json=payload)
assert created.status_code == 200
schema_id = created.json()["id"]
identity = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
match = [s for s in identity if s["displayName"] == "MS-TN1-LAB0"]
assert len(match) == 1, f"Expected exactly one list-identity match, got {match}"
assert match[0]["id"] == schema_id
detail = client.get(f"/mso/api/v1/schemas/{schema_id}")
assert detail.status_code == 200
body = detail.json()
assert body["templates"][0]["name"] == "LAB1-LAB2"
def test_schema_patch_add_bd_then_get_reflects_it(client):
"""PATCH adds a BD to an existing template; a subsequent GET must show
it — the mso_schema_template_bd.py 'BD does not exist, add it' flow:
ops = [{"op":"add","path":"/templates/{tmpl}/bds/-","value":{...}}].
"""
payload = {
"displayName": "MS-TN2-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "xyz", "bds": [], "anps": []}],
"sites": [],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
bd_ops = [
{
"op": "add",
"path": "/templates/LAB1/bds/-",
"value": {"name": "bd-App1_LAB1", "vrfRef": "/vrfs/vrf-L3_LAB0", "subnets": []},
}
]
patched = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=bd_ops)
assert patched.status_code == 200
assert patched.json()["templates"][0]["bds"][0]["name"] == "bd-App1_LAB1"
# Independent GET after the PATCH must reflect the same mutation.
detail = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
bd_names = {b["name"] for b in detail["templates"][0]["bds"]}
assert "bd-App1_LAB1" in bd_names
def test_schema_patch_add_template_to_existing_schema(client):
"""mso_schema_template.py's 'schema exists, template doesn't' branch:
PATCH ops = [{"op":"add","path":"/templates/-","value":{...}}]."""
payload = {"displayName": "MS-Multi-Schema", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
ops = [{"op": "add", "path": "/templates/-", "value": {"name": "LAB2", "displayName": "LAB2", "tenantId": "t1"}}]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=ops)
assert resp.status_code == 200
assert any(t["name"] == "LAB2" for t in resp.json()["templates"])
# list-identity / GET /schemas summary must reflect the new template too.
schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
summary = next(s for s in schemas if s["id"] == schema_id)
assert {t["name"] for t in summary["templates"]} == {"LAB2"}
def test_schema_patch_external_epg_then_subnet_add(client):
"""End-to-end replica of the real playbook sequence: add an externalEpg
(mso_schema_template_external_epg.py, no subnets key in its payload),
then add a subnet to it (mso_schema_template_external_epg_subnet.py,
which bare-subscripts externalEpgs[idx]["subnets"])."""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "t1"}],
"sites": [],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
add_epg_ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/externalEpgs/-",
"value": {"name": "xepg-All_LAB0", "vrfRef": "/vrfs/vrf-L3_LAB0", "l3outRef": "/l3outs/l3o-bgp-Core_LAB0"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_epg_ops)
assert resp.status_code == 200
assert resp.json()["templates"][0]["externalEpgs"][0]["subnets"] == []
add_subnet_ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/externalEpgs/xepg-All_LAB0/subnets/-",
"value": {"ip": "0.0.0.0/0"},
}
]
resp2 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_subnet_ops)
assert resp2.status_code == 200
subnets = resp2.json()["templates"][0]["externalEpgs"][0]["subnets"]
assert subnets == [{"ip": "0.0.0.0/0"}]
def test_schema_validate_route(client):
"""mso_schema_template_deploy.py calls GET schemas/{id}/validate before
every deploy on ND-platform NDO — a real hardware run 404'd here because
the route was entirely missing."""
payload = {"displayName": "MS-Validate-Check", "templates": [], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
resp = client.get(f"/mso/api/v1/schemas/{schema_id}/validate")
assert resp.status_code == 200
def test_schema_validate_unknown_id_404(client):
resp = client.get("/mso/api/v1/schemas/does-not-exist/validate")
assert resp.status_code == 404
def test_execute_and_status_schema_template(client):
"""mso_schema_template_deploy.py's actual deploy/status request:
GET execute|status/schema/{id}/template/{name} — response is splatted
into mso.exit_json(**status), so it must be a JSON object."""
payload = {"displayName": "MS-Deploy-Check", "templates": [{"name": "LAB1"}], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
execute_resp = client.get(f"/mso/api/v1/execute/schema/{schema_id}/template/LAB1")
assert execute_resp.status_code == 200
assert isinstance(execute_resp.json(), dict)
status_resp = client.get(f"/mso/api/v1/status/schema/{schema_id}/template/LAB1")
assert status_resp.status_code == 200
assert isinstance(status_resp.json(), dict)
def test_schema_patch_site_local_bd_end_to_end(client):
"""Full replica of the real create_bd MS-TN1 sequence: schema already
has a `sites[]` entry (from mso_schema_site.py's earlier "Add site to
template" run), then mso_schema_site_bd.py PATCHes a site-local BD by
composite key. A real hardware run 400'd with "path segment '1-LAB1' not
found in list" before the composite-key resolver was added.
The write payload's `bdRef` is a DICT (schemaId/templateName/bdName —
what mso_schema_site_bd.py actually sends), but the server must store
and serve it back as the canonical STRING ref
`/schemas/{id}/templates/{tmpl}/bds/{name}` — a sibling module
(custom_mso_schema_site_bd_subnet.py) does `bd_ref_string in [v.get(
'bdRef') for v in ...]` and `', '.join(bds)` on a failed match; a dict
stored verbatim crashes that join with "sequence item 0: expected str
instance, dict found" (confirmed on a real hardware "Add site-local subnet
to BD" run).
"""
payload = {
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "t1", "bds": [{"name": "bd-App1_LAB1"}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {
"bdRef": {"schemaId": schema_id, "templateName": "LAB1", "bdName": "bd-App1_LAB1"},
"hostBasedRouting": False,
},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=ops)
assert resp.status_code == 200
site_entry = resp.json()["sites"][0]
assert site_entry["bds"][0]["bdRef"] == f"/schemas/{schema_id}/templates/LAB1/bds/bd-App1_LAB1"
assert site_entry["bds"][0]["subnets"] == []
def test_schema_patch_site_local_bd_subnet_end_to_end(client):
"""create_bd's full MS-TN1 sequence tail: after the site-local BD exists
(bdRef-only, no name), add a subnet to it addressed by the BD's bare
name — real NDO/ACI-hardware request shape."""
schema_id = client.post(
"/mso/api/v1/schemas",
json={
"displayName": "MS-TN1-LAB0",
"templates": [{"name": "LAB1", "bds": [{"name": "bd-App1_LAB1"}]}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
},
).json()["id"]
add_bd_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/-",
"value": {
"bdRef": {"schemaId": schema_id, "templateName": "LAB1", "bdName": "bd-App1_LAB1"},
"hostBasedRouting": False,
},
}
]
client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_bd_ops)
add_subnet_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=add_subnet_ops)
assert resp.status_code == 200
site_bd = resp.json()["sites"][0]["bds"][0]
assert site_bd["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
def test_post_task_deploy(client):
"""cisco.mso.ndo_schema_template_deploy — the module aci-ansible's
mso-model role actually invokes — sends deploy/redeploy as
POST /mso/api/v1/task {schemaId, templateName, isRedeploy}. A real
real hardware run 404'd here; confirmed against the collection installed there
(differs from the legacy mso_schema_template_deploy.py's execute/status
GET routes, which are exercised separately above)."""
payload = {"displayName": "MS-Task-Deploy-Check", "templates": [{"name": "LAB1"}], "sites": []}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": schema_id, "templateName": "LAB1", "isRedeploy": False},
)
assert resp.status_code == 200
body = resp.json()
assert body["schemaId"] == schema_id
assert body["templateName"] == "LAB1"
def test_post_task_unknown_schema_404(client):
resp = client.post(
"/mso/api/v1/task",
json={"schemaId": "does-not-exist", "templateName": "LAB1", "isRedeploy": False},
)
assert resp.status_code == 404
def test_schema_patch_unknown_id_404(client):
resp = client.patch("/mso/api/v1/schemas/does-not-exist", json=[{"op": "add", "path": "/x", "value": 1}])
assert resp.status_code == 404
def test_schema_patch_no_double_listing_after_mutation(client):
"""Regression guard: patching a schema must not create a duplicate
summary entry in GET /schemas (seeded-schema id collision check)."""
payload = {"displayName": "MS-Dup-Check", "templates": [], "sites": []}
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/-", "value": {"name": "T1"}}],
)
schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
matching = [s for s in schemas if s["id"] == schema_id]
assert len(matching) == 1, f"Expected exactly one entry, found {len(matching)}"
+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"
+742
View File
@@ -0,0 +1,742 @@
"""PR-13 — NDO tenant-policy-template DHCP surface + schema serviceGraphs
for cisco.mso.
Two confirmed sim gaps from a real aci-ansible run against ACI hardware, both
blocking the last NDO write surfaces in the multi-site (MS) suite:
GAP 1 — `cisco.mso.ndo_template`'s tenant-policy-template CREATE flow
(`prereq_tenantpol.yml`'s `Create TenantPol-<tenant> tenant policy template`
task) 404'd on `GET /api/v1/templates/summaries` (note: BARE path, no `/mso`
prefix) — confirmed root cause: that task carries `delegate_to: localhost`,
which forces `ansible-mso`'s `MSOModule` down its direct-HTTP branch
(`module._socket_path is None`) instead of the persistent
`ansible.netcommon.httpapi` connection every OTHER task on the same
playbook host uses — and the direct-HTTP branch never adds the `/mso`
prefix (`ansible-mso`'s `plugins/module_utils/mso.py::request()`:
`self.url = "{0}api/{1}/{2}".format(self.base_only_uri, api_version,
self.path...)`). Once the template exists, `cisco.mso.ndo_dhcp_relay_policy`
(`create_dhcp_relay`) and `cisco.mso.ndo_schema_template_bd_dhcp_policy`
(`bind_dhcp_relay_to_bd`) — both running over the normal httpapi connection,
hence the mso-prefixed path — must be able to create/read/cross-reference
DHCP relay policies against it, including resolving a schema-template EPG's
`uuid` (a field the sim never populated before this PR) as a DHCP relay
provider ref.
GAP 2 — the "Create service graph" task (mso-model role's
`custom_mso_schema_service_graph.py`, a pre-`MSOTemplate`/`MSOSchema`
community module) bare-subscripts `schema_obj.get('sites')[site_idx]
['serviceGraphs']` — a real hardware MS-TN2 `create_tenant` pass-2 run
(`-e automate_contract_graph=true`) hit `KeyError: 'serviceGraphs'` on
exactly this line. The TEMPLATE-level `serviceGraphs` bare-subscript was
already covered by PR-11's `TEMPLATE_COLLECTION_KEYS`; this PR closes the
SITE-level sibling gap (`SITE_TOP_LEVEL_DEFAULTS`) and the additional
`GET /schemas` full-detail + `schemas/service-node-types` + template
`tenantId` gaps this same legacy module needs, since it bare-subscripts the
raw `/schemas` list response instead of going through `MSOSchema`.
GAP 2 DEEPER LAYER (found while chasing GAP 2 to green on real hardware, with
`-e automate_contract_graph=true -e automate_site_redirect=true`) — once
the site-level `serviceGraphs` KeyError was fixed, the same real run
progressed to the mso-model role's raw `cisco.mso.mso_rest` "Atomic PATCH —
bind service-graph redirect on ALL fabrics in one request" task, which
addresses a SITE-LOCAL contract by bare name:
`/sites/{siteId}-{template}/contracts/{contract}/serviceGraphRelationship`.
The sim never mirrored template-level contract adds into
`sites[].contracts[]` at all (unlike ANPs/EPGs, mirrored since PR-12), so
`apply_json_patch`'s `_find_by_name` raised `PatchError: path segment
'con-Firewall_LAB0' not found in list` — confirmed verbatim on the real
hardware run. This PR adds `_mirror_template_contract_to_sites` (PATCH-time,
parallel to `_mirror_template_anp_to_sites`) and the equivalent boot-time
mirror in `build_ndo_model`, closing this fully — the real hardware MS-TN2
`create_tenant` pass-2 run now reaches `failed=0`.
"""
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 TENANT_POLICY_TEMPLATE_ID, build_ndo_model
from aci_sim.ndo.patch import (
SITE_TOP_LEVEL_DEFAULTS,
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, sandbox=True)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
def _tenant_id(client, name: str) -> str:
tenants = client.get("/api/v1/tenants").json()["tenants"]
return next(t["id"] for t in tenants if t["name"] == name)
def _site_ids(client) -> list[str]:
return [s["id"] for s in client.get("/api/v1/sites").json()["sites"]]
# ---------------------------------------------------------------------------
# GAP 1a — bare (delegate_to: localhost) template routes
# ---------------------------------------------------------------------------
def test_bare_templates_summaries_route_exists(client):
"""`ndo_template`'s `delegate_to: localhost` prereq task hits the BARE
`/api/v1/templates/summaries` (no `/mso` prefix) — a real hardware run
404'd here before this fix."""
resp = client.get("/api/v1/templates/summaries")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_bare_and_mso_prefixed_templates_summaries_share_data(client):
"""A template created via the bare path must be visible to a caller
using the mso-prefixed path (and vice versa) — both back the same
mutable store, matching the existing /api/v1/tenants <-> /mso/api/v1/
tenants pattern PR-10/PR-11 established for the same delegate_to split."""
tenant_id = _tenant_id(client, "MS-TN1")
site_ids = _site_ids(client)
payload = {
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {"tenantId": tenant_id},
"sites": [{"siteId": s} for s in site_ids],
},
}
created = client.post("/api/v1/templates", json=payload)
assert created.status_code == 200
template_id = created.json()["templateId"]
mso_summaries = client.get("/mso/api/v1/templates/summaries").json()
match = [s for s in mso_summaries if s.get("templateName") == "TenantPol-MS-TN1"]
assert len(match) == 1
assert match[0]["templateId"] == template_id
assert match[0]["templateType"] == "tenantPolicy"
# And the reverse: mso-prefixed GET finds the bare-created template.
detail = client.get(f"/mso/api/v1/templates/{template_id}").json()
assert detail["tenantPolicyTemplate"]["template"]["tenantId"] == tenant_id
# ---------------------------------------------------------------------------
# GAP 1b — ndo_template create -> lookup round trip (full sequence)
# ---------------------------------------------------------------------------
def test_ndo_template_create_then_lookup_round_trip(client):
"""Full replica of `ndo_template`'s CREATE flow: `lookup_tenant()` (GET
tenants), `lookup_site()` per site (GET sites), `MSOTemplate.__init__`
(GET templates/summaries?templateName=... finds nothing) -> POST
templates with the tenantPolicyTemplate payload shape -> template must
then resolve by name via templates/summaries."""
tenant_id = _tenant_id(client, "MS-TN1")
site_ids = _site_ids(client)
# MSOTemplate.__init__ pre-create lookup: nothing found yet.
before = client.get("/api/v1/templates/summaries").json()
assert not any(s.get("templateName") == "TenantPol-MS-TN1" for s in before)
payload = {
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {"tenantId": tenant_id},
"sites": [{"siteId": s} for s in site_ids],
},
}
resp = client.post("/api/v1/templates", json=payload)
assert resp.status_code == 200
template_id = resp.json()["templateId"]
assert template_id
after = client.get("/api/v1/templates/summaries").json()
match = next(s for s in after if s.get("templateName") == "TenantPol-MS-TN1")
assert match["templateType"] == "tenantPolicy"
assert match["templateId"] == template_id
detail = client.get(f"/api/v1/templates/{template_id}").json()
assert detail["templateType"] == "tenantPolicy"
assert detail["tenantPolicyTemplate"]["template"]["tenantId"] == tenant_id
assert {s["siteId"] for s in detail["tenantPolicyTemplate"]["sites"]} == set(site_ids)
def test_ndo_template_create_is_idempotent_across_prefixes(client):
"""Creating the same-named template twice (e.g. a retried playbook
task) must not silently corrupt the summaries list — this sim's create
handler always assigns a fresh id (matching MSOTemplate's own
"not found -> create" branch, which only runs when the by-name lookup
truly found nothing); the test asserts the store stays internally
consistent rather than asserting real NDO's idempotency semantics."""
tenant_id = _tenant_id(client, "MS-TN1")
payload = {
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": []},
}
r1 = client.post("/api/v1/templates", json=payload)
tid1 = r1.json()["templateId"]
summaries = client.get("/mso/api/v1/templates/summaries").json()
assert sum(1 for s in summaries if s["templateId"] == tid1) == 1
# ---------------------------------------------------------------------------
# GAP 1c — DHCP relay policy add/read (ndo_dhcp_relay_policy + bd binding)
# ---------------------------------------------------------------------------
def test_dhcp_relay_policy_add_and_read_via_patch(client):
"""Full replica of `create_dhcp_relay`'s NDO 4.x path
(`cisco.mso.ndo_dhcp_relay_policy`): PATCH `add
/tenantPolicyTemplate/template/dhcpRelayPolicies/-` against the
tenant-policy template, using a schema-template EPG's `uuid` as the
provider's `epgRef` (this PR backfills that uuid — previously absent)."""
tenant_id = _tenant_id(client, "MS-TN1")
site_ids = _site_ids(client)
template_id = client.post(
"/api/v1/templates",
json={
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": [{"siteId": s} for s in site_ids]},
},
).json()["templateId"]
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if "MS-TN1" in s["displayName"])
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl = detail["templates"][0]
anp = tmpl["anps"][0]
epg = anp["epgs"][0]
assert epg.get("uuid"), "schema-template EPG must carry a uuid for DHCP relay provider refs"
ops = [
{
"op": "add",
"path": "/tenantPolicyTemplate/template/dhcpRelayPolicies/-",
"value": {
"name": "pol-dhcp_relay-Test1",
"providers": [{"ip": "192.168.41.10", "useServerVrf": False, "epgRef": epg["uuid"], "epgName": epg["name"]}],
},
}
]
resp = client.patch(f"/mso/api/v1/templates/{template_id}", json=ops)
assert resp.status_code == 200
relays = resp.json()["tenantPolicyTemplate"]["template"]["dhcpRelayPolicies"]
added = next(r for r in relays if r["name"] == "pol-dhcp_relay-Test1")
assert added.get("uuid"), "sim must backfill a uuid on a newly-added DHCP relay policy"
assert added["providers"][0]["epgRef"] == epg["uuid"]
def test_dhcp_relay_policy_uuid_resolves_via_templates_objects(client):
"""`ndo_schema_template_bd_dhcp_policy`'s `get_dhcp_relay_policy_uuid()`
(the `bind_dhcp_relay_to_bd` playbook step) queries `GET
templates/objects?type=dhcpRelay&name=...&tenantId=<matched-client-side>`
and requires BOTH `tenantId` and `uuid` on the returned entry."""
tenant_id = _tenant_id(client, "MS-TN1")
template_id = client.post(
"/api/v1/templates",
json={
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": []},
},
).json()["templateId"]
client.patch(
f"/mso/api/v1/templates/{template_id}",
json=[
{
"op": "add",
"path": "/tenantPolicyTemplate/template/dhcpRelayPolicies/-",
"value": {"name": "pol-dhcp_relay-Test1", "providers": [{"ip": "10.0.0.1", "useServerVrf": False}]},
}
],
)
resp = client.get("/mso/api/v1/templates/objects", params={"type": "dhcpRelay", "name": "pol-dhcp_relay-Test1"})
assert resp.status_code == 200
matches = [r for r in resp.json() if r.get("tenantId") == tenant_id and r.get("name") == "pol-dhcp_relay-Test1"]
assert len(matches) == 1
relay_uuid = matches[0]["uuid"]
assert relay_uuid
# get_dhcp_relay_label_name() query-back-by-uuid (bind_dhcp_relay_to_bd's
# post-PATCH name resolution step).
by_uuid = client.get("/mso/api/v1/templates/objects", params={"type": "dhcpRelay", "uuid": relay_uuid}).json()
assert by_uuid.get("name") == "pol-dhcp_relay-Test1"
def test_bind_dhcp_relay_to_bd_end_to_end(client):
"""Full replica of `bind_dhcp_relay_to_bd`'s
`cisco.mso.ndo_schema_template_bd_dhcp_policy` PATCH: resolve the relay
policy's uuid (templates/objects), then PATCH `add
/templates/{t}/bds/{bd}/dhcpLabels/-` with `{ref: uuid, name}` on the
schema template — must round-trip through a fresh GET."""
tenant_id = _tenant_id(client, "MS-TN1")
template_id = client.post(
"/api/v1/templates",
json={
"displayName": "TenantPol-MS-TN1",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {"template": {"tenantId": tenant_id}, "sites": []},
},
).json()["templateId"]
client.patch(
f"/mso/api/v1/templates/{template_id}",
json=[
{
"op": "add",
"path": "/tenantPolicyTemplate/template/dhcpRelayPolicies/-",
"value": {"name": "pol-dhcp_relay-Test1", "providers": [{"ip": "10.0.0.1", "useServerVrf": False}]},
}
],
)
relay_uuid = client.get(
"/mso/api/v1/templates/objects", params={"type": "dhcpRelay", "name": "pol-dhcp_relay-Test1"}
).json()[0]["uuid"]
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if "MS-TN1" in s["displayName"])
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl = detail["templates"][0]
bd = tmpl["bds"][0]
ops = [
{
"op": "add",
"path": f"/templates/{tmpl['name']}/bds/{bd['name']}/dhcpLabels/-",
"value": {"ref": relay_uuid, "name": "pol-dhcp_relay-Test1"},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=ops)
assert resp.status_code == 200
bd_after = next(b for b in resp.json()["templates"][0]["bds"] if b["name"] == bd["name"])
assert any(label["ref"] == relay_uuid for label in bd_after["dhcpLabels"])
# Independent GET must reflect the same mutation.
fresh = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
bd_fresh = next(b for b in fresh["templates"][0]["bds"] if b["name"] == bd["name"])
assert any(label["ref"] == relay_uuid for label in bd_fresh["dhcpLabels"])
# ---------------------------------------------------------------------------
# GAP 2a — site-level serviceGraphs default
# ---------------------------------------------------------------------------
def test_site_top_level_defaults_include_service_graphs():
assert SITE_TOP_LEVEL_DEFAULTS["serviceGraphs"] == []
def test_normalize_site_backfills_service_graphs():
"""`custom_mso_schema_service_graph.py` bare-subscripts
`schema_obj.get('sites')[site_idx]['serviceGraphs']` — a schema site
entry that never had this key backfilled must not KeyError."""
site = {"siteId": "1", "templateName": "LAB1"}
normalize_site(site)
assert site["serviceGraphs"] == []
assert site["bds"] == []
assert site["anps"] == []
def test_normalize_site_service_graphs_idempotent():
site = {"siteId": "1", "templateName": "LAB1", "serviceGraphs": [{"name": "sgt-1"}]}
normalize_site(site)
normalize_site(site)
assert site["serviceGraphs"] == [{"name": "sgt-1"}]
def test_apply_json_patch_add_site_service_graph_by_composite_key():
"""Full replica of `custom_mso_schema_service_graph.py`'s site-local
add: `/sites/{siteId}-{templateName}/serviceGraphs/-`."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1", "serviceGraphs": []}],
"sites": [{"siteId": "1", "templateName": "LAB1", "serviceGraphs": []}],
}
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/serviceGraphs/-",
"value": {
"serviceGraphRef": {"schemaId": "schema-abc", "templateName": "LAB1", "serviceGraphName": "sgt-FW_LAB0"},
"serviceNodes": [],
},
}
]
apply_json_patch(doc, ops)
site = doc["sites"][0]
assert len(site["serviceGraphs"]) == 1
assert site["serviceGraphs"][0]["serviceGraphRef"] == "/schemas/schema-abc/templates/LAB1/serviceGraphs/sgt-FW_LAB0"
# ---------------------------------------------------------------------------
# GAP 2b — full schema GET returns rich detail (not the trimmed summary)
# ---------------------------------------------------------------------------
def test_get_schemas_returns_full_detail_not_trimmed_summary(client):
"""`custom_mso_schema_service_graph.py` (and sibling legacy modules)
bare-subscript straight into the plain `GET /schemas` list response —
`schema_obj.get('templates')[idx]['serviceGraphs']`,
`schema_obj.get('sites')[idx]['serviceGraphs']` — so this route must
return the FULL nested doc per schema, not the `{name}`-only summary
`schemas/list-identity` is optimized for."""
resp = client.get("/mso/api/v1/schemas")
assert resp.status_code == 200
schemas = resp.json()["schemas"]
assert len(schemas) > 0
for s in schemas:
assert "sites" in s
for tmpl in s.get("templates", []):
assert "serviceGraphs" in tmpl
assert "bds" in tmpl
assert "anps" in tmpl
for s in schemas:
for site in s.get("sites", []):
assert "serviceGraphs" in site
def test_get_schemas_service_graph_bare_subscript_flow_end_to_end(client):
"""End-to-end replica of `custom_mso_schema_service_graph.py`'s full
read sequence: `mso.get_obj('schemas', displayName=schema)` ->
`schema_obj.get('templates')[idx]['serviceGraphs']` (create branch) ->
`schema_obj.get('sites')[site_idx]['serviceGraphs']` (site-bind
branch) — none of these may KeyError, matching the real hardware MS-TN2
`create_tenant` pass-2 crash signature this PR fixes."""
all_schemas = client.get("/mso/api/v1/schemas").json()["schemas"]
schema_obj = next(s for s in all_schemas if s.get("displayName") == "MS-TN1-schema")
templates = [t.get("name") for t in schema_obj.get("templates")]
template_idx = templates.index("MS-TN1-template")
assert schema_obj.get("templates")[template_idx]["serviceGraphs"] == []
assert schema_obj.get("templates")[template_idx]["tenantId"], "template must carry tenantId for the update branch"
sites = schema_obj.get("sites")
assert sites, "expected at least one site associated with MS-TN1-template"
assert sites[0]["serviceGraphs"] == []
def test_schemas_service_node_types_route(client):
"""`custom_mso_schema_service_graph.py` resolves Firewall/Load
Balancer/Other to a stable id via `schemas/service-node-types` before
every service-graph node add — must be routed BEFORE the parameterized
`/schemas/{schema_id}` route (same ordering hazard as `list-identity`)."""
resp = client.get("/mso/api/v1/schemas/service-node-types")
assert resp.status_code == 200
types = {t["displayName"]: t["id"] for t in resp.json()["serviceNodeTypes"]}
assert "Firewall" in types
assert types["Firewall"]
# Bare-path variant too (defensive — no delegate_to precedent observed
# for this specific task, but kept consistent with every other bare/
# mso-prefixed pair this sim maintains).
resp2 = client.get("/api/v1/schemas/service-node-types")
assert resp2.status_code == 200
def test_schema_template_and_site_service_graph_patch_round_trip(client):
"""Full multi-step replica of the real "Create service graph" +
site-bind sequence against a live schema: add the template-level
serviceGraph, then the site-level serviceGraph binding, and confirm
both survive an independent re-GET."""
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if s["displayName"] == "MS-TN1-schema")
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl_name = detail["templates"][0]["name"]
site = detail["sites"][0]
site_key = f"{site['siteId']}-{tmpl_name}"
template_sg_ops = [
{
"op": "add",
"path": f"/templates/{tmpl_name}/serviceGraphs/-",
"value": {
"name": "sgt-FW_LAB0",
"displayName": "sgt-FW_LAB0",
"serviceNodes": [{"name": "node1", "serviceNodeTypeId": "svc-node-type-firewall", "index": 1}],
},
}
]
r1 = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=template_sg_ops)
assert r1.status_code == 200
site_sg_ops = [
{
"op": "add",
"path": f"/sites/{site_key}/serviceGraphs/-",
"value": {
"serviceGraphRef": {"schemaId": ms_schema["id"], "templateName": tmpl_name, "serviceGraphName": "sgt-FW_LAB0"},
"serviceNodes": [
{
"serviceNodeRef": {
"schemaId": ms_schema["id"],
"templateName": tmpl_name,
"serviceGraphName": "sgt-FW_LAB0",
"serviceNodeName": "node1",
},
"device": {"dn": "uni/tn-MS-TN1/lDevVip-fw-FW_LAB0"},
}
],
},
}
]
r2 = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=site_sg_ops)
assert r2.status_code == 200
fresh = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
fresh_tmpl = fresh["templates"][0]
assert any(sg["name"] == "sgt-FW_LAB0" for sg in fresh_tmpl["serviceGraphs"])
fresh_site = next(s for s in fresh["sites"] if s["siteId"] == site["siteId"])
assert len(fresh_site["serviceGraphs"]) == 1
assert fresh_site["serviceGraphs"][0]["serviceGraphRef"].endswith("/serviceGraphs/sgt-FW_LAB0")
def test_contract_service_graph_relationship_bind(client):
"""`cisco.mso.mso_schema_template_contract_service_graph`'s bind (used
when `automate_contract_graph=true`) PATCHes `add
/templates/{t}/contracts/{c}/serviceGraphRelationship` — must resolve
against a contract that already has `serviceGraphs` normalized on its
parent template (this PR doesn't touch this module directly since it
already uses safe `.get()`, but the full chain is asserted here)."""
schemas = client.get("/mso/api/v1/schemas/list-identity").json()["schemas"]
ms_schema = next(s for s in schemas if s["displayName"] == "MS-TN1-schema")
detail = client.get(f"/mso/api/v1/schemas/{ms_schema['id']}").json()
tmpl = detail["templates"][0]
contract = tmpl["contracts"][0]
assert contract.get("serviceGraphRelationship") is None
client.patch(
f"/mso/api/v1/schemas/{ms_schema['id']}",
json=[
{
"op": "add",
"path": f"/templates/{tmpl['name']}/serviceGraphs/-",
"value": {"name": "sgt-FW_LAB0", "displayName": "sgt-FW_LAB0", "serviceNodes": []},
}
],
)
bind_ops = [
{
"op": "add",
"path": f"/templates/{tmpl['name']}/contracts/{contract['name']}/serviceGraphRelationship",
"value": {
"serviceGraphRef": {"serviceGraphName": "sgt-FW_LAB0", "templateName": tmpl["name"], "schemaId": ms_schema["id"]},
"serviceNodesRelationship": [],
},
}
]
resp = client.patch(f"/mso/api/v1/schemas/{ms_schema['id']}", json=bind_ops)
assert resp.status_code == 200
updated_contract = next(c for c in resp.json()["templates"][0]["contracts"] if c["name"] == contract["name"])
assert updated_contract["serviceGraphRelationship"]["serviceGraphRef"].endswith("/serviceGraphs/sgt-FW_LAB0")
# ---------------------------------------------------------------------------
# uuid backfill on schema-template epgs/externalEpgs (normalize_template)
# ---------------------------------------------------------------------------
def test_normalize_template_backfills_epg_uuid():
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.get("uuid")
assert len(epg["uuid"]) == 32
def test_normalize_template_epg_uuid_is_stable_and_idempotent():
tmpl = {"name": "LAB1", "anps": [{"name": "app-Web_LAB1", "epgs": [{"name": "epg-web"}]}]}
normalize_template(tmpl, "schema-abc")
first = tmpl["anps"][0]["epgs"][0]["uuid"]
normalize_template(tmpl, "schema-abc")
assert tmpl["anps"][0]["epgs"][0]["uuid"] == first
def test_normalize_template_backfills_external_epg_uuid():
tmpl = {"name": "LAB1", "externalEpgs": [{"name": "xepg-All_LAB0"}]}
normalize_template(tmpl, "schema-abc")
ext_epg = tmpl["externalEpgs"][0]
assert ext_epg.get("uuid")
def test_build_ndo_model_seeds_epg_uuid_for_topology_tenants(topo):
"""Every schema-template EPG a topology.yaml tenant boots with already
must carry a uuid — not just ones added at PATCH-runtime — since
`ndo_dhcp_relay_policy`'s provider resolution can target any
pre-existing topology EPG."""
state = build_ndo_model(topo)
found_any_epg = False
for detail in state.schema_details.values():
for tmpl in detail.get("templates", []):
for anp in tmpl.get("anps", []):
for epg in anp.get("epgs", []):
found_any_epg = True
assert epg.get("uuid"), f"EPG {epg.get('name')} missing uuid"
assert found_any_epg, "expected at least one topology tenant with an EPG"
# ---------------------------------------------------------------------------
# Seeded tenantPolicy template summary carries templateName (name-based
# lookup requirement)
# ---------------------------------------------------------------------------
def test_seeded_tenant_policy_template_summary_has_template_name(client):
"""`MSOTemplate.__init__`'s by-name lookup filters
`templateName`+`templateType` — the boot-seeded tenantPolicy template
summary must carry a `templateName`, not just `templateId`/
`templateType`, or a real `ndo_template`/`ndo_dhcp_relay_policy` call
targeting it by name could never match."""
summaries = client.get("/mso/api/v1/templates/summaries").json()
seeded = next(s for s in summaries if s["templateId"] == TENANT_POLICY_TEMPLATE_ID)
assert seeded.get("templateName")
assert seeded["templateType"] == "tenantPolicy"
# ---------------------------------------------------------------------------
# GAP 2 deeper layer — site-local contracts[] mirroring (service-graph
# redirect atomic PATCH)
# ---------------------------------------------------------------------------
def test_site_top_level_defaults_include_contracts():
assert SITE_TOP_LEVEL_DEFAULTS["contracts"] == []
def test_build_ndo_model_seeds_site_local_contracts_for_topology_tenants(topo):
"""A topology.yaml tenant that already ships with contracts configured
must boot with its site-local `sites[].contracts[]` already mirrored —
otherwise the FIRST raw mso_rest PATCH addressing a site-local contract
by bare name would hit the same 'not found in list' crash this PR
fixes for the PATCH-built case."""
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("contracts"):
continue
for site in detail.get("sites", []):
if site.get("templateName") != tmpl["name"]:
continue
assert "contracts" in site
for contract in tmpl["contracts"]:
matches = [c for c in site["contracts"] if c.get("contractRef", "").endswith(f"/contracts/{contract['name']}")]
assert matches, f"site missing mirrored contractRef for {contract['name']}"
found_any = True
assert found_any, "expected at least one topology tenant with a mirrored site-local contract"
def test_template_contract_add_mirrors_into_associated_site():
"""Adding a template-level contract to a template that already has a
site attached must auto-create a matching site-local `{contractRef}`
entry — mirrors `test_template_anp_add_mirrors_into_associated_site`
(PR-12) for the contracts array."""
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "contracts": []}],
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
}
ops = [
{
"op": "add",
"path": "/templates/LAB1-LAB2/contracts/-",
"value": {"name": "con-Firewall_LAB0", "scope": "vrf", "filterRelationships": []},
}
]
apply_json_patch(doc, ops)
site = doc["sites"][0]
assert site["contracts"][0]["contractRef"] == "/schemas/schema-abc/templates/LAB1-LAB2/contracts/con-Firewall_LAB0"
def test_template_contract_add_mirror_is_idempotent():
doc = {
"id": "schema-abc",
"templates": [{"name": "LAB1-LAB2", "contracts": []}],
"sites": [{"siteId": "1", "templateName": "LAB1-LAB2"}],
}
ops = [{"op": "add", "path": "/templates/LAB1-LAB2/contracts/-", "value": {"name": "con-Firewall_LAB0"}}]
apply_json_patch(doc, ops)
apply_json_patch(doc, ops)
assert len(doc["sites"][0]["contracts"]) == 1
def test_atomic_service_graph_redirect_patch_on_site_local_contract_end_to_end(client):
"""Full replica of the mso-model role's raw `cisco.mso.mso_rest`
"Atomic PATCH — bind service-graph redirect on ALL fabrics in one
request" task: a schema built up entirely via runtime PATCH (matching
MS-TN2's actual flow), with a template contract added, then a
site-local `serviceGraphRelationship` bound on EACH site's mirrored
contract in a single PATCH batch — the real hardware MS-TN2 `create_tenant`
pass-2 crash signature (`"path segment 'con-Firewall_LAB0' not found in
list"`) this PR fixes."""
payload = {
"displayName": "MS-TN2-LAB0",
"templates": [{"name": "LAB1-LAB2", "displayName": "LAB1-LAB2", "tenantId": "t2", "contracts": []}],
"sites": [
{"siteId": "1", "templateName": "LAB1-LAB2"},
{"siteId": "2", "templateName": "LAB1-LAB2"},
],
}
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-LAB2/contracts/-",
"value": {"name": "con-Firewall_LAB0", "scope": "vrf", "filterRelationships": []},
}
],
)
redirect_ops = [
{
"op": "add",
"path": f"/sites/{site_id}-LAB1-LAB2/contracts/con-Firewall_LAB0/serviceGraphRelationship",
"value": {
"serviceGraphRef": {"schemaId": schema_id, "serviceGraphName": "sgt-FW_LAB0", "templateName": "LAB1-LAB2"},
"serviceNodesRelationship": [
{
"serviceNodeRef": {
"schemaId": schema_id,
"serviceGraphName": "sgt-FW_LAB0",
"serviceNodeName": "node1",
"templateName": "LAB1-LAB2",
},
"consumerConnector": {"clusterInterface": {"dn": "uni/tn-MS-TN2/x"}, "redirectPolicy": {"dn": "uni/tn-MS-TN2/y"}, "subnets": []},
"providerConnector": {"clusterInterface": {"dn": "uni/tn-MS-TN2/x"}, "redirectPolicy": {"dn": "uni/tn-MS-TN2/y"}},
}
],
},
}
for site_id in ("1", "2")
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=redirect_ops)
assert resp.status_code == 200
fresh = client.get(f"/mso/api/v1/schemas/{schema_id}").json()
for site_id in ("1", "2"):
site = next(s for s in fresh["sites"] if s["siteId"] == site_id)
contract = next(c for c in site["contracts"] if c["contractRef"].endswith("/contracts/con-Firewall_LAB0"))
assert contract["serviceGraphRelationship"]["serviceGraphRef"].endswith("/serviceGraphs/sgt-FW_LAB0")
+36
View File
@@ -0,0 +1,36 @@
"""PR-14: classic ND bare /login + /logout endpoints (used by aci-py, cisco.nd)."""
from __future__ import annotations
import copy
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.topology.loader import load_topology
def _client():
topo = load_topology("topology.yaml")
state = build_ndo_model(topo, sandbox=False)
return TestClient(make_ndo_app(copy.deepcopy(state)), raise_server_exceptions=False)
def test_bare_login_returns_token_and_jwttoken():
c = _client()
r = c.post("/login", json={"userName": "admin", "userPasswd": "cisco", "domain": "local"})
assert r.status_code == 200
body = r.json()
assert body["token"] and body["token"] == body["jwttoken"]
assert body["userName"] == "admin"
def test_bare_logout_ok():
c = _client()
assert c.post("/logout").status_code == 200
def test_new_auth_login_still_works():
c = _client()
r = c.post("/api/v1/auth/login", json={"userName": "admin", "userPasswd": "cisco"})
assert r.status_code == 200 and r.json()["token"]
+325
View File
@@ -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
+321
View File
@@ -0,0 +1,321 @@
"""PR-16 — NDO site-local BD duplication (bdRef normalization).
CONFIRMED PRODUCTION SYMPTOM: after deploying the Acme App1-Net tenant through
`create_tenant.yml` + `create_bd.yml` against a real NDO schema
(`App1-Net-LAB0`), `sites[0].bds` carried TWO entries for the same template
BD `App1-Net_VLAN10` — one empty mirror
(`bdRef=".../templates/LAB1/bds/App1-Net_VLAN10"`, `subnets=[]`)
created by `_mirror_template_bd_to_sites` (PR-15) when the template BD was
added, and a SECOND entry carrying the real subnet
(`10.10.10.1/24`) added later by `mso_schema_site_bd`'s own
`add /sites/{siteId}-{tmpl}/bds/-` (append) payload — confirmed by the
pre-existing `test_patch_add_site_bd_by_composite_key` (test_pr11_ndo_write.py)
to use the DICT bdRef form (`{bdName: ...}`, no `schemaId`/`templateName`),
a form `_find_by_name`'s ref-lookup never matched because the append (`-`)
branch of `apply_json_patch` didn't consult `_find_by_name`/ref-resolution
AT ALL before appending — it always appended unconditionally.
ROOT CAUSE: `apply_json_patch`'s `op == "add"` branch has three
sub-branches keyed by the *last path token* — `"-"` (append),
a numeric index (`insert`), or a name (resolved via `_find_by_name`, which
DOES already dedup by matching a `*Ref` field's bare trailing name). Only
the named-segment sub-branch deduped. The append (`"-"`) sub-branch — the
exact form `mso_schema_site_bd.py`/`mso_schema_site_anp.py`/
`mso_schema_template_contract_filter.py`'s "does not exist yet" branches
all use when addressing a site-local `bds`/`anps`/`contracts` collection —
never checked for an existing entry, so a template-add mirror followed by
a site-module add for the SAME underlying object always produced a
duplicate.
FIX: `_find_shadow_by_identity()` resolves a candidate append *value*'s own
IDENTITY ref for the target collection (`bds`→`bdRef`, `anps`→`anpRef`,
`epgs`→`epgRef`, `contracts`→`contractRef`), in either string or dict form,
to a bare name and looks for an existing NAMELESS shadow entry with that
same identity ref. The `add`+`-` branch calls it before appending: a match
REPLACES that entry in place (real NDO's "one site-BD row per template BD"
invariant and cisco.mso's GET-then-write idiom); only a genuinely new
shadow appends.
CRITICAL SCOPING (the regression the first PR-16 cut had): identity
matching is restricted to NAMELESS shadows and to the ONE identity ref per
collection. A NAMED object (a template-level BD add, which carries its own
`name`) always appends — it is never deduped by a `*Ref`. And a *property*
ref like `vrfRef` (which every template BD under one L3-attached VRF shares,
e.g. `vrf-L3_LAB0`) is NEVER used to establish identity. Matching on shared
property refs collapsed every template's BDs into a single entry in the
first cut; `test_distinct_template_bds_sharing_vrfref_do_not_collapse`
guards against any recurrence.
"""
from __future__ import annotations
from aci_sim.ndo.patch import apply_json_patch
def _mirror_bd(doc: dict) -> None:
"""Template BD add — fires _mirror_template_bd_to_sites (PR-15)."""
ops = [
{
"op": "add",
"path": "/templates/LAB1/bds/-",
"value": {"name": "App1-Net_VLAN10", "displayName": "App1-Net_VLAN10"},
}
]
apply_json_patch(doc, ops)
def _base_doc() -> dict:
return {
"id": "schema-app1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
def test_site_module_add_by_dict_bdref_updates_mirrored_entry_not_duplicates():
"""The exact production sequence: template BD add (mirror fires, string
bdRef, subnets=[]) THEN a site-module-style `add /sites/{sid}-{tmpl}/
bds/-` addressed by the OTHER bdRef form (a dict, no schemaId/
templateName — `mso_schema_site_bd.py`'s literal "BD does not exist"
payload per test_patch_add_site_bd_by_composite_key). Must converge on
ONE site-bd entry, not two."""
doc = _base_doc()
_mirror_bd(doc)
site = doc["sites"][0]
assert len(site["bds"]) == 1, "mirror must create exactly one shadow"
assert site["bds"][0]["bdRef"] == "/schemas/schema-app1/templates/LAB1/bds/App1-Net_VLAN10"
assert site["bds"][0]["subnets"] == []
site_add_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {
"bdRef": {"bdName": "App1-Net_VLAN10"},
"hostBasedRouting": False,
},
}
]
apply_json_patch(doc, site_add_ops)
# No duplicate entry: the site-module add must have resolved to the
# SAME list slot the mirror created (updating it in place), not
# appended a second row. The incoming bare {"bdName": ...} form (no
# schemaId/templateName) isn't stringified — matching pre-existing,
# unchanged `_stringify_refs` behavior — so the stored bdRef becomes
# whatever the caller sent; what matters is there is exactly ONE row.
assert len(site["bds"]) == 1, (
f"expected exactly one site-bd entry after site-module add, got {len(site['bds'])}: {site['bds']}"
)
assert site["bds"][0]["bdRef"] == {"bdName": "App1-Net_VLAN10"}
assert site["bds"][0]["hostBasedRouting"] is False
def test_site_module_add_by_full_schema_dict_bdref_then_subnet_converges_on_one_entry():
"""Same as above but the dict form carries schemaId+templateName (the
OTHER real cisco.mso payload shape, see test_pr11_ndo_write.py's
`test_patch_add_site_bd_subnet_by_ref_name_resolution` family), and a
subsequent subnet add lands on the SAME entry the mirror created — this
is the exact Acme App1-Net symptom (empty mirror + subnet-carrying entry
must be ONE row, not two)."""
doc = _base_doc()
_mirror_bd(doc)
site_add_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {
"bdRef": {
"schemaId": "schema-app1",
"templateName": "LAB1",
"bdName": "App1-Net_VLAN10",
},
"hostBasedRouting": False,
},
}
]
apply_json_patch(doc, site_add_ops)
site = doc["sites"][0]
assert len(site["bds"]) == 1, f"expected one entry after site add, got {site['bds']}"
subnet_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/App1-Net_VLAN10/subnets/-",
"value": {"ip": "10.10.10.1/24", "scope": "private"},
}
]
apply_json_patch(doc, subnet_ops)
assert len(site["bds"]) == 1, (
f"subnet add must not create a second entry, got {len(site['bds'])}: {site['bds']}"
)
only_bd = site["bds"][0]
assert only_bd["bdRef"] == "/schemas/schema-app1/templates/LAB1/bds/App1-Net_VLAN10"
assert only_bd["subnets"] == [{"ip": "10.10.10.1/24", "scope": "private"}]
def test_site_module_add_before_mirror_still_dedups_on_later_mirror_call():
"""Order-independence: if the site-module add somehow lands BEFORE a
(re-applied/idempotent) template mirror call, the mirror's own
_find_by_name dedup (unchanged by this fix) still recognizes the
site-module's entry and does not append a second one."""
doc = _base_doc()
site_add_ops = [
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {"bdRef": {"bdName": "App1-Net_VLAN10"}, "hostBasedRouting": False},
}
]
apply_json_patch(doc, site_add_ops)
assert len(doc["sites"][0]["bds"]) == 1
_mirror_bd(doc)
assert len(doc["sites"][0]["bds"]) == 1, "re-mirroring must not duplicate an existing site-bd"
def test_unrelated_appends_without_ref_field_still_append_normally():
"""Values with no identity `*Ref` (e.g. a template-level BD add, which
carries its own name, or a subnet add's own value) must keep appending
unconditionally — the dedup-on-append behavior is scoped to nameless
site-local shadow objects only, via _find_shadow_by_identity returning
None for named / non-shadow values."""
doc = {"templates": [{"name": "LAB1", "bds": []}], "sites": []}
ops = [
{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-A"}},
{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-B"}},
]
apply_json_patch(doc, ops)
assert [b["name"] for b in doc["templates"][0]["bds"]] == ["bd-A", "bd-B"]
def test_multiple_distinct_bds_in_same_site_all_kept_separate():
"""Dedup must match by bare name, not accidentally collapse different
BDs mirrored into the same site."""
doc = {
"id": "schema-app1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-A"}}])
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-B"}}])
site = doc["sites"][0]
assert len(site["bds"]) == 2
apply_json_patch(
doc,
[
{
"op": "add",
"path": "/sites/101-LAB1/bds/-",
"value": {"bdRef": {"bdName": "bd-B"}, "hostBasedRouting": True},
}
],
)
assert len(site["bds"]) == 2, f"expected bd-A untouched, bd-B updated in place, got {site['bds']}"
def _name(bd: dict) -> str | None:
ref = bd["bdRef"]
if isinstance(ref, str):
return ref.rsplit("/", 1)[-1]
return ref.get("bdName")
names = {_name(b) for b in site["bds"]}
assert names == {"bd-A", "bd-B"}
bd_b = next(b for b in site["bds"] if _name(b) == "bd-B")
assert bd_b["hostBasedRouting"] is True
# ---------------------------------------------------------------------------
# REGRESSION GUARD — the first PR-16 cut collapsed every template's BDs to
# one entry because it deduped named objects via a SHARED non-identity ref.
# ---------------------------------------------------------------------------
def test_distinct_template_bds_sharing_vrfref_do_not_collapse():
"""CATASTROPHIC REGRESSION GUARD. In the real Acme create_bd run, every
template BD carries the SAME `vrfRef` (`vrf-L3_LAB0`). The first PR-16
cut resolved an appended value's identity by iterating ALL
`_REF_LOOKUP_KEYS` (including `vrfRef`), so two DISTINCT template BDs
(`bd-App2-...` and `bd-App3-...`) both matched via their shared
`vrfRef` and collapsed — each `add /templates/{T}/bds/-` overwrote the
previous BD, leaving exactly ONE BD per template. A NAMED object must
match ONLY by name, and identity refs must exclude property refs like
`vrfRef`."""
doc = {"id": "s1", "templates": [{"name": "T", "bds": []}], "sites": []}
shared_vrf = {"schemaId": "s1", "templateName": "T", "vrfName": "vrf-L3_LAB0"}
for name in ("bd-App2-Net", "bd-App3-Hst", "bd-App4-Bkp"):
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/T/bds/-", "value": {"name": name, "vrfRef": dict(shared_vrf)}}],
)
names = [b["name"] for b in doc["templates"][0]["bds"]]
assert names == ["bd-App2-Net", "bd-App3-Hst", "bd-App4-Bkp"], (
f"distinct template BDs sharing a vrfRef must NOT collapse — got {names}"
)
def test_template_bd_add_with_shared_vrfref_mirrors_all_into_site():
"""The site-mirror side of the same regression: three distinct template
BDs sharing one `vrfRef`, added to a template with a site attached, must
each produce their OWN site-local shadow (3 shadows, not 1)."""
doc = {
"id": "s1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
shared_vrf = {"schemaId": "s1", "templateName": "LAB1", "vrfName": "vrf-L3_LAB0"}
for name in ("bd-One", "bd-Two", "bd-Three"):
apply_json_patch(
doc,
[{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": name, "vrfRef": dict(shared_vrf)}}],
)
assert len(doc["templates"][0]["bds"]) == 3
site = doc["sites"][0]
mirrored = {b["bdRef"].rsplit("/", 1)[-1] for b in site["bds"]}
assert mirrored == {"bd-One", "bd-Two", "bd-Three"}, f"each BD needs its own shadow, got {site['bds']}"
def test_site_shadow_with_vrfref_property_still_dedups_only_on_bdref():
"""A site-BD shadow may legitimately carry a `vrfRef` property in
addition to its identity `bdRef`. Two site-module adds for DIFFERENT
BDs that happen to share a `vrfRef` must still stay separate (dedup is
on `bdRef` identity, not the shared `vrfRef` property)."""
doc = {
"id": "s1",
"templates": [{"name": "LAB1", "bds": []}],
"sites": [{"siteId": "101", "templateName": "LAB1"}],
}
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-P"}}])
apply_json_patch(doc, [{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-Q"}}])
site = doc["sites"][0]
assert len(site["bds"]) == 2
vrf = "/schemas/s1/templates/LAB1/vrfs/vrf-Shared"
apply_json_patch(
doc,
[{"op": "add", "path": "/sites/101-LAB1/bds/-", "value": {"bdRef": {"bdName": "bd-P"}, "vrfRef": vrf}}],
)
apply_json_patch(
doc,
[{"op": "add", "path": "/sites/101-LAB1/bds/-", "value": {"bdRef": {"bdName": "bd-Q"}, "vrfRef": vrf}}],
)
assert len(site["bds"]) == 2, f"distinct shadows sharing a vrfRef must not merge, got {site['bds']}"
def test_replace_named_object_still_resolves_by_name_only():
"""A `replace` addressing a named template BD by name must still work,
and must not accidentally resolve to a sibling via a shared ref."""
doc = {"id": "s1", "templates": [{"name": "T", "bds": []}], "sites": []}
shared_vrf = {"schemaId": "s1", "templateName": "T", "vrfName": "vrf-Shared"}
apply_json_patch(doc, [{"op": "add", "path": "/templates/T/bds/-", "value": {"name": "bd-A", "vrfRef": dict(shared_vrf)}}])
apply_json_patch(doc, [{"op": "add", "path": "/templates/T/bds/-", "value": {"name": "bd-B", "vrfRef": dict(shared_vrf)}}])
apply_json_patch(
doc,
[{"op": "replace", "path": "/templates/T/bds/bd-B", "value": {"name": "bd-B", "displayName": "B-updated"}}],
)
bds = {b["name"]: b for b in doc["templates"][0]["bds"]}
assert set(bds) == {"bd-A", "bd-B"}
assert bds["bd-B"].get("displayName") == "B-updated"
assert "displayName" not in bds["bd-A"], "replace must not have touched the sibling BD"
+252
View File
@@ -0,0 +1,252 @@
"""Tests for PR-18 — Tier-1 fabric configuration parameters.
Covers each Tier-1 param defaulting correctly when absent from
topology.yaml, overriding when set, the deterministic-serial fallback,
`site.pod` DN correctness, N-site scaffold collision-freedom, and
`infraWiNode` count tracking `site.controllers`.
See docs/DESIGN.md's "PR-18 — Tier-1 fabric configuration parameters"
section for the full design rationale, including the documented N-site
ISN/`other_site()` limitation this file's `TestNSite` class verifies
(builds without collision; does NOT assert a true N-site ISN full mesh,
which is explicitly out of scope for this PR).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import orchestrator
from aci_sim.build.fabric import default_serial
from aci_sim.cli import generate_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Fabric, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
@pytest.fixture(scope="module")
def repo_topo() -> Topology:
return load_topology(TOPOLOGY_YAML)
# ---------------------------------------------------------------------------
# Defaults — absent from topology.yaml, each param resolves to its default
# ---------------------------------------------------------------------------
class TestDefaults:
def test_controllers_defaults_to_1(self, repo_topo: Topology) -> None:
"""PR-21: single-APIC-per-site is now the default (was 3 pre-PR-21)."""
for site in repo_topo.sites:
assert site.controllers == 1
def test_pod_defaults_to_1(self, repo_topo: Topology) -> None:
for site in repo_topo.sites:
assert site.pod == 1
def test_tep_pool_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.tep_pool == "10.0.0.0/16"
def test_infra_vlan_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.infra_vlan == 3967
def test_gipo_pool_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.gipo_pool == "225.0.0.0/15"
def test_repo_topology_yaml_still_validates_unchanged(self) -> None:
"""The backward-compat acceptance bar: the real topology.yaml, with
no Tier-1 fields added, must still load + validate cleanly."""
topo = load_topology(TOPOLOGY_YAML)
assert len(topo.sites) == 2
# ---------------------------------------------------------------------------
# Overrides — each param actually takes effect when set
# ---------------------------------------------------------------------------
class TestOverrides:
def test_controllers_override(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = 5
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].controllers == 5
def test_pod_override(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["pod"] = 2
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].pod == 2
def test_tep_pool_override(self) -> None:
fab = Fabric(name="x", tep_pool="172.16.0.0/16")
assert fab.tep_pool == "172.16.0.0/16"
def test_infra_vlan_override(self) -> None:
fab = Fabric(name="x", infra_vlan=100)
assert fab.infra_vlan == 100
def test_gipo_pool_override(self) -> None:
fab = Fabric(name="x", gipo_pool="226.0.0.0/15")
assert fab.gipo_pool == "226.0.0.0/15"
def test_infra_vlan_out_of_range_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["infra_vlan"] = 4095
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_infra_vlan_zero_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["infra_vlan"] = 0
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_tep_pool_bad_cidr_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["tep_pool"] = "not-a-cidr"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_gipo_pool_bad_cidr_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["gipo_pool"] = "garbage"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# Deterministic serials
# ---------------------------------------------------------------------------
class TestSerials:
def test_default_serial_format(self) -> None:
assert default_serial("1", 101) == "SAL10101"
assert default_serial("2", 301) == "SAL20301"
def test_default_serial_deterministic(self) -> None:
assert default_serial("1", 101) == default_serial("1", 101)
def test_auto_node_gets_nonempty_serial_in_built_fabricNode(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
for mo in store.by_class("fabricNode"):
assert mo.attrs.get("serial"), f"empty serial for {mo.dn}"
def test_auto_node_serial_matches_default_serial_scheme(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
node101 = next(mo for mo in store.by_class("fabricNode") if mo.attrs.get("id") == "101")
assert node101.attrs["serial"] == default_serial(site.id, 101)
def test_explicit_yaml_serial_overrides_default(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["leaves"][0]["serial"] = "EXPLICIT-SERIAL-1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
leaf_id = site.leaf_nodes()[0].id
mo = next(m for m in store.by_class("fabricNode") if m.attrs.get("id") == str(leaf_id))
assert mo.attrs["serial"] == "EXPLICIT-SERIAL-1"
def test_fabricNodeIdentP_serial_matches_fabricNode_serial(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
fn_serials = {mo.attrs["id"]: mo.attrs["serial"] for mo in store.by_class("fabricNode")}
for mo in store.by_class("fabricNodeIdentP"):
assert mo.attrs["serial"] == fn_serials[mo.attrs["nodeId"]]
# ---------------------------------------------------------------------------
# Pod DN correctness
# ---------------------------------------------------------------------------
class TestPod:
def test_pod2_site_builds_pod2_dns(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["pod"] = 2
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
assert fabric_nodes, "expected at least one fabricNode"
for mo in fabric_nodes:
assert mo.dn.startswith("topology/pod-2/"), mo.dn
for mo in store.by_class("topSystem"):
assert mo.dn.startswith("topology/pod-2/"), mo.dn
assert mo.attrs["podId"] == "2"
# ---------------------------------------------------------------------------
# Controllers -> infraWiNode count
# ---------------------------------------------------------------------------
class TestControllersWiring:
@pytest.mark.parametrize("n_controllers", [1, 3, 5])
def test_infraWiNode_count_matches_controllers_squared(self, n_controllers: int) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = n_controllers
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
wi = store.by_class("infraWiNode")
assert len(wi) == n_controllers * n_controllers
def test_controller_fabricNode_count_matches_controllers(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = 4
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
controllers = [mo for mo in store.by_class("fabricNode") if mo.attrs.get("role") == "controller"]
assert len(controllers) == 4
# ---------------------------------------------------------------------------
# N-site — collision-free build; documented ISN limit not asserted as a bug
# ---------------------------------------------------------------------------
class TestNSite:
def test_3site_scaffold_builds_without_node_id_collision(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 3
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
assert len(all_ids) == len(set(all_ids))
def test_3site_scaffold_distinct_pods_serials_controllers(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1)
# Give each site a distinct pod to prove pod-per-site isn't collapsed.
for i, site_def in enumerate(topo_dict["sites"], start=1):
site_def["pod"] = i
site_def["controllers"] = i + 2 # 3, 4, 5
topo = Topology.model_validate(topo_dict)
assert [s.pod for s in topo.sites] == [1, 2, 3]
assert [s.controllers for s in topo.sites] == [3, 4, 5]
for site in topo.sites:
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
for mo in fabric_nodes:
assert mo.dn.startswith(f"topology/pod-{site.pod}/")
assert mo.attrs["serial"] # non-empty
controllers = [mo for mo in fabric_nodes if mo.attrs.get("role") == "controller"]
assert len(controllers) == site.controllers
def test_3site_new_cli_controllers_flag(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1, controllers=5)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 3
for site in topo.sites:
assert site.controllers == 5
+396
View File
@@ -0,0 +1,396 @@
"""Tests for PR-19 — Tier-2 topology-elasticity parameters.
Covers:
- ISN ospf_area/mtu: schema defaults+overrides, validation, and that
build/underlay.py's ospfIf/ospfAdjEp + build/interfaces.py's ISN uplink
l1PhysIf actually reflect the configured values (not just stored).
- VMM domain vCenter config: schema defaults+overrides, vlan_pool
cross-reference validation, and that build/access.py produces a
vmmDomP/vmmCtrlrP/vmmUsrAccP tree with the right attrs (hostOrIp,
rootContName, dvsName) only when vcenter_ip is set.
- Leaf/spine count elasticity: `aci-sim new` generates collision-free
topologies at larger counts (8 leaves / 4 spines, multiple border
pairs), and the previously-unguarded ID-scheme overrun (large
--leaves-per-site/--spines-per-site/--border-pairs) is now rejected
with a clear error at generation time instead of a confusing
Topology.normalize_and_validate collision error.
- isn.peer_mesh: partial is a documented validate-and-reject (unchanged
from PR-17/18 lineage; re-asserted here as part of PR-19's Tier-2 pass).
See docs/DESIGN.md's "PR-19 — Tier-2 topology-elasticity parameters"
section for the full design rationale, including why VMM domain MOs are a
pure-addition (no existing builder/production-chain reads a vmmDomP/
vmmCtrlrP object — 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).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import orchestrator
from aci_sim.cli import generate_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import ISN, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
@pytest.fixture(scope="module")
def repo_topo() -> Topology:
return load_topology(TOPOLOGY_YAML)
# ---------------------------------------------------------------------------
# ISN ospf_area / mtu — defaults
# ---------------------------------------------------------------------------
class TestIsnDefaults:
def test_ospf_area_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.isn.ospf_area == "0.0.0.0"
def test_mtu_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.isn.mtu == 9150
def test_repo_topology_yaml_still_validates_unchanged(self) -> None:
"""Backward-compat bar: the real topology.yaml, with no Tier-2
ISN fields added, must still load + validate cleanly."""
topo = load_topology(TOPOLOGY_YAML)
assert len(topo.sites) == 2
def test_ospfIf_reflects_default_area(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
ospf_ifs = store.by_class("ospfIf")
assert ospf_ifs, "expected at least one ospfIf"
for mo in ospf_ifs:
assert mo.attrs["area"] == "0.0.0.0"
def test_isn_uplink_l1PhysIf_reflects_default_mtu(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
spine_ids = {n.id for n in site.spine_nodes()}
# Every spine's dedicated ISN uplink port (eth1/{49+i}, per
# interfaces.py's _UPLINK_START convention) must carry isn.mtu.
spine_isn_dns = {
f"topology/pod-{site.pod}/node-{sid}/sys/phys-[eth1/{49 + i}]"
for i, sid in enumerate(sorted(spine_ids))
}
found = [mo for mo in store.by_class("l1PhysIf") if mo.dn in spine_isn_dns]
assert len(found) == len(spine_ids)
for mo in found:
assert mo.attrs["mtu"] == "9150"
# ---------------------------------------------------------------------------
# ISN ospf_area / mtu — overrides
# ---------------------------------------------------------------------------
class TestIsnOverrides:
def test_ospf_area_override(self) -> None:
isn = ISN(ospf_area="10.0.0.0")
assert isn.ospf_area == "10.0.0.0"
def test_ospf_area_decimal_form_accepted(self) -> None:
isn = ISN(ospf_area="10")
assert isn.ospf_area == "10"
def test_mtu_override(self) -> None:
isn = ISN(mtu=9000)
assert isn.mtu == 9000
def test_ospf_area_garbage_rejected(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["ospf_area"] = "not-an-area"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_mtu_too_low_rejected(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["mtu"] = 100
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_mtu_too_high_rejected(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["mtu"] = 20000
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_ospfIf_reflects_overridden_area(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["ospf_area"] = "0.0.0.1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ospf_ifs = store.by_class("ospfIf")
assert ospf_ifs
for mo in ospf_ifs:
assert mo.attrs["area"] == "0.0.0.1"
for mo in store.by_class("ospfAdjEp"):
assert mo.attrs["area"] == "0.0.0.1"
def test_isn_uplink_l1PhysIf_reflects_overridden_mtu(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["mtu"] = 9000
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
spine_id = site.spine_nodes()[0].id
isn_dn = f"topology/pod-{site.pod}/node-{spine_id}/sys/phys-[eth1/49]"
mo = next(m for m in store.by_class("l1PhysIf") if m.dn == isn_dn)
assert mo.attrs["mtu"] == "9000"
def test_fabric_uplink_mtu_unaffected_by_isn_mtu_override(self) -> None:
"""Only the dedicated ISN spine uplink port should pick up isn.mtu —
ordinary intra-fabric ports (leaves, spine fabric-uplinks used for
the Clos mesh) must keep the 9216 fabric default."""
topo_dict = generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=0)
topo_dict["isn"]["mtu"] = 9000
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
leaf_id = site.leaf_nodes()[0].id
# A leaf's fabric-uplink port is never an ISN port (ISN ports are
# spine-only) — its mtu must remain the fabric default.
leaf_ports = [mo for mo in store.by_class("l1PhysIf") if f"node-{leaf_id}/" in mo.dn]
assert leaf_ports
for mo in leaf_ports:
assert mo.attrs["mtu"] == "9216"
# ---------------------------------------------------------------------------
# isn.peer_mesh: partial — documented validate-and-reject (unchanged)
# ---------------------------------------------------------------------------
class TestPeerMeshPartial:
def test_partial_mesh_rejected_at_build_time(self) -> None:
"""isn.peer_mesh: partial passes schema validation (no schema-level
enum restriction — see ISN.peer_mesh's `partial` note) but
build/overlay.py fails fast rather than silently building full-mesh
or an arbitrary subset."""
topo_dict = generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["isn"]["peer_mesh"] = "partial"
topo = Topology.model_validate(topo_dict) # schema-level: OK
site = topo.sites[0]
with pytest.raises(ValueError, match="peer_mesh"):
orchestrator.build_site(topo, site)
# ---------------------------------------------------------------------------
# VMM domain — defaults (empty list; backward compat)
# ---------------------------------------------------------------------------
class TestVmmDefaults:
def test_repo_topology_yaml_has_no_vmm_domains(self, repo_topo: Topology) -> None:
assert repo_topo.access.vmm_domains == []
def test_no_vmmDomP_built_when_vmm_domains_empty(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
assert store.by_class("vmmDomP") == []
assert store.by_class("vmmCtrlrP") == []
def test_bare_vmm_domain_no_vcenter_ip_produces_no_ctrlrp(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [{"name": "vmm-vmw-bare"}]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
dom_mos = store.by_class("vmmDomP")
assert len(dom_mos) == 1
assert dom_mos[0].attrs["name"] == "vmm-vmw-bare"
assert store.by_class("vmmCtrlrP") == []
# ---------------------------------------------------------------------------
# VMM domain — overrides (vCenter attrs populate vmmCtrlrP)
# ---------------------------------------------------------------------------
class TestVmmOverrides:
def test_vcenter_attrs_populate_vmmCtrlrP(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{
"name": "vmm-vmw-lab1",
"vcenter_ip": "10.50.1.10",
"vcenter_dvs": "DVS-LAB1",
"datacenter": "DC-LAB1",
}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrlrs = store.by_class("vmmCtrlrP")
assert len(ctrlrs) == 1
ctrlr = ctrlrs[0]
assert ctrlr.attrs["hostOrIp"] == "10.50.1.10"
assert ctrlr.attrs["rootContName"] == "DC-LAB1"
assert ctrlr.attrs["dvsName"] == "DVS-LAB1"
assert ctrlr.dn.startswith("uni/vmmp-VMware/dom-vmm-vmw-lab1/ctrlr-")
def test_vcenter_ip_produces_vmmUsrAccP_child(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vcenter_ip": "10.50.1.10"}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
usracc = store.by_class("vmmUsrAccP")
assert len(usracc) == 1
def test_datacenter_defaults_to_domain_name_when_unset(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vcenter_ip": "10.50.1.10"}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrlr = store.by_class("vmmCtrlrP")[0]
assert ctrlr.attrs["rootContName"] == "vmm-vmw-lab1"
def test_multiple_vmm_domains_each_get_own_ctrlrp(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vcenter_ip": "10.50.1.10"},
{"name": "vmm-vmw-lab2", "vcenter_ip": "10.50.2.10"},
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrlrs = {mo.attrs["hostOrIp"] for mo in store.by_class("vmmCtrlrP")}
assert ctrlrs == {"10.50.1.10", "10.50.2.10"}
# ---------------------------------------------------------------------------
# VMM domain — vlan_pool cross-reference validation
# ---------------------------------------------------------------------------
class TestVmmVlanPoolValidation:
def test_unknown_vlan_pool_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vlan_pool": "does-not-exist"}
]
with pytest.raises(ValidationError, match="vlan_pool"):
Topology.model_validate(topo_dict)
def test_known_vlan_pool_accepted(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
pool_name = topo_dict["access"]["vlan_pools"][0]["name"]
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vlan_pool": pool_name}
]
topo = Topology.model_validate(topo_dict)
assert topo.access.vmm_domains[0].vlan_pool == pool_name
def test_vlan_pool_bind_produces_infraRsVlanNs_child(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
pool_name = topo_dict["access"]["vlan_pools"][0]["name"]
topo_dict["access"]["vmm_domains"] = [
{"name": "vmm-vmw-lab1", "vlan_pool": pool_name}
]
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
rsvlan = [
mo for mo in store.by_class("infraRsVlanNs")
if mo.dn.startswith("uni/vmmp-VMware/dom-vmm-vmw-lab1")
]
assert len(rsvlan) == 1
# ---------------------------------------------------------------------------
# Leaf/spine count elasticity — `aci-sim new` sugar
# ---------------------------------------------------------------------------
class TestLeafSpineElasticity:
def test_8_leaves_4_spines_validates_clean(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=8, spines_per_site=4)
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
assert len(site.leaf_nodes()) == 8
assert len(site.spine_nodes()) == 4
def test_8_leaves_4_spines_no_id_collision(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=8, spines_per_site=4, border_pairs=2)
topo = Topology.model_validate(topo_dict)
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
assert len(all_ids) == len(set(all_ids))
def test_border_pairs_greater_than_1_works(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=4, spines_per_site=2, border_pairs=3)
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
assert len(site.border_leaves) == 6 # 3 pairs = 6 border leaves
# each pair shares one vpc_domain
domains = {bl.vpc_domain for bl in site.border_leaves}
assert len(domains) == 3
@pytest.mark.parametrize(
"leaves,spines,borders",
[(101, 2, 0), (2, 101, 0), (150, 4, 3), (2, 2, 50)],
)
def test_id_scheme_overrun_rejected_with_clear_error(
self, leaves: int, spines: int, borders: int
) -> None:
"""Previously: these combinations silently produced a Topology dict
with colliding node ids, only caught deep inside
Topology.normalize_and_validate with a message that didn't name the
offending flag. Now: generate_topology itself rejects them."""
with pytest.raises(ValueError):
generate_topology(
sites=2, leaves_per_site=leaves, spines_per_site=spines, border_pairs=borders
)
def test_100_leaves_100_spines_boundary_still_works(self) -> None:
"""Exact boundary: 100 is the max safe leaves/spines-per-site count
before the next block/site is reached."""
topo_dict = generate_topology(sites=2, leaves_per_site=100, spines_per_site=100, border_pairs=0)
topo = Topology.model_validate(topo_dict)
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
assert len(all_ids) == len(set(all_ids))
def test_101_leaves_rejected(self) -> None:
with pytest.raises(ValueError, match="leaves-per-site"):
generate_topology(sites=2, leaves_per_site=101, spines_per_site=2, border_pairs=0)
def test_101_spines_rejected(self) -> None:
with pytest.raises(ValueError, match="spines-per-site"):
generate_topology(sites=2, leaves_per_site=2, spines_per_site=101, border_pairs=0)
def test_border_pairs_overrun_rejected(self) -> None:
with pytest.raises(ValueError, match="border-pairs"):
generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=50)
def test_full_build_succeeds_for_8_leaves_4_spines(self) -> None:
"""Confirms the orchestrator can actually build a site (not just
validate the schema) at the larger count."""
topo_dict = generate_topology(sites=2, leaves_per_site=8, spines_per_site=4, border_pairs=1)
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
switch_ids = {
int(mo.attrs["id"]) for mo in fabric_nodes if mo.attrs.get("role") != "controller"
}
expected_ids = {n.id for n in site.all_nodes()}
assert switch_ids == expected_ids
assert len(expected_ids) == 8 + 4 + 2 # leaves + spines + 1 border pair
+467
View File
@@ -0,0 +1,467 @@
"""Tests for PR-20 — Tier-3 fine-tuning parameters (the last param tier).
Covers:
- fabric.oob_subnet / fabric.inb_subnet: schema defaults (None/omitted),
validation (CIDR well-formedness + address-family range), and that
`aci-sim show` surfaces both — inb_subnet explicitly STORE-ONLY (no MO
reflects it; this sim has no mgmtInB/inbMgmtAddr-style object anywhere).
- fvBD.mac: `bd.mac` (per-BD) falling back to `fabric.default_bd_mac`
(fabric-wide), which itself defaults to the real ACI default gateway
MAC "00:22:BD:F8:19:FF" — the exact literal build/tenants.py already
hardcoded pre-PR-20, so an unset bd.mac/default_bd_mac reproduces
byte-identical fvBD.mac output.
- BGP keepalive/hold (l3out.csw_peer): schema defaults (60/180, real ACI
defaults) reflected on the built bgpPeerP MO; overrides honored;
hold<=keepalive rejected.
- OSPF hello/dead (isn.ospf_hello/ospf_dead): schema defaults (10/40,
real ACI defaults) reflected on the built ospfIf MO; overrides
honored; dead<=hello rejected.
- BFD timers (isn.bfd_min_rx/min_tx/multiplier): schema defaults (50/50/3,
real ACI defaults), validated, surfaced in `aci-sim show` — explicitly
STORE-ONLY, no bfdIfP MO built anywhere in this sim.
- fabric.default_apic_version: fabric-wide override that takes
precedence over site.apic_version (already independently settable from
switch-node Node.version since PR-18/PR-9 lineage) on the controller's
fabricNode/topSystem/firmwareCtrlrRunning.
See docs/DESIGN.md's "PR-20 — Tier-3 fine-tuning parameters" section for
the full design rationale, including which timers are builder-wired vs
store-only.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import orchestrator
from aci_sim.cli import generate_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import BD, CswPeer, Fabric, ISN, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
@pytest.fixture(scope="module")
def repo_topo() -> Topology:
return load_topology(TOPOLOGY_YAML)
def _base_topo_dict(**kwargs) -> dict:
return generate_topology(sites=2, leaves_per_site=1, spines_per_site=1, border_pairs=1, **kwargs)
def _topo_dict_with_l3out(**kwargs) -> dict:
"""`_base_topo_dict()` plus one l3out+csw_peer on site 0, agreeing with
that site's own border-leaf `csw` (name/asn/peer_ip), matching
`topology.yaml`'s real l3o-bgp-Core_LAB1 shape — needed because
`generate_topology()`'s scaffold never emits an l3out (verified: no
"l3outs"/"csw_peer" call sites in cli.py's generator; l3outs is always
an empty list `[]`)."""
topo_dict = _base_topo_dict(**kwargs)
site0 = topo_dict["sites"][0]
bl = site0["border_leaves"][0]
tenant = topo_dict["tenants"][0]
tenant["l3outs"] = [
{
"name": "l3o-bgp-test",
"vrf": tenant["vrfs"][0]["name"],
"site": site0["name"],
"border_leaves": [bl["id"]],
"csw_peer": {
"name": bl["csw"]["name"],
"asn": bl["csw"]["asn"],
"peer_ip": bl["csw"]["peer_ip"],
},
"ext_epgs": [],
}
]
return topo_dict
# ---------------------------------------------------------------------------
# Backward compat — repo topology.yaml unchanged
# ---------------------------------------------------------------------------
class TestBackwardCompat:
def test_repo_topology_yaml_still_validates_unchanged(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
assert len(topo.sites) == 2
def test_all_new_fabric_fields_default(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.oob_subnet is None
assert repo_topo.fabric.inb_subnet is None
assert repo_topo.fabric.default_bd_mac == "00:22:BD:F8:19:FF"
assert repo_topo.fabric.default_apic_version is None
def test_all_new_isn_fields_default(self, repo_topo: Topology) -> None:
assert repo_topo.isn.ospf_hello == 10
assert repo_topo.isn.ospf_dead == 40
assert repo_topo.isn.bfd_min_rx == 50
assert repo_topo.isn.bfd_min_tx == 50
assert repo_topo.isn.bfd_multiplier == 3
def test_repo_fvBD_mac_is_real_aci_default(self, repo_topo: Topology) -> None:
"""Critical backward-compat assertion: every existing BD's fvBD.mac
stays exactly "00:22:BD:F8:19:FF" (the literal build/tenants.py
already hardcoded pre-PR-20) with no topology.yaml changes."""
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
bds = store.by_class("fvBD")
assert bds, "expected at least one fvBD"
for mo in bds:
assert mo.attrs["mac"] == "00:22:BD:F8:19:FF"
def test_repo_csw_peer_keepalive_hold_default(self, repo_topo: Topology) -> None:
for tenant in repo_topo.tenants:
for l3out in tenant.l3outs:
if l3out.csw_peer is not None:
assert l3out.csw_peer.keepalive == 60
assert l3out.csw_peer.hold == 180
def test_repo_bgpPeerP_reflects_default_timers(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
peers = store.by_class("bgpPeerP")
assert peers, "expected at least one bgpPeerP"
for mo in peers:
assert mo.attrs["keepAliveIntvl"] == "60"
assert mo.attrs["holdIntvl"] == "180"
def test_repo_ospfIf_reflects_default_hello_dead(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
ospf_ifs = store.by_class("ospfIf")
assert ospf_ifs, "expected at least one ospfIf"
for mo in ospf_ifs:
assert mo.attrs["helloIntvl"] == "10"
assert mo.attrs["deadIntvl"] == "40"
def test_site_apic_version_unaffected_when_default_apic_version_unset(
self, repo_topo: Topology
) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
ctrl_top_system = next(
m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-1/sys"
)
assert ctrl_top_system.attrs["version"] == site.apic_version == "4.2(7f)"
# ---------------------------------------------------------------------------
# fvBD.mac — per-BD override + fabric-wide default override
# ---------------------------------------------------------------------------
class TestBdMac:
def test_bd_mac_schema_default_is_none(self) -> None:
bd = BD(name="bd-x", vrf="vrf-x")
assert bd.mac is None
def test_fabric_default_bd_mac_schema_default(self) -> None:
fab = Fabric(name="fab")
assert fab.default_bd_mac == "00:22:BD:F8:19:FF"
def test_per_bd_mac_override_wins(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["tenants"][0]["bds"][0]["mac"] = "AA:BB:CC:DD:EE:FF"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
bd_name = topo_dict["tenants"][0]["bds"][0]["name"]
mo = next(m for m in store.by_class("fvBD") if m.attrs["name"] == bd_name)
assert mo.attrs["mac"] == "AA:BB:CC:DD:EE:FF"
def test_fabric_wide_default_bd_mac_applies_when_bd_mac_unset(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["default_bd_mac"] = "02:00:00:00:00:01"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
bds = store.by_class("fvBD")
assert bds
for mo in bds:
assert mo.attrs["mac"] == "02:00:00:00:00:01"
def test_per_bd_mac_wins_over_fabric_wide_default(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["default_bd_mac"] = "02:00:00:00:00:01"
topo_dict["tenants"][0]["bds"][0]["mac"] = "AA:BB:CC:DD:EE:FF"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
bd_name = topo_dict["tenants"][0]["bds"][0]["name"]
mo = next(m for m in store.by_class("fvBD") if m.attrs["name"] == bd_name)
assert mo.attrs["mac"] == "AA:BB:CC:DD:EE:FF"
def test_invalid_bd_mac_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["tenants"][0]["bds"][0]["mac"] = "not-a-mac"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_invalid_fabric_default_bd_mac_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["default_bd_mac"] = "nope"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# BGP keepalive/hold — l3out.csw_peer -> bgpPeerP
# ---------------------------------------------------------------------------
class TestBgpTimers:
def test_schema_defaults(self) -> None:
cp = CswPeer(name="csw", asn=65100, peer_ip="10.0.0.1")
assert cp.keepalive == 60
assert cp.hold == 180
def test_override_reflected_on_bgpPeerP(self) -> None:
topo_dict = _topo_dict_with_l3out()
l3out = topo_dict["tenants"][0]["l3outs"][0]
l3out["csw_peer"]["keepalive"] = 10
l3out["csw_peer"]["hold"] = 30
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
peers = store.by_class("bgpPeerP")
assert peers
for mo in peers:
assert mo.attrs["keepAliveIntvl"] == "10"
assert mo.attrs["holdIntvl"] == "30"
def test_hold_at_or_below_keepalive_rejected(self) -> None:
topo_dict = _topo_dict_with_l3out()
l3out = topo_dict["tenants"][0]["l3outs"][0]
l3out["csw_peer"]["keepalive"] = 60
l3out["csw_peer"]["hold"] = 60
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_negative_timers_rejected(self) -> None:
topo_dict = _topo_dict_with_l3out()
l3out = topo_dict["tenants"][0]["l3outs"][0]
l3out["csw_peer"]["keepalive"] = -1
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# OSPF hello/dead — isn.ospf_hello/ospf_dead -> ospfIf
# ---------------------------------------------------------------------------
class TestOspfTimers:
def test_schema_defaults(self) -> None:
isn = ISN()
assert isn.ospf_hello == 10
assert isn.ospf_dead == 40
def test_override_reflected_on_ospfIf(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["isn"]["ospf_hello"] = 5
topo_dict["isn"]["ospf_dead"] = 20
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ospf_ifs = store.by_class("ospfIf")
assert ospf_ifs
for mo in ospf_ifs:
assert mo.attrs["helloIntvl"] == "5"
assert mo.attrs["deadIntvl"] == "20"
def test_dead_at_or_below_hello_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["isn"]["ospf_hello"] = 10
topo_dict["isn"]["ospf_dead"] = 10
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_non_positive_hello_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["isn"]["ospf_hello"] = 0
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# BFD timers — store+validate+surface only (no bfdIfP MO anywhere)
# ---------------------------------------------------------------------------
class TestBfdTimersStoreOnly:
def test_schema_defaults(self) -> None:
isn = ISN()
assert isn.bfd_min_rx == 50
assert isn.bfd_min_tx == 50
assert isn.bfd_multiplier == 3
def test_override_accepted_and_stored(self) -> None:
isn = ISN(bfd_min_rx=100, bfd_min_tx=100, bfd_multiplier=5)
assert isn.bfd_min_rx == 100
assert isn.bfd_min_tx == 100
assert isn.bfd_multiplier == 5
def test_no_bfd_mo_built_anywhere(self) -> None:
"""Confirms the store-only claim: building a full site produces no
MO with 'bfd' anywhere in its class name."""
topo_dict = _base_topo_dict()
topo_dict["isn"]["bfd_min_rx"] = 999
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
bfd_classes = {mo.class_name for mo in store.all() if "bfd" in mo.class_name.lower()}
assert bfd_classes == set()
def test_multiplier_out_of_range_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["isn"]["bfd_multiplier"] = 51
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_non_positive_min_rx_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["isn"]["bfd_min_rx"] = 0
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# oob_subnet / inb_subnet — store+validate+surface
# ---------------------------------------------------------------------------
class TestMgmtSubnets:
def test_defaults_are_none(self) -> None:
fab = Fabric(name="fab")
assert fab.oob_subnet is None
assert fab.inb_subnet is None
def test_oob_subnet_accepted_within_family(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["oob_subnet"] = "192.168.50.0/24"
topo = Topology.model_validate(topo_dict)
assert topo.fabric.oob_subnet == "192.168.50.0/24"
def test_oob_subnet_outside_family_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["oob_subnet"] = "172.20.0.0/24"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_oob_subnet_malformed_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["oob_subnet"] = "not-a-cidr"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_inb_subnet_accepted_within_rfc1918(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["inb_subnet"] = "10.50.0.0/16"
topo = Topology.model_validate(topo_dict)
assert topo.fabric.inb_subnet == "10.50.0.0/16"
def test_inb_subnet_outside_rfc1918_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["inb_subnet"] = "8.8.8.0/24"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_inb_subnet_no_mo_built(self) -> None:
"""Confirms the store-only claim: no mgmtInB/inbMgmtAddr-style MO
exists anywhere in the built store."""
topo_dict = _base_topo_dict()
topo_dict["fabric"]["inb_subnet"] = "10.50.0.0/16"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
inb_classes = {
mo.class_name for mo in store.all()
if "inb" in mo.class_name.lower() or "mgmtinb" in mo.class_name.lower()
}
assert inb_classes == set()
def test_show_surfaces_both_subnets(self) -> None:
from aci_sim.cli import build_inventory
topo_dict = _base_topo_dict()
topo_dict["fabric"]["oob_subnet"] = "192.168.50.0/24"
topo_dict["fabric"]["inb_subnet"] = "10.50.0.0/16"
topo = Topology.model_validate(topo_dict)
inv = build_inventory(topo)
assert inv["oob_subnet"] == "192.168.50.0/24"
assert inv["inb_subnet"] == "10.50.0.0/16"
# ---------------------------------------------------------------------------
# fabric.default_apic_version — fabric-wide override, independent of
# switch-node Node.version
# ---------------------------------------------------------------------------
class TestApicVersion:
def test_schema_default_is_none(self) -> None:
fab = Fabric(name="fab")
assert fab.default_apic_version is None
def test_unset_uses_site_apic_version(self) -> None:
topo_dict = _base_topo_dict()
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrl_top_system = next(
m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-1/sys"
)
assert ctrl_top_system.attrs["version"] == site.apic_version
def test_override_drives_controller_topSystem_independent_of_switch_version(self) -> None:
"""apic_version override must show up on the controller's topSystem/
firmwareCtrlrRunning while switch-node fabricNode/topSystem.version
(Node.version, PR-18) stays completely unaffected."""
topo_dict = _base_topo_dict()
topo_dict["fabric"]["default_apic_version"] = "5.2(7g)"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrl_top_system = next(
m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-1/sys"
)
assert ctrl_top_system.attrs["version"] == "5.2(7g)"
ctrl_node = next(m for m in store.by_class("fabricNode") if m.dn == f"topology/pod-{site.pod}/node-1")
assert ctrl_node.attrs["version"] == "5.2(7g)"
fw = next(m for m in store.by_class("firmwareCtrlrRunning"))
assert fw.attrs["version"] == "5.2(7g)"
# Switch-node version is untouched — independence confirmed.
leaf_id = site.leaf_nodes()[0].id
leaf_top_system = next(
m for m in store.by_class("topSystem") if m.dn == f"topology/pod-{site.pod}/node-{leaf_id}/sys"
)
assert leaf_top_system.attrs["version"] == "n9000-14.2(7f)"
def test_override_applies_to_all_controllers(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["fabric"]["default_apic_version"] = "5.2(7g)"
topo_dict["sites"][0]["controllers"] = 3
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
fw_versions = {m.attrs["version"] for m in store.by_class("firmwareCtrlrRunning")}
assert fw_versions == {"5.2(7g)"}
def test_show_surfaces_effective_apic_version(self) -> None:
from aci_sim.cli import build_inventory
topo_dict = _base_topo_dict()
topo_dict["fabric"]["default_apic_version"] = "5.2(7g)"
topo = Topology.model_validate(topo_dict)
inv = build_inventory(topo)
assert all(s["apic_version"] == "5.2(7g)" for s in inv["sites"])
+246
View File
@@ -0,0 +1,246 @@
"""Tests for PR-21 — single-APIC default + per-controller OOB IPs.
Design intent (owner directive, see README §6a / docs/DESIGN.md "PR-21"):
APIC cluster size is irrelevant to Ansible playbook *deployment* testing (a
playbook connects to and pushes config through exactly one APIC endpoint);
cluster size only matters for cluster-health/appliance-vector tooling (e.g.
autoACI's health_score.py, which reads infraWiNode across the cluster).
`Site.controllers` therefore now defaults to 1 (was 3 pre-PR-21), while
remaining fully configurable in the validated range 1-5.
Covers:
- `site.controllers` defaults to 1 (both the repo topology.yaml and a
freshly-scaffolded `generate_topology()` dict).
- `controllers` range validation: 1-5 accepted, 0/6 rejected.
- `site.controller_ips` unset (default): sequential OOB-IP derivation from
`site.mgmt_ip` (controller 1 = mgmt_ip, controller 2 = mgmt_ip+1, ...),
reflected on the built controller `topSystem.oobMgmtAddr`.
- `site.controller_ips` explicit: those exact IPs used verbatim, honored
on the built controller `topSystem.oobMgmtAddr`; length mismatch
against `controllers` is rejected; an invalid IP entry is rejected.
- `infraWiNode` count continues to track `controllers` (== controllers²)
regardless of default/override — this was already true pre-PR-21
(test_pr3b_batch2.py asserts it dynamically), verified again here at
the new default.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import orchestrator
from aci_sim.cli import generate_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
def _base_topo_dict(**kwargs) -> dict:
return generate_topology(sites=1, leaves_per_site=2, spines_per_site=2, border_pairs=0, **kwargs)
# ---------------------------------------------------------------------------
# Default: controllers == 1
# ---------------------------------------------------------------------------
class TestDefaultIsSingleApic:
def test_repo_topology_defaults_to_1_controller_per_site(self) -> None:
topo = load_topology(TOPOLOGY_YAML)
for site in topo.sites:
assert site.controllers == 1
def test_scaffolded_topology_defaults_to_1_controller_per_site(self) -> None:
topo_dict = generate_topology(sites=2, leaves_per_site=2, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
for site in topo.sites:
assert site.controllers == 1
def test_single_apic_default_yields_7_fabricnodes_for_2x2x2_site(self) -> None:
"""2 spines + 2 leaves + 2 border-leaves + 1 controller (new default) = 7
(was 9 pre-PR-21 at the old controllers=3 default)."""
topo_dict = generate_topology(sites=1, leaves_per_site=2, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
nodes = store.by_class("fabricNode")
assert len(nodes) == 7, f"Expected 7 fabricNodes, got {len(nodes)}"
controllers = [n for n in nodes if n.attrs["role"] == "controller"]
assert len(controllers) == 1
# ---------------------------------------------------------------------------
# Range validation: 1-5
# ---------------------------------------------------------------------------
class TestControllersRangeValidation:
@pytest.mark.parametrize("n", [1, 2, 3, 4, 5])
def test_in_range_accepted(self, n: int) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["controllers"] = n
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].controllers == n
@pytest.mark.parametrize("n", [0, -1, 6, 10])
def test_out_of_range_rejected(self, n: int) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["controllers"] = n
with pytest.raises(ValidationError, match="controllers"):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# controller_ips unset (default): sequential derivation from mgmt_ip
# ---------------------------------------------------------------------------
class TestSequentialDerivationFromMgmtIp:
def test_controller_oob_ips_helper_sequential(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.11"
topo_dict["sites"][0]["controllers"] = 3
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
assert site.controller_oob_ips() == ["10.192.0.11", "10.192.0.12", "10.192.0.13"]
def test_built_topSystem_oob_addrs_are_sequential(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.11"
topo_dict["sites"][0]["controllers"] = 3
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
ctrl_top_systems = {
mo.attrs["id"]: mo.attrs["oobMgmtAddr"]
for mo in store.by_class("topSystem")
if mo.attrs.get("role") == "controller"
}
assert ctrl_top_systems == {
"1": "10.192.0.11",
"2": "10.192.0.12",
"3": "10.192.0.13",
}
def test_sequential_derivation_rolls_over_octet(self) -> None:
"""mgmt_ip near the end of a /24-style boundary still derives
correctly via plain ipaddress integer arithmetic (no wraparound bug)."""
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["mgmt_ip"] = "10.192.0.254"
topo_dict["sites"][0]["controllers"] = 3
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
assert site.controller_oob_ips() == ["10.192.0.254", "10.192.0.255", "10.192.1.0"]
def test_unset_mgmt_ip_falls_back_to_legacy_oob_ip_scheme(self) -> None:
"""No mgmt_ip and no controller_ips -> controller_oob_ips() is empty,
and build/fabric.py falls back to the legacy oob_ip(pod, cid) scheme
(pre-PR-21 behavior), unchanged for topologies that never set mgmt_ip."""
from aci_sim.build.fabric import oob_ip
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["mgmt_ip"] = ""
topo_dict["sites"][0]["controllers"] = 3
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
assert site.controller_oob_ips() == []
store = orchestrator.build_site(topo, site)
ctrl_top_systems = {
mo.attrs["id"]: mo.attrs["oobMgmtAddr"]
for mo in store.by_class("topSystem")
if mo.attrs.get("role") == "controller"
}
assert ctrl_top_systems == {
"1": oob_ip(site.pod, 1),
"2": oob_ip(site.pod, 2),
"3": oob_ip(site.pod, 3),
}
# ---------------------------------------------------------------------------
# controller_ips explicit: honored verbatim + validated
# ---------------------------------------------------------------------------
class TestExplicitControllerIps:
def test_explicit_controller_ips_honored_on_built_topSystem(self) -> None:
explicit_ips = ["172.20.0.5", "172.20.0.9", "172.20.0.20"]
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["controllers"] = 3
topo_dict["sites"][0]["controller_ips"] = explicit_ips
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
assert site.controller_oob_ips() == explicit_ips
store = orchestrator.build_site(topo, site)
ctrl_top_systems = {
mo.attrs["id"]: mo.attrs["oobMgmtAddr"]
for mo in store.by_class("topSystem")
if mo.attrs.get("role") == "controller"
}
assert ctrl_top_systems == {"1": explicit_ips[0], "2": explicit_ips[1], "3": explicit_ips[2]}
def test_explicit_controller_ips_also_honored_on_lldp_adjep(self) -> None:
"""The leaf-side lldpAdjEp.mgmtIp must match the controller's own
topSystem.oobMgmtAddr — a real LLDP neighbor report always announces
its own mgmt IP; a mismatch would mean the sim's topology view is
internally inconsistent."""
explicit_ips = ["172.20.0.5", "172.20.0.9"]
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["controllers"] = 2
topo_dict["sites"][0]["controller_ips"] = explicit_ips
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
lldp_ips = {mo.attrs["mgmtIp"] for mo in store.by_class("lldpAdjEp") if "APIC" in mo.attrs.get("sysName", "")}
assert lldp_ips == set(explicit_ips)
def test_controller_ips_length_mismatch_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["controllers"] = 3
topo_dict["sites"][0]["controller_ips"] = ["10.0.0.1", "10.0.0.2"] # only 2, need 3
with pytest.raises(ValidationError, match="controller_ips"):
Topology.model_validate(topo_dict)
def test_controller_ips_invalid_ip_rejected(self) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["controllers"] = 1
topo_dict["sites"][0]["controller_ips"] = ["not-an-ip"]
with pytest.raises(ValidationError, match="controller_ips"):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# infraWiNode count still tracks controllers (unchanged wiring, new default)
# ---------------------------------------------------------------------------
class TestInfraWiNodeTracksControllers:
@pytest.mark.parametrize("n_controllers", [1, 2, 3, 5])
def test_infraWiNode_count_equals_controllers_squared(self, n_controllers: int) -> None:
topo_dict = _base_topo_dict()
topo_dict["sites"][0]["controllers"] = n_controllers
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
wi = store.by_class("infraWiNode")
assert len(wi) == n_controllers * n_controllers
def test_infraWiNode_count_at_new_default(self) -> None:
"""At the new default (controllers=1), infraWiNode is a 1x1 matrix —
a single controller's self-health-check entry."""
topo_dict = _base_topo_dict()
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
assert site.controllers == 1
store = orchestrator.build_site(topo, site)
wi = store.by_class("infraWiNode")
assert len(wi) == 1
assert wi[0].attrs["health"] == "fully-fit"
+479
View File
@@ -0,0 +1,479 @@
"""Tests for PR-22 — `aci-sim init`, an interactive APIC-setup-style wizard.
Covers:
- `--defaults` (and non-TTY stdin) accepts every suggested default without
ever reading stdin, producing a file that passes `load_topology()` +
validation and is BYTE-IDENTICAL to calling `generate_topology()`
directly with the same known default parameter set (the wizard is a
thin front-end — it must not duplicate topology-emission logic or drift
from it).
- A multi-site run with `controllers=3` produces `controller_ips`
sequential from the answered OOB IP (PR-21's `Site.controller_ips`).
- Custom answers (via `--answers`/`run_wizard(answers=...)` and via
scripted stdin) override the suggested defaults.
- The deployment-type branch: single-fabric -> exactly 1 site, no ISN/NDO
prompts/section; multi-site -> ISN block printed + N sites.
- Non-TTY stdin auto-accepts defaults with no hang (exercised via
`subprocess` with stdin explicitly closed/empty, which is what makes
`sys.stdin.isatty()` false in a test process).
No test ever blocks on real interactive stdin — every wizard drive in this
file uses `io.StringIO` (fully-buffered, returns EOF instead of hanging) or
`--defaults`/`accept_defaults=True` (skips reading stdin entirely).
"""
from __future__ import annotations
import io
import subprocess
import sys
from pathlib import Path
import yaml
from aci_sim.cli import generate_topology, run_wizard
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Topology
REPO_ROOT = Path(__file__).parent.parent
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _wizard_defaults_topology() -> dict:
"""The topology `aci-sim init --defaults` produces at every wizard default
(2 sites, multi-site — the wizard's own Step-0 default is "2")."""
out = io.StringIO()
topo, _gateway = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=True)
return topo
def _direct_equivalent_topology() -> dict:
"""The SAME topology built by calling `generate_topology()` directly with
the wizard's own known default values — i.e. what a caller would get if
they replicated the wizard's Step-1/Step-2 defaults by hand. Asserting
the wizard's output equals this proves `run_wizard` is a thin front-end
that does not duplicate/diverge from `generate_topology()`."""
site_overrides = [
{
"name": "Site1-IT-ACI",
"id": "1",
"mgmt_ip": "10.192.0.11",
"fabric_name": "Site1-IT-ACI",
"asn": 65001,
"pod": 1,
"controllers": 1,
"controller_ips": ["10.192.0.11"],
# OOB-gateway PR: the wizard's own default-gateway derivation is
# "first usable IP of the /24 block containing the site's APIC
# OOB IP" (run_wizard: gw_block - (gw_block % 256) + 1).
"oob_gateway": "10.192.0.1",
},
{
"name": "Site2-IT-ACI",
"id": "2",
"mgmt_ip": "10.192.128.11",
"fabric_name": "Site2-IT-ACI",
"asn": 65002,
"pod": 1,
"controllers": 1,
"controller_ips": ["10.192.128.11"],
"oob_gateway": "10.192.128.1",
},
]
topo = generate_topology(
sites=2,
leaves_per_site=[2, 2],
spines_per_site=[2, 2],
border_pairs=[1, 1],
fabric_name_prefix="",
ndo_ip="10.192.0.10",
tep_pool="10.0.0.0/16",
infra_vlan=3967,
gipo_pool="225.0.0.0/15",
isn_ospf_area="0.0.0.0",
isn_mtu=9150,
site_overrides=site_overrides,
)
topo["fabric"]["name"] = "MULTI-SITE-ACI-IT-ACI"
topo["fabric"]["oob_subnet"] = "192.168.0.0/16"
topo["isn"]["enabled"] = True
# Admin account step (Step 0): the wizard's own defaults (admin/cisco),
# ndo_same_account=yes so no ndo_username/ndo_password keys are written.
topo["auth"] = {"username": "admin", "password": "cisco"}
return topo
# ---------------------------------------------------------------------------
# 1. --defaults consistency: passes validation + equals generate_topology()
# called directly with the wizard's own default values.
# ---------------------------------------------------------------------------
class TestDefaultsConsistency:
def test_defaults_topology_passes_validation(self) -> None:
topo_dict = _wizard_defaults_topology()
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 2
def test_defaults_topology_loads_and_builds_from_disk(self, tmp_path: Path) -> None:
topo_dict = _wizard_defaults_topology()
out_file = tmp_path / "wizard.topology.yaml"
out_file.write_text(yaml.dump(topo_dict, sort_keys=False), encoding="utf-8")
topo = load_topology(out_file) # same validation `aci-sim validate` runs
assert len(topo.sites) == 2
from aci_sim.build import orchestrator
for site in topo.sites:
store = orchestrator.build_site(topo, site)
assert store.by_class("fabricNode")
def test_defaults_topology_equals_direct_generate_topology_call(self) -> None:
wizard_topo = _wizard_defaults_topology()
direct_topo = _direct_equivalent_topology()
assert wizard_topo == direct_topo
# ---------------------------------------------------------------------------
# 2. Multi-site + controllers=3 -> sequential controller_ips from the
# answered OOB IP (PR-21's Site.controller_ips).
# ---------------------------------------------------------------------------
class TestControllersThreeSequentialIps:
def test_answers_file_controllers_3_sequential_from_oob_ip(self) -> None:
answers = {
"deployment_type": "2",
"num_sites": "2",
"site1_apic1_ip": "10.50.0.20",
"site1_controllers": "3",
}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
site1 = topo["sites"][0]
assert site1["controllers"] == 3
assert site1["controller_ips"] == ["10.50.0.20", "10.50.0.21", "10.50.0.22"]
# Round-trips through full validation + Site.controller_oob_ips().
validated = Topology.model_validate(topo)
assert validated.sites[0].controller_oob_ips() == ["10.50.0.20", "10.50.0.21", "10.50.0.22"]
def test_scripted_stdin_controllers_3_sequential_suggestion_accepted(self) -> None:
"""Drives the wizard with a real scripted stdin stream (not
--answers/accept_defaults) accepting the SUGGESTED sequential IP at
each of the 3 controller prompts by pressing ENTER (blank line)."""
stdin_script = (
"\n" # admin username -> default
"\n" # admin password -> default
"\n" # ndo_same_account -> default (yes)
"1\n" # deployment type -> single fabric
"\n" # site name -> default
"\n" # site id -> default
"\n" # asn -> default
"\n" # pod -> default
"3\n" # controllers -> 3
"10.7.7.10\n" # apic1 ip -> custom
"\n" # apic2 ip -> accept suggested 10.7.7.11
"\n" # apic3 ip -> accept suggested 10.7.7.12
)
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False)
site1 = topo["sites"][0]
assert site1["controller_ips"] == ["10.7.7.10", "10.7.7.11", "10.7.7.12"]
def test_generated_file_with_controllers_3_validates_and_builds(self, tmp_path: Path) -> None:
answers = {"deployment_type": "1", "site1_controllers": "3", "site1_apic1_ip": "10.192.0.11"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
out_file = tmp_path / "ctrl3.topology.yaml"
out_file.write_text(yaml.dump(topo, sort_keys=False), encoding="utf-8")
loaded = load_topology(out_file) # raises on validation failure
from aci_sim.build import orchestrator
store = orchestrator.build_site(loaded, loaded.sites[0])
ctrl_top_systems = {
mo.attrs["id"]: mo.attrs["oobMgmtAddr"]
for mo in store.by_class("topSystem")
if mo.attrs.get("role") == "controller"
}
assert ctrl_top_systems == {"1": "10.192.0.11", "2": "10.192.0.12", "3": "10.192.0.13"}
# ---------------------------------------------------------------------------
# 3. Custom answers override defaults (both --answers-style dict and
# scripted stdin).
# ---------------------------------------------------------------------------
class TestCustomAnswersOverrideDefaults:
def test_answers_dict_overrides_site_name_and_asn(self) -> None:
answers = {
"deployment_type": "1",
"site1_name": "CustomFabric",
"site1_asn": "70000",
}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
assert topo["sites"][0]["name"] == "CustomFabric"
assert topo["sites"][0]["asn"] == 70000
# Untouched fields still take the wizard's own default.
assert topo["sites"][0]["pod"] == 1
def test_scripted_stdin_overrides_fabric_name(self) -> None:
# admin username/password/ndo_same_account -> default (3 blank lines),
# then deployment=1, site name=MyCustomLab, rest EOF->defaults.
stdin_script = "\n\n\n1\nMyCustomLab\n"
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False)
assert topo["sites"][0]["name"] == "MyCustomLab"
def test_custom_topology_still_validates(self) -> None:
answers = {"deployment_type": "1", "site1_name": "CustomFabric", "site1_leaves": "4", "site1_spines": "3"}
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers=answers)
validated = Topology.model_validate(topo)
assert len(validated.sites[0].leaf_nodes()) == 4
assert len(validated.sites[0].spine_nodes()) == 3
def test_invalid_custom_answer_reprompts_then_accepts_valid_value(self) -> None:
"""A bad value typed at a validated prompt re-prompts (per _prompt's
validator contract) rather than silently accepting garbage."""
stdin_script = (
"\n" # admin username -> default
"\n" # admin password -> default
"\n" # ndo_same_account -> default (yes)
"1\n" # deployment type
"\n" # site name
"\n" # site id
"\n" # asn
"\n" # pod
"not-a-number\n2\n" # controllers: invalid then valid
)
out = io.StringIO()
topo, _gw = run_wizard(stream_in=io.StringIO(stdin_script), stream_out=out, accept_defaults=False)
assert topo["sites"][0]["controllers"] == 2
assert "must be an integer" in out.getvalue()
# ---------------------------------------------------------------------------
# 4. Deployment-type branch: single-fabric vs multi-site.
# ---------------------------------------------------------------------------
class TestDeploymentTypeBranch:
def test_single_fabric_yields_exactly_one_site_no_isn(self) -> None:
out = io.StringIO()
topo, _gw = run_wizard(
stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers={"deployment_type": "1"}
)
assert len(topo["sites"]) == 1
assert topo["isn"]["enabled"] is False
# "Step 3" is the multi-site-only NDO+ISN step (run_wizard) — must be
# absent entirely on the single-fabric branch.
assert "Step 3" not in out.getvalue()
assert "ISN" not in out.getvalue().split("Step 3", 1)[-1] if "Step 3" in out.getvalue() else True
def test_single_fabric_never_prompts_for_ndo_or_isn_fields(self) -> None:
# Deliberately supply an --answers value for an ISN-only field to
# prove it's simply never consulted on the single-fabric branch
# (rather than testing an absence of output, which is brittle).
out = io.StringIO()
topo, _gw = run_wizard(
stream_in=io.StringIO(""),
stream_out=out,
accept_defaults=False,
answers={"deployment_type": "1", "isn_mtu": "1234"},
)
assert topo["isn"]["mtu"] == 9150 # wizard default, NOT the supplied 1234 (never asked)
def test_multi_site_default_yields_two_sites_and_isn_enabled(self) -> None:
out = io.StringIO()
topo, _gw = run_wizard(
stream_in=io.StringIO(""), stream_out=out, accept_defaults=False, answers={"deployment_type": "2"}
)
assert len(topo["sites"]) == 2
assert topo["isn"]["enabled"] is True
assert "Step 3" in out.getvalue()
def test_multi_site_n_sites_override(self) -> None:
out = io.StringIO()
topo, _gw = run_wizard(
stream_in=io.StringIO(""),
stream_out=out,
accept_defaults=False,
answers={"deployment_type": "2", "num_sites": "3"},
)
assert len(topo["sites"]) == 3
assert topo["isn"]["enabled"] is True
# Sequential ASN/site-id/name defaults continue past site 2.
assert topo["sites"][2]["asn"] == 65003
assert topo["sites"][2]["name"] == "Site3-IT-ACI"
def test_multi_site_topology_validates_and_builds_all_sites(self) -> None:
out = io.StringIO()
topo, _gw = run_wizard(
stream_in=io.StringIO(""), stream_out=out, accept_defaults=True, answers={"deployment_type": "2"}
)
topo_obj = Topology.model_validate(topo)
from aci_sim.build import orchestrator
for site in topo_obj.sites:
store = orchestrator.build_site(topo_obj, site)
assert store.by_class("fabricNode")
# ---------------------------------------------------------------------------
# 5. Non-TTY stdin auto-accepts defaults with no hang.
# ---------------------------------------------------------------------------
class TestNonTtyAutoAccept:
def test_accept_defaults_flag_never_reads_stdin(self) -> None:
"""accept_defaults=True must short-circuit _prompt before it ever
calls stream_in.readline() — assert by passing a stream that raises
if read from."""
class _ExplodingStream(io.StringIO):
def readline(self, *a, **kw): # noqa: D401 - test double
raise AssertionError("stdin should never be read when accept_defaults=True")
out = io.StringIO()
topo, _gw = run_wizard(stream_in=_ExplodingStream(""), stream_out=out, accept_defaults=True)
assert len(topo["sites"]) == 2 # completed without ever touching stdin
def test_cli_subprocess_with_empty_stdin_completes_without_defaults_flag(self, tmp_path: Path) -> None:
"""End-to-end: invoke the real `aci-sim init` subcommand (via -m,
matching how the CLI is actually launched) with stdin explicitly
set to DEVNULL (non-TTY, immediate EOF) and NO --defaults flag —
must still complete (isatty()==False auto-enables accept_defaults)
rather than hang waiting for input. A timeout means this test caught
a real hang, not a flaky assertion."""
out_file = tmp_path / "auto.topology.yaml"
result = subprocess.run(
[sys.executable, "-m", "aci_sim.cli", "init", "-o", str(out_file)],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
cwd=REPO_ROOT,
)
assert result.returncode == 0, result.stdout + result.stderr
assert out_file.exists()
loaded = load_topology(out_file)
assert len(loaded.sites) == 2
# ---------------------------------------------------------------------------
# Misc: --answers file loading (YAML and JSON), CLI wiring smoke test.
# ---------------------------------------------------------------------------
class TestAnswersFileLoading:
def test_answers_file_yaml(self, tmp_path: Path) -> None:
answers_file = tmp_path / "answers.yaml"
answers_file.write_text("deployment_type: '1'\nsite1_name: FromYaml\n", encoding="utf-8")
out_file = tmp_path / "out.yaml"
result = subprocess.run(
[
sys.executable, "-m", "aci_sim.cli", "init",
"-o", str(out_file), "--answers", str(answers_file),
],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
cwd=REPO_ROOT,
)
assert result.returncode == 0, result.stdout + result.stderr
loaded = load_topology(out_file)
assert loaded.sites[0].name == "FromYaml"
def test_answers_file_json(self, tmp_path: Path) -> None:
answers_file = tmp_path / "answers.json"
answers_file.write_text('{"deployment_type": "1", "site1_name": "FromJson"}', encoding="utf-8")
out_file = tmp_path / "out.yaml"
result = subprocess.run(
[
sys.executable, "-m", "aci_sim.cli", "init",
"-o", str(out_file), "--answers", str(answers_file),
],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
cwd=REPO_ROOT,
)
assert result.returncode == 0, result.stdout + result.stderr
loaded = load_topology(out_file)
assert loaded.sites[0].name == "FromJson"
def test_answers_file_not_found_reports_error_exit_1(self, tmp_path: Path) -> None:
result = subprocess.run(
[sys.executable, "-m", "aci_sim.cli", "init", "--answers", str(tmp_path / "nope.yaml")],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
cwd=REPO_ROOT,
)
assert result.returncode == 1
assert "ERROR" in result.stderr
class TestConfirmWrite:
def test_confirm_write_false_aborts_without_writing(self, tmp_path: Path) -> None:
answers_file = tmp_path / "answers.yaml"
answers_file.write_text("deployment_type: '1'\nconfirm_write: 'no'\n", encoding="utf-8")
out_file = tmp_path / "should_not_exist.yaml"
result = subprocess.run(
[
sys.executable, "-m", "aci_sim.cli", "init",
"-o", str(out_file), "--answers", str(answers_file),
],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
cwd=REPO_ROOT,
)
assert result.returncode == 1
assert not out_file.exists()
assert "Aborted" in result.stdout
class TestCliSmoke:
def test_init_appears_in_help(self) -> None:
result = subprocess.run(
[sys.executable, "-m", "aci_sim.cli", "--help"],
capture_output=True,
text=True,
timeout=30,
cwd=REPO_ROOT,
)
assert "init" in result.stdout
def test_defaults_flag_and_output_flag_smoke(self, tmp_path: Path) -> None:
out_file = tmp_path / "smoke.topology.yaml"
result = subprocess.run(
[sys.executable, "-m", "aci_sim.cli", "init", "--defaults", "-o", str(out_file)],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=30,
cwd=REPO_ROOT,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "OK: 2 site(s)" in result.stdout
loaded = load_topology(out_file)
assert len(loaded.sites) == 2
+213
View File
@@ -0,0 +1,213 @@
"""Regression tests for PR-3 P0 findings.
FIX 1 — node IDs > 255 (LAB2: 301-402) previously produced invalid IPv4
addresses (e.g. 10.1.401.1 has a 401 octet, 192.168.1.401 likewise) via
build/fabric.py's loopback_ip/oob_ip. Now every derived address must parse as
valid IPv4, stay unique per node within a site, and node IDs <= 255 (LAB1)
must keep their exact legacy addresses unchanged.
FIX 2 — GET /api/mo/uni/userprofile-<user>.json?query-target=subtree&
target-subtree-class=aaaUserDomain (CONTRACT.md §1 login probe) 404'd because
no userprofile MO was ever built. build/userprofile.py now seeds a static
uni/userprofile-admin (aaaUserEp) + aaaUserDomain child in every site's
baseline store.
"""
from __future__ import annotations
import copy
import ipaddress
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.build.fabric import loopback_ip, oob_ip
from aci_sim.build.l3out import _build_node_profile, _upsert_ebgp_sessions
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml" # run from project root
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def site_b(topo):
return topo.site_by_name("LAB2")
@pytest.fixture(scope="module")
def store_a(topo, site_a) -> MITStore:
return build_site(topo, site_a)
@pytest.fixture(scope="module")
def store_b(topo, site_b) -> MITStore:
return build_site(topo, site_b)
@pytest.fixture(scope="module")
def client_a(topo, site_a, store_a):
state = ApicSiteState(
name=site_a.name, site=site_a, topo=topo, store=store_a,
baseline=copy.deepcopy(store_a),
)
c = TestClient(make_apic_app(state))
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c
# ---------------------------------------------------------------------------
# FIX 1 — Test 1: all topSystem address/oobMgmtAddr parse as valid IPv4 and
# are unique within each site (both sites, with Site B's 301-402 explicit).
# ---------------------------------------------------------------------------
def _topsystem_addrs(store: MITStore) -> list[tuple[str, str, str]]:
"""Return (node_id, address, oobMgmtAddr) for every topSystem MO."""
out = []
for mo in store.by_class("topSystem"):
out.append((mo.attrs["id"], mo.attrs["address"], mo.attrs["oobMgmtAddr"]))
return out
def test_site_a_addresses_valid_and_unique(store_a):
rows = _topsystem_addrs(store_a)
assert rows, "no topSystem MOs found for LAB1"
addrs = [r[1] for r in rows]
oobs = [r[2] for r in rows]
for a in addrs:
ipaddress.ip_address(a)
for o in oobs:
ipaddress.ip_address(o)
assert len(addrs) == len(set(addrs)), f"duplicate loopback addresses in LAB1: {addrs}"
assert len(oobs) == len(set(oobs)), f"duplicate oob addresses in LAB1: {oobs}"
def test_site_b_addresses_valid_and_unique(store_b):
"""LAB2 node IDs are 301-402 — the previously-invalid range."""
rows = _topsystem_addrs(store_b)
node_ids = {r[0] for r in rows}
# sanity: LAB2's high-numbered nodes are actually present in this fixture
assert {"301", "302", "303", "304", "401", "402"} <= node_ids, node_ids
addrs = [r[1] for r in rows]
oobs = [r[2] for r in rows]
for node_id, addr, oob in rows:
# Every address must parse as valid IPv4 -- this is the core regression
# check: pre-fix, node 401 produced "10.1.401.1" / "192.168.1.401",
# both of which ipaddress.ip_address() rejects (octet > 255).
ipaddress.ip_address(addr)
ipaddress.ip_address(oob)
assert len(addrs) == len(set(addrs)), f"duplicate loopback addresses in LAB2: {addrs}"
assert len(oobs) == len(set(oobs)), f"duplicate oob addresses in LAB2: {oobs}"
# ---------------------------------------------------------------------------
# FIX 1 — Test 2: node IDs <= 255 keep the exact legacy addresses
# (backward compatibility for LAB1: nodes 1-3, 101-104, 201-202).
# ---------------------------------------------------------------------------
def test_legacy_node_ids_unchanged(site_a):
"""Node IDs <= 255 must keep the pre-fix 10.<pod>.<id>.1 / 192.168.<pod>.<id> form."""
pod = site_a.pod
assert loopback_ip(pod, 101) == "10.1.101.1"
assert oob_ip(pod, 101) == "192.168.1.101"
assert loopback_ip(pod, 201) == "10.1.201.1"
assert oob_ip(pod, 201) == "192.168.1.201"
def test_legacy_top_system_addresses_in_store(store_a, site_a):
"""The actual built topSystem MOs for LAB1 (all node IDs <= 255) must show
the unchanged legacy addresses."""
pod = site_a.pod
node = next(n for n in site_a.all_nodes() if n.id == 101)
ts_dn = f"topology/pod-{pod}/node-{node.id}/sys"
mo = store_a.get(ts_dn)
assert mo is not None
assert mo.attrs["address"] == "10.1.101.1"
assert mo.attrs["oobMgmtAddr"] == "192.168.1.101"
# ---------------------------------------------------------------------------
# FIX 1 — Test 4: refactored producers (l3out.py rtr_id) also emit valid IPv4
# ---------------------------------------------------------------------------
def test_l3out_node_profile_rtr_id_valid_ipv4(topo, site_b):
"""l3out.py's _build_node_profile derives rtr_id via loopback_ip for
border leaves; LAB2's border leaves (303/304) are <= 255, but this
verifies the refactored call-site (no more inline f"10.{pod}.{id}.1")
still produces valid IPv4, and stays consistent with fabric.py."""
tenant = next(t for t in topo.tenants if t.l3outs)
l3out = next(lo for lo in tenant.l3outs if lo.site == site_b.name or
any(bl in {b.id for b in site_b.border_leaves} for bl in lo.border_leaves))
np_mo = _build_node_profile(f"uni/tn-{tenant.name}/out-{l3out.name}", l3out, site_b, site_b.pod)
rs_atts = [c for c in np_mo.children if c.class_name == "l3extRsNodeL3OutAtt"]
assert rs_atts, "no l3extRsNodeL3OutAtt children built"
for att in rs_atts:
ipaddress.ip_address(att.attrs["rtrId"])
def test_l3out_ebgp_session_rtr_id_valid_ipv4(topo, site_b, store_b):
"""_upsert_ebgp_sessions's rtr_id (also refactored onto loopback_ip) must
be valid IPv4 in the built store."""
bgp_peer_entries = [
mo for mo in store_b.by_class("bgpPeerEntry")
if mo.attrs.get("type") == "ebgp"
]
assert bgp_peer_entries, "no ebgp bgpPeerEntry found for LAB2"
for mo in bgp_peer_entries:
ipaddress.ip_address(mo.attrs["rtrId"])
def test_cabling_lldp_cdp_mgmt_ip_valid_ipv4(store_b):
"""cabling.py's lldpAdjEp/cdpAdjEp mgmtIp (built via oob_ip) must be valid
IPv4 even for LAB2 nodes > 255."""
adj_mos = list(store_b.by_class("lldpAdjEp")) + list(store_b.by_class("cdpAdjEp"))
assert adj_mos, "no lldp/cdp adjacency MOs found for LAB2"
for mo in adj_mos:
ipaddress.ip_address(mo.attrs["mgmtIp"])
def test_overlay_isn_peer_addr_valid_ipv4(store_a):
"""overlay.py's ISN bgpPeer addr (loopback_ip + '/32') must have a valid
IPv4 host part even when the remote site's spines are > 255 (LAB2: 401/402)."""
isn_peers = [
mo for mo in store_a.by_class("bgpPeer")
if mo.attrs.get("type") == "inter-site"
]
assert isn_peers, "no ISN bgpPeer found on LAB1 spines"
for mo in isn_peers:
addr = mo.attrs["addr"]
assert addr.endswith("/32")
host = addr.rsplit("/", 1)[0]
ipaddress.ip_address(host)
# ---------------------------------------------------------------------------
# FIX 2 — Test 3: login probe target resolves with totalCount >= 1
# ---------------------------------------------------------------------------
def test_userprofile_login_probe(client_a):
resp = client_a.get(
"/api/mo/uni/userprofile-admin.json"
"?query-target=subtree&target-subtree-class=aaaUserDomain"
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) >= 1
assert data["imdata"], "expected at least one aaaUserDomain row"
for item in data["imdata"]:
assert "aaaUserDomain" in item
+440
View File
@@ -0,0 +1,440 @@
"""Regression/coverage tests for PR-3b-batch1 — 21 previously-documented-but-
unbuilt classes from docs/CONTRACT.md §6, seeded from the existing topology:
Access-policy cluster (build/access.py): infraAccPortP, infraHPortS,
infraPortBlk, infraRsAccBaseGrp, infraAccBndlGrp.
Route-control cluster (build/l3out.py): rtctrlProfile, rtctrlCtxP,
rtctrlSubjP, rtctrlMatchRtDest, rtctrlSetComm, rtctrlSetPref, ipRouteP,
ipNexthopP, l3extMember, ospfExtP.
EPG/contract classes (build/tenants.py): fvRsPathAtt, vzRsSubjGraphAtt.
Operational/routing classes (build/routing.py + build/underlay.py):
uribv4Route, uribv4Nexthop, arpAdjEp, ospfIf.
Auth is enforced (PR-4): every client fixture logs in via
POST /api/aaaLogin.json (admin/cisco) before making authenticated calls,
following the existing tests/test_pr5_topology_consistency.py pattern.
"""
from __future__ import annotations
import copy
import re
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def site_b(topo):
return topo.site_by_name("LAB2")
@pytest.fixture(scope="module")
def store_a(topo, site_a):
return build_site(topo, site_a)
@pytest.fixture(scope="module")
def store_b(topo, site_b):
return build_site(topo, site_b)
def _make_client(topo, site, store) -> TestClient:
state = ApicSiteState(
name=site.name, site=site, topo=topo, store=store,
baseline=copy.deepcopy(store),
)
c = TestClient(make_apic_app(state))
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c
@pytest.fixture(scope="module")
def client_a(topo, site_a, store_a):
return _make_client(topo, site_a, store_a)
@pytest.fixture(scope="module")
def client_b(topo, site_b, store_b):
return _make_client(topo, site_b, store_b)
_ALL_STORES = ["store_a", "store_b"]
def _l1physif_ports(store) -> dict[int, set[str]]:
"""Return {node_id: {port_id, ...}} from every l1PhysIf DN in the store."""
ports: dict[int, set[str]] = {}
for mo in store.by_class("l1PhysIf"):
m = re.search(r"/node-(\d+)/sys/phys-\[([^\]]+)\]", mo.dn)
assert m is not None, f"unparsable l1PhysIf dn {mo.dn!r}"
node_id = int(m.group(1))
ports.setdefault(node_id, set()).add(m.group(2))
return ports
# ---------------------------------------------------------------------------
# Access-policy cluster
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_access_policy_classes_nonempty(store_name, request):
store = request.getfixturevalue(store_name)
for cls in (
"infraAccPortP", "infraHPortS", "infraPortBlk",
"infraRsAccBaseGrp", "infraAccBndlGrp",
):
mos = store.by_class(cls)
assert mos, f"{cls} query returned totalCount 0"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_infra_port_blk_ports_exist_in_l1physif_inventory(store_name, request):
"""Every infraPortBlk from/to port must exist as a real l1PhysIf — the
port block's fromCard/from/to describe a leaf host-port range, but
infraPortBlk itself carries no node id, so we resolve it via its parent
infraHPortS -> infraRsAccBaseGrp -> infraAccBndlGrp path is not node-
scoped either (access profiles are site-independent in this sim); we
instead assert the from/to port NUMBERS fall within the host-port range
(1.._HOST_PORTS) that interfaces.py builds on every leaf, i.e. the block
is representable on any leaf's real inventory.
"""
store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store)
all_host_ports = {int(p.split("/")[1]) for ps in ports.values() for p in ps if "/" in p}
blks = store.by_class("infraPortBlk")
assert blks, "no infraPortBlk found"
for blk in blks:
from_p = int(blk.attrs["fromPort"])
to_p = int(blk.attrs["toPort"])
assert blk.attrs["fromCard"] == "1"
assert from_p <= to_p
assert from_p in all_host_ports, (
f"infraPortBlk {blk.dn!r} fromPort {from_p} does not match any "
f"real l1PhysIf port number"
)
assert to_p in all_host_ports, (
f"infraPortBlk {blk.dn!r} toPort {to_p} does not match any real "
f"l1PhysIf port number"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_hports_rs_acc_base_grp_targets_real_bndl_grp(store_name, request):
store = request.getfixturevalue(store_name)
bndl_dns = {mo.dn for mo in store.by_class("infraAccBndlGrp")}
assert bndl_dns, "no infraAccBndlGrp found"
rs_grps = store.by_class("infraRsAccBaseGrp")
assert rs_grps, "no infraRsAccBaseGrp found"
for rs in rs_grps:
assert rs.attrs["tDn"] in bndl_dns, (
f"infraRsAccBaseGrp {rs.dn!r} tDn {rs.attrs['tDn']!r} does not "
f"resolve to a real infraAccBndlGrp"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_at_least_one_vpc_bndl_grp(store_name, request):
"""One infraAccBndlGrp (lagT=node) per border-leaf vPC domain, consistent
with fabric.py's vpcDom/vpcIf for the same pairs."""
store = request.getfixturevalue(store_name)
vpc_bndls = [m for m in store.by_class("infraAccBndlGrp") if m.attrs.get("lagT") == "node"]
assert vpc_bndls, "expected at least one vPC (lagT=node) infraAccBndlGrp"
vpc_doms = store.by_class("vpcDom")
assert vpc_doms, "no vpcDom found to cross-check against"
def test_infra_subtree_full_returns_nested_access_policy_tree(client_a):
"""rsp-subtree=full on uni/infra returns the nested access-policy tree."""
resp = client_a.get("/api/mo/uni/infra.json?rsp-subtree=full")
assert resp.status_code == 200, f"uni/infra -> {resp.status_code}: {resp.text[:200]}"
data = resp.json()
assert data["imdata"], "expected uni/infra to resolve to a real MO"
body = str(data)
for cls in ("infraAccPortP", "infraHPortS", "infraPortBlk", "infraRsAccBaseGrp", "infraAccBndlGrp"):
assert cls in body, f"{cls} missing from uni/infra rsp-subtree=full response"
# ---------------------------------------------------------------------------
# Route-control cluster
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_route_control_classes_nonempty(store_name, request):
store = request.getfixturevalue(store_name)
for cls in (
"rtctrlProfile", "rtctrlCtxP", "rtctrlSubjP", "rtctrlMatchRtDest",
"rtctrlSetComm", "rtctrlSetPref", "ipRouteP", "ipNexthopP",
"l3extMember", "ospfExtP",
):
mos = store.by_class(cls)
assert mos, f"{cls} query returned totalCount 0"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_rtctrl_match_rt_dest_uses_real_bd_subnet(store_name, request, topo):
store = request.getfixturevalue(store_name)
all_subnets = {
cidr
for tenant in topo.tenants
for bd in tenant.bds
for cidr in bd.subnets
}
dests = store.by_class("rtctrlMatchRtDest")
assert dests, "no rtctrlMatchRtDest found"
for d in dests:
assert d.attrs["ip"] in all_subnets, (
f"rtctrlMatchRtDest {d.dn!r} ip {d.attrs['ip']!r} is not a real BD subnet"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ip_route_p_under_real_rsnode_l3out_att(store_name, request):
"""Every ipRouteP dn must nest under a real l3extRsNodeL3OutAtt (i.e. an
actual border-leaf node attachment), per the placement table."""
store = request.getfixturevalue(store_name)
rsnode_dns = {mo.dn for mo in store.by_class("l3extRsNodeL3OutAtt")}
assert rsnode_dns, "no l3extRsNodeL3OutAtt found"
routes = store.by_class("ipRouteP")
assert routes, "no ipRouteP found"
for r in routes:
parent = r.dn.rsplit("/rt-[", 1)[0]
assert parent in rsnode_dns, f"ipRouteP {r.dn!r} not nested under a real rsnodeL3OutAtt"
assert r.attrs["ip"] == "0.0.0.0/0"
assert r.attrs.get("pref")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ip_nexthop_p_matches_csw_peer_ip(store_name, request, topo):
store = request.getfixturevalue(store_name)
nhs = store.by_class("ipNexthopP")
assert nhs, "no ipNexthopP found"
all_csw_peer_ips = {
bl.csw.peer_ip
for site in topo.sites
for bl in site.border_leaves
}
for nh in nhs:
assert nh.attrs["nhAddr"] in all_csw_peer_ips, (
f"ipNexthopP {nh.dn!r} nhAddr {nh.attrs['nhAddr']!r} is not a real CSW peer IP"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_l3ext_member_on_real_path(store_name, request):
store = request.getfixturevalue(store_name)
path_dns = {mo.dn for mo in store.by_class("l3extRsPathL3OutAtt")}
assert path_dns, "no l3extRsPathL3OutAtt found"
members = store.by_class("l3extMember")
assert members, "no l3extMember found"
for m in members:
parent = m.dn.rsplit("/mem-", 1)[0]
assert parent in path_dns, f"l3extMember {m.dn!r} not nested under a real l3extRsPathL3OutAtt"
assert m.attrs["side"] == "A"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ospf_ext_p_coexists_with_bgp_ext_p_on_same_l3out(store_name, request):
"""Batch-1 design choice: ospfExtP added to the SAME L3Out as the
existing bgpExtP (real ACI allows OSPF+BGP coexistence on one L3Out)."""
store = request.getfixturevalue(store_name)
ospf_ext = store.by_class("ospfExtP")
bgp_ext = store.by_class("bgpExtP")
assert ospf_ext, "no ospfExtP found"
assert bgp_ext, "no bgpExtP found"
ospf_parents = {mo.dn.rsplit("/ospfExtP", 1)[0] for mo in ospf_ext}
bgp_parents = {mo.dn.rsplit("/bgpExtP", 1)[0] for mo in bgp_ext}
assert ospf_parents & bgp_parents, "ospfExtP and bgpExtP do not share a common L3Out parent"
# ---------------------------------------------------------------------------
# EPG/contract classes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_epg_contract_classes_nonempty(store_name, request):
store = request.getfixturevalue(store_name)
for cls in ("fvRsPathAtt", "vzRsSubjGraphAtt"):
mos = store.by_class(cls)
assert mos, f"{cls} query returned totalCount 0"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_fv_rs_path_att_tdn_is_real_path_with_existing_port(store_name, request):
"""Every fvRsPathAtt tDn must be a real path whose port exists as a real
l1PhysIf (same port an EPG's own endpoints already use)."""
store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store)
bindings = store.by_class("fvRsPathAtt")
assert bindings, "no fvRsPathAtt found"
for b in bindings:
m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", b.attrs["tDn"])
assert m is not None, f"unparsable fvRsPathAtt tDn {b.attrs['tDn']!r}"
node_id, port_id = int(m.group(1)), m.group(2)
assert port_id in ports.get(node_id, set()), (
f"fvRsPathAtt {b.dn!r} tDn references {port_id!r} on node {node_id}, "
f"which has no matching l1PhysIf"
)
# encap must reuse the EPG's own vlan (parsed from the same store's fvCEp)
assert b.attrs["encap"].startswith("vlan-")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_vz_rs_subj_graph_att_on_real_subject(store_name, request):
store = request.getfixturevalue(store_name)
subj_dns = {mo.dn for mo in store.by_class("vzSubj")}
assert subj_dns, "no vzSubj found"
graphs = store.by_class("vzRsSubjGraphAtt")
assert graphs, "no vzRsSubjGraphAtt found"
for g in graphs:
parent = g.dn.rsplit("/rsSubjGraphAtt", 1)[0]
assert parent in subj_dns, f"vzRsSubjGraphAtt {g.dn!r} not nested under a real vzSubj"
assert g.attrs.get("tnVnsAbsGraphName")
# ---------------------------------------------------------------------------
# Operational/routing classes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_routing_classes_nonempty(store_name, request):
store = request.getfixturevalue(store_name)
for cls in ("uribv4Route", "uribv4Nexthop", "arpAdjEp", "ospfIf"):
mos = store.by_class(cls)
assert mos, f"{cls} query returned totalCount 0"
_RE_NH_PARENT = re.compile(r"^(.*/rt-\[[^\]]+\])/nh-")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_uribv4_nexthop_rn_parses_into_4_bracketed_parts(store_name, request):
"""Every uribv4Nexthop RN must parse into 4 bracketed parts:
nh-[proto]-[addr]-[ifname]-[vrf] — matching autoACI's
_RE_NH_PARENT = re.compile(r"^(.*/rt-\\[[^\\]]+\\])/nh-") parent-recovery
regex (verified read-only against route_table.py / routing_state.py)."""
store = request.getfixturevalue(store_name)
nhs = store.by_class("uribv4Nexthop")
assert nhs, "no uribv4Nexthop found"
for nh in nhs:
m = _RE_NH_PARENT.match(nh.dn)
assert m is not None, f"uribv4Nexthop {nh.dn!r} does not match autoACI's parent-recovery regex"
rn = nh.dn.rsplit("/nh-", 1)[1]
bracket_groups = re.findall(r"\[([^\]]*)\]", rn)
assert len(bracket_groups) == 4, (
f"uribv4Nexthop {nh.dn!r} RN has {len(bracket_groups)} bracketed "
f"parts, expected 4 (proto, addr, ifname, vrf)"
)
# parent route must actually exist in the store
parent_dn = m.group(1)
assert store.get(parent_dn) is not None, f"parent uribv4Route {parent_dn!r} missing"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_uribv4_route_dom_segment_matches_real_vrf(store_name, request, topo):
store = request.getfixturevalue(store_name)
routes = store.by_class("uribv4Route")
assert routes, "no uribv4Route found"
known_vrf_doms = {
f"{tenant.name}:{vrf.name}"
for tenant in topo.tenants
for vrf in tenant.vrfs
}
for r in routes:
m = re.search(r"/dom-([^/]+)/db-rt/", r.dn)
assert m is not None, f"unparsable uribv4Route dn {r.dn!r} (no dom- segment)"
assert m.group(1) in known_vrf_doms, (
f"uribv4Route {r.dn!r} dom {m.group(1)!r} is not a real tenant:vrf"
)
assert r.attrs.get("prefix")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_arp_adj_ep_count_equals_local_endpoint_count(store_name, request):
"""arpAdjEp count == (locally-learned) endpoint count."""
store = request.getfixturevalue(store_name)
local_ceps = [c for c in store.by_class("fvCEp") if c.attrs.get("lcC") == "learned"]
arp_adjs = store.by_class("arpAdjEp")
assert local_ceps, "no locally-learned fvCEp found"
assert len(arp_adjs) == len(local_ceps), (
f"arpAdjEp count {len(arp_adjs)} != local endpoint count {len(local_ceps)}"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_arp_adj_ep_mac_matches_a_real_endpoint(store_name, request):
store = request.getfixturevalue(store_name)
local_ceps = {c.attrs["mac"] for c in store.by_class("fvCEp") if c.attrs.get("lcC") == "learned"}
for adj in store.by_class("arpAdjEp"):
assert adj.attrs["mac"] in local_ceps, f"arpAdjEp {adj.dn!r} mac not a real endpoint MAC"
assert adj.attrs["operSt"] == "up"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ospf_adj_ep_sits_on_an_ospf_if(store_name, request):
"""Every ospfAdjEp must nest directly under a real ospfIf (the ospfIf's
own DN being the ospfAdjEp's parent — CONTRACT.md batch-1 placement:
ospfIf nested between ospfDom and ospfAdjEp)."""
store = request.getfixturevalue(store_name)
ospf_if_dns = {mo.dn for mo in store.by_class("ospfIf")}
adjs = store.by_class("ospfAdjEp")
assert adjs, "no ospfAdjEp found (multi-site topology expected)"
assert ospf_if_dns, "no ospfIf found"
for adj in adjs:
parent = adj.dn.rsplit("/adj-[", 1)[0]
assert parent in ospf_if_dns, f"ospfAdjEp {adj.dn!r} does not sit on a real ospfIf"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ospf_if_references_real_port(store_name, request):
store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store)
ifs = store.by_class("ospfIf")
assert ifs, "no ospfIf found"
for ospf_if in ifs:
m = re.search(r"/node-(\d+)/sys/ospf/.*?/if-\[([^\]]+)\]", ospf_if.dn)
assert m is not None, f"unparsable ospfIf dn {ospf_if.dn!r}"
node_id, port_id = int(m.group(1)), m.group(2)
assert port_id in ports.get(node_id, set()), (
f"ospfIf {ospf_if.dn!r} references {port_id!r} on node {node_id}, "
f"which has no matching l1PhysIf"
)
assert ospf_if.attrs.get("id") == port_id
# ---------------------------------------------------------------------------
# Cross-cluster sanity: full-site build still round-trips through the REST
# layer for at least one class per cluster (auth-enforced client).
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"cls",
[
"infraAccPortP", "rtctrlProfile", "ipRouteP", "fvRsPathAtt",
"uribv4Route", "arpAdjEp", "ospfIf", "l3extMember", "ospfExtP",
"vzRsSubjGraphAtt",
],
)
def test_class_query_returns_total_count_gt_0(client_a, cls):
resp = client_a.get(f"/api/class/{cls}.json")
assert resp.status_code == 200, f"{cls} -> {resp.status_code}: {resp.text[:200]}"
data = resp.json()
assert int(data["totalCount"]) > 0, f"{cls} totalCount is 0"
+471
View File
@@ -0,0 +1,471 @@
"""Regression/coverage tests for PR-3b-batch2 — remaining previously-
catalogued-but-unbuilt classes from docs/CONTRACT.md §6 (batches 2+3),
seeded from the existing topology:
Port-channel/vPC (build/fabric.py): pcAggrIf, pcRsMbrIfs (CONTRACT.md
corrected: was catalogued as "vpcRsMbrIfs", real ACI/autoACI class is
pcRsMbrIfs — see docs/CONTRACT.md §6 changelog note).
Optics (build/interfaces.py): ethpmFcot.
Cluster health (build/fabric.py): infraWiNode.
Zoning-rule / pcTag plumbing (build/tenants.py + new build/zoning.py):
fvEpP, actrlRule (+ fvAEPg.pcTag / fvCtx.pcTag+scope, not separately
catalogued but required for the above to cross-reference).
EVPN routes (build/overlay.py): bgpVpnRoute, bgpPath.
Node registration (build/fabric.py + rest_aci/writes.py): fabricNodeIdentP.
infraNodeIdentP is intentionally NOT built — see docs/CONTRACT.md §6 for the
decision (no autoACI consumer, and real ACI's infraNodeIdentP is a node
*provisioning policy* object, not the same thing as fabricNodeIdentP's
Fabric Membership registration; building it under the same semantics as
fabricNodeIdentP would be a faithfulness regression, not a coverage gain).
Auth is enforced (PR-4): every client fixture logs in via
POST /api/aaaLogin.json (admin/cisco) before making authenticated calls,
following the existing tests/test_pr3b_batch1.py pattern.
"""
from __future__ import annotations
import copy
import re
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def site_b(topo):
return topo.site_by_name("LAB2")
@pytest.fixture(scope="module")
def store_a(topo, site_a):
return build_site(topo, site_a)
@pytest.fixture(scope="module")
def store_b(topo, site_b):
return build_site(topo, site_b)
def _make_client(topo, site, store) -> TestClient:
state = ApicSiteState(
name=site.name, site=site, topo=topo, store=store,
baseline=copy.deepcopy(store),
)
c = TestClient(make_apic_app(state))
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c
@pytest.fixture(scope="module")
def client_a(topo, site_a, store_a):
return _make_client(topo, site_a, store_a)
@pytest.fixture(scope="module")
def client_b(topo, site_b, store_b):
return _make_client(topo, site_b, store_b)
_ALL_STORES = ["store_a", "store_b"]
def _l1physif_dns(store) -> set[str]:
return {mo.dn for mo in store.by_class("l1PhysIf")}
# ---------------------------------------------------------------------------
# Non-empty coverage — every batch-2 class must have totalCount > 0
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_batch2_classes_nonempty(store_name, request):
store = request.getfixturevalue(store_name)
for cls in (
"pcAggrIf", "pcRsMbrIfs", "ethpmFcot", "infraWiNode",
"fvEpP", "actrlRule", "bgpVpnRoute", "bgpPath", "fabricNodeIdentP",
):
mos = store.by_class(cls)
assert mos, f"{cls} query returned totalCount 0"
@pytest.mark.parametrize(
"cls",
[
"pcAggrIf", "pcRsMbrIfs", "ethpmFcot", "infraWiNode",
"fvEpP", "actrlRule", "bgpVpnRoute", "bgpPath", "fabricNodeIdentP",
],
)
def test_class_query_returns_total_count_gt_0(client_a, cls):
resp = client_a.get(f"/api/class/{cls}.json")
assert resp.status_code == 200, f"{cls} -> {resp.status_code}: {resp.text[:200]}"
data = resp.json()
assert int(data["totalCount"]) > 0, f"{cls} totalCount is 0"
# ---------------------------------------------------------------------------
# pcAggrIf / pcRsMbrIfs — vPC port-channel + member ports
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_pc_aggr_if_matches_a_real_vpc_if(store_name, request):
"""vpc_status.py matches pcAggrIf to vpcIf by (node, name==ipg_name)."""
store = request.getfixturevalue(store_name)
vpc_by_node_name = set()
for vif in store.by_class("vpcIf"):
m = re.search(r"/node-(\d+)/", vif.dn)
assert m is not None
vpc_by_node_name.add((m.group(1), vif.attrs["name"]))
aggrs = store.by_class("pcAggrIf")
assert aggrs, "no pcAggrIf found"
for aggr in aggrs:
m = re.search(r"/node-(\d+)/", aggr.dn)
assert m is not None, f"unparsable pcAggrIf dn {aggr.dn!r}"
key = (m.group(1), aggr.attrs["name"])
assert key in vpc_by_node_name, (
f"pcAggrIf {aggr.dn!r} (node={key[0]}, name={key[1]}) has no matching vpcIf"
)
assert aggr.attrs["id"].startswith("po")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_pc_rs_mbr_ifs_member_dns_exist_in_l1physif(store_name, request):
"""Every pcRsMbrIfs.tDn must resolve to a real l1PhysIf — vpc_status.py
parses the member port out of tDn's phys-[...] segment and expects it to
be a real interface."""
store = request.getfixturevalue(store_name)
phys_dns = _l1physif_dns(store)
assert phys_dns, "no l1PhysIf found"
members = store.by_class("pcRsMbrIfs")
assert members, "no pcRsMbrIfs found"
for mbr in members:
t_dn = mbr.attrs["tDn"]
assert "phys-[" in t_dn, f"pcRsMbrIfs {mbr.dn!r} tDn {t_dn!r} has no phys-[...] segment"
assert t_dn in phys_dns, (
f"pcRsMbrIfs {mbr.dn!r} tDn {t_dn!r} does not resolve to a real l1PhysIf"
)
assert mbr.attrs["parentSKey"], "pcRsMbrIfs missing parentSKey (po id)"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_pc_rs_mbr_ifs_nested_under_real_pc_aggr_if(store_name, request):
store = request.getfixturevalue(store_name)
aggr_dns = {mo.dn for mo in store.by_class("pcAggrIf")}
assert aggr_dns, "no pcAggrIf found"
for mbr in store.by_class("pcRsMbrIfs"):
parent = mbr.dn.rsplit("/rsmbrIfs-[", 1)[0]
assert parent in aggr_dns, f"pcRsMbrIfs {mbr.dn!r} not nested under a real pcAggrIf"
# ---------------------------------------------------------------------------
# ethpmFcot — SFP/transceiver inventory
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ethpm_fcot_sits_on_real_port(store_name, request):
store = request.getfixturevalue(store_name)
phys_dns = _l1physif_dns(store)
assert phys_dns, "no l1PhysIf found"
fcots = store.by_class("ethpmFcot")
assert fcots, "no ethpmFcot found"
for fcot in fcots:
parent = fcot.dn.rsplit("/fcot", 1)[0]
assert parent in phys_dns, f"ethpmFcot {fcot.dn!r} does not sit on a real l1PhysIf port"
assert fcot.attrs.get("typeName")
assert fcot.attrs.get("vendorName")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_ethpm_fcot_only_on_ethpm_phys_if_ports(store_name, request):
"""Every ethpmFcot DN must share its port with a real ethpmPhysIf (sibling
placement, per the module docstring) — i.e. ethpmFcot is a subset of the
ports that have ethpmPhysIf, never a port that has no ethpmPhysIf."""
store = request.getfixturevalue(store_name)
ethpm_ports = {mo.dn.rsplit("/phys", 1)[0] for mo in store.by_class("ethpmPhysIf")}
for fcot in store.by_class("ethpmFcot"):
port_dn = fcot.dn.rsplit("/fcot", 1)[0]
assert port_dn in ethpm_ports, f"ethpmFcot {fcot.dn!r} has no sibling ethpmPhysIf"
# ---------------------------------------------------------------------------
# infraWiNode — APIC cluster fitness
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_infra_wi_node_all_fully_fit(store_name, request):
store = request.getfixturevalue(store_name)
wi_nodes = store.by_class("infraWiNode")
assert wi_nodes, "no infraWiNode found"
for wi in wi_nodes:
assert wi.attrs["health"] == "fully-fit", f"infraWiNode {wi.dn!r} not fully-fit"
@pytest.mark.parametrize("store_name,site_name", [("store_a", "site_a"), ("store_b", "site_b")])
def test_infra_wi_node_count_matches_cluster_squared(store_name, site_name, request):
"""Every controller has its OWN view of every other controller — an NxN
appliance-vector matrix (N = site.controllers)."""
store = request.getfixturevalue(store_name)
site = request.getfixturevalue(site_name)
n = site.controllers
wi_nodes = store.by_class("infraWiNode")
assert len(wi_nodes) == n * n, (
f"infraWiNode count {len(wi_nodes)} != controllers^2 ({n}^2={n*n})"
)
# ---------------------------------------------------------------------------
# fvEpP / fvAEPg.pcTag / fvCtx.pcTag+scope — zoning-rule EPG name resolution
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_fv_epp_epg_pkey_matches_a_real_epg(store_name, request):
store = request.getfixturevalue(store_name)
epg_dns = {mo.dn for mo in store.by_class("fvAEPg")}
assert epg_dns, "no fvAEPg found"
epps = store.by_class("fvEpP")
assert epps, "no fvEpP found"
for epp in epps:
epg_pkey = epp.attrs.get("epgPKey", "")
assert epg_pkey in epg_dns, f"fvEpP {epp.dn!r} epgPKey {epg_pkey!r} is not a real fvAEPg"
assert epp.dn == f"uni/epp/fv-[{epg_pkey}]"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_fv_epp_pctag_matches_owning_epg(store_name, request):
store = request.getfixturevalue(store_name)
epg_pctag = {mo.dn: mo.attrs.get("pcTag") for mo in store.by_class("fvAEPg")}
for epp in store.by_class("fvEpP"):
epg_dn = epp.attrs["epgPKey"]
assert epp.attrs.get("pcTag"), f"fvEpP {epp.dn!r} missing pcTag"
assert epp.attrs["pcTag"] == epg_pctag.get(epg_dn), (
f"fvEpP {epp.dn!r} pcTag does not match its owning fvAEPg's pcTag"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_fv_aepg_pctag_is_stable_and_nonempty(store_name, request):
store = request.getfixturevalue(store_name)
epgs = store.by_class("fvAEPg")
assert epgs, "no fvAEPg found"
seen_tags = set()
for epg in epgs:
tag = epg.attrs.get("pcTag")
assert tag, f"fvAEPg {epg.dn!r} missing pcTag"
assert tag not in seen_tags, f"pcTag {tag!r} collides across EPGs"
seen_tags.add(tag)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_fv_ctx_has_pctag_and_scope(store_name, request):
store = request.getfixturevalue(store_name)
ctxs = store.by_class("fvCtx")
assert ctxs, "no fvCtx found"
for ctx in ctxs:
assert ctx.attrs.get("pcTag"), f"fvCtx {ctx.dn!r} missing pcTag"
assert ctx.attrs.get("scope"), f"fvCtx {ctx.dn!r} missing scope"
# ---------------------------------------------------------------------------
# actrlRule — compiled zoning rules
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_actrl_rule_pctags_match_fv_aepg(store_name, request):
"""actrlRule.sPcTag/dPcTag must both resolve to real fvAEPg.pcTag values —
i.e. the rule's provider/consumer EPG pcTags are consistent with the
fvAEPg/fvEpP data zoning_rules.py cross-references against."""
store = request.getfixturevalue(store_name)
real_pctags = {mo.attrs["pcTag"] for mo in store.by_class("fvAEPg")}
assert real_pctags, "no fvAEPg pcTags found"
rules = store.by_class("actrlRule")
assert rules, "no actrlRule found"
for rule in rules:
assert rule.attrs["sPcTag"] in real_pctags, (
f"actrlRule {rule.dn!r} sPcTag {rule.attrs['sPcTag']!r} is not a real EPG pcTag"
)
assert rule.attrs["dPcTag"] in real_pctags, (
f"actrlRule {rule.dn!r} dPcTag {rule.attrs['dPcTag']!r} is not a real EPG pcTag"
)
assert rule.attrs["action"] == "permit"
assert rule.attrs.get("ctrctName")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_actrl_rule_scope_matches_a_real_vrf_scope(store_name, request):
store = request.getfixturevalue(store_name)
real_scopes = {mo.attrs["scope"] for mo in store.by_class("fvCtx")}
assert real_scopes, "no fvCtx scopes found"
for rule in store.by_class("actrlRule"):
assert rule.attrs["scopeId"] in real_scopes, (
f"actrlRule {rule.dn!r} scopeId {rule.attrs['scopeId']!r} is not a real VRF scope"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_actrl_rule_dn_under_real_node(store_name, request):
store = request.getfixturevalue(store_name)
node_ids = set()
for node in store.by_class("fabricNode"):
if node.attrs.get("role") != "controller":
node_ids.add(node.attrs["id"])
rules = store.by_class("actrlRule")
assert rules, "no actrlRule found"
for rule in rules:
m = re.search(r"/node-(\d+)/sys/actrl/", rule.dn)
assert m is not None, f"unparsable actrlRule dn {rule.dn!r}"
assert m.group(1) in node_ids, f"actrlRule {rule.dn!r} references a non-real node"
# ---------------------------------------------------------------------------
# bgpVpnRoute / bgpPath — EVPN route table (two-pass parseable)
# ---------------------------------------------------------------------------
_RE_PATH_PARENT = re.compile(r"^(.*)/path-[^/]+$")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_bgp_path_is_child_of_bgp_vpn_route_two_pass_parseable(store_name, request):
"""Mirrors autoACI's fabric_bgp_evpn.py two-pass parse: pass 1 collects
bgpVpnRoute by dn, pass 2 recovers the parent route dn from a bgpPath dn
via `path_dn.rsplit("/path-", 1)[0]`."""
store = request.getfixturevalue(store_name)
routes = {mo.dn for mo in store.by_class("bgpVpnRoute")}
assert routes, "no bgpVpnRoute found"
paths = store.by_class("bgpPath")
assert paths, "no bgpPath found"
for path in paths:
assert "/path-" in path.dn, f"bgpPath {path.dn!r} has no /path- segment"
parent_dn = path.dn.rsplit("/path-", 1)[0]
assert parent_dn in routes, (
f"bgpPath {path.dn!r} parent {parent_dn!r} is not a real bgpVpnRoute"
)
assert path.attrs.get("nh"), f"bgpPath {path.dn!r} missing nh"
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_bgp_vpn_route_prefix_is_real_bd_subnet(store_name, request, topo):
store = request.getfixturevalue(store_name)
all_subnets = {
cidr for tenant in topo.tenants for bd in tenant.bds for cidr in bd.subnets
}
routes = store.by_class("bgpVpnRoute")
assert routes, "no bgpVpnRoute found"
for route in routes:
assert route.attrs["pfx"] in all_subnets, (
f"bgpVpnRoute {route.dn!r} pfx {route.attrs['pfx']!r} is not a real BD subnet"
)
assert route.attrs.get("rd")
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_bgp_vpn_route_dn_under_a_real_spine(store_name, request):
store = request.getfixturevalue(store_name)
spine_ids = {
mo.attrs["id"] for mo in store.by_class("fabricNode") if mo.attrs.get("role") == "spine"
}
assert spine_ids, "no spine fabricNode found"
for route in store.by_class("bgpVpnRoute"):
m = re.search(r"/node-(\d+)/sys/bgp/inst/", route.dn)
assert m is not None, f"unparsable bgpVpnRoute dn {route.dn!r}"
assert m.group(1) in spine_ids, f"bgpVpnRoute {route.dn!r} not seeded on a real spine"
# ---------------------------------------------------------------------------
# fabricNodeIdentP — boot-seeded node registration
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_fabric_node_ident_p_count_equals_real_switch_count(store_name, request):
"""One fabricNodeIdentP per real switch fabricNode (spines/leaves/
border-leaves) — controllers are NOT registered via nodeidentpol (real
ACI: that's Fabric Membership for switches joining the fabric, not the
APICs themselves)."""
store = request.getfixturevalue(store_name)
switch_nodes = [
mo for mo in store.by_class("fabricNode") if mo.attrs.get("role") != "controller"
]
assert switch_nodes, "no switch fabricNode found"
idents = store.by_class("fabricNodeIdentP")
assert idents, "no fabricNodeIdentP found"
assert len(idents) == len(switch_nodes), (
f"fabricNodeIdentP count {len(idents)} != real switch fabricNode count {len(switch_nodes)}"
)
@pytest.mark.parametrize("store_name", _ALL_STORES)
def test_fabric_node_ident_p_nodeid_matches_real_fabric_node(store_name, request):
store = request.getfixturevalue(store_name)
switch_ids = {
mo.attrs["id"] for mo in store.by_class("fabricNode") if mo.attrs.get("role") != "controller"
}
for ident in store.by_class("fabricNodeIdentP"):
m = re.search(r"nodep-(\d+)$", ident.dn)
assert m is not None, f"unparsable fabricNodeIdentP dn {ident.dn!r}"
assert m.group(1) in switch_ids, f"fabricNodeIdentP {ident.dn!r} not a real switch node"
assert ident.attrs.get("nodeId") == m.group(1)
assert ident.attrs.get("serial")
def test_add_leaf_reaction_still_materializes_new_node(client_a):
"""Regression guard (per task instructions): a POSTed new nodeidentpol
must still materialize a fabricNode, exactly as
tests/test_rest_aci.py::test_add_leaf_reaction already verifies — batch-2
boot-seeding fabricNodeIdentP for EXISTING nodes must not interfere with
the write-path reaction for a NEW node id."""
resp = client_a.post(
"/api/mo/uni/controller/nodeidentpol/nodep-950.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-950",
"name": "leaf-950",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200, f"POST fabricNodeIdentP -> {resp.status_code}: {resp.text[:200]}"
resp2 = client_a.get("/api/class/fabricNode.json")
ids = [item["fabricNode"]["attributes"]["id"] for item in resp2.json()["imdata"]]
assert "950" in ids, "add-leaf reaction did not materialize the new fabricNode"
# ---------------------------------------------------------------------------
# Cross-cluster sanity: full-site build still round-trips through the REST
# layer with rsp-subtree=full for the node-scoped actrlRule shape.
# ---------------------------------------------------------------------------
def test_actrl_subtree_query_returns_rules(client_a, store_a):
"""Mirrors zoning_rules.py's node-scoped subtree query shape."""
rule = store_a.by_class("actrlRule")[0]
node_m = re.search(r"topology/pod-(\d+)/node-(\d+)/sys/actrl", rule.dn)
assert node_m is not None
pod, node_id = node_m.group(1), node_m.group(2)
actrl_dn = f"topology/pod-{pod}/node-{node_id}/sys/actrl"
resp = client_a.get(
f"/api/mo/{actrl_dn}.json?query-target=subtree&target-subtree-class=actrlRule"
)
assert resp.status_code == 200, f"{actrl_dn} -> {resp.status_code}: {resp.text[:200]}"
data = resp.json()
assert data["imdata"], f"expected actrlRule descendants under {actrl_dn}"
assert any("actrlRule" in item for item in data["imdata"])
+276
View File
@@ -0,0 +1,276 @@
"""Regression tests for PR-5 topology-consistency findings #13-#18 (all in
aci_sim/build/):
FIX 1 (#13) — l3extRsPathL3OutAtt tDn referenced a nonexistent leaf port
(eth1/48 on border leaves, outside the eth1/1-8 host range). interfaces.py
now builds a dedicated routed l1PhysIf at that exact port on border leaves.
FIX 2 (#14) — spine ospfAdjEp referenced ports outside the spine's fabric-
uplink range (eth1/1-4). interfaces.py now builds a dedicated ISN uplink
l1PhysIf on spines (multi-site only) at the same eth1/{49+i} ports
underlay.py already used.
FIX 3 (#15) — endpoint host ports collided with APIC-controller-reserved
leaf ports (both used eth1/1..3 on the same leaves). endpoints.py now
starts endpoint port allocation right after the APIC-reserved range.
FIX 4 (#16) — a stretched tenant's endpoint appeared identically
front-panel-local on BOTH sites. endpoints.py now picks one HOME site per
endpoint (front-panel port, lcC="learned") and represents it on the peer
site as learned via a multi-site overlay tunnel (lcC="learned,vxlan",
fabricPathDn -> tunnelIf). See docs/DESIGN.md.
FIX 5 (#17) — routed BDs (has fvSubnet children) reported unicastRoute="no"
whenever l2stretch was true, disagreeing with NDO's unicastRouting=true.
tenants.py now derives unicastRoute from whether the BD has subnets.
FIX 6 (#18) — controller nodes had no healthInst (404 on GET .../sys/health).
health_faults.py now emits healthInst for controller node ids too.
Auth is enforced (PR-4): every client fixture logs in via
POST /api/aaaLogin.json (admin/cisco) before making authenticated calls,
following the existing tests/test_p5_fixes.py / test_pr3_fixes.py pattern.
"""
from __future__ import annotations
import copy
import re
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture(scope="module")
def site_a(topo):
return topo.site_by_name("LAB1")
@pytest.fixture(scope="module")
def site_b(topo):
return topo.site_by_name("LAB2")
@pytest.fixture(scope="module")
def store_a(topo, site_a):
return build_site(topo, site_a)
@pytest.fixture(scope="module")
def store_b(topo, site_b):
return build_site(topo, site_b)
def _make_client(topo, site, store) -> TestClient:
state = ApicSiteState(
name=site.name, site=site, topo=topo, store=store,
baseline=copy.deepcopy(store),
)
c = TestClient(make_apic_app(state))
c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
return c
@pytest.fixture(scope="module")
def client_a(topo, site_a, store_a):
return _make_client(topo, site_a, store_a)
@pytest.fixture(scope="module")
def client_b(topo, site_b, store_b):
return _make_client(topo, site_b, store_b)
# ---------------------------------------------------------------------------
# FIX 1 (#13) — every l3extRsPathL3OutAtt tDn's port exists as a real l1PhysIf
# ---------------------------------------------------------------------------
def _l1physif_ports(store) -> dict[int, set[str]]:
"""Return {node_id: {port_id, ...}} from every l1PhysIf DN in the store."""
ports: dict[int, set[str]] = {}
for mo in store.by_class("l1PhysIf"):
m = re.search(r"/node-(\d+)/sys/phys-\[([^\]]+)\]", mo.dn)
assert m is not None, f"unparsable l1PhysIf dn {mo.dn!r}"
node_id = int(m.group(1))
ports.setdefault(node_id, set()).add(m.group(2))
return ports
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
def test_l3out_path_att_references_real_port(store_name, request):
store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store)
atts = list(store.by_class("l3extRsPathL3OutAtt"))
assert atts, "no l3extRsPathL3OutAtt found"
for att in atts:
m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", att.attrs["tDn"])
assert m is not None, f"unparsable tDn {att.attrs['tDn']!r}"
node_id, port_id = int(m.group(1)), m.group(2)
assert port_id in ports.get(node_id, set()), (
f"l3extRsPathL3OutAtt {att.dn!r} references {port_id!r} on node "
f"{node_id}, which has no matching l1PhysIf (has {ports.get(node_id)})"
)
# ---------------------------------------------------------------------------
# FIX 2 (#14) — every ospfAdjEp interface exists as a real l1PhysIf on that node
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
def test_ospf_adjacency_references_real_port(store_name, request):
store = request.getfixturevalue(store_name)
ports = _l1physif_ports(store)
adjs = list(store.by_class("ospfAdjEp"))
assert adjs, "no ospfAdjEp found (multi-site topology expected)"
for adj in adjs:
m = re.search(r"/node-(\d+)/sys/ospf/.*?/if-\[([^\]]+)\]", adj.dn)
assert m is not None, f"unparsable ospfAdjEp dn {adj.dn!r}"
node_id, port_id = int(m.group(1)), m.group(2)
assert port_id in ports.get(node_id, set()), (
f"ospfAdjEp {adj.dn!r} references {port_id!r} on node {node_id}, "
f"which has no matching l1PhysIf (has {ports.get(node_id)})"
)
# ---------------------------------------------------------------------------
# FIX 3 (#15) — no port hosts both a controller lldpAdjEp and an endpoint path
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
def test_no_port_double_booked_between_apic_and_endpoint(store_name, request):
store = request.getfixturevalue(store_name)
controller_ports: set[tuple[int, str]] = set()
for mo in store.by_class("lldpAdjEp"):
# topology/pod-1/node-101/sys/lldp/inst/if-[eth1/1]/adj-1
m = re.search(r"/node-(\d+)/sys/lldp/inst/if-\[([^\]]+)\]", mo.dn)
if not m:
continue
if "APIC" in mo.attrs.get("sysName", ""):
controller_ports.add((int(m.group(1)), m.group(2)))
assert controller_ports, "no controller lldpAdjEp found"
endpoint_ports: set[tuple[int, str]] = set()
for mo in store.by_class("fvCEp"):
path = mo.attrs.get("fabricPathDn", "")
m = re.search(r"paths-(\d+)/pathep-\[([^\]]+)\]", path)
if not m:
continue
endpoint_ports.add((int(m.group(1)), m.group(2)))
assert endpoint_ports, "no endpoint fabricPathDn found"
collisions = controller_ports & endpoint_ports
assert not collisions, f"port(s) double-booked between APIC and endpoint: {collisions}"
# ---------------------------------------------------------------------------
# FIX 4 (#16) — each stretched EP is local on exactly one site, vxlan-learned
# on the other (cross-site test: build BOTH sites' stores and compare).
# ---------------------------------------------------------------------------
def test_stretched_endpoint_local_on_exactly_one_site(store_a, store_b):
ceps_a = {
m.attrs["mac"]: m for m in store_a.by_class("fvCEp")
if m.dn.startswith("uni/tn-MS-TN1/")
}
ceps_b = {
m.attrs["mac"]: m for m in store_b.by_class("fvCEp")
if m.dn.startswith("uni/tn-MS-TN1/")
}
common_macs = set(ceps_a) & set(ceps_b)
assert common_macs, "no stretched (MS-TN1) endpoints found on both sites"
for mac in common_macs:
cep_a, cep_b = ceps_a[mac], ceps_b[mac]
lc_a, lc_b = cep_a.attrs.get("lcC"), cep_b.attrs.get("lcC")
# Exactly one side is HOME (front-panel-learned)...
homes = [lc for lc in (lc_a, lc_b) if lc == "learned"]
assert len(homes) == 1, (
f"MAC {mac}: expected exactly one site with lcC='learned', "
f"got A={lc_a!r} B={lc_b!r}"
)
# ...and the other is REMOTE (vxlan/tunnel-learned).
remotes = [lc for lc in (lc_a, lc_b) if lc == "learned,vxlan"]
assert len(remotes) == 1, (
f"MAC {mac}: expected exactly one site with lcC='learned,vxlan', "
f"got A={lc_a!r} B={lc_b!r}"
)
home_cep = cep_a if lc_a == "learned" else cep_b
remote_cep = cep_b if lc_a == "learned" else cep_a
# HOME: fabricPathDn is a real front-panel port (pathep-[ethX/Y]).
assert re.search(r"pathep-\[eth\d+/\d+\]", home_cep.attrs["fabricPathDn"]), (
f"HOME fvCEp {home_cep.dn!r} fabricPathDn not a front-panel port: "
f"{home_cep.attrs['fabricPathDn']!r}"
)
# REMOTE: fabricPathDn points at a tunnel interface, not a front port.
assert re.search(r"pathep-\[tunnel\d+\]", remote_cep.attrs["fabricPathDn"]), (
f"REMOTE fvCEp {remote_cep.dn!r} fabricPathDn not a tunnel path: "
f"{remote_cep.attrs['fabricPathDn']!r}"
)
def test_stretched_endpoint_remote_tunnel_if_exists(store_a, store_b):
"""The REMOTE fvCEp's tunnelIf must actually exist in that site's store."""
for store in (store_a, store_b):
for cep in store.by_class("fvCEp"):
if cep.attrs.get("lcC") != "learned,vxlan":
continue
m = re.search(r"paths-(\d+)/pathep-\[(tunnel\d+)\]", cep.attrs["fabricPathDn"])
assert m is not None
spine_id, tunnel_id = m.group(1), m.group(2)
matches = [
t for t in store.by_class("tunnelIf")
if t.attrs.get("id") == tunnel_id and f"/node-{spine_id}/" in t.dn
]
assert matches, (
f"no tunnelIf with id={tunnel_id!r} on node {spine_id} for "
f"remote fvCEp {cep.dn!r}"
)
# ---------------------------------------------------------------------------
# FIX 5 (#17) — every BD with fvSubnet children has unicastRoute="yes"
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("store_name", ["store_a", "store_b"])
def test_routed_bd_has_unicast_route_yes(store_name, request):
store = request.getfixturevalue(store_name)
bds = list(store.by_class("fvBD"))
assert bds, "no fvBD found"
checked = 0
for bd in bds:
subnets = store.children(bd.dn, {"fvSubnet"})
if not subnets:
continue
checked += 1
assert bd.attrs.get("unicastRoute") == "yes", (
f"fvBD {bd.dn!r} has {len(subnets)} fvSubnet children but "
f"unicastRoute={bd.attrs.get('unicastRoute')!r} (expected 'yes')"
)
assert checked, "no routed (subnet-bearing) BD found to check"
# ---------------------------------------------------------------------------
# FIX 6 (#18) — node-1 health returns 200 on both sites' controllers
# ---------------------------------------------------------------------------
def test_controller_health_returns_200_both_sites(client_a, client_b):
for client in (client_a, client_b):
resp = client.get("/api/mo/topology/pod-1/node-1/sys/health.json")
assert resp.status_code == 200, (
f"controller node-1 health -> {resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
assert data["imdata"], "expected a healthInst row for controller node-1"
assert "healthInst" in data["imdata"][0]
+300
View File
@@ -0,0 +1,300 @@
"""Tests for PR-6 — control-plane fixes:
1. /_sim/reload rebuilds from the FRESH post-reload Site object, not the
stale pre-reload one (finding #11).
2. fabricNodeIdentP write reaction materializes a full node-registration MO
set (fabricNode + topSystem + healthInst + fabricLink cabling + l1PhysIf
inventory), fires for a nested (not just top-level) fabricNodeIdentP, and
derives pod from the site instead of hardcoding 1 (finding #19).
3. GET /api/node/class/{cls}.json (no DN prefix) runs a fabric-wide class
query, matching /api/class/{cls}.json (finding #12).
"""
from __future__ import annotations
import copy
import ipaddress
import yaml
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml" # run from project root
def _login(client: TestClient) -> None:
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200, resp.text
def _fresh_client(site_name: str = "LAB1") -> tuple[ApicSiteState, TestClient]:
topo = load_topology(TOPO_PATH)
site = topo.site_by_name(site_name)
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
client = TestClient(make_apic_app(state))
_login(client)
return state, client
# ---------------------------------------------------------------------------
# Fix 1 — /_sim/reload uses the fresh Site, not the stale one
# ---------------------------------------------------------------------------
def test_reload_picks_up_topology_yaml_edit(tmp_path, monkeypatch):
"""Editing topology.yaml (adding a leaf) and POSTing /_sim/reload must
make the new leaf show up — the old code rebuilt against the STALE
pre-reload state.site object, so the edit was silently ignored.
"""
with open(TOPO_PATH, encoding="utf-8") as f:
raw = yaml.safe_load(f)
# Add a brand-new leaf to LAB1 in a modified copy of the topology.
lab1 = next(s for s in raw["sites"] if s["name"] == "LAB1")
lab1["leaves"].append({"id": 199, "name": "LAB1-ACI-LF199"})
modified_path = tmp_path / "topology_modified.yaml"
modified_path.write_text(yaml.safe_dump(raw), encoding="utf-8")
# Build initial state from the ORIGINAL topology (no node 199 yet).
state, client = _fresh_client("LAB1")
ids_before = [
item["fabricNode"]["attributes"]["id"]
for item in client.get("/api/class/fabricNode.json").json()["imdata"]
]
assert "199" not in ids_before
# Point TOPOLOGY_PATH at the modified file and reload.
monkeypatch.setenv("TOPOLOGY_PATH", str(modified_path))
import aci_sim.runtime.config as config_mod
monkeypatch.setattr(config_mod, "TOPOLOGY_PATH", str(modified_path))
resp = client.post("/_sim/reload")
assert resp.status_code == 200, resp.text
ids_after = [
item["fabricNode"]["attributes"]["id"]
for item in client.get("/api/class/fabricNode.json").json()["imdata"]
]
assert "199" in ids_after, f"reload did not pick up the new leaf; ids: {ids_after}"
# state.site must now be the fresh Site object (has the new leaf), not
# the stale one captured at client construction time.
leaf_ids = {n.id for n in state.site.leaf_nodes()}
assert 199 in leaf_ids
def test_reload_errors_when_site_removed(tmp_path, monkeypatch):
"""If the reloaded topology no longer contains this site, /_sim/reload
must return an APIC-style 400 error, not crash or silently keep stale
state.
"""
with open(TOPO_PATH, encoding="utf-8") as f:
raw = yaml.safe_load(f)
# Remove LAB2 entirely; also drop anything that references it so the
# remaining topology still validates.
raw["sites"] = [s for s in raw["sites"] if s["name"] != "LAB2"]
raw["isn"] = {"enabled": False}
for tenant in raw.get("tenants", []):
tenant["sites"] = [s for s in tenant.get("sites", []) if s != "LAB2"]
if tenant.get("stretch") and len(tenant["sites"]) < 2:
tenant["stretch"] = False
tenant["l3outs"] = [
l3o for l3o in tenant.get("l3outs", []) if l3o.get("site") != "LAB2"
]
modified_path = tmp_path / "topology_no_lab2.yaml"
modified_path.write_text(yaml.safe_dump(raw), encoding="utf-8")
state, client = _fresh_client("LAB2")
import aci_sim.runtime.config as config_mod
monkeypatch.setattr(config_mod, "TOPOLOGY_PATH", str(modified_path))
resp = client.post("/_sim/reload")
assert resp.status_code == 400, resp.text
data = resp.json()
assert "error" in data["imdata"][0]
# Original state must be left untouched (no crash / partial mutation).
assert state.site.name == "LAB2"
# ---------------------------------------------------------------------------
# Fix 2 — enriched fabricNodeIdentP reaction (+ nested form) + /_sim/add-leaf
# ---------------------------------------------------------------------------
def _assert_enriched_node(client: TestClient, node_id: int, pod: int = 1) -> None:
node_dn = f"topology/pod-{pod}/node-{node_id}"
# fabricNode
fn = client.get("/api/class/fabricNode.json").json()["imdata"]
ids = [item["fabricNode"]["attributes"]["id"] for item in fn]
assert str(node_id) in ids, f"fabricNode {node_id} missing; ids={ids}"
# topSystem with valid, parseable IPs
ts_resp = client.get(f"/api/mo/{node_dn}/sys.json")
assert ts_resp.status_code == 200, ts_resp.text
ts_attrs = ts_resp.json()["imdata"][0]["topSystem"]["attributes"]
ipaddress.IPv4Address(ts_attrs["address"]) # raises if invalid
ipaddress.IPv4Address(ts_attrs["oobMgmtAddr"]) # raises if invalid
# healthInst
health_resp = client.get(f"/api/mo/{node_dn}/sys/health.json")
assert health_resp.status_code == 200, health_resp.text
# At least one fabricLink to a spine
links = client.get("/api/class/fabricLink.json").json()["imdata"]
node_links = [
item["fabricLink"]["attributes"]
for item in links
if str(node_id) in (item["fabricLink"]["attributes"].get("n1"), item["fabricLink"]["attributes"].get("n2"))
]
assert node_links, f"no fabricLink found for node {node_id}"
# l1PhysIf inventory present under this node
resp = client.get(f"/api/node/class/{node_dn}/l1PhysIf.json")
assert resp.status_code == 200, resp.text
assert int(resp.json()["totalCount"]) >= 1, "expected at least one l1PhysIf for the new node"
def test_fabric_node_ident_reaction_top_level():
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-910.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-910",
"name": "leaf-910",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200, resp.text
_assert_enriched_node(client, 910, pod=1)
def test_fabric_node_ident_reaction_nested_under_parent():
"""A nested (non-top-level) fabricNodeIdentP must ALSO trigger the
reaction — previously the reaction only fired when fabricNodeIdentP was
the top-level POSTed class.
"""
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller.json",
json={
"fabricInst": {
"attributes": {"dn": "uni/controller"},
"children": [
{
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-911",
"name": "leaf-911",
"nodeType": "leaf",
}
}
}
],
}
},
)
assert resp.status_code == 200, resp.text
_assert_enriched_node(client, 911, pod=1)
def test_add_leaf_produces_same_enriched_set():
_, client = _fresh_client("LAB1")
resp = client.post("/_sim/add-leaf", json={"id": 912, "name": "leaf-912"})
assert resp.status_code == 200, resp.text
_assert_enriched_node(client, 912, pod=1)
def test_add_leaf_reaction_still_materializes_new_node_class_query():
"""Regression guard for the existing test_add_leaf_reaction /
verify_autoaci T1-09 behavior: fabricNode must still show up by class
query after the enrichment."""
_, client = _fresh_client("LAB1")
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-913.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-913",
"name": "leaf-913",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200
ids = [
item["fabricNode"]["attributes"]["id"]
for item in client.get("/api/class/fabricNode.json").json()["imdata"]
]
assert "913" in ids
def test_node_registration_derives_pod_from_site_not_hardcoded():
"""Registering a node on a site whose pod != 1 must use that site's pod,
not a hardcoded 1. topology.yaml's sites both use pod=1 today, so this
test builds a synthetic site with pod=2 directly to prove the reaction
reads site.pod instead of hardcoding.
"""
from aci_sim.mit.store import MITStore
from aci_sim.rest_aci.writes import materialize_node_registration
from aci_sim.topology.loader import load_topology
topo = load_topology(TOPO_PATH)
site = topo.site_by_name("LAB1").model_copy(deep=True)
site.pod = 7
store = MITStore()
materialize_node_registration(store, topo=topo, site=site, node_id=950, name="leaf-950", role="leaf")
assert store.get("topology/pod-7/node-950") is not None
assert store.get("topology/pod-1/node-950") is None
# ---------------------------------------------------------------------------
# Fix 3 — fabric-wide node/class query (no DN prefix)
# ---------------------------------------------------------------------------
def test_fabric_wide_node_class_query_matches_class_query():
_, client = _fresh_client("LAB1")
fabric_wide = client.get("/api/node/class/topSystem.json")
assert fabric_wide.status_code == 200, fabric_wide.text
class_query = client.get("/api/class/topSystem.json")
assert class_query.status_code == 200
assert fabric_wide.json()["totalCount"] == class_query.json()["totalCount"]
fw_ids = sorted(item["topSystem"]["attributes"]["id"] for item in fabric_wide.json()["imdata"])
cq_ids = sorted(item["topSystem"]["attributes"]["id"] for item in class_query.json()["imdata"])
assert fw_ids == cq_ids
def test_node_scoped_class_query_still_works():
_, client = _fresh_client("LAB1")
resp = client.get("/api/node/class/topology/pod-1/node-101/faultInst.json")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data["imdata"], list)
assert isinstance(data["totalCount"], str)
+206
View File
@@ -0,0 +1,206 @@
"""Tests for PR-8 cross-platform + deployment engineering (findings #23-#28).
Covers:
- SIM_BIND default/override parsing (runtime/config.py, #23)
- port-selection function (A/B/fallback) (runtime/supervisor.py, #25)
- CSW peer/border-leaf mismatch validation raises (topology/schema.py, #28)
- `bash -n` syntax check for both sandbox scripts (#24)
"""
from __future__ import annotations
import importlib
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from pydantic import ValidationError
from aci_sim.topology.schema import Topology
REPO_ROOT = Path(__file__).parent.parent
# ---------------------------------------------------------------------------
# SIM_BIND (#23)
# ---------------------------------------------------------------------------
class TestSimBind:
def test_default_is_loopback(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SIM_BIND", raising=False)
from aci_sim.runtime import config
importlib.reload(config)
assert config.SIM_BIND == "127.0.0.1"
def test_override_via_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SIM_BIND", "0.0.0.0")
from aci_sim.runtime import config
importlib.reload(config)
try:
assert config.SIM_BIND == "0.0.0.0"
finally:
monkeypatch.delenv("SIM_BIND", raising=False)
importlib.reload(config) # restore default for any later importers
# ---------------------------------------------------------------------------
# Port selection (#25)
# ---------------------------------------------------------------------------
class TestApicPortForSite:
def test_site_zero_uses_apic_a_port(self) -> None:
from aci_sim.runtime.config import APIC_A_PORT
from aci_sim.runtime.supervisor import apic_port_for_site
assert apic_port_for_site(0) == APIC_A_PORT
def test_site_one_uses_apic_b_port(self) -> None:
"""APIC_B_PORT (config.py) must actually be honored for the second
site, not silently shadowed by an APIC_A_PORT+i formula."""
from aci_sim.runtime.config import APIC_B_PORT
from aci_sim.runtime.supervisor import apic_port_for_site
assert apic_port_for_site(1) == APIC_B_PORT
def test_site_two_falls_back_to_apic_a_port_plus_index(self) -> None:
from aci_sim.runtime.config import APIC_A_PORT
from aci_sim.runtime.supervisor import apic_port_for_site
assert apic_port_for_site(2) == APIC_A_PORT + 2
def test_apic_b_port_env_override_takes_effect(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("APIC_B_PORT", "9999")
from aci_sim.runtime import config
importlib.reload(config)
from aci_sim.runtime import supervisor
importlib.reload(supervisor)
try:
assert supervisor.apic_port_for_site(1) == 9999
finally:
monkeypatch.delenv("APIC_B_PORT", raising=False)
importlib.reload(config)
importlib.reload(supervisor)
# ---------------------------------------------------------------------------
# CSW mismatch validation (#28)
# ---------------------------------------------------------------------------
_BASE_TOPOLOGY = {
"fabric": {"name": "test-fabric"},
"sites": [
{
"name": "SiteA",
"id": "1",
"apic_host": "127.0.0.1:8443",
"asn": 65001,
"pod": 1,
"spines": 1,
"leaves": 1,
"cabling": "auto",
"border_leaves": [
{
"id": 103,
"name": "SiteA-BL103",
"vpc_domain": "vpcdom-A",
"csw": {
"name": "CSW01",
"asn": 65100,
"peer_ip": "10.100.0.1",
"local_ip": "10.100.0.2",
"vlan": 100,
},
}
],
}
],
"isn": {"enabled": False},
"tenants": [
{
"name": "TN1",
"stretch": False,
"sites": ["SiteA"],
"vrfs": [{"name": "vrf1"}],
"bds": [],
"aps": [],
"contracts": [],
"l3outs": [
{
"name": "l3o-test",
"vrf": "vrf1",
"site": "SiteA",
"border_leaves": [103],
"csw_peer": {
"name": "CSW01",
"asn": 65100,
"peer_ip": "10.100.0.1",
},
}
],
}
],
}
def _deep_copy(d):
return yaml.safe_load(yaml.safe_dump(d))
class TestCswMismatchValidation:
def test_matching_csw_peer_loads_fine(self) -> None:
Topology.model_validate(_deep_copy(_BASE_TOPOLOGY))
def test_mismatched_asn_raises(self) -> None:
data = _deep_copy(_BASE_TOPOLOGY)
data["tenants"][0]["l3outs"][0]["csw_peer"]["asn"] = 65999
with pytest.raises(ValidationError, match="does not match border-leaf"):
Topology.model_validate(data)
def test_mismatched_peer_ip_raises(self) -> None:
data = _deep_copy(_BASE_TOPOLOGY)
data["tenants"][0]["l3outs"][0]["csw_peer"]["peer_ip"] = "10.100.0.99"
with pytest.raises(ValidationError, match="does not match border-leaf"):
Topology.model_validate(data)
def test_mismatched_name_raises(self) -> None:
data = _deep_copy(_BASE_TOPOLOGY)
data["tenants"][0]["l3outs"][0]["csw_peer"]["name"] = "CSW-WRONG"
with pytest.raises(ValidationError, match="does not match border-leaf"):
Topology.model_validate(data)
# ---------------------------------------------------------------------------
# Sandbox scripts syntax (#24)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"script",
["scripts/sandbox-up.sh", "scripts/sandbox-down.sh"],
)
def test_sandbox_script_bash_syntax_ok(script: str) -> None:
result = subprocess.run(
["bash", "-n", str(REPO_ROOT / script)],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize(
"script",
["scripts/sandbox-up.sh", "scripts/sandbox-down.sh"],
)
def test_sandbox_script_has_linux_branch(script: str) -> None:
text = (REPO_ROOT / script).read_text()
assert "Linux" in text
assert "ip addr" in text
+338
View File
@@ -0,0 +1,338 @@
"""Regression tests for PR-9: cisco.aci Ansible-fidelity hardening.
Covers (see docs/CONTRACT.md §Ansible-compatibility for the full contract):
1. HTTP DELETE /api/mo/{dn}.json (cisco.aci state=absent) — existing DN
removes the subtree; missing DN is idempotent 200-empty.
2. status="deleted" cascade actually removes the store subtree (not just
skips planning children); status="created,modified" is treated as a
normal upsert (ignored gracefully, not a KeyError/malformed-body 400).
3. Arbitrary attribute passthrough (annotation="orchestrator:ansible",
nameAlias, descr) round-trips exactly through POST -> mo GET -> class
query.
4. Certificate signature accept-mode auth — a request carrying the four
APIC-Certificate-*/APIC-Request-Signature cookies (matching SIM_USERNAME)
authenticates without ever calling aaaLogin; a cookie set naming a
different user is rejected with the standard 403 envelope.
5. firmwareCtrlrRunning is seeded per controller with the site's
apic_version (if implemented — see build/fabric.py).
FEATURE 6 (live-blocker fold-in): GET /api/mo/{dn}.json on a nonexistent DN
must be 200 + empty imdata, not 404 — verified against upstream cisco.aci's
module_utils/aci.py `api_call()`, which treats ANY non-200 GET response as
fatal (`fail_json`) with no special-case for "not found is fine". Every
`state=present` module does a GET-before-POST existence check
(`get_existing()`) before ever building its diff, so a 404 there breaks the
very first task of any create playbook run against a brand-new object —
this was caught as a live blocker running an actual cisco.aci playbook
against the sim (see chapter note / commit message). Regression test named
`test_mo_get_missing_returns_200_empty_ansible_precheck` below.
"""
from __future__ import annotations
import base64
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
def _new_client() -> TestClient:
topo = load_topology(TOPO_PATH)
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 _logged_in_client() -> TestClient:
c = _new_client()
resp = c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
return c
# ---------------------------------------------------------------------------
# FEATURE 6 (live blocker) — GET on missing DN is 200-empty, not 404
# ---------------------------------------------------------------------------
def test_mo_get_missing_returns_200_empty_ansible_precheck():
c = _logged_in_client()
resp = c.get("/api/mo/uni/tn-ANSIBLE-SMOKE.json")
assert resp.status_code == 200
data = resp.json()
assert data["totalCount"] == "0"
assert data["imdata"] == []
def test_mo_get_missing_then_create_flow_succeeds_end_to_end():
# Full cisco.aci state=present shape: GET (existence check, empty) ->
# POST (create) -> GET (post-verification, now present).
c = _logged_in_client()
pre = c.get("/api/mo/uni/tn-ANSIBLE-SMOKE.json")
assert pre.status_code == 200
assert pre.json()["totalCount"] == "0"
post = c.post(
"/api/mo/uni/tn-ANSIBLE-SMOKE.json",
json={"fvTenant": {"attributes": {"name": "ANSIBLE-SMOKE", "dn": "uni/tn-ANSIBLE-SMOKE"}}},
)
assert post.status_code == 200
verify = c.get("/api/mo/uni/tn-ANSIBLE-SMOKE.json")
assert verify.status_code == 200
assert verify.json()["totalCount"] == "1"
# ---------------------------------------------------------------------------
# FEATURE 1 — HTTP DELETE
# ---------------------------------------------------------------------------
def test_delete_existing_dn_removes_subtree():
c = _logged_in_client()
c.post(
"/api/mo/uni/tn-DelT.json",
json={
"fvTenant": {
"attributes": {"name": "DelT", "dn": "uni/tn-DelT"},
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
}
},
)
assert c.get("/api/mo/uni/tn-DelT/BD-bd1.json").json()["totalCount"] == "1"
resp = c.delete("/api/mo/uni/tn-DelT.json")
assert resp.status_code == 200
data = resp.json()
assert data["imdata"] == []
assert data["totalCount"] == "0"
# Subtree (the BD child) is gone too, not just the tenant itself.
assert c.get("/api/mo/uni/tn-DelT.json").json()["totalCount"] == "0"
assert c.get("/api/mo/uni/tn-DelT/BD-bd1.json").json()["totalCount"] == "0"
def test_delete_missing_dn_is_idempotent_200():
c = _logged_in_client()
resp = c.delete("/api/mo/uni/tn-NeverExisted.json")
assert resp.status_code == 200
data = resp.json()
assert data["imdata"] == []
assert data["totalCount"] == "0"
def test_delete_without_auth_returns_403():
c = _new_client()
resp = c.delete("/api/mo/uni/tn-Blocked.json")
assert resp.status_code == 403
assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403"
# ---------------------------------------------------------------------------
# FEATURE 2 — status="deleted" cascade + status="created,modified" upsert
# ---------------------------------------------------------------------------
def test_status_deleted_via_post_removes_subtree():
c = _logged_in_client()
c.post(
"/api/mo/uni/tn-StT.json",
json={
"fvTenant": {
"attributes": {"name": "StT", "dn": "uni/tn-StT"},
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
}
},
)
assert c.get("/api/mo/uni/tn-StT/BD-bd1.json").json()["totalCount"] == "1"
resp = c.post(
"/api/mo/uni/tn-StT.json",
json={"fvTenant": {"attributes": {"name": "StT", "dn": "uni/tn-StT", "status": "deleted"}}},
)
assert resp.status_code == 200
assert resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "deleted"
# The whole subtree, including the never-mentioned-in-this-POST child, is gone.
assert c.get("/api/mo/uni/tn-StT.json").json()["totalCount"] == "0"
assert c.get("/api/mo/uni/tn-StT/BD-bd1.json").json()["totalCount"] == "0"
def test_status_created_modified_is_treated_as_upsert():
# cisco.aci does not actually send this on the wire (module_utils/aci.py
# never sets a "status" key in `proposed`), but real APIC accepts the
# value gracefully as a normal upsert rather than erroring — verified via
# this sim's own status=="deleted" special-case being the ONLY branch
# that diverts from upsert. Guard against a regression that special-cases
# any other status string.
c = _logged_in_client()
resp = c.post(
"/api/mo/uni/tn-CmT.json",
json={
"fvTenant": {
"attributes": {"name": "CmT", "dn": "uni/tn-CmT", "status": "created,modified"},
}
},
)
assert resp.status_code == 200
assert resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created"
get = c.get("/api/mo/uni/tn-CmT.json")
assert get.status_code == 200
assert get.json()["totalCount"] == "1"
attrs = get.json()["imdata"][0]["fvTenant"]["attributes"]
assert attrs["name"] == "CmT"
# The literal "created,modified" string must not have been stored as a
# real store attribute in a way that later confuses upsert/delete logic —
# a second write with status="deleted" must still remove it.
resp2 = c.post(
"/api/mo/uni/tn-CmT.json",
json={"fvTenant": {"attributes": {"name": "CmT", "dn": "uni/tn-CmT", "status": "deleted"}}},
)
assert resp2.status_code == 200
assert c.get("/api/mo/uni/tn-CmT.json").json()["totalCount"] == "0"
# ---------------------------------------------------------------------------
# FEATURE 3 — annotation / nameAlias / descr passthrough
# ---------------------------------------------------------------------------
def test_annotation_roundtrips_on_mo_get_and_class_query():
c = _logged_in_client()
resp = c.post(
"/api/mo/uni/tn-AnnT.json",
json={
"fvTenant": {
"attributes": {
"name": "AnnT",
"dn": "uni/tn-AnnT",
"annotation": "orchestrator:ansible",
"nameAlias": "friendly-name",
"descr": "managed by ansible",
}
}
},
)
assert resp.status_code == 200
mo = c.get("/api/mo/uni/tn-AnnT.json")
assert mo.status_code == 200
attrs = mo.json()["imdata"][0]["fvTenant"]["attributes"]
assert attrs["annotation"] == "orchestrator:ansible"
assert attrs["nameAlias"] == "friendly-name"
assert attrs["descr"] == "managed by ansible"
cls = c.get('/api/class/fvTenant.json?query-target-filter=eq(fvTenant.name,"AnnT")')
assert cls.status_code == 200
rows = cls.json()["imdata"]
assert len(rows) == 1
cls_attrs = rows[0]["fvTenant"]["attributes"]
assert cls_attrs["annotation"] == "orchestrator:ansible"
assert cls_attrs["nameAlias"] == "friendly-name"
# ---------------------------------------------------------------------------
# FEATURE 4 — certificate signature auth (accept-mode)
# ---------------------------------------------------------------------------
def _cert_cookies(user: str = "admin", certname: str = "ansible") -> dict[str, str]:
return {
"APIC-Certificate-Algorithm": "v1.0",
"APIC-Certificate-Fingerprint": "fingerprint",
"APIC-Certificate-DN": f"uni/userext/user-{user}/usercert-{certname}",
"APIC-Request-Signature": base64.b64encode(b"not-a-real-signature").decode(),
}
def test_cert_cookie_authenticates_without_prior_login():
c = _new_client()
for name, value in _cert_cookies().items():
c.cookies.set(name, value)
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 200
assert int(resp.json()["totalCount"]) > 0
def test_cert_cookie_write_and_delete_work_without_prior_login():
c = _new_client()
for name, value in _cert_cookies().items():
c.cookies.set(name, value)
post = c.post(
"/api/mo/uni/tn-CertT.json",
json={"fvTenant": {"attributes": {"name": "CertT", "dn": "uni/tn-CertT"}}},
)
assert post.status_code == 200
assert c.get("/api/mo/uni/tn-CertT.json").json()["totalCount"] == "1"
delete = c.delete("/api/mo/uni/tn-CertT.json")
assert delete.status_code == 200
assert c.get("/api/mo/uni/tn-CertT.json").json()["totalCount"] == "0"
def test_cert_cookie_wrong_user_returns_403():
c = _new_client()
for name, value in _cert_cookies(user="notadmin").items():
c.cookies.set(name, value)
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403"
def test_cert_cookie_missing_one_field_returns_403():
c = _new_client()
cookies = _cert_cookies()
del cookies["APIC-Request-Signature"]
for name, value in cookies.items():
c.cookies.set(name, value)
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
def test_cert_cookie_malformed_dn_returns_403():
c = _new_client()
cookies = _cert_cookies()
cookies["APIC-Certificate-DN"] = "not-a-valid-cert-dn"
for name, value in cookies.items():
c.cookies.set(name, value)
resp = c.get("/api/class/fabricNode.json")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# FEATURE 5 — firmwareCtrlrRunning
# ---------------------------------------------------------------------------
def test_firmware_ctrlr_running_seeded_if_implemented():
c = _logged_in_client()
resp = c.get("/api/class/firmwareCtrlrRunning.json")
assert resp.status_code == 200
data = resp.json()
if int(data["totalCount"]) == 0:
pytest.skip("firmwareCtrlrRunning not seeded in this build")
for item in data["imdata"]:
assert "version" in item["firmwareCtrlrRunning"]["attributes"]
+613
View File
@@ -0,0 +1,613 @@
"""Tests for query/ — filters and engine."""
import pytest
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.filters import FilterParseError, parse_filter
from aci_sim.query.engine import (
QueryParams,
params_from_dict,
run_class_query,
run_mo_query,
run_node_scoped,
)
# ---------------------------------------------------------------------------
# Shared fixture
# ---------------------------------------------------------------------------
def _make_store() -> MITStore:
"""Minimal but representative store covering all query shapes."""
s = MITStore()
# Tenants
for name in ["T1", "T2", "T3"]:
s.add(MO("fvTenant", dn=f"uni/tn-{name}", name=name))
# BDs + subnets under T1
for bd_name in ["bd1", "bd2"]:
s.add(MO("fvBD", dn=f"uni/tn-T1/BD-{bd_name}", name=bd_name))
s.add(MO(
"fvSubnet",
dn=f"uni/tn-T1/BD-{bd_name}/subnet-[10.0.0.1/24]",
ip="10.0.0.1/24",
))
# Fabric nodes
for node_id, role in [("101", "spine"), ("102", "spine"), ("103", "leaf"), ("104", "leaf")]:
s.add(MO(
"fabricNode",
dn=f"topology/pod-1/node-{node_id}",
id=node_id,
role=role,
name=f"node-{node_id}",
))
# Node-local faults
s.add(MO(
"faultInst",
dn="topology/pod-1/node-103/local/svc-x/fault-F0001",
severity="critical",
code="F0001",
))
s.add(MO(
"faultInst",
dn="topology/pod-1/node-104/local/svc-y/fault-F0002",
severity="major",
code="F0002",
))
return s
# ---------------------------------------------------------------------------
# filters.py
# ---------------------------------------------------------------------------
class TestFilters:
def setup_method(self):
self.s = _make_store()
# -- eq --
def test_eq_spine(self):
pred = parse_filter('eq(fabricNode.role,"spine")')
spines = [mo for mo in self.s.by_class("fabricNode") if pred(mo)]
assert len(spines) == 2
assert all(mo.attrs["role"] == "spine" for mo in spines)
def test_eq_no_match(self):
pred = parse_filter('eq(fabricNode.role,"controller")')
assert [mo for mo in self.s.by_class("fabricNode") if pred(mo)] == []
def test_eq_dn(self):
pred = parse_filter('eq(fvTenant.dn,"uni/tn-T1")')
result = [mo for mo in self.s.by_class("fvTenant") if pred(mo)]
assert len(result) == 1
assert result[0].dn == "uni/tn-T1"
# -- wcard --
def test_wcard_dn_substring(self):
pred = parse_filter('wcard(fvBD.dn,"tn-T1/")')
bds = [mo for mo in self.s.by_class("fvBD") if pred(mo)]
assert len(bds) == 2
def test_wcard_no_match(self):
pred = parse_filter('wcard(fvBD.dn,"tn-T2/")')
assert [mo for mo in self.s.by_class("fvBD") if pred(mo)] == []
# -- and --
def test_and_filter(self):
pred = parse_filter('and(eq(fvBD.name,"bd1"),wcard(fvBD.dn,"tn-T1/"))')
bds = [mo for mo in self.s.by_class("fvBD") if pred(mo)]
assert len(bds) == 1
assert bds[0].attrs["name"] == "bd1"
# -- or --
def test_or_filter(self):
pred = parse_filter(
'or(eq(faultInst.severity,"critical"),eq(faultInst.severity,"major"))'
)
faults = [mo for mo in self.s.by_class("faultInst") if pred(mo)]
assert len(faults) == 2
# -- nested --
def test_nested_and_or(self):
pred = parse_filter(
'and('
' or(eq(fabricNode.role,"spine"),eq(fabricNode.role,"leaf")),'
' eq(fabricNode.id,"101")'
')'
)
nodes = [mo for mo in self.s.by_class("fabricNode") if pred(mo)]
assert len(nodes) == 1
assert nodes[0].attrs["id"] == "101"
def test_deeply_nested_or_inside_and(self):
# Exactly mirrors the APIC example wcard(fvCtx.dn,"tn-Tenant1/")
pred = parse_filter('wcard(fvTenant.dn,"tn-T")')
all_tenants = self.s.by_class("fvTenant")
result = [mo for mo in all_tenants if pred(mo)]
assert len(result) == 3 # T1, T2, T3 all contain "tn-T"
# -- edge cases --
def test_unknown_attr_is_false(self):
pred = parse_filter('eq(fvBD.nonexistent,"x")')
assert not any(pred(mo) for mo in self.s.by_class("fvBD"))
def test_empty_expr_matches_all(self):
pred = parse_filter("")
mo = self.s.get("uni/tn-T1")
assert pred(mo) is True
def test_none_expr_matches_all(self):
pred = parse_filter(None)
mo = self.s.get("uni/tn-T1")
assert pred(mo) is True
# ---------------------------------------------------------------------------
# engine.py — QueryParams / params_from_dict
# ---------------------------------------------------------------------------
class TestParamsFromDict:
def test_basic_filter_and_page(self):
p = params_from_dict({
"query-target-filter": 'eq(fabricNode.role,"spine")',
"page-size": "100",
"page": "2",
})
assert p.filter_expr == 'eq(fabricNode.role,"spine")'
assert p.page_size == 100
assert p.page == 2
def test_rsp_subtree_class_split(self):
p = params_from_dict({
"rsp-subtree": "children",
"rsp-subtree-class": "fvBD,fvSubnet",
})
assert p.rsp_subtree == "children"
assert p.rsp_subtree_class == ["fvBD", "fvSubnet"]
def test_legacy_query_target_subtree(self):
p = params_from_dict({
"query-target": "subtree",
"target-subtree-class": "bgpPeer",
})
assert p.query_target == "subtree"
assert p.target_subtree_class == ["bgpPeer"]
def test_page_size_clamped_to_5000(self):
p = params_from_dict({"page-size": "99999"})
assert p.page_size == 5000
def test_defaults(self):
p = params_from_dict({})
assert p.filter_expr is None
assert p.rsp_subtree is None
assert p.page == 0
assert p.page_size == 500
# -- defensive page/page-size parsing (PR-1 F2 regression) --
def test_non_numeric_page_raises(self):
with pytest.raises(FilterParseError):
params_from_dict({"page": "abc"})
def test_non_numeric_page_size_raises(self):
with pytest.raises(FilterParseError):
params_from_dict({"page-size": "abc"})
def test_negative_page_size_raises(self):
with pytest.raises(FilterParseError):
params_from_dict({"page-size": "-1"})
def test_zero_page_size_raises(self):
with pytest.raises(FilterParseError):
params_from_dict({"page-size": "0"})
def test_negative_page_raises(self):
with pytest.raises(FilterParseError):
params_from_dict({"page": "-1"})
# ---------------------------------------------------------------------------
# engine.py — run_class_query
# ---------------------------------------------------------------------------
class TestRunClassQuery:
def setup_method(self):
self.s = _make_store()
def test_basic_all_tenants(self):
imdata, total = run_class_query(self.s, "fvTenant", QueryParams())
assert total == 3
assert len(imdata) == 3
assert all("fvTenant" in d for d in imdata)
def test_with_eq_filter(self):
params = QueryParams(filter_expr='eq(fabricNode.role,"spine")')
imdata, total = run_class_query(self.s, "fabricNode", params)
assert total == 2
assert len(imdata) == 2
def test_no_children_by_default(self):
imdata, _ = run_class_query(self.s, "fvBD", QueryParams())
for item in imdata:
body = list(item.values())[0]
assert "children" not in body
# -- pagination --
def test_pagination_page0(self):
params = QueryParams(page=0, page_size=2)
imdata, total = run_class_query(self.s, "fvTenant", params)
assert total == 3
assert len(imdata) == 2
def test_pagination_page1(self):
params = QueryParams(page=1, page_size=2)
imdata, total = run_class_query(self.s, "fvTenant", params)
assert total == 3
assert len(imdata) == 1 # 3 total → page 1 has 1 item
def test_pagination_beyond_end(self):
params = QueryParams(page=5, page_size=10)
imdata, total = run_class_query(self.s, "fvTenant", params)
assert total == 3
assert imdata == []
def test_total_unaffected_by_page(self):
"""total is always pre-pagination count."""
p0 = QueryParams(page=0, page_size=1)
p1 = QueryParams(page=1, page_size=1)
_, total0 = run_class_query(self.s, "fvTenant", p0)
_, total1 = run_class_query(self.s, "fvTenant", p1)
assert total0 == total1 == 3
# -- subtree --
def test_rsp_subtree_children_attaches_descendants(self):
params = QueryParams(rsp_subtree="children")
imdata, total = run_class_query(self.s, "fvBD", params)
assert total == 2
for item in imdata:
body = list(item.values())[0]
assert "children" in body
child_classes = {list(c.keys())[0] for c in body["children"]}
assert "fvSubnet" in child_classes
def test_rsp_subtree_class_filters_descendants(self):
params = QueryParams(rsp_subtree="full", rsp_subtree_class=["fvSubnet"])
imdata, _ = run_class_query(self.s, "fvBD", params)
for item in imdata:
body = list(item.values())[0]
children = body.get("children", [])
# Every attached descendant must be fvSubnet
for c in children:
assert list(c.keys())[0] == "fvSubnet"
def test_rsp_subtree_no_match_class_no_children_key(self):
"""When rsp-subtree-class matches nothing, 'children' key absent."""
params = QueryParams(rsp_subtree="full", rsp_subtree_class=["fvAp"])
imdata, _ = run_class_query(self.s, "fvBD", params)
for item in imdata:
body = list(item.values())[0]
assert "children" not in body
# -- envelope shape --
def test_envelope_shape(self):
imdata, total = run_class_query(self.s, "fabricNode", QueryParams())
for item in imdata:
assert len(item) == 1
cls = list(item.keys())[0]
assert cls == "fabricNode"
body = item[cls]
assert "attributes" in body
assert "dn" in body["attributes"]
# ---------------------------------------------------------------------------
# engine.py — run_mo_query
# ---------------------------------------------------------------------------
class TestRunMoQuery:
def setup_method(self):
self.s = _make_store()
def test_get_single_mo(self):
imdata, total = run_mo_query(self.s, "uni/tn-T1", QueryParams())
assert total == 1
assert len(imdata) == 1
assert "fvTenant" in imdata[0]
assert imdata[0]["fvTenant"]["attributes"]["dn"] == "uni/tn-T1"
def test_not_found_returns_empty(self):
imdata, total = run_mo_query(self.s, "uni/tn-MISSING", QueryParams())
assert total == 0
assert imdata == []
def test_subtree_returns_descendants_flat(self):
# legacy query-target=subtree → FLAT top-level imdata (root + all descendants),
# NOT nested under the root (matches real APIC + autoACI's top-level reads).
params = QueryParams(query_target="subtree")
imdata, total = run_mo_query(self.s, "uni/tn-T1", params)
classes = [next(iter(e)) for e in imdata]
assert total == 5 # fvTenant + 2 fvBD + 2 fvSubnet
assert classes.count("fvTenant") == 1
assert classes.count("fvBD") == 2
assert classes.count("fvSubnet") == 2
for e in imdata: # flat, never nested
assert "children" not in next(iter(e.values()))
def test_subtree_class_filter_flat(self):
params = QueryParams(query_target="subtree", target_subtree_class=["fvBD"])
imdata, total = run_mo_query(self.s, "uni/tn-T1", params)
classes = {next(iter(e)) for e in imdata}
assert classes == {"fvBD"} # root + subnets excluded by the class filter
assert total == 2
def test_subtree_class_filter_deep(self):
"""Class filter on legacy subtree reaches deeply-nested descendants, flat at top level."""
params = QueryParams(query_target="subtree", target_subtree_class=["fvSubnet"])
imdata, total = run_mo_query(self.s, "uni/tn-T1", params)
classes = [next(iter(e)) for e in imdata]
assert total == 2
assert all(c == "fvSubnet" for c in classes)
def test_bracket_dn(self):
imdata, total = run_mo_query(
self.s, "uni/tn-T1/BD-bd1/subnet-[10.0.0.1/24]", QueryParams()
)
assert total == 1
assert "fvSubnet" in imdata[0]
# ---------------------------------------------------------------------------
# engine.py — run_node_scoped
# ---------------------------------------------------------------------------
class TestRunNodeScoped:
def setup_method(self):
self.s = _make_store()
def test_returns_faults_under_node(self):
imdata, total = run_node_scoped(
self.s, "topology/pod-1/node-103", "faultInst", QueryParams()
)
assert total == 1
assert imdata[0]["faultInst"]["attributes"]["code"] == "F0001"
def test_no_match_different_node(self):
imdata, total = run_node_scoped(
self.s, "topology/pod-1/node-101", "faultInst", QueryParams()
)
assert total == 0
assert imdata == []
def test_with_eq_filter(self):
params = QueryParams(filter_expr='eq(faultInst.severity,"major")')
imdata, total = run_node_scoped(
self.s, "topology/pod-1/node-104", "faultInst", params
)
assert total == 1
assert imdata[0]["faultInst"]["attributes"]["severity"] == "major"
def test_filter_excludes_non_matching(self):
params = QueryParams(filter_expr='eq(faultInst.severity,"critical")')
imdata, total = run_node_scoped(
self.s, "topology/pod-1/node-104", "faultInst", params
)
assert total == 0
def test_envelope_shape(self):
imdata, _ = run_node_scoped(
self.s, "topology/pod-1/node-103", "faultInst", QueryParams()
)
item = imdata[0]
assert "faultInst" in item
assert "attributes" in item["faultInst"]
assert "dn" in item["faultInst"]["attributes"]
assert "children" not in item["faultInst"] # no subtree requested
# ---------------------------------------------------------------------------
# engine.py — rsp-subtree=full MUST preserve multi-level nesting (B1 regression)
# ---------------------------------------------------------------------------
class TestRspSubtreeFullNesting:
"""rsp-subtree=full must keep grandchildren nested under their parent, not
hoist them flat onto the root. autoACI contract_map/access_policy read two
levels (vzBrCP.children→vzSubj, vzSubj.children→vzRsSubjFiltAtt); flattening
silently empties the filter/entry column."""
def _contract_store(self) -> MITStore:
s = MITStore()
s.add(MO("vzBrCP", dn="uni/tn-T1/brc-Web", name="Web"))
s.add(MO("vzSubj", dn="uni/tn-T1/brc-Web/subj-S1", name="S1"))
s.add(MO(
"vzRsSubjFiltAtt",
dn="uni/tn-T1/brc-Web/subj-S1/rssubjFiltAtt-http",
tnVzFilterName="http",
))
s.add(MO(
"vzRsSubjFiltAtt",
dn="uni/tn-T1/brc-Web/subj-S1/rssubjFiltAtt-https",
tnVzFilterName="https",
))
return s
def test_full_with_class_filter_keeps_grandchildren_nested(self):
s = self._contract_store()
params = QueryParams(
rsp_subtree="full",
rsp_subtree_class=["vzSubj", "vzRsSubjFiltAtt"],
)
imdata, total = run_class_query(s, "vzBrCP", params)
assert total == 1
brc = imdata[0]["vzBrCP"]
# vzSubj is the ONLY direct child of vzBrCP (filter attrs never hoisted)
assert [next(iter(c)) for c in brc["children"]] == ["vzSubj"]
subj = brc["children"][0]["vzSubj"]
# the two filter bindings are nested UNDER vzSubj, not under vzBrCP
filt_classes = [next(iter(c)) for c in subj["children"]]
assert filt_classes == ["vzRsSubjFiltAtt", "vzRsSubjFiltAtt"]
def test_full_no_class_filter_nests_every_level(self):
s = MITStore()
s.add(MO("fvTenant", dn="uni/tn-T1", name="T1"))
s.add(MO("fvBD", dn="uni/tn-T1/BD-bd1", name="bd1"))
s.add(MO("fvSubnet", dn="uni/tn-T1/BD-bd1/subnet-[10.0.0.1/24]", ip="10.0.0.1/24"))
imdata, _ = run_mo_query(s, "uni/tn-T1", QueryParams(rsp_subtree="full"))
tn = imdata[0]["fvTenant"]
assert next(iter(tn["children"][0])) == "fvBD"
bd = tn["children"][0]["fvBD"]
# grandchild nested under BD, not flattened onto the tenant
assert next(iter(bd["children"][0])) == "fvSubnet"
assert all(next(iter(c)) != "fvSubnet" for c in tn["children"])
# ---------------------------------------------------------------------------
# filters.py — comparison operators + parse errors (B2 regression)
# ---------------------------------------------------------------------------
class TestComparisonFilters:
def setup_method(self):
self.s = _make_store()
def _ids(self, expr: str) -> set[str]:
pred = parse_filter(expr)
return {mo.attrs["id"] for mo in self.s.by_class("fabricNode") if pred(mo)}
def test_ne(self):
pred = parse_filter('ne(fvTenant.name,"T1")')
names = {mo.attrs["name"] for mo in self.s.by_class("fvTenant") if pred(mo)}
assert names == {"T2", "T3"}
def test_gt_numeric(self):
assert self._ids('gt(fabricNode.id,"102")') == {"103", "104"}
def test_lt_numeric(self):
assert self._ids('lt(fabricNode.id,"102")') == {"101"}
def test_ge_numeric(self):
assert self._ids('ge(fabricNode.id,"103")') == {"103", "104"}
def test_le_numeric(self):
assert self._ids('le(fabricNode.id,"102")') == {"101", "102"}
def test_bw_inclusive_range(self):
assert self._ids('bw(fabricNode.id,"102","103")') == {"102", "103"}
def test_compare_missing_attr_is_false(self):
assert self._ids('gt(fabricNode.nonexistent,"1")') == set()
def test_ne_nested_in_and(self):
assert self._ids('and(ne(fabricNode.role,"spine"),gt(fabricNode.id,"100"))') == {"103", "104"}
def test_string_fallback_when_non_numeric(self):
# role is not numeric → lexical compare still works, doesn't raise
pred = parse_filter('gt(fabricNode.role,"leaf")')
roles = {mo.attrs["role"] for mo in self.s.by_class("fabricNode") if pred(mo)}
assert roles == {"spine"} # "spine" > "leaf" lexically
def test_unknown_operator_raises(self):
from aci_sim.query.filters import FilterParseError
with pytest.raises(FilterParseError):
parse_filter('frobnicate(fvTenant.name,"x")')
def test_truncated_expression_raises(self):
from aci_sim.query.filters import FilterParseError
with pytest.raises(FilterParseError):
parse_filter('eq(fvTenant.name')
# -- numeric comparison guard (kills lexical-only mutation) --
def test_bw_numeric_wide_range_matches_all_nodes(self):
# "150" < "99" lexically, so a lexical-only bw would empty the range;
# numerically 99 <= id <= 150 must match every node (101-104).
assert self._ids('bw(fabricNode.id,"99","150")') == {"101", "102", "103", "104"}
def test_gt_numeric_low_bound_matches_all_nodes(self):
assert self._ids('gt(fabricNode.id,"99")') == {"101", "102", "103", "104"}
# -- strict parsing: trailing tokens / zero-arg composites (regression) --
def test_comma_joined_double_filter_raises(self):
with pytest.raises(FilterParseError):
parse_filter('eq(a.b,"x"),eq(a.b,"y")')
def test_unbalanced_trailing_paren_raises(self):
with pytest.raises(FilterParseError):
parse_filter('eq(a.b,"x"))')
def test_zero_arg_or_raises(self):
with pytest.raises(FilterParseError):
parse_filter('or()')
def test_zero_arg_and_raises(self):
with pytest.raises(FilterParseError):
parse_filter('and()')
# ---------------------------------------------------------------------------
# engine.py — subtree scope filtering (PR-1 F1 regression)
# ---------------------------------------------------------------------------
class TestSubtreeScopeFiltering:
"""query-target=subtree + query-target-filter must filter the EXPANDED
subtree scope (root + descendants), not pre-filter the root before
expansion. A filter on a descendant-only attribute (e.g. faultInst.severity
under a topSystem root) must still reach matching descendants."""
def _make_topsystem_store(self) -> MITStore:
s = MITStore()
s.add(MO("topSystem", dn="topology/pod-1/node-101/sys", id="101", role="leaf"))
s.add(MO(
"faultInst",
dn="topology/pod-1/node-101/sys/faults/fault-F1",
severity="minor",
code="F1",
))
s.add(MO(
"faultInst",
dn="topology/pod-1/node-101/sys/faults/fault-F2",
severity="major",
code="F2",
))
return s
def test_run_mo_query_subtree_filter_on_descendant_attr(self):
s = self._make_topsystem_store()
params = QueryParams(
query_target="subtree",
target_subtree_class=["faultInst"],
filter_expr='eq(faultInst.severity,"minor")',
)
imdata, total = run_mo_query(s, "topology/pod-1/node-101/sys", params)
assert total == 1
assert imdata[0]["faultInst"]["attributes"]["code"] == "F1"
assert imdata[0]["faultInst"]["attributes"]["severity"] == "minor"
def test_run_class_query_subtree_filter_on_descendant_attr(self):
s = self._make_topsystem_store()
params = QueryParams(filter_expr='eq(faultInst.severity,"minor")')
imdata, total = run_class_query(s, "faultInst", params)
assert total == 1
assert imdata[0]["faultInst"]["attributes"]["code"] == "F1"
+186
View File
@@ -0,0 +1,186 @@
"""Mutation-killers for filters.py numeric-comparison semantics (finding #21).
A prior audit replaced the numeric branch of gt/ge/lt/le/bw with pure-lexical
string comparison and the full suite stayed green — the numeric-vs-lexical
fork in ``_cmp_pred`` / ``_bw_pred`` was effectively untested. Every case
below is chosen so lexical and numeric ordering DISAGREE: if the numeric
branch were deleted (or the ``rn is not None and vn is not None`` guard were
forced to always fail), these assertions flip and the test fails.
Covers both the parse_filter unit level (this file) and the HTTP
class-query path (tests/test_rest_aci.py::TestNumericSemanticsHttp-style
cases live alongside the existing REST fault/filter tests — see the
`test_bw_wide_numeric_range_matches_non_controller_nodes` /
`test_gt_numeric_filter_matches_all_non_controller_nodes` companions in
tests/test_rest_aci.py, exercised here again via the FastAPI TestClient
for the same fixture used by fabricNode ids 101-104).
"""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.mit.mo import MO
from aci_sim.mit.store import MITStore
from aci_sim.query.filters import parse_filter
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml" # run from project root
def _make_store() -> MITStore:
"""Fabric nodes with three-digit ids 101-104 — same fixture shape as
tests/test_query.py::_make_store, so lexical "150" < "99" and
"101" < "99" traps are exercised identically."""
s = MITStore()
for node_id, role in [("101", "spine"), ("102", "spine"), ("103", "leaf"), ("104", "leaf")]:
s.add(MO(
"fabricNode",
dn=f"topology/pod-1/node-{node_id}",
id=node_id,
role=role,
name=f"node-{node_id}",
))
return s
# ---------------------------------------------------------------------------
# parse_filter unit level
# ---------------------------------------------------------------------------
class TestNumericSemanticsUnit:
def setup_method(self):
self.s = _make_store()
def _ids(self, expr: str) -> set[str]:
pred = parse_filter(expr)
return {mo.attrs["id"] for mo in self.s.by_class("fabricNode") if pred(mo)}
# -- bw: lexically "150" < "99" empties the range; numerically it must
# cover every 3-digit node id 101-104. --
def test_bw_lexically_inverted_bounds_matches_all_nodes(self):
assert self._ids('bw(fabricNode.id,"99","150")') == {"101", "102", "103", "104"}
def test_bw_lexically_inverted_bounds_excludes_below_low(self):
# Numeric lower bound 99 excludes nothing here (all ids >= 101), but
# pin a case where the lexical trap would ALSO wrongly exclude if the
# numeric branch were dropped: bw with a tight numeric window that a
# lexical comparison would evaluate completely differently.
assert self._ids('bw(fabricNode.id,"101","102")') == {"101", "102"}
# -- gt: "99" is lexically greater than "101".."104" (since "9" > "1"),
# so a lexical-only gt(id,"99") would match NOTHING; numerically it must
# match every node. --
def test_gt_low_numeric_bound_matches_all_nodes(self):
assert self._ids('gt(fabricNode.id,"99")') == {"101", "102", "103", "104"}
def test_gt_three_digit_ids_numeric(self):
# "103" > "102" holds both lexically and numerically for same-width
# strings, so pair it with the mixed-width case below to force the
# numeric branch specifically.
assert self._ids('gt(fabricNode.id,"102")') == {"103", "104"}
# -- ge: mirrors gt with mixed-width low bound. --
def test_ge_low_numeric_bound_matches_all_nodes(self):
assert self._ids('ge(fabricNode.id,"99")') == {"101", "102", "103", "104"}
# -- lt / le: "9" is lexically greater than "101".."104", so a
# lexical-only lt(id,"9") would match every node (each id < "9" lexically
# is False since "1..." > "9" is False... actually "101" < "9" is True
# lexically because '1' < '9'). Use a case where lexical and numeric
# DISAGREE in direction: lt(id, "20") — lexically "101" < "20" is True
# (since '1' < '2'), but numerically 101 < 20 is False. --
def test_lt_mixed_width_bound_matches_nothing_numerically(self):
# Lexical comparison would wrongly match all nodes ("101" < "20",
# "102" < "20", ... all True as strings); numeric comparison must
# correctly match none (101-104 are not < 20).
assert self._ids('lt(fabricNode.id,"20")') == set()
def test_le_mixed_width_bound_matches_nothing_numerically(self):
assert self._ids('le(fabricNode.id,"20")') == set()
def test_lt_numeric_within_range(self):
assert self._ids('lt(fabricNode.id,"103")') == {"101", "102"}
def test_le_numeric_within_range(self):
assert self._ids('le(fabricNode.id,"102")') == {"101", "102"}
# ---------------------------------------------------------------------------
# HTTP class-query path
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def client():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
c = TestClient(app)
login = c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert login.status_code == 200
return c
class TestNumericSemanticsHttp:
def test_bw_lexically_inverted_bounds_matches_all_non_controller_nodes(self, client):
# "150" < "99" lexically would empty the range entirely; numerically
# 99 <= id <= 150 must match all four LAB1 leaf/border-leaf nodes.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=bw(fabricNode.id,"99","150")'
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) == 4
ids = {i["fabricNode"]["attributes"]["id"] for i in data["imdata"]}
assert ids == {"101", "102", "103", "104"}
def test_gt_low_numeric_bound_matches_all_non_controller_nodes(self, client):
# Lexically "99" > "101".."104" (since '9' > '1'), so a lexical-only
# gt would match nothing; numerically every 3-digit node id passes.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"99")'
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) >= 4
roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]]
assert "controller" not in roles
def test_lt_mixed_width_bound_excludes_leaves_and_spines(self, client):
# Lexically "101".."104"/"201"/"202" are all < "50" (since '1'/'2' <
# '5'), so a lexical-only lt would wrongly match every leaf/spine;
# numerically none of 101-104/201/202 is < 50, so only the
# single-digit controllers (id 1-3) may pass.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=lt(fabricNode.id,"50")'
)
assert resp.status_code == 200
data = resp.json()
roles = {i["fabricNode"]["attributes"]["role"] for i in data["imdata"]}
ids = {i["fabricNode"]["attributes"]["id"] for i in data["imdata"]}
assert roles <= {"controller"}
assert "leaf" not in roles
assert "spine" not in roles
assert ids <= {"1", "2", "3"}
+717
View File
@@ -0,0 +1,717 @@
"""Tests for the APIC REST simulation layer (Phase 5)."""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml" # run from project root
@pytest.fixture(scope="module")
def client():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
return TestClient(app)
def test_login(client):
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
data = resp.json()
assert "aaaLogin" in data["imdata"][0]
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
assert token
assert "APIC-cookie" in resp.cookies
def test_refresh(client):
resp = client.get("/api/aaaRefresh.json")
assert resp.status_code == 200
data = resp.json()
assert "aaaLogin" in data["imdata"][0]
def test_class_fabric_node(client):
resp = client.get("/api/class/fabricNode.json")
assert resp.status_code == 200
data = resp.json()
# 6 switches (2 spine + 2 leaf + 2 border-leaf) + 1 APIC controller (PR-21 single-APIC default) = 7
assert data["totalCount"] == "7"
assert len(data["imdata"]) == 7
roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]]
assert sum(r in ("spine", "leaf") for r in roles) == 6
assert roles.count("controller") == 1
def test_class_fault_filter(client):
resp = client.get(
'/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")'
)
assert resp.status_code == 200
data = resp.json()
# Topology seeds exactly one "minor" faultInst (F0532 on node-101) — a
# regression that silently empties imdata (e.g. 200-with-empty-imdata)
# must not pass silently.
assert int(data["totalCount"]) == 1
assert len(data["imdata"]) == 1
for item in data["imdata"]:
assert item["faultInst"]["attributes"]["severity"] == "minor"
def test_fault_inst_rows_carry_seeded_severities(client):
# Stable premise for the PR-1 subtree+filter regression tests: the
# topology seeds exactly two faultInst rows (severity=minor F0532 on
# node-101, severity=warning F0554 on node-102). Assert both are present
# with their seeded severity so a regression that drops/renames severity
# values is caught here rather than only in the filter-specific tests.
resp = client.get("/api/class/faultInst.json")
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) == 2
assert len(data["imdata"]) == 2
severities = {i["faultInst"]["attributes"]["severity"] for i in data["imdata"]}
assert severities == {"minor", "warning"}
codes = {i["faultInst"]["attributes"]["code"] for i in data["imdata"]}
assert codes == {"F0532", "F0554"}
def test_mo_subtree_flat(client):
resp = client.get(
"/api/mo/uni/tn-MS-TN1.json?query-target=subtree&target-subtree-class=fvBD"
)
assert resp.status_code == 200
data = resp.json()
# tn-MS-TN1 seeds at least one BD in the topology fixture; a regression
# returning 200-with-empty-imdata must fail loudly rather than pass this
# now-vacuous loop.
assert int(data["totalCount"]) > 0
assert len(data["imdata"]) > 0
for item in data["imdata"]:
assert "fvBD" in item
def test_bracket_dn(client):
resp = client.get("/api/class/fvSubnet.json")
assert resp.status_code == 200
data = resp.json()
if not data["imdata"]:
pytest.skip("no subnets in topology")
dn = data["imdata"][0]["fvSubnet"]["attributes"]["dn"]
resp2 = client.get(f"/api/mo/{dn}.json")
assert resp2.status_code == 200
d2 = resp2.json()
assert len(d2["imdata"]) == 1
def test_node_scoped_fault(client):
resp = client.get("/api/node/class/topology/pod-1/node-101/faultInst.json")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data["imdata"], list)
assert isinstance(data["totalCount"], str)
def test_write_readback(client):
resp = client.post(
"/api/mo/uni/tn-NEW.json",
json={"fvTenant": {"attributes": {"name": "NEW", "dn": "uni/tn-NEW", "descr": "test"}}},
)
assert resp.status_code == 200
resp2 = client.get("/api/class/fvTenant.json")
names = [item["fvTenant"]["attributes"]["name"] for item in resp2.json()["imdata"]]
assert "NEW" in names
def test_add_leaf_reaction(client):
resp = client.post(
"/api/mo/uni/controller/nodeidentpol/nodep-901.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-901",
"name": "leaf-901",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200
resp2 = client.get("/api/class/fabricNode.json")
ids = [item["fabricNode"]["attributes"]["id"] for item in resp2.json()["imdata"]]
assert "901" in ids
def test_missing_mo_returns_200_empty(client):
# PR-9 FEATURE 6: real APIC (and cisco.aci's GET-before-POST existence
# check) requires 200 + empty imdata for a well-formed-but-absent DN, not
# 404 — see tests/test_pr9_ansible.py for the verified module-source
# rationale. This was test_missing_mo_404 pre-PR-9.
resp = client.get("/api/mo/uni/tn-DOESNOTEXIST.json")
assert resp.status_code == 200
data = resp.json()
assert data["totalCount"] == "0"
assert data["imdata"] == []
# -- B2 regression: comparison filters work, malformed filters are 400 not 500 --
def test_ne_filter_returns_200_not_500(client):
# ne/gt/lt/ge/le/bw used to hit `raise ValueError` -> uncaught -> HTTP 500.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=ne(fabricNode.role,"controller")'
)
assert resp.status_code == 200
roles = [i["fabricNode"]["attributes"]["role"] for i in resp.json()["imdata"]]
assert roles and "controller" not in roles
def test_gt_numeric_filter_returns_200(client):
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"200")'
)
assert resp.status_code == 200
ids = [int(i["fabricNode"]["attributes"]["id"]) for i in resp.json()["imdata"]]
assert ids and all(i > 200 for i in ids)
def test_malformed_filter_returns_apic_400_not_500(client):
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=frobnicate(fabricNode.id,"1")'
)
assert resp.status_code == 400
err = resp.json()["imdata"][0]["error"]["attributes"]
assert err["code"] == "107"
assert "frobnicate" in err["text"]
# -- PR-1 F1 regression: query-target=subtree + query-target-filter on a
# descendant-only attribute must filter the EXPANDED scope, not the root --
def test_subtree_filter_on_descendant_attr_mo_query(client):
# topology/pod-1/node-101 has a seeded faultInst descendant (severity=minor)
# at .../local/svc-policyelem-id-0/uni/fault-F0532. Filtering on
# faultInst.severity used to pre-filter the fabricNode root (which lacks a
# "severity" attr → False) BEFORE subtree expansion, always returning empty
# imdata regardless of matching descendants.
resp = client.get(
'/api/mo/topology/pod-1/node-101.json'
'?query-target=subtree&target-subtree-class=faultInst'
'&query-target-filter=eq(faultInst.severity,"minor")'
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) > 0
for item in data["imdata"]:
assert "faultInst" in item
assert item["faultInst"]["attributes"]["severity"] == "minor"
def test_subtree_filter_on_descendant_attr_class_query(client):
resp = client.get(
'/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")'
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) > 0
for item in data["imdata"]:
assert item["faultInst"]["attributes"]["severity"] == "minor"
# -- PR-1 F2 regression: non-numeric / out-of-range page & page-size -> APIC 400 --
@pytest.mark.parametrize(
"query",
[
"page=abc",
"page-size=abc",
"page-size=-1",
"page-size=0",
],
)
def test_bad_page_params_return_apic_400(client, query):
resp = client.get(f"/api/class/fabricNode.json?{query}")
assert resp.status_code == 400
body = resp.json()
assert "imdata" in body
assert "detail" not in body
assert "error" in body["imdata"][0]
def test_bad_page_size_return_apic_400_on_mo_query(client):
resp = client.get("/api/mo/uni/tn-MS-TN1.json?page-size=-1")
assert resp.status_code == 400
body = resp.json()
assert "imdata" in body
assert "detail" not in body
def test_bad_page_size_return_apic_400_on_node_scoped_query(client):
resp = client.get(
"/api/node/class/topology/pod-1/node-101/faultInst.json?page-size=abc"
)
assert resp.status_code == 400
body = resp.json()
assert "imdata" in body
assert "detail" not in body
# -- PR-1 F3 regression: strict filter parsing (trailing tokens, zero-arg composites) --
@pytest.mark.parametrize(
"expr",
[
'eq(fabricNode.role,"spine"),eq(fabricNode.role,"leaf")', # comma-joined double filter
'eq(fabricNode.role,"spine"))', # unbalanced trailing paren
"or()", # zero-arg or
"and()", # zero-arg and
],
)
def test_strict_filter_parsing_returns_apic_400(client, expr):
resp = client.get(f"/api/class/fabricNode.json?query-target-filter={expr}")
assert resp.status_code == 400
err = resp.json()["imdata"][0]["error"]["attributes"]
assert err["code"] == "107"
# -- PR-1 numeric comparison guard: kills lexical-only mutation on bw/gt --
def test_bw_wide_numeric_range_matches_non_controller_nodes(client):
# "150" < "99" lexically, so a lexical-only bw would empty the range;
# numerically 99 <= id <= 150 must match all 4 LAB1 leaf/border-leaf nodes.
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=bw(fabricNode.id,"99","150")'
)
assert resp.status_code == 200
ids = {i["fabricNode"]["attributes"]["id"] for i in resp.json()["imdata"]}
assert ids == {"101", "102", "103", "104"}
def test_gt_numeric_filter_matches_all_non_controller_nodes(client):
resp = client.get(
'/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"99")'
)
assert resp.status_code == 200
data = resp.json()
# Controllers are id 1..N (<99); every id > 99 must be a non-controller node.
# (Don't assert an exact count here — the shared module-scoped `client`
# fixture may have accumulated extra nodes from earlier write tests.)
roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]]
assert roles
assert "controller" not in roles
# -- PR-2 R1: atomic validate-then-commit POST (no partial writes on 400) --
def test_malformed_child_rejects_with_400_and_writes_nothing(client):
resp = client.post(
"/api/mo/uni/tn-AtomT.json",
json={
"fvTenant": {
"attributes": {"name": "AtomT", "dn": "uni/tn-AtomT"},
"children": ["not-a-dict"],
}
},
)
assert resp.status_code == 400
err = resp.json()["imdata"][0]["error"]["attributes"]
assert err["code"] == "103"
resp2 = client.get("/api/mo/uni/tn-AtomT.json")
assert resp2.status_code == 200
assert resp2.json()["totalCount"] == "0"
def test_valid_first_child_then_malformed_second_child_rejects_everything(client):
resp = client.post(
"/api/mo/uni/tn-AtomT2.json",
json={
"fvTenant": {
"attributes": {"name": "AtomT2", "dn": "uni/tn-AtomT2"},
"children": [
{"fvCtx": {"attributes": {"name": "goodctx"}}},
["not-a-dict"],
],
}
},
)
assert resp.status_code == 400
err = resp.json()["imdata"][0]["error"]["attributes"]
assert err["code"] == "103"
# Neither the tenant nor the valid first child persisted.
resp2 = client.get("/api/mo/uni/tn-AtomT2.json")
assert resp2.status_code == 200
assert resp2.json()["totalCount"] == "0"
resp3 = client.get("/api/mo/uni/tn-AtomT2/ctx-goodctx.json")
assert resp3.status_code == 200
assert resp3.json()["totalCount"] == "0"
def test_malformed_grandchild_key_count_rejects_with_400_and_writes_nothing(client):
# A child_entry dict with != 1 key is also a shape violation.
resp = client.post(
"/api/mo/uni/tn-AtomT3.json",
json={
"fvTenant": {
"attributes": {"name": "AtomT3", "dn": "uni/tn-AtomT3"},
"children": [{"fvCtx": {"attributes": {"name": "c1"}}, "fvBD": {"attributes": {"name": "b1"}}}],
}
},
)
assert resp.status_code == 400
resp2 = client.get("/api/mo/uni/tn-AtomT3.json")
assert resp2.status_code == 200
assert resp2.json()["totalCount"] == "0"
# -- PR-2 R2: canonical RN synthesis for dn-less children --
def test_child_bd_without_dn_lands_on_canonical_BD_dn(client):
resp = client.post(
"/api/mo/uni/tn-RnT.json",
json={
"fvTenant": {
"attributes": {"name": "RnT", "dn": "uni/tn-RnT"},
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
}
},
)
assert resp.status_code == 200
ok = client.get("/api/mo/uni/tn-RnT/BD-bd1.json")
assert ok.status_code == 200
assert ok.json()["imdata"][0]["fvBD"]["attributes"]["name"] == "bd1"
# The old '{cls}-{name}' heuristic must NOT have been used.
stale = client.get("/api/mo/uni/tn-RnT/fvBD-bd1.json")
assert stale.status_code == 200
assert stale.json()["totalCount"] == "0"
def test_nested_ap_epg_without_dn_land_on_canonical_dns(client):
resp = client.post(
"/api/mo/uni/tn-RnT2.json",
json={
"fvTenant": {
"attributes": {"name": "RnT2", "dn": "uni/tn-RnT2"},
"children": [
{
"fvAp": {
"attributes": {"name": "ap1"},
"children": [{"fvAEPg": {"attributes": {"name": "epg1"}}}],
}
}
],
}
},
)
assert resp.status_code == 200
ap = client.get("/api/mo/uni/tn-RnT2/ap-ap1.json")
assert ap.status_code == 200
epg = client.get("/api/mo/uni/tn-RnT2/ap-ap1/epg-epg1.json")
assert epg.status_code == 200
assert epg.json()["imdata"][0]["fvAEPg"]["attributes"]["name"] == "epg1"
# Old heuristics must not have created a shadow object.
stale_ap = client.get("/api/mo/uni/tn-RnT2/fvAp-ap1.json")
assert stale_ap.status_code == 200
assert stale_ap.json()["totalCount"] == "0"
stale_epg = client.get("/api/mo/uni/tn-RnT2/ap-ap1/fvAEPg-epg1.json")
assert stale_epg.status_code == 200
assert stale_epg.json()["totalCount"] == "0"
def test_subnet_without_dn_uses_bracketed_ip_rn(client):
resp = client.post(
"/api/mo/uni/tn-RnT3.json",
json={
"fvTenant": {
"attributes": {"name": "RnT3", "dn": "uni/tn-RnT3"},
"children": [
{
"fvBD": {
"attributes": {"name": "bd1"},
"children": [
{"fvSubnet": {"attributes": {"ip": "10.20.30.1/24"}}}
],
}
}
],
}
},
)
assert resp.status_code == 200
subnet = client.get("/api/mo/uni/tn-RnT3/BD-bd1/subnet-[10.20.30.1/24].json")
assert subnet.status_code == 200
assert subnet.json()["imdata"][0]["fvSubnet"]["attributes"]["ip"] == "10.20.30.1/24"
def test_dnless_child_then_explicit_canonical_dn_does_not_duplicate(client):
# First POST without dn (synthesizes canonical dn), then POST again with
# the explicit canonical dn — must upsert the SAME object, not duplicate.
resp1 = client.post(
"/api/mo/uni/tn-RnT4.json",
json={
"fvTenant": {
"attributes": {"name": "RnT4", "dn": "uni/tn-RnT4"},
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
}
},
)
assert resp1.status_code == 200
resp2 = client.post(
"/api/mo/uni/tn-RnT4/BD-bd1.json",
json={
"fvBD": {
"attributes": {
"dn": "uni/tn-RnT4/BD-bd1",
"name": "bd1",
"descr": "explicit dn write",
}
}
},
)
assert resp2.status_code == 200
cls_resp = client.get('/api/class/fvBD.json?query-target-filter=eq(fvBD.name,"bd1")')
assert cls_resp.status_code == 200
matches = [
i for i in cls_resp.json()["imdata"] if i["fvBD"]["attributes"]["dn"] == "uni/tn-RnT4/BD-bd1"
]
assert len(matches) == 1
assert matches[0]["fvBD"]["attributes"]["descr"] == "explicit dn write"
# -- PR-2: valid multi-level write still works end-to-end --
def test_valid_multilevel_write_reads_back_every_level(client):
resp = client.post(
"/api/mo/uni/tn-FullT.json",
json={
"fvTenant": {
"attributes": {"name": "FullT", "dn": "uni/tn-FullT"},
"children": [
{
"fvCtx": {
"attributes": {"name": "vrf1"},
}
},
{
"fvBD": {
"attributes": {"name": "bd1"},
"children": [
{"fvRsCtx": {"attributes": {"tnFvCtxName": "vrf1"}}},
{"fvSubnet": {"attributes": {"ip": "192.168.1.1/24"}}},
],
}
},
{
"fvAp": {
"attributes": {"name": "ap1"},
"children": [
{
"fvAEPg": {
"attributes": {"name": "epg1"},
"children": [
{"fvRsBd": {"attributes": {"tnFvBDName": "bd1"}}}
],
}
}
],
}
},
],
}
},
)
assert resp.status_code == 200
tenant = client.get("/api/mo/uni/tn-FullT.json")
assert tenant.status_code == 200
ctx = client.get("/api/mo/uni/tn-FullT/ctx-vrf1.json")
assert ctx.status_code == 200
assert ctx.json()["imdata"][0]["fvCtx"]["attributes"]["name"] == "vrf1"
bd = client.get("/api/mo/uni/tn-FullT/BD-bd1.json")
assert bd.status_code == 200
rsctx = client.get("/api/mo/uni/tn-FullT/BD-bd1/rsctx.json")
assert rsctx.status_code == 200
assert rsctx.json()["imdata"][0]["fvRsCtx"]["attributes"]["tnFvCtxName"] == "vrf1"
subnet = client.get("/api/mo/uni/tn-FullT/BD-bd1/subnet-[192.168.1.1/24].json")
assert subnet.status_code == 200
ap = client.get("/api/mo/uni/tn-FullT/ap-ap1.json")
assert ap.status_code == 200
epg = client.get("/api/mo/uni/tn-FullT/ap-ap1/epg-epg1.json")
assert epg.status_code == 200
rsbd = client.get("/api/mo/uni/tn-FullT/ap-ap1/epg-epg1/rsbd.json")
assert rsbd.status_code == 200
assert rsbd.json()["imdata"][0]["fvRsBd"]["attributes"]["tnFvBDName"] == "bd1"
# -- fvBD create-time defaults (fidelity fix: real APIC fills object defaults
# -- on CREATE so a sparse POST still reads back full BD-detail attributes) --
def test_posted_bd_gets_create_time_defaults(client):
resp = client.post(
"/api/mo/uni/tn-BdDefT.json",
json={
"fvTenant": {
"attributes": {"name": "BdDefT", "dn": "uni/tn-BdDefT"},
"children": [
{
"fvBD": {
"attributes": {
"name": "bd1",
"unicastRoute": "yes",
"unkMacUcastAct": "proxy",
}
}
}
],
}
},
)
assert resp.status_code == 200
bd = client.get("/api/mo/uni/tn-BdDefT/BD-bd1.json")
assert bd.status_code == 200
attrs = bd.json()["imdata"][0]["fvBD"]["attributes"]
assert attrs["epMoveDetectMode"] == ""
assert attrs["hostBasedRouting"] == "no"
assert attrs["arpFlood"] == "no"
assert attrs["unkMcastAct"] == "flood"
assert attrs["v6unkMcastAct"] == "nd"
assert attrs["multiDstPktAct"] == "bd-flood"
assert attrs["limitIpLearnToSubnets"] == "yes"
assert attrs["ipLearning"] == "yes"
assert attrs["mac"] == "00:22:BD:F8:19:FF"
assert attrs["type"] == "regular"
# Posted attrs still present alongside the filled-in defaults.
assert attrs["unicastRoute"] == "yes"
assert attrs["unkMacUcastAct"] == "proxy"
def test_posted_bd_explicit_attr_wins_over_default(client):
resp = client.post(
"/api/mo/uni/tn-BdDefT2.json",
json={
"fvTenant": {
"attributes": {"name": "BdDefT2", "dn": "uni/tn-BdDefT2"},
"children": [
{"fvBD": {"attributes": {"name": "bd1", "arpFlood": "yes"}}}
],
}
},
)
assert resp.status_code == 200
bd = client.get("/api/mo/uni/tn-BdDefT2/BD-bd1.json")
assert bd.status_code == 200
assert bd.json()["imdata"][0]["fvBD"]["attributes"]["arpFlood"] == "yes"
def test_bd_partial_update_does_not_reset_to_defaults(client):
# Create with an explicit non-default arpFlood.
resp1 = client.post(
"/api/mo/uni/tn-BdDefT3.json",
json={
"fvTenant": {
"attributes": {"name": "BdDefT3", "dn": "uni/tn-BdDefT3"},
"children": [
{"fvBD": {"attributes": {"name": "bd1", "arpFlood": "yes"}}}
],
}
},
)
assert resp1.status_code == 200
# Re-POST the SAME dn without arpFlood — a partial update that changes
# only descr. Real APIC preserves the prior arpFlood="yes"; it must NOT
# be reset to the create-time default "no".
resp2 = client.post(
"/api/mo/uni/tn-BdDefT3/BD-bd1.json",
json={
"fvBD": {
"attributes": {
"dn": "uni/tn-BdDefT3/BD-bd1",
"name": "bd1",
"descr": "partial update",
}
}
},
)
assert resp2.status_code == 200
bd = client.get("/api/mo/uni/tn-BdDefT3/BD-bd1.json")
assert bd.status_code == 200
attrs = bd.json()["imdata"][0]["fvBD"]["attributes"]
assert attrs["arpFlood"] == "yes"
assert attrs["descr"] == "partial update"
def test_deleted_bd_is_removed_not_resurrected_with_defaults(client):
resp1 = client.post(
"/api/mo/uni/tn-BdDefT4.json",
json={
"fvTenant": {
"attributes": {"name": "BdDefT4", "dn": "uni/tn-BdDefT4"},
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
}
},
)
assert resp1.status_code == 200
resp2 = client.post(
"/api/mo/uni/tn-BdDefT4/BD-bd1.json",
json={
"fvBD": {
"attributes": {
"dn": "uni/tn-BdDefT4/BD-bd1",
"status": "deleted",
}
}
},
)
assert resp2.status_code == 200
gone = client.get("/api/mo/uni/tn-BdDefT4/BD-bd1.json")
assert gone.status_code == 200
assert gone.json()["totalCount"] == "0"
+285
View File
@@ -0,0 +1,285 @@
"""Tests for APIC query subscriptions + websocket push-on-change.
Covers:
1. `?subscription=yes` on the class-query route returns a top-level
`subscriptionId`; the SAME query WITHOUT that param is byte-identical
to the pre-subscription response (backward compat — critical).
2. Websocket `/socket<token>` push for created/modified/deleted events on a
subscribed class.
3. A change to an UNsubscribed class does not push anything.
4. `GET /api/subscriptionRefresh.json?id=<id>` returns 200.
5. An unknown-token websocket is rejected (closed before any message).
6. Disconnecting a websocket cleans up its subscriptions (a later change no
longer has anywhere to push, and does not error).
This starlette version's WebSocketTestSession.receive_json() has no built-in
timeout, so every websocket receive here goes through a thread+Future-bounded
wrapper (`_receive_json_with_timeout` / `_assert_no_event` below) so a
missing/never-arriving event fails fast instead of hanging the suite.
"""
from __future__ import annotations
import copy
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci import subscriptions as subs
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml" # run from project root
def _receive_json_with_timeout(ws, timeout: float = 5.0):
"""Bounded-wait wrapper around WebSocketTestSession.receive_json().
This starlette version's TestClient websocket session has no built-in
receive timeout (`receive()` is a blocking `anyio` portal call with no
deadline) — an event that never arrives due to a regression would hang
the whole test suite instead of failing fast. Run the blocking call on a
worker thread and bound it with `Future.result(timeout=...)` so a missing
push surfaces as a prompt, clear test failure.
Deliberately does NOT use the ThreadPoolExecutor context manager: on a
timeout the worker thread is still blocked inside `receive()` (there is
no way to cancel it), and `__exit__`'s default `shutdown(wait=True)`
would hang waiting for it — defeating the entire point of this helper.
The pool (and its one leaked, permanently-blocked thread) is simply
abandoned; harmless for a short-lived test process.
"""
pool = ThreadPoolExecutor(max_workers=1)
future = pool.submit(ws.receive_json)
try:
return future.result(timeout=timeout)
except FutureTimeoutError:
pytest.fail(f"no websocket event received within {timeout}s")
def _assert_no_event(ws, timeout: float = 2.0) -> None:
"""Assert NO event arrives within *timeout* (the negative-case counterpart).
`pytest.fail` (used by `_receive_json_with_timeout` above) raises
`_pytest.outcomes.Failed`, which subclasses `BaseException` — NOT
`Exception` — specifically so a real test failure can never be
accidentally swallowed by a broad `except Exception`/`pytest.raises
(Exception)`. That means this helper must check for the timeout directly
rather than wrapping `_receive_json_with_timeout` in `pytest.raises`.
"""
pool = ThreadPoolExecutor(max_workers=1)
future = pool.submit(ws.receive_json)
try:
event = future.result(timeout=timeout)
except FutureTimeoutError:
return # correct: no event arrived
pytest.fail(f"unexpected websocket event arrived: {event!r}")
@pytest.fixture()
def client():
"""A fresh app/store/client per test, plus a clean subscriptions registry.
Unlike test_rest_aci.py's module-scoped fixture, subscription tests need
per-test isolation: the subscriptions module holds process-wide module
state (subscriptionId counter, connections, live subs) that must not leak
between tests — a stale connection/subscription from a previous test
could cause a false-positive push or mask a missing one.
"""
subs.reset()
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name,
site=site,
topo=topo,
store=store,
baseline=copy.deepcopy(store),
)
app = make_apic_app(state)
with TestClient(app) as c:
yield c
subs.reset()
@pytest.fixture()
def token(client):
"""Log in and return the APIC-cookie token (also left set on the client)."""
resp = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
return resp.cookies["APIC-cookie"]
# ---------------------------------------------------------------------------
# Subscribe (opt-in query param) + backward compat
# ---------------------------------------------------------------------------
def test_subscribe_returns_subscription_id(client, token):
resp = client.get("/api/class/fvTenant.json?subscription=yes")
assert resp.status_code == 200
data = resp.json()
assert "subscriptionId" in data
assert isinstance(data["subscriptionId"], str)
assert data["subscriptionId"]
# Normal query fields must still be present and correct.
assert "imdata" in data
assert "totalCount" in data
def test_plain_query_byte_identical_without_subscription_param(client, token):
"""The critical backward-compat guarantee: no ?subscription=yes => no
subscriptionId field, no behavior change at all vs. pre-feature shape."""
resp = client.get("/api/class/fvTenant.json")
assert resp.status_code == 200
data = resp.json()
assert "subscriptionId" not in data
assert set(data.keys()) == {"imdata", "totalCount"}
def test_mo_query_subscribe_returns_subscription_id(client, token):
resp = client.get("/api/mo/uni/tn-mgmt.json?subscription=yes")
assert resp.status_code == 200
data = resp.json()
assert "subscriptionId" in data
def test_node_class_fabric_wide_subscribe_returns_subscription_id(client, token):
resp = client.get("/api/node/class/fvTenant.json?subscription=yes")
assert resp.status_code == 200
data = resp.json()
assert "subscriptionId" in data
# ---------------------------------------------------------------------------
# Subscription refresh
# ---------------------------------------------------------------------------
def test_subscription_refresh_returns_200(client, token):
resp = client.get("/api/class/fvTenant.json?subscription=yes")
sid = resp.json()["subscriptionId"]
refresh_resp = client.get(f"/api/subscriptionRefresh.json?id={sid}")
assert refresh_resp.status_code == 200
def test_subscription_refresh_unknown_id_still_200(client, token):
# Real APIC does not error a stale/unknown refresh id.
resp = client.get("/api/subscriptionRefresh.json?id=999999")
assert resp.status_code == 200
def test_subscription_refresh_requires_auth(client):
resp = client.get("/api/subscriptionRefresh.json?id=1")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Websocket push
# ---------------------------------------------------------------------------
def test_websocket_rejects_unknown_token(client):
with pytest.raises(Exception):
with client.websocket_connect("/socket-not-a-real-token") as ws:
_receive_json_with_timeout(ws, timeout=2)
def test_websocket_push_created_modified_deleted(client, token):
# Subscribe to fvTenant BEFORE opening the websocket (matches the
# acceptance flow: subscribe via query, then open the socket).
sub_resp = client.get("/api/class/fvTenant.json?subscription=yes")
assert sub_resp.status_code == 200
subscription_id = sub_resp.json()["subscriptionId"]
with client.websocket_connect(f"/socket{token}") as ws:
# (c) POST a new fvTenant.
post_resp = client.post(
"/api/mo/uni/tn-SubTest.json",
json={"fvTenant": {"attributes": {"name": "SubTest"}}},
)
assert post_resp.status_code == 200
# (d) assert a created event is pushed with the new tenant's dn.
event = _receive_json_with_timeout(ws, timeout=5)
assert subscription_id in event["subscriptionId"]
assert len(event["imdata"]) == 1
tenant_body = event["imdata"][0]["fvTenant"]
assert tenant_body["attributes"]["dn"] == "uni/tn-SubTest"
assert tenant_body["attributes"]["status"] == "created"
# Modify it (POST again on the same DN => pre-existing => "modified").
mod_resp = client.post(
"/api/mo/uni/tn-SubTest.json",
json={"fvTenant": {"attributes": {"name": "SubTest", "descr": "updated"}}},
)
assert mod_resp.status_code == 200
mod_event = _receive_json_with_timeout(ws, timeout=5)
assert mod_event["imdata"][0]["fvTenant"]["attributes"]["status"] == "modified"
assert mod_event["imdata"][0]["fvTenant"]["attributes"]["dn"] == "uni/tn-SubTest"
# (e) DELETE it => assert a status=deleted event.
del_resp = client.delete("/api/mo/uni/tn-SubTest.json")
assert del_resp.status_code == 200
del_event = _receive_json_with_timeout(ws, timeout=5)
assert subscription_id in del_event["subscriptionId"]
del_tenant = del_event["imdata"][0]["fvTenant"]
assert del_tenant["attributes"]["status"] == "deleted"
assert del_tenant["attributes"]["dn"] == "uni/tn-SubTest"
def test_unsubscribed_class_does_not_push(client, token):
# Subscribe only to fvTenant.
client.get("/api/class/fvTenant.json?subscription=yes")
with client.websocket_connect(f"/socket{token}") as ws:
# Change an entirely different, unsubscribed class.
resp = client.post(
"/api/mo/uni/tn-mgmt/mgmtp-default.json",
json={"mgmtMgmtP": {"attributes": {"descr": "no-op change"}}},
)
assert resp.status_code == 200
# No event should arrive for this — bounded wait, not a hang.
_assert_no_event(ws, timeout=2)
def test_dn_scoped_subscription_matches_subtree_change(client, token):
# Subscribe via the MO/DN query shape on a parent DN.
sub_resp = client.get("/api/mo/uni/tn-mgmt.json?subscription=yes")
subscription_id = sub_resp.json()["subscriptionId"]
with client.websocket_connect(f"/socket{token}") as ws:
# A write to a *child* DN under uni/tn-mgmt should still notify the
# parent-DN subscriber (subtree match).
resp = client.post(
"/api/mo/uni/tn-mgmt/ap-testap.json",
json={"fvAp": {"attributes": {"name": "testap"}}},
)
assert resp.status_code == 200
event = _receive_json_with_timeout(ws, timeout=5)
assert subscription_id in event["subscriptionId"]
assert event["imdata"][0]["fvAp"]["attributes"]["dn"] == "uni/tn-mgmt/ap-testap"
def test_disconnect_cleans_up_subscriptions(client, token):
sub_resp = client.get("/api/class/fvTenant.json?subscription=yes")
assert sub_resp.status_code == 200
with client.websocket_connect(f"/socket{token}") as ws:
pass # connect then immediately disconnect (context manager exit)
# After disconnect, a change to fvTenant must not raise/error even though
# the (now-dead) subscription still matches the scope — notify() must
# silently skip a token with no live connection.
resp = client.post(
"/api/mo/uni/tn-AfterDisconnect.json",
json={"fvTenant": {"attributes": {"name": "AfterDisconnect"}}},
)
assert resp.status_code == 200
+620
View File
@@ -0,0 +1,620 @@
"""Tests for topology/schema.py and topology/loader.py (Phase 2).
All tests load the default topology.yaml at the repo root. One negative test
builds a minimal bad topology inline to verify cross-reference validation.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from pydantic import ValidationError
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Node, Topology
TOPOLOGY_YAML = Path(__file__).parent.parent / "topology.yaml"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_topo() -> Topology:
return load_topology(TOPOLOGY_YAML)
# ---------------------------------------------------------------------------
# Positive tests against default topology.yaml
# ---------------------------------------------------------------------------
class TestDefaultTopologyLoad:
def test_has_two_sites(self) -> None:
topo = get_topo()
assert len(topo.sites) == 2
def test_site_names(self) -> None:
topo = get_topo()
names = [s.name for s in topo.sites]
assert "LAB1" in names
assert "LAB2" in names
def test_site_a_spine_count(self) -> None:
topo = get_topo()
site_a = topo.site_by_name("LAB1")
assert len(site_a.spine_nodes()) == 2
def test_site_a_leaf_count(self) -> None:
topo = get_topo()
site_a = topo.site_by_name("LAB1")
assert len(site_a.leaf_nodes()) == 2
def test_site_a_border_leaf_count(self) -> None:
topo = get_topo()
site_a = topo.site_by_name("LAB1")
assert len(site_a.border_leaves) == 2
def test_site_b_spine_count(self) -> None:
topo = get_topo()
site_b = topo.site_by_name("LAB2")
assert len(site_b.spine_nodes()) == 2
def test_site_b_leaf_count(self) -> None:
topo = get_topo()
site_b = topo.site_by_name("LAB2")
assert len(site_b.leaf_nodes()) == 2
def test_site_b_border_leaf_count(self) -> None:
topo = get_topo()
site_b = topo.site_by_name("LAB2")
assert len(site_b.border_leaves) == 2
class TestNodeIdExpansion:
"""Int-count → Node expansion produces deterministic, collision-free IDs."""
def test_site_a_spine_ids(self) -> None:
# site_index=0: spine_id = 201 + 0*200 + i → 201, 202
topo = get_topo()
ids = sorted(n.id for n in topo.site_by_name("LAB1").spine_nodes())
assert ids == [201, 202]
def test_site_a_leaf_ids(self) -> None:
# site_index=0: leaf_id = 101 + 0*200 + i → 101, 102
topo = get_topo()
ids = sorted(n.id for n in topo.site_by_name("LAB1").leaf_nodes())
assert ids == [101, 102]
def test_site_b_spine_ids(self) -> None:
# site_index=1: spine_id = 201 + 1*200 + i → 401, 402
topo = get_topo()
ids = sorted(n.id for n in topo.site_by_name("LAB2").spine_nodes())
assert ids == [401, 402]
def test_site_b_leaf_ids(self) -> None:
# site_index=1: leaf_id = 101 + 1*200 + i → 301, 302
topo = get_topo()
ids = sorted(n.id for n in topo.site_by_name("LAB2").leaf_nodes())
assert ids == [301, 302]
def test_no_collisions_across_sites(self) -> None:
topo = get_topo()
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
duplicates = [nid for nid in set(all_ids) if all_ids.count(nid) > 1]
assert duplicates == [], f"Duplicate node IDs across sites: {duplicates}"
def test_spine_names_deterministic(self) -> None:
topo = get_topo()
site_a = topo.site_by_name("LAB1")
names = sorted(n.name for n in site_a.spine_nodes())
assert names == ["LAB1-ACI-SP01", "LAB1-ACI-SP02"]
def test_leaf_names_deterministic(self) -> None:
topo = get_topo()
site_b = topo.site_by_name("LAB2")
names = sorted(n.name for n in site_b.leaf_nodes())
assert names == ["LAB2-ACI-LF301", "LAB2-ACI-LF302"]
class TestStretchedTenant:
def test_stretched_tenant_exists(self) -> None:
topo = get_topo()
stretched = [t for t in topo.tenants if t.stretch]
assert len(stretched) >= 1, "No stretched tenant found in topology"
def test_stretched_tenant_in_both_sites(self) -> None:
topo = get_topo()
for tenant in topo.tenants:
if tenant.stretch:
assert "LAB1" in tenant.sites, (
f"Stretched tenant '{tenant.name}' missing LAB1"
)
assert "LAB2" in tenant.sites, (
f"Stretched tenant '{tenant.name}' missing LAB2"
)
def test_corp_tenant_vrfs_bds_aps(self) -> None:
topo = get_topo()
corp = next(t for t in topo.tenants if t.name == "MS-TN1")
assert len(corp.vrfs) >= 1
assert len(corp.bds) >= 1
assert len(corp.aps) >= 1
def test_corp_contracts_non_empty(self) -> None:
topo = get_topo()
corp = next(t for t in topo.tenants if t.name == "MS-TN1")
assert len(corp.contracts) >= 1
def test_corp_l3outs_per_site(self) -> None:
topo = get_topo()
corp = next(t for t in topo.tenants if t.name == "MS-TN1")
assert len(corp.l3outs) >= 2, "MS-TN1 tenant should have ≥1 L3Out per site"
class TestL3OutVrfResolves:
def test_l3out_vrfs_valid(self) -> None:
topo = get_topo()
for tenant in topo.tenants:
vrf_names = {v.name for v in tenant.vrfs}
for l3out in tenant.l3outs:
assert l3out.vrf in vrf_names, (
f"Tenant '{tenant.name}', L3Out '{l3out.name}' references "
f"VRF '{l3out.vrf}' not in {vrf_names}"
)
class TestHelpers:
def test_site_by_name_found(self) -> None:
topo = get_topo()
site = topo.site_by_name("LAB1")
assert site.name == "LAB1"
def test_site_by_name_missing_raises(self) -> None:
topo = get_topo()
with pytest.raises(KeyError):
topo.site_by_name("Nonexistent")
def test_other_site_from_a_returns_b(self) -> None:
topo = get_topo()
other = topo.other_site("LAB1")
assert other.name == "LAB2"
def test_other_site_from_b_returns_a(self) -> None:
topo = get_topo()
other = topo.other_site("LAB2")
assert other.name == "LAB1"
def test_other_site_gives_remote_asn(self) -> None:
"""other_site() should let builders look up the remote fabric ASN for ISN."""
topo = get_topo()
# LAB1's ISN peer is LAB2's ASN
remote_asn = topo.other_site("LAB1").asn
assert remote_asn == topo.site_by_name("LAB2").asn
def test_all_nodes_count(self) -> None:
topo = get_topo()
site_a = topo.site_by_name("LAB1")
# 2 spines + 2 leaves + 2 border leaves = 6
assert len(site_a.all_nodes()) == 6
class TestISN:
def test_isn_enabled(self) -> None:
topo = get_topo()
assert topo.isn.enabled is True
def test_isn_peer_mesh_full(self) -> None:
topo = get_topo()
assert topo.isn.peer_mesh == "full"
class TestFaultsAndEndpoints:
def test_fault_seeds_present(self) -> None:
topo = get_topo()
assert len(topo.faults.seed) >= 1
def test_health_defaults(self) -> None:
topo = get_topo()
assert topo.faults.health_defaults.node > 0
assert topo.faults.health_defaults.tenant > 0
def test_endpoints_per_epg(self) -> None:
topo = get_topo()
assert topo.endpoints.per_epg >= 1
class TestNewBuilderFields:
"""Fields added for the Phase 3/4 builders (version, l3 domains, addr pools)."""
def test_node_has_version_default(self) -> None:
topo = get_topo()
for node in topo.site_by_name("LAB1").all_nodes():
assert node.version, f"Node {node.id} missing version"
def test_explicit_node_version_override(self) -> None:
n = Node(id=201, name="spine-201", version="n9000-16.0(1)")
assert n.version == "n9000-16.0(1)"
def test_access_has_l3_domain(self) -> None:
topo = get_topo()
assert len(topo.access.l3_domains) >= 1
assert topo.access.l3_domains[0].name
def test_fabric_address_pools(self) -> None:
topo = get_topo()
assert topo.fabric.loopback_pool
assert topo.fabric.oob_pool
def test_l3out_site_association(self) -> None:
topo = get_topo()
corp = next(t for t in topo.tenants if t.name == "MS-TN1")
by_name = {lo.name: lo for lo in corp.l3outs}
assert by_name["l3o-bgp-Core_LAB1"].site == "LAB1"
assert by_name["l3o-bgp-Core_LAB2"].site == "LAB2"
def test_l3out_border_leaves_are_real(self) -> None:
topo = get_topo()
all_border_ids = {
bl.id for site in topo.sites for bl in site.border_leaves
}
for tenant in topo.tenants:
for l3out in tenant.l3outs:
for bl_id in l3out.border_leaves:
assert bl_id in all_border_ids
def test_fault_seed_nodes_are_real(self) -> None:
topo = get_topo()
real_ids = {n.id for site in topo.sites for n in site.all_nodes()}
for fseed in topo.faults.seed:
assert fseed.node in real_ids
# ---------------------------------------------------------------------------
# Negative test — bad cross-reference raises ValidationError
# ---------------------------------------------------------------------------
class TestValidationFailures:
def _load_bad(self, yaml_text: str) -> None:
data = yaml.safe_load(yaml_text)
Topology.model_validate(data)
def test_epg_references_missing_bd_raises(self) -> None:
"""EPG.bd that does not exist in tenant.bds must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
cabling: auto
isn:
enabled: false
tenants:
- name: BadTenant
stretch: false
sites: [SiteA]
vrfs:
- name: vrf1
bds:
- name: real-bd
vrf: vrf1
subnets: []
aps:
- name: App
epgs:
- name: bad-epg
bd: nonexistent-bd
contracts: {}
contracts: []
l3outs: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_bd_references_missing_vrf_raises(self) -> None:
"""BD.vrf that does not exist in tenant.vrfs must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
cabling: auto
isn:
enabled: false
tenants:
- name: BadTenant
stretch: false
sites: [SiteA]
vrfs:
- name: vrf1
bds:
- name: bd1
vrf: ghost-vrf
subnets: []
aps: []
contracts: []
l3outs: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_stretched_tenant_with_one_site_raises(self) -> None:
"""stretch:true but only one site listed must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
cabling: auto
- name: SiteB
id: "2"
apic_host: "127.0.0.1:8444"
asn: 65002
pod: 1
spines: 1
leaves: 1
cabling: auto
isn:
enabled: true
peer_mesh: full
tenants:
- name: BadStretch
stretch: true
sites: [SiteA]
vrfs:
- name: vrf1
bds: []
aps: []
contracts: []
l3outs: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_tenant_references_unknown_site_raises(self) -> None:
"""tenant.sites that names a non-existent site must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
cabling: auto
isn:
enabled: false
tenants:
- name: BadTenant
stretch: false
sites: [SiteX]
vrfs: []
bds: []
aps: []
contracts: []
l3outs: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_l3out_references_missing_vrf_raises(self) -> None:
"""L3Out.vrf that does not exist in tenant.vrfs must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
border_leaves:
- id: 103
name: leaf-103
vpc_domain: vpcdom-siteA
csw: {name: CSW-A, asn: 65100, peer_ip: 10.100.0.1, local_ip: 10.100.0.2, vlan: 100}
cabling: auto
isn:
enabled: false
tenants:
- name: BadTenant
stretch: false
sites: [SiteA]
vrfs:
- name: vrf1
bds: []
aps: []
contracts: []
l3outs:
- name: bad-l3out
vrf: ghost
border_leaves: [103]
ext_epgs: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_isn_full_mesh_single_site_raises(self) -> None:
"""isn.peer_mesh='full' with fewer than 2 sites must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
cabling: auto
isn:
enabled: true
peer_mesh: full
tenants: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_l3out_references_missing_border_leaf_raises(self) -> None:
"""L3Out.border_leaves id not present in any site's border_leaves must raise."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
border_leaves:
- id: 103
name: leaf-103
vpc_domain: vpcdom-siteA
csw: {name: CSW-A, asn: 65100, peer_ip: 10.100.0.1, local_ip: 10.100.0.2, vlan: 100}
cabling: auto
isn:
enabled: false
tenants:
- name: T
stretch: false
sites: [SiteA]
vrfs:
- name: vrf1
bds: []
aps: []
contracts: []
l3outs:
- name: l3o
vrf: vrf1
border_leaves: [999]
ext_epgs: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_fault_seed_unknown_node_raises(self) -> None:
"""faults.seed[i].node that is not a real node id must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
cabling: auto
isn:
enabled: false
tenants: []
faults:
seed:
- severity: minor
code: "F0532"
node: 9999
descr: "fault on a non-existent node"
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_epg_references_missing_contract_raises(self) -> None:
"""EPG contract ref not present in tenant.contracts must raise ValidationError."""
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 1
leaves: 1
cabling: auto
isn:
enabled: false
tenants:
- name: T
stretch: false
sites: [SiteA]
vrfs:
- name: vrf1
bds:
- name: bd1
vrf: vrf1
subnets: []
aps:
- name: App
epgs:
- name: epg1
bd: bd1
contracts:
provide: [ghost-contract]
consume: []
contracts: []
l3outs: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
def test_border_leaf_id_collision_raises(self) -> None:
"""A border-leaf id colliding with an auto-generated leaf id must raise."""
# SiteA auto leaves are 101,102; giving a border leaf id=101 collides.
bad_yaml = """
fabric:
name: bad-topo
sites:
- name: SiteA
id: "1"
apic_host: "127.0.0.1:8443"
asn: 65001
pod: 1
spines: 2
leaves: 2
border_leaves:
- id: 101
name: leaf-101-dup
vpc_domain: vpcdom-siteA
csw: {name: CSW-A, asn: 65100, peer_ip: 10.100.0.1, local_ip: 10.100.0.2, vlan: 100}
cabling: auto
isn:
enabled: false
tenants: []
"""
with pytest.raises(ValidationError):
self._load_bad(bad_yaml)
+668
View File
@@ -0,0 +1,668 @@
"""
verify_autoaci.py — end-to-end verification harness for aci-sim.
Dual mode:
pytest tests/verify_autoaci.py -v (Tier 1 = must-pass, Tier 2 = best-effort)
python tests/verify_autoaci.py (standalone — prints coverage table)
Architecture:
• Module-level setup builds the topology, per-site MITStores, and NdoState once.
• SimConnector / SimNDOConnector wrap FastAPI TestClient with the same async
interface as the real ACI/NDO connectors, so autoACI plugins run unmodified.
• _run() bridges async plugin coroutines into the synchronous pytest context.
• Tier 1 (17 tests) must all pass — they cover the APIC REST surface, the /_sim
control API, and the NDO plane.
• Tier 2 (plugin smoke tests) are best-effort; SKIP if the autoACI repo is
unavailable.
"""
from __future__ import annotations
import asyncio
import copy
import sys
import traceback
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
# ─── Path setup (needed for standalone / different cwd) ──────────────────────
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# ─── Sim imports ─────────────────────────────────────────────────────────────
from aci_sim.topology.loader import load_topology
from aci_sim.build.orchestrator import build_all, build_site
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.ndo.app import make_ndo_app
# ─── Module-level setup (runs once per pytest session) ───────────────────────
TOPO_PATH = PROJECT_ROOT / "topology.yaml"
_topo = load_topology(TOPO_PATH)
_stores = build_all(_topo) # {site_name: MITStore}
def _fresh_apic_client(site_name: str = "LAB1") -> tuple[ApicSiteState, TestClient]:
"""Return a *fresh* (deep-copied store) APIC state + TestClient pair.
The client is pre-authenticated (aaaLogin admin/cisco) so its cookie jar
already carries a valid APIC-cookie — every data route now requires one.
"""
site = next(s for s in _topo.sites if s.name == site_name)
store = copy.deepcopy(_stores[site_name])
state = ApicSiteState(
name=site.name,
site=site,
topo=_topo,
store=store,
baseline=copy.deepcopy(store),
)
client = TestClient(make_apic_app(state))
login = client.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert login.status_code == 200, f"pre-auth login failed: {login.status_code} {login.text}"
return state, client
def _fresh_ndo_client() -> TestClient:
"""Return a fresh NDO TestClient backed by a newly derived NdoState."""
ndo_state = build_ndo_model(_topo)
return TestClient(make_ndo_app(ndo_state))
# Shared read-only clients (tests that only read use these for speed)
_state_a, _apic_a = _fresh_apic_client("LAB1")
_ndo_client = _fresh_ndo_client()
# ─── SimConnector ─────────────────────────────────────────────────────────────
class SimConnector:
"""Async interface mirroring the real ACI connector, backed by TestClient.
URL-building matches aci_connector.py exactly so autoACI plugins work
without any modification.
"""
def __init__(self, client: TestClient, *, site_name: str = "sim"):
self._c = client
self.site_name = site_name
async def query_class(
self,
class_name: str,
filter_expr: str = "",
subtree_class: str = "",
query_target: str = "",
subtree: str = "",
page: int = 0,
page_size: int = 500,
) -> dict:
path = f"/api/class/{class_name}.json?"
params = [f"page-size={page_size}", f"page={page}"]
if filter_expr:
params.append(f"query-target-filter={filter_expr}")
if subtree:
params.append(f"rsp-subtree={subtree}")
if subtree_class:
params.append(f"rsp-subtree-class={subtree_class}")
else:
if subtree_class:
params.append(f"target-subtree-class={subtree_class}")
if not query_target:
query_target = "subtree"
if query_target:
params.append(f"query-target={query_target}")
path += "&".join(params)
return self._c.get(path).json()
async def query_dn(
self, dn: str, subtree: bool = False, subtree_class: str = ""
) -> dict:
path = f"/api/mo/{dn}.json"
params: list[str] = []
if subtree:
params.append("query-target=subtree")
if subtree_class:
params.append(f"target-subtree-class={subtree_class}")
if not subtree:
params.append("query-target=subtree")
if params:
path += "?" + "&".join(params)
return self._c.get(path).json()
async def query_class_scoped(
self, dn: str, class_name: str, filter_expr: str = ""
) -> dict:
path = f"/api/node/class/{dn}/{class_name}.json"
if filter_expr:
path += f"?query-target-filter={filter_expr}"
return self._c.get(path).json()
async def _raw_get(self, path: str) -> dict:
return self._c.get(path).json()
# ─── SimNDOConnector ─────────────────────────────────────────────────────────
class SimNDOConnector:
"""Async interface mirroring the real NDO connector, backed by TestClient."""
def __init__(self, client: TestClient):
self._c = client
async def _get(self, path: str) -> dict:
return self._c.get(path).json()
async def get_sites(self) -> list:
data = await self._get("/mso/api/v1/sites")
return data.get("sites", [])
async def get_tenants(self) -> list:
data = await self._get("/mso/api/v1/tenants")
return data.get("tenants", [])
async def get_schemas(self) -> list:
data = await self._get("/mso/api/v1/schemas")
return data.get("schemas", [])
async def get_schema(self, schema_id: str) -> dict:
return await self._get(f"/mso/api/v1/schemas/{schema_id}")
async def get_dhcp_policy_map(self) -> dict:
raw = await self._get("/mso/api/v1/templates/summaries")
summaries = raw if isinstance(raw, list) else raw.get("templates", [])
dhcp_map: dict[str, str] = {}
for s in summaries:
if s.get("templateType") != "tenantPolicy":
continue
tmpl = await self._get(f"/mso/api/v1/templates/{s['templateId']}")
inner = tmpl.get("tenantPolicyTemplate", {}).get("template", {})
for p in inner.get("dhcpRelayPolicies", []):
if p.get("uuid") and p.get("name"):
dhcp_map[p["uuid"]] = p["name"]
return dhcp_map
def _run(coro):
"""Run a coroutine in a fresh event loop (sync bridge for Tier 2 + standalone)."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
# =============================================================================
# Tier 1 — MUST PASS
# =============================================================================
# ── APIC auth ─────────────────────────────────────────────────────────────────
def test_t1_apic_login():
"""APIC aaaLogin returns a token and sets APIC-cookie."""
resp = _apic_a.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
data = resp.json()
assert "aaaLogin" in data["imdata"][0]
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
assert token, "token must be non-empty"
assert "APIC-cookie" in resp.cookies
def test_t1_apic_fabric_nodes_count():
"""SiteA fabricNodes: 6 switches (2 spine + 2 leaf + 2 border-leaf) + 1 APIC controller
(PR-21 single-APIC default) = 7."""
resp = _apic_a.get("/api/class/fabricNode.json")
assert resp.status_code == 200
data = resp.json()
roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]]
switches = [r for r in roles if r in ("spine", "leaf")]
controllers = [r for r in roles if r == "controller"]
assert len(switches) == 6, f"Expected 6 switch nodes, got {len(switches)} ({roles})"
assert len(controllers) == 1, f"Expected 1 controller node, got {len(controllers)} ({roles})"
assert data["totalCount"] == "7", f"Expected totalCount='7', got {data['totalCount']!r}"
def test_t1_apic_tenant_mo_query():
"""GET /api/mo/uni/tn-MS-TN1.json returns the MS-TN1 fvTenant MO."""
resp = _apic_a.get("/api/mo/uni/tn-MS-TN1.json")
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) >= 1
assert "fvTenant" in data["imdata"][0]
assert data["imdata"][0]["fvTenant"]["attributes"]["name"] == "MS-TN1"
def test_t1_apic_missing_mo_returns_200_empty():
"""A non-existent DN returns HTTP 200 + empty imdata (PR-9 FEATURE 6).
Real APIC (and cisco.aci's GET-before-POST existence check in
module_utils/aci.py, which treats any non-200 as fatal) requires this —
not 404. See tests/test_pr9_ansible.py for the verified rationale.
"""
resp = _apic_a.get("/api/mo/uni/tn-NOSUCHTENANTXYZ.json")
assert resp.status_code == 200
data = resp.json()
assert data["totalCount"] == "0"
assert data["imdata"] == []
def test_t1_apic_fault_severity_filter():
"""Filter by severity=minor returns only minor-severity faultInst objects."""
resp = _apic_a.get(
'/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")'
)
assert resp.status_code == 200
data = resp.json()
# topology.yaml seeds F0532/minor on node-101
assert int(data["totalCount"]) >= 1, "Expected at least one seeded minor fault"
for item in data["imdata"]:
assert item["faultInst"]["attributes"]["severity"] == "minor", (
f"Non-minor fault slipped through filter: {item}"
)
def test_t1_apic_node_scoped_fault_on_101():
"""Node-scoped fault query on node-101 returns the seeded fault.
topology.yaml seeds ``code: "F0532"`` on node 101. The materialized faultInst
must carry that exact, valid ACI fault code (guards the double-prefix regression).
"""
resp = _apic_a.get(
"/api/node/class/topology/pod-1/node-101/faultInst.json"
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) >= 1, "node-101 must carry the seeded fault"
codes = [
item["faultInst"]["attributes"].get("code", "")
for item in data["imdata"]
]
assert "F0532" in codes, (
f"Expected exact fault code 'F0532' on node-101; found: {codes}"
)
def test_t1_apic_mo_subtree_flat_bd():
"""query-target=subtree + target-subtree-class=fvBD returns BDs at imdata top level."""
resp = _apic_a.get(
"/api/mo/uni/tn-MS-TN1.json"
"?query-target=subtree&target-subtree-class=fvBD"
)
assert resp.status_code == 200
data = resp.json()
assert int(data["totalCount"]) >= 1, "MS-TN1 must have at least one BD"
for item in data["imdata"]:
assert "fvBD" in item, (
f"Non-fvBD entry in subtree response: {list(item.keys())}"
)
def test_t1_apic_write_and_readback():
"""POST a new fvTenant MO and verify it appears in /api/class/fvTenant.json."""
_, wc = _fresh_apic_client("LAB1")
resp = wc.post(
"/api/mo/uni/tn-VERIFY.json",
json={"fvTenant": {"attributes": {"name": "VERIFY", "dn": "uni/tn-VERIFY"}}},
)
assert resp.status_code == 200
names = [
item["fvTenant"]["attributes"]["name"]
for item in wc.get("/api/class/fvTenant.json").json()["imdata"]
]
assert "VERIFY" in names, f"VERIFY tenant not found after write; list: {names}"
def test_t1_apic_fabric_node_reaction():
"""POST fabricNodeIdentP causes a fabricNode MO to materialize at topology/.../node-901."""
_, wc = _fresh_apic_client("LAB1")
resp = wc.post(
"/api/mo/uni/controller/nodeidentpol/nodep-901.json",
json={
"fabricNodeIdentP": {
"attributes": {
"dn": "uni/controller/nodeidentpol/nodep-901",
"name": "leaf-901",
"nodeType": "leaf",
}
}
},
)
assert resp.status_code == 200
ids = [
item["fabricNode"]["attributes"]["id"]
for item in wc.get("/api/class/fabricNode.json").json()["imdata"]
]
assert "901" in ids, (
f"fabricNode 901 not materialized after fabricNodeIdentP write; ids: {ids}"
)
# ── Control API ───────────────────────────────────────────────────────────────
def test_t1_sim_reset():
"""POST /_sim/reset restores the store to baseline; a written tenant disappears."""
_, cc = _fresh_apic_client("LAB1")
# Write a sentinel tenant
cc.post(
"/api/mo/uni/tn-RESETME.json",
json={"fvTenant": {"attributes": {"name": "RESETME", "dn": "uni/tn-RESETME"}}},
)
before = [
item["fvTenant"]["attributes"]["name"]
for item in cc.get("/api/class/fvTenant.json").json()["imdata"]
]
assert "RESETME" in before, "Sentinel write must succeed before reset"
# Reset
resp = cc.post("/_sim/reset")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Sentinel must be gone
after = [
item["fvTenant"]["attributes"]["name"]
for item in cc.get("/api/class/fvTenant.json").json()["imdata"]
]
assert "RESETME" not in after, (
"RESETME tenant must disappear after /_sim/reset"
)
def test_t1_sim_snapshot_restore():
"""/_sim/snapshot + /_sim/restore round-trip preserves exact store state."""
_, cc = _fresh_apic_client("LAB1")
# Snapshot of clean state
resp = cc.post("/_sim/snapshot/checkpoint-1")
assert resp.status_code == 200
# Dirty the store
cc.post(
"/api/mo/uni/tn-SNAP.json",
json={"fvTenant": {"attributes": {"name": "SNAP", "dn": "uni/tn-SNAP"}}},
)
mid = [
item["fvTenant"]["attributes"]["name"]
for item in cc.get("/api/class/fvTenant.json").json()["imdata"]
]
assert "SNAP" in mid, "Write must succeed before restore"
# Restore snapshot
resp = cc.post("/_sim/restore/checkpoint-1")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Dirty tenant must be gone
after = [
item["fvTenant"]["attributes"]["name"]
for item in cc.get("/api/class/fvTenant.json").json()["imdata"]
]
assert "SNAP" not in after, (
"SNAP tenant must disappear after /_sim/restore/checkpoint-1"
)
# ── NDO ───────────────────────────────────────────────────────────────────────
def test_t1_ndo_login():
"""NDO /api/v1/auth/login returns a non-empty bearer token."""
resp = _ndo_client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
data = resp.json()
assert "token" in data, f"Login response missing 'token': {data}"
assert data["token"], "NDO token must be non-empty"
def test_t1_ndo_sites_match_topology():
"""NDO /mso/api/v1/sites returns one entry per topology site with correct names."""
resp = _ndo_client.get("/mso/api/v1/sites")
assert resp.status_code == 200
sites = resp.json()["sites"]
assert len(sites) == len(_topo.sites), (
f"Expected {len(_topo.sites)} NDO sites, got {len(sites)}"
)
topo_names = {s.name for s in _topo.sites}
ndo_names = {s["name"] for s in sites}
assert topo_names == ndo_names, (
f"NDO site names {ndo_names} differ from topology {topo_names}"
)
def test_t1_ndo_tenants_match_topology():
"""NDO /mso/api/v1/tenants returns one entry per topology tenant."""
resp = _ndo_client.get("/mso/api/v1/tenants")
assert resp.status_code == 200
tenants = resp.json()["tenants"]
assert len(tenants) == len(_topo.tenants), (
f"Expected {len(_topo.tenants)} tenants, got {len(tenants)}"
)
topo_names = {t.name for t in _topo.tenants}
ndo_names = {t["name"] for t in tenants}
assert topo_names == ndo_names
def test_t1_ndo_schemas_list():
"""NDO /mso/api/v1/schemas returns at least one schema per topology tenant."""
resp = _ndo_client.get("/mso/api/v1/schemas")
assert resp.status_code == 200
schemas = resp.json()["schemas"]
assert len(schemas) >= len(_topo.tenants), (
f"Expected >= {len(_topo.tenants)} schemas, got {len(schemas)}"
)
for s in schemas:
assert "id" in s, f"Schema entry missing 'id': {s}"
assert "name" in s, f"Schema entry missing 'name': {s}"
def test_t1_ndo_corp_schema_vrf_bd_match():
"""MS-TN1 schema detail: VRF and BD names match topology exactly."""
schemas = _ndo_client.get("/mso/api/v1/schemas").json()["schemas"]
corp_summary = next(s for s in schemas if "MS-TN1" in s["name"])
detail = _ndo_client.get(
f"/mso/api/v1/schemas/{corp_summary['id']}"
).json()
tmpl = detail["templates"][0]
corp_topo = next(t for t in _topo.tenants if t.name == "MS-TN1")
topo_vrfs = {v.name for v in corp_topo.vrfs}
ndo_vrfs = {v["name"] for v in tmpl["vrfs"]}
assert topo_vrfs == ndo_vrfs, f"VRF mismatch: topo={topo_vrfs} ndo={ndo_vrfs}"
topo_bds = {b.name for b in corp_topo.bds}
ndo_bds = {b["name"] for b in tmpl["bds"]}
assert topo_bds == ndo_bds, f"BD mismatch: topo={topo_bds} ndo={ndo_bds}"
def test_t1_ndo_post_schema_readback():
"""POST /mso/api/v1/schemas creates a schema that appears in the list."""
fc = _fresh_ndo_client()
resp = fc.post(
"/mso/api/v1/schemas",
json={
"displayName": "verify-schema",
"name": "verify-schema",
"templates": [{"name": "t1", "templateType": "application"}],
},
)
assert resp.status_code == 200
created = resp.json()
assert "id" in created, f"POST /schemas response missing 'id': {created}"
assert created.get("status") == "active", (
f"Expected status='active', got {created.get('status')!r}"
)
schema_id = created["id"]
list_ids = {s["id"] for s in fc.get("/mso/api/v1/schemas").json()["schemas"]}
assert schema_id in list_ids, (
f"Schema {schema_id!r} not found in list after POST"
)
# =============================================================================
# Tier 2 — best-effort plugin smoke tests
# =============================================================================
_AUTOACI_BACKEND = PROJECT_ROOT.parent / "autoACI" / "backend"
def _import_plugin(module: str, class_name: str):
"""Try to import a plugin class from the autoACI backend; return (cls, None) or (None, reason)."""
backend = _AUTOACI_BACKEND
if not backend.exists():
return None, f"autoACI backend not found at {backend}"
if str(backend) not in sys.path:
sys.path.insert(0, str(backend))
try:
mod = __import__(module, fromlist=[class_name])
return getattr(mod, class_name), None
except Exception as exc:
return None, str(exc)
def _run_plugin(plugin_cls, connector, params: dict) -> str:
"""Execute a plugin and return a status string."""
try:
result = _run(plugin_cls().execute(connector, params))
return f"OK ({len(result.rows)} rows)"
except Exception as exc:
return f"FAIL ({exc})"
def _tier2_epg_detail() -> str:
cls, err = _import_plugin("plugins.epg_detail", "EpgDetail")
if cls is None:
return f"SKIP ({err})"
_, client = _fresh_apic_client("LAB1")
connector = SimConnector(client, site_name="LAB1")
return _run_plugin(cls, connector, {"tenant_name": "MS-TN1"})
def _tier2_l3out_detail() -> str:
cls, err = _import_plugin("plugins.l3out_detail", "L3OutDetail")
if cls is None:
return f"SKIP ({err})"
_, client = _fresh_apic_client("LAB1")
connector = SimConnector(client, site_name="LAB1")
return _run_plugin(cls, connector, {"tenant_name": "MS-TN1"})
# =============================================================================
# Coverage table
# =============================================================================
_TIER1_TESTS: list[tuple[str, str, Any]] = [
("T1-01", "APIC login (token + cookie)", test_t1_apic_login),
("T1-02", "APIC fabricNode count = 6 (LAB1)", test_t1_apic_fabric_nodes_count),
("T1-03", "APIC MO query — MS-TN1 fvTenant", test_t1_apic_tenant_mo_query),
("T1-04", "APIC 200-empty for unknown DN", test_t1_apic_missing_mo_returns_200_empty),
("T1-05", "APIC fault severity filter (minor)", test_t1_apic_fault_severity_filter),
("T1-06", "APIC node-scoped fault node-101 F0532",test_t1_apic_node_scoped_fault_on_101),
("T1-07", "APIC subtree flat BD query", test_t1_apic_mo_subtree_flat_bd),
("T1-08", "APIC write + readback (fvTenant)", test_t1_apic_write_and_readback),
("T1-09", "APIC fabricNodeIdentP → fabricNode", test_t1_apic_fabric_node_reaction),
("T1-10", "/_sim/reset clears written MOs", test_t1_sim_reset),
("T1-11", "/_sim/snapshot + restore round-trip", test_t1_sim_snapshot_restore),
("T1-12", "NDO login → bearer token", test_t1_ndo_login),
("T1-13", "NDO sites match topology", test_t1_ndo_sites_match_topology),
("T1-14", "NDO tenants match topology", test_t1_ndo_tenants_match_topology),
("T1-15", "NDO schemas list (>= 1 per tenant)", test_t1_ndo_schemas_list),
("T1-16", "NDO MS-TN1 schema VRF+BD names match", test_t1_ndo_corp_schema_vrf_bd_match),
("T1-17", "NDO POST schema + readback", test_t1_ndo_post_schema_readback),
]
_TIER2_TESTS: list[tuple[str, str, Any]] = [
("T2-01", "epg_detail plugin smoke (MS-TN1, LAB1)", _tier2_epg_detail),
("T2-02", "l3out_detail plugin smoke (MS-TN1, LAB1)", _tier2_l3out_detail),
]
def _print_coverage(results: list[tuple[str, str, str]]) -> None:
W_ID, W_DESC, W_RES = 6, 42, 24
sep = f"+{'-'*(W_ID+2)}+{'-'*(W_DESC+2)}+{'-'*(W_RES+2)}+"
print()
print("=" * (W_ID + W_DESC + W_RES + 10))
print(" aci-sim · autoACI Verification Coverage")
print("=" * (W_ID + W_DESC + W_RES + 10))
print(sep)
print(f"| {'ID':<{W_ID}} | {'Description':<{W_DESC}} | {'Result':<{W_RES}} |")
print(sep)
tier1_pass = tier1_fail = tier2_ok = tier2_skip = tier2_fail = 0
for id_, desc, result in results:
print(f"| {id_:<{W_ID}} | {desc:<{W_DESC}} | {result[:W_RES]:<{W_RES}} |")
if id_.startswith("T1"):
if result.startswith("PASS"):
tier1_pass += 1
else:
tier1_fail += 1
else:
if result.startswith("OK"):
tier2_ok += 1
elif result.startswith("SKIP"):
tier2_skip += 1
else:
tier2_fail += 1
print(sep)
print(f" Tier 1: {tier1_pass}/{tier1_pass + tier1_fail} passed"
+ (f", {tier1_fail} FAILED" if tier1_fail else ""))
print(f" Tier 2: {tier2_ok} OK, {tier2_skip} skipped, {tier2_fail} failed")
print()
# =============================================================================
# Standalone entry-point
# =============================================================================
if __name__ == "__main__":
results: list[tuple[str, str, str]] = []
all_pass = True
print("\nTier 1 — must-pass tests")
print("-" * 52)
for id_, desc, fn in _TIER1_TESTS:
try:
fn()
status = "PASS"
print(f" {id_} PASS {desc}")
except Exception as exc:
status = f"FAIL ({exc})"
print(f" {id_} FAIL {desc}")
print(f" {exc}")
all_pass = False
results.append((id_, desc, status))
print("\nTier 2 — plugin smoke tests (best-effort)")
print("-" * 52)
for id_, desc, fn in _TIER2_TESTS:
status = fn()
print(f" {id_} {status} {desc}")
results.append((id_, desc, status))
_print_coverage(results)
if all_pass:
print("All Tier 1 tests passed.")
sys.exit(0)
else:
t1_fails = sum(
1 for id_, _, r in results
if id_.startswith("T1") and not r.startswith("PASS")
)
print(f"FAILED: {t1_fails} Tier 1 test(s) did not pass.")
sys.exit(1)