mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +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>
207 lines
6.5 KiB
Python
207 lines
6.5 KiB
Python
"""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
|