Files
aci-sim/tests/test_pr21_single_apic.py
taodengandClaude Fable 5 7e9a175ce6 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>
2026-07-05 20:06:36 +10:00

247 lines
11 KiB
Python

"""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"