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