Initial public release — aci-sim v0.16.0

aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
"""Tests for PR-18 — Tier-1 fabric configuration parameters.
Covers each Tier-1 param defaulting correctly when absent from
topology.yaml, overriding when set, the deterministic-serial fallback,
`site.pod` DN correctness, N-site scaffold collision-freedom, and
`infraWiNode` count tracking `site.controllers`.
See docs/DESIGN.md's "PR-18 — Tier-1 fabric configuration parameters"
section for the full design rationale, including the documented N-site
ISN/`other_site()` limitation this file's `TestNSite` class verifies
(builds without collision; does NOT assert a true N-site ISN full mesh,
which is explicitly out of scope for this PR).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from aci_sim.build import orchestrator
from aci_sim.build.fabric import default_serial
from aci_sim.cli import generate_topology
from aci_sim.topology.loader import load_topology
from aci_sim.topology.schema import Fabric, Topology
REPO_ROOT = Path(__file__).parent.parent
TOPOLOGY_YAML = REPO_ROOT / "topology.yaml"
@pytest.fixture(scope="module")
def repo_topo() -> Topology:
return load_topology(TOPOLOGY_YAML)
# ---------------------------------------------------------------------------
# Defaults — absent from topology.yaml, each param resolves to its default
# ---------------------------------------------------------------------------
class TestDefaults:
def test_controllers_defaults_to_1(self, repo_topo: Topology) -> None:
"""PR-21: single-APIC-per-site is now the default (was 3 pre-PR-21)."""
for site in repo_topo.sites:
assert site.controllers == 1
def test_pod_defaults_to_1(self, repo_topo: Topology) -> None:
for site in repo_topo.sites:
assert site.pod == 1
def test_tep_pool_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.tep_pool == "10.0.0.0/16"
def test_infra_vlan_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.infra_vlan == 3967
def test_gipo_pool_defaults(self, repo_topo: Topology) -> None:
assert repo_topo.fabric.gipo_pool == "225.0.0.0/15"
def test_repo_topology_yaml_still_validates_unchanged(self) -> None:
"""The backward-compat acceptance bar: the real topology.yaml, with
no Tier-1 fields added, must still load + validate cleanly."""
topo = load_topology(TOPOLOGY_YAML)
assert len(topo.sites) == 2
# ---------------------------------------------------------------------------
# Overrides — each param actually takes effect when set
# ---------------------------------------------------------------------------
class TestOverrides:
def test_controllers_override(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = 5
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].controllers == 5
def test_pod_override(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["pod"] = 2
topo = Topology.model_validate(topo_dict)
assert topo.sites[0].pod == 2
def test_tep_pool_override(self) -> None:
fab = Fabric(name="x", tep_pool="172.16.0.0/16")
assert fab.tep_pool == "172.16.0.0/16"
def test_infra_vlan_override(self) -> None:
fab = Fabric(name="x", infra_vlan=100)
assert fab.infra_vlan == 100
def test_gipo_pool_override(self) -> None:
fab = Fabric(name="x", gipo_pool="226.0.0.0/15")
assert fab.gipo_pool == "226.0.0.0/15"
def test_infra_vlan_out_of_range_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["infra_vlan"] = 4095
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_infra_vlan_zero_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["infra_vlan"] = 0
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_tep_pool_bad_cidr_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["tep_pool"] = "not-a-cidr"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
def test_gipo_pool_bad_cidr_rejected(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["fabric"]["gipo_pool"] = "garbage"
with pytest.raises(ValidationError):
Topology.model_validate(topo_dict)
# ---------------------------------------------------------------------------
# Deterministic serials
# ---------------------------------------------------------------------------
class TestSerials:
def test_default_serial_format(self) -> None:
assert default_serial("1", 101) == "SAL10101"
assert default_serial("2", 301) == "SAL20301"
def test_default_serial_deterministic(self) -> None:
assert default_serial("1", 101) == default_serial("1", 101)
def test_auto_node_gets_nonempty_serial_in_built_fabricNode(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
for mo in store.by_class("fabricNode"):
assert mo.attrs.get("serial"), f"empty serial for {mo.dn}"
def test_auto_node_serial_matches_default_serial_scheme(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
node101 = next(mo for mo in store.by_class("fabricNode") if mo.attrs.get("id") == "101")
assert node101.attrs["serial"] == default_serial(site.id, 101)
def test_explicit_yaml_serial_overrides_default(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["leaves"][0]["serial"] = "EXPLICIT-SERIAL-1"
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
leaf_id = site.leaf_nodes()[0].id
mo = next(m for m in store.by_class("fabricNode") if m.attrs.get("id") == str(leaf_id))
assert mo.attrs["serial"] == "EXPLICIT-SERIAL-1"
def test_fabricNodeIdentP_serial_matches_fabricNode_serial(self, repo_topo: Topology) -> None:
site = repo_topo.site_by_name("LAB1")
store = orchestrator.build_site(repo_topo, site)
fn_serials = {mo.attrs["id"]: mo.attrs["serial"] for mo in store.by_class("fabricNode")}
for mo in store.by_class("fabricNodeIdentP"):
assert mo.attrs["serial"] == fn_serials[mo.attrs["nodeId"]]
# ---------------------------------------------------------------------------
# Pod DN correctness
# ---------------------------------------------------------------------------
class TestPod:
def test_pod2_site_builds_pod2_dns(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["pod"] = 2
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
assert fabric_nodes, "expected at least one fabricNode"
for mo in fabric_nodes:
assert mo.dn.startswith("topology/pod-2/"), mo.dn
for mo in store.by_class("topSystem"):
assert mo.dn.startswith("topology/pod-2/"), mo.dn
assert mo.attrs["podId"] == "2"
# ---------------------------------------------------------------------------
# Controllers -> infraWiNode count
# ---------------------------------------------------------------------------
class TestControllersWiring:
@pytest.mark.parametrize("n_controllers", [1, 3, 5])
def test_infraWiNode_count_matches_controllers_squared(self, n_controllers: int) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = n_controllers
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
wi = store.by_class("infraWiNode")
assert len(wi) == n_controllers * n_controllers
def test_controller_fabricNode_count_matches_controllers(self) -> None:
topo_dict = generate_topology(sites=1, leaves_per_site=1, spines_per_site=1, border_pairs=0)
topo_dict["sites"][0]["controllers"] = 4
topo = Topology.model_validate(topo_dict)
site = topo.sites[0]
store = orchestrator.build_site(topo, site)
controllers = [mo for mo in store.by_class("fabricNode") if mo.attrs.get("role") == "controller"]
assert len(controllers) == 4
# ---------------------------------------------------------------------------
# N-site — collision-free build; documented ISN limit not asserted as a bug
# ---------------------------------------------------------------------------
class TestNSite:
def test_3site_scaffold_builds_without_node_id_collision(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 3
all_ids: list[int] = []
for site in topo.sites:
all_ids.extend(n.id for n in site.all_nodes())
assert len(all_ids) == len(set(all_ids))
def test_3site_scaffold_distinct_pods_serials_controllers(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1)
# Give each site a distinct pod to prove pod-per-site isn't collapsed.
for i, site_def in enumerate(topo_dict["sites"], start=1):
site_def["pod"] = i
site_def["controllers"] = i + 2 # 3, 4, 5
topo = Topology.model_validate(topo_dict)
assert [s.pod for s in topo.sites] == [1, 2, 3]
assert [s.controllers for s in topo.sites] == [3, 4, 5]
for site in topo.sites:
store = orchestrator.build_site(topo, site)
fabric_nodes = store.by_class("fabricNode")
for mo in fabric_nodes:
assert mo.dn.startswith(f"topology/pod-{site.pod}/")
assert mo.attrs["serial"] # non-empty
controllers = [mo for mo in fabric_nodes if mo.attrs.get("role") == "controller"]
assert len(controllers) == site.controllers
def test_3site_new_cli_controllers_flag(self) -> None:
topo_dict = generate_topology(sites=3, leaves_per_site=2, spines_per_site=2, border_pairs=1, controllers=5)
topo = Topology.model_validate(topo_dict)
assert len(topo.sites) == 3
for site in topo.sites:
assert site.controllers == 5