mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
Initial public release — aci-sim v0.16.0
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO management planes) — for testing ACI automation (Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates without real hardware. Highlights: APIC + NDO REST surface served from one in-memory MIT built from a declarative topology.yaml; port mode + sandbox (real per-device IPs on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror (multi-site templates materialize onto the target sites' APIC stores); real-APIC fvBD default attributes; file-backed state save/restore; an `aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite. History squashed for the public release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"])
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user