Files
aci-sim/tests/test_pr20_tier3.py
T
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

468 lines
19 KiB
Python

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