mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
Real ACI multi-site spines carry the OSPF address toward the ISN on a
routed sub-interface, not the bare port: ospfIf id/dn are now
eth1/{49+si}.4 with addr=172.16.{site}.{si+1}/24 (same /24 as the
existing .254 peer); ospfAdjEp nests under the sub-if. Each spine ISN
uplink physical port also gets an ISN-CSW lldpAdjEp/cdpAdjEp
(sysName ISN-CSW{site}, portIdV Ethernet1/{si+1}, model N9K-C9364C,
locally-administered 02:1B:0D MAC hashed from site.id). Single-site
fabrics build none of this. Closes the autoACI Fabric (physical) gap
where spine uplink rows showed blank Local IP / Neighbor Port.
908 tests green.
462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""LLDP/CDP fidelity feature tests (v0.12.0):
|
|
|
|
1. lldpInst/cdpInst materialized (one per switch node with adjacencies);
|
|
lldpIf/cdpIf materialized per adjacency interface.
|
|
2. Regression fix: deep-root subtree query
|
|
(`GET /api/mo/.../sys/lldp/inst.json?query-target=subtree&
|
|
target-subtree-class=lldpAdjEp`) returns totalCount>0 (was 0 before
|
|
lldpInst/lldpIf were materialized) — plus /api/class/lldpInst.json and
|
|
/api/class/lldpIf.json both return >0.
|
|
3. lldpAdjEp bidirectional portIdV consistency (spine<->leaf link).
|
|
4. cdpAdjEp.devId == sysName, cdpAdjEp.portId non-empty.
|
|
5. Backward-compat: sysName + mgmtIp unchanged on both lldpAdjEp/cdpAdjEp.
|
|
6. Dynamic path: POSTing a fabricNodeIdentP (writes.py's
|
|
materialize_node_registration reaction) gets the same treatment.
|
|
7. CLI: `aci-sim lldp` — text, --json, --cdp, and a graceful non-existent
|
|
node id.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from aci_sim import cli
|
|
from aci_sim.build import cabling, fabric, health_faults, interfaces, overlay, underlay
|
|
from aci_sim.build.orchestrator import build_site
|
|
from aci_sim.mit.store import MITStore
|
|
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
|
from aci_sim.topology.loader import load_topology
|
|
from aci_sim.topology.schema import Topology
|
|
|
|
TOPO_YAML = Path(__file__).parent.parent / "topology.yaml"
|
|
TOPO_PATH_STR = "topology.yaml" # run from project root, matches other test modules
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(scope="module")
|
|
def topo() -> Topology:
|
|
return load_topology(TOPO_YAML)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def site_a(topo):
|
|
return topo.site_by_name("LAB1")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def store(topo, site_a) -> MITStore:
|
|
s = MITStore()
|
|
fabric.build(topo, site_a, s)
|
|
cabling.build(topo, site_a, s)
|
|
underlay.build(topo, site_a, s)
|
|
overlay.build(topo, site_a, s)
|
|
interfaces.build(topo, site_a, s)
|
|
health_faults.build(topo, site_a, s)
|
|
return s
|
|
|
|
|
|
def _login(client: TestClient) -> None:
|
|
resp = client.post(
|
|
"/api/aaaLogin.json",
|
|
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
def _fresh_client(site_name: str = "LAB1") -> tuple[ApicSiteState, TestClient]:
|
|
topo_obj = load_topology(TOPO_PATH_STR)
|
|
site = topo_obj.site_by_name(site_name)
|
|
built_store = build_site(topo_obj, site)
|
|
state = ApicSiteState(
|
|
name=site.name,
|
|
site=site,
|
|
topo=topo_obj,
|
|
store=built_store,
|
|
baseline=copy.deepcopy(built_store),
|
|
)
|
|
client = TestClient(make_apic_app(state))
|
|
_login(client)
|
|
return state, client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Containers materialized
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_lldp_inst_materialized_per_switch_node(store, site_a):
|
|
"""One lldpInst per switch node that has at least one lldpAdjEp."""
|
|
node_ids_with_adj = set()
|
|
for mo in store.by_class("lldpAdjEp"):
|
|
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
|
|
if m:
|
|
node_ids_with_adj.add(int(m.group(1)))
|
|
|
|
lldp_inst_node_ids = set()
|
|
for mo in store.by_class("lldpInst"):
|
|
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
|
|
assert m, f"lldpInst dn missing node id: {mo.attrs['dn']}"
|
|
lldp_inst_node_ids.add(int(m.group(1)))
|
|
|
|
assert node_ids_with_adj, "expected at least one node with an lldpAdjEp"
|
|
assert lldp_inst_node_ids == node_ids_with_adj, (
|
|
f"lldpInst node set mismatch: adj nodes={node_ids_with_adj} "
|
|
f"lldpInst nodes={lldp_inst_node_ids}"
|
|
)
|
|
|
|
inst = store.by_class("lldpInst")[0]
|
|
assert inst.attrs["adminSt"] == "enabled"
|
|
assert inst.attrs["name"] == "default"
|
|
assert inst.attrs["holdTime"] == "120"
|
|
assert inst.attrs["initDelayTime"] == "2"
|
|
assert inst.attrs["txFreq"] == "30"
|
|
assert inst.attrs["ctrl"] == "rx-en,tx-en"
|
|
assert "sys-name" in inst.attrs["optTlvSel"]
|
|
|
|
|
|
def test_cdp_inst_materialized_per_switch_node(store, site_a):
|
|
node_ids_with_adj = set()
|
|
for mo in store.by_class("cdpAdjEp"):
|
|
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
|
|
if m:
|
|
node_ids_with_adj.add(int(m.group(1)))
|
|
|
|
cdp_inst_node_ids = set()
|
|
for mo in store.by_class("cdpInst"):
|
|
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
|
|
assert m
|
|
cdp_inst_node_ids.add(int(m.group(1)))
|
|
|
|
assert cdp_inst_node_ids == node_ids_with_adj
|
|
|
|
inst = store.by_class("cdpInst")[0]
|
|
assert inst.attrs["adminSt"] == "enabled"
|
|
assert inst.attrs["name"] == "default"
|
|
assert inst.attrs["holdIntvl"] == "180"
|
|
assert inst.attrs["txFreq"] == "60"
|
|
assert inst.attrs["ver"] == "v2"
|
|
|
|
|
|
def test_lldp_if_cdp_if_materialized_per_adjacency_interface(store, site_a):
|
|
"""One lldpIf/cdpIf per (node, local_port) that has an adjacency."""
|
|
expected_if_dns = set()
|
|
for mo in store.by_class("lldpAdjEp"):
|
|
# .../sys/lldp/inst/if-[{port}]/adj-1 -> .../sys/lldp/inst/if-[{port}]
|
|
if_dn = mo.attrs["dn"].rsplit("/", 1)[0]
|
|
expected_if_dns.add(if_dn)
|
|
|
|
actual_if_dns = {mo.attrs["dn"] for mo in store.by_class("lldpIf")}
|
|
assert actual_if_dns == expected_if_dns
|
|
|
|
lldp_if = store.by_class("lldpIf")[0]
|
|
assert lldp_if.attrs["adminRxSt"] == "enabled"
|
|
assert lldp_if.attrs["adminTxSt"] == "enabled"
|
|
assert lldp_if.attrs["operRxSt"] == "up"
|
|
assert lldp_if.attrs["operTxSt"] == "up"
|
|
assert lldp_if.attrs["wiring"] == "valid"
|
|
assert re.match(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", lldp_if.attrs["mac"])
|
|
|
|
expected_cdp_if_dns = set()
|
|
for mo in store.by_class("cdpAdjEp"):
|
|
expected_cdp_if_dns.add(mo.attrs["dn"].rsplit("/", 1)[0])
|
|
actual_cdp_if_dns = {mo.attrs["dn"] for mo in store.by_class("cdpIf")}
|
|
assert actual_cdp_if_dns == expected_cdp_if_dns
|
|
|
|
cdp_if = store.by_class("cdpIf")[0]
|
|
assert cdp_if.attrs["adminSt"] == "enabled"
|
|
assert cdp_if.attrs["operSt"] == "up"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Regression fix — deep-root subtree query + class queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_deep_root_subtree_query_returns_neighbors():
|
|
"""The bug this feature fixes: GET .../sys/lldp/inst.json?query-target=
|
|
subtree&target-subtree-class=lldpAdjEp used to return totalCount=0
|
|
because the root DN had no materialized MO (run_mo_query short-circuits
|
|
to ([], 0) when store.get(dn) is None)."""
|
|
_, client = _fresh_client("LAB1")
|
|
resp = client.get(
|
|
"/api/mo/topology/pod-1/node-101/sys/lldp/inst.json"
|
|
"?query-target=subtree&target-subtree-class=lldpAdjEp"
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert int(data["totalCount"]) > 0, f"expected totalCount>0, got {data}"
|
|
for item in data["imdata"]:
|
|
assert "lldpAdjEp" in item
|
|
|
|
|
|
def test_deep_root_cdp_subtree_query_returns_neighbors():
|
|
_, client = _fresh_client("LAB1")
|
|
resp = client.get(
|
|
"/api/mo/topology/pod-1/node-101/sys/cdp/inst.json"
|
|
"?query-target=subtree&target-subtree-class=cdpAdjEp"
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert int(data["totalCount"]) > 0, f"expected totalCount>0, got {data}"
|
|
|
|
|
|
def test_lldp_inst_class_query_nonzero():
|
|
_, client = _fresh_client("LAB1")
|
|
resp = client.get("/api/class/lldpInst.json")
|
|
assert resp.status_code == 200, resp.text
|
|
assert int(resp.json()["totalCount"]) > 0
|
|
|
|
|
|
def test_lldp_if_class_query_nonzero():
|
|
_, client = _fresh_client("LAB1")
|
|
resp = client.get("/api/class/lldpIf.json")
|
|
assert resp.status_code == 200, resp.text
|
|
assert int(resp.json()["totalCount"]) > 0
|
|
|
|
|
|
def test_cdp_inst_and_if_class_query_nonzero():
|
|
_, client = _fresh_client("LAB1")
|
|
resp_inst = client.get("/api/class/cdpInst.json")
|
|
assert resp_inst.status_code == 200, resp_inst.text
|
|
assert int(resp_inst.json()["totalCount"]) > 0
|
|
|
|
resp_if = client.get("/api/class/cdpIf.json")
|
|
assert resp_if.status_code == 200, resp_if.text
|
|
assert int(resp_if.json()["totalCount"]) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Bidirectional portIdV consistency (spine<->leaf link)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_lldp_adjep_bidirectional_portidv_consistency(store, site_a):
|
|
"""For a spine<->leaf link, the leaf-side adj's portIdV must equal the
|
|
spine's own local port, and vice-versa."""
|
|
from aci_sim.build.cabling import cabling_links
|
|
|
|
pod = site_a.pod
|
|
checked = 0
|
|
for n1, s1, p1, n2, s2, p2 in cabling_links(site_a):
|
|
port1 = f"eth{s1}/{p1}"
|
|
port2 = f"eth{s2}/{p2}"
|
|
|
|
adj_n1 = store.get(f"topology/pod-{pod}/node-{n1}/sys/lldp/inst/if-[{port1}]/adj-1")
|
|
adj_n2 = store.get(f"topology/pod-{pod}/node-{n2}/sys/lldp/inst/if-[{port2}]/adj-1")
|
|
assert adj_n1 is not None
|
|
assert adj_n2 is not None
|
|
|
|
# n1 sees n2 on port1; n2's own port is port2 -> n1's portIdV == port2
|
|
assert adj_n1.attrs["portIdV"] == port2
|
|
# n2 sees n1 on port2; n1's own port is port1 -> n2's portIdV == port1
|
|
assert adj_n2.attrs["portIdV"] == port1
|
|
checked += 1
|
|
|
|
assert checked > 0, "no cabling links found to check"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. cdpAdjEp devId/portId
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_cdp_adjep_devid_matches_sysname_and_portid_nonempty(store, site_a):
|
|
cdp_mos = store.by_class("cdpAdjEp")
|
|
assert cdp_mos, "expected at least one cdpAdjEp"
|
|
for mo in cdp_mos:
|
|
assert mo.attrs["devId"] == mo.attrs["sysName"]
|
|
assert mo.attrs["portId"], f"empty portId on {mo.attrs['dn']}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Backward-compat: sysName + mgmtIp unchanged
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_backward_compat_sysname_and_mgmtip_present(store, site_a):
|
|
for cls in ("lldpAdjEp", "cdpAdjEp"):
|
|
mos = store.by_class(cls)
|
|
assert mos, f"expected at least one {cls}"
|
|
for mo in mos:
|
|
assert mo.attrs.get("sysName"), f"{cls} missing sysName: {mo.attrs['dn']}"
|
|
assert mo.attrs.get("mgmtIp"), f"{cls} missing mgmtIp: {mo.attrs['dn']}"
|
|
|
|
|
|
def test_backward_compat_matches_cabling_graph(store, site_a, topo):
|
|
"""Same assertion test_build_fabric.py's test_lldp_cdp_matches_cabling
|
|
already makes (sysName set matches the cabling graph) — re-verified here
|
|
to prove the enrichment didn't alter the pre-existing neighbor set."""
|
|
from aci_sim.build.cabling import cabling_links
|
|
|
|
node_map = {n.id: n for n in site_a.all_nodes()}
|
|
expected: set[tuple[int, str]] = set()
|
|
for n1, _s1, _p1, n2, _s2, _p2 in cabling_links(site_a):
|
|
expected.add((n1, node_map[n2].name))
|
|
expected.add((n2, node_map[n1].name))
|
|
for cid in range(1, site_a.controllers + 1):
|
|
for leaf in site_a.leaf_nodes()[:2]:
|
|
expected.add((leaf.id, f"{site_a.name}-ACI-APIC{cid:02d}"))
|
|
# v0.19.0: multi-site fabrics also see the ISN CSW on each spine's
|
|
# dedicated uplink port (fabric.py's ISN-CSW add_adjacency block).
|
|
if len(topo.sites) > 1:
|
|
for spine in site_a.spine_nodes():
|
|
expected.add((spine.id, f"ISN-CSW{site_a.id}"))
|
|
|
|
actual: set[tuple[int, str]] = set()
|
|
for mo in store.by_class("lldpAdjEp"):
|
|
m = re.search(r"/node-(\d+)/", mo.attrs["dn"])
|
|
if m:
|
|
actual.add((int(m.group(1)), mo.attrs.get("sysName", "")))
|
|
|
|
assert expected == actual
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Dynamic path — fabricNodeIdentP registration reaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_dynamic_node_registration_materializes_lldp_containers():
|
|
"""POSTing a fabricNodeIdentP (writes.py's materialize_node_registration
|
|
reaction) must produce the same lldpInst/lldpIf materialization the
|
|
boot-time builders produce, so its subtree query also works."""
|
|
_, client = _fresh_client("LAB1")
|
|
resp = client.post(
|
|
"/api/mo/uni/controller/nodeidentpol/nodep-920.json",
|
|
json={
|
|
"fabricNodeIdentP": {
|
|
"attributes": {
|
|
"dn": "uni/controller/nodeidentpol/nodep-920",
|
|
"name": "leaf-920",
|
|
"nodeType": "leaf",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
node_dn = "topology/pod-1/node-920"
|
|
inst_resp = client.get(f"/api/mo/{node_dn}/sys/lldp/inst.json")
|
|
assert inst_resp.status_code == 200, inst_resp.text
|
|
assert int(inst_resp.json()["totalCount"]) > 0, "new node's lldpInst missing"
|
|
|
|
subtree_resp = client.get(
|
|
f"/api/mo/{node_dn}/sys/lldp/inst.json"
|
|
"?query-target=subtree&target-subtree-class=lldpAdjEp"
|
|
)
|
|
assert subtree_resp.status_code == 200, subtree_resp.text
|
|
subtree_data = subtree_resp.json()
|
|
assert int(subtree_data["totalCount"]) > 0, (
|
|
f"new node's lldpAdjEp subtree query returned 0: {subtree_data}"
|
|
)
|
|
|
|
# Also verify the enriched fields landed on the dynamic path (not just
|
|
# boot-time build) and the spine-side reciprocal adjacency was created too.
|
|
adj = subtree_data["imdata"][0]["lldpAdjEp"]["attributes"]
|
|
assert adj["sysName"]
|
|
assert adj["mgmtIp"]
|
|
assert adj["portIdV"]
|
|
assert adj["chassisIdV"]
|
|
|
|
links_resp = client.get("/api/class/fabricLink.json")
|
|
node_links = [
|
|
item["fabricLink"]["attributes"]
|
|
for item in links_resp.json()["imdata"]
|
|
if "920" in (item["fabricLink"]["attributes"].get("n1"), item["fabricLink"]["attributes"].get("n2"))
|
|
]
|
|
assert node_links, "expected a fabricLink for the newly registered node"
|
|
|
|
|
|
def test_dynamic_node_registration_cdp_also_materialized():
|
|
_, client = _fresh_client("LAB1")
|
|
resp = client.post(
|
|
"/api/mo/uni/controller/nodeidentpol/nodep-921.json",
|
|
json={
|
|
"fabricNodeIdentP": {
|
|
"attributes": {
|
|
"dn": "uni/controller/nodeidentpol/nodep-921",
|
|
"name": "leaf-921",
|
|
"nodeType": "leaf",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
node_dn = "topology/pod-1/node-921"
|
|
resp2 = client.get(
|
|
f"/api/mo/{node_dn}/sys/cdp/inst.json"
|
|
"?query-target=subtree&target-subtree-class=cdpAdjEp"
|
|
)
|
|
assert resp2.status_code == 200, resp2.text
|
|
data = resp2.json()
|
|
assert int(data["totalCount"]) > 0
|
|
adj = data["imdata"][0]["cdpAdjEp"]["attributes"]
|
|
assert adj["devId"] == adj["sysName"]
|
|
assert adj["portId"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. CLI: aci-sim lldp
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_cli_lldp_text_shows_neighbor(monkeypatch, capsys):
|
|
monkeypatch.chdir(Path(__file__).parent.parent)
|
|
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101"])
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "node-101" in out
|
|
assert "LAB1-ACI-SP01" in out or "LAB1-ACI-SP02" in out
|
|
|
|
|
|
def test_cli_lldp_json(monkeypatch, capsys):
|
|
monkeypatch.chdir(Path(__file__).parent.parent)
|
|
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101", "--json"])
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
data = json.loads(out)
|
|
assert isinstance(data, list)
|
|
assert len(data) > 0
|
|
row = data[0]
|
|
assert "local_port" in row
|
|
assert "neighbor" in row
|
|
assert "neighbor_port" in row
|
|
assert "mgmt_ip" in row
|
|
assert "platform" in row
|
|
|
|
|
|
def test_cli_lldp_cdp_flag(monkeypatch, capsys):
|
|
monkeypatch.chdir(Path(__file__).parent.parent)
|
|
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "101", "--cdp", "--json"])
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
data = json.loads(out)
|
|
assert len(data) > 0
|
|
# cdp platId values are real model/platform strings (e.g. APIC-SERVER-M3
|
|
# or N9K-C9332C), not the lldp sysDesc free-text form.
|
|
assert any(row["platform"] for row in data)
|
|
|
|
|
|
def test_cli_lldp_nonexistent_node_is_graceful(monkeypatch, capsys):
|
|
monkeypatch.chdir(Path(__file__).parent.parent)
|
|
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "999999"])
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "No neighbors found" in out
|
|
|
|
|
|
def test_cli_lldp_nonexistent_node_json_is_empty_list(monkeypatch, capsys):
|
|
monkeypatch.chdir(Path(__file__).parent.parent)
|
|
rc = cli.main(["lldp", TOPO_PATH_STR, "--node", "999999", "--json"])
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
data = json.loads(out)
|
|
assert data == []
|
|
|
|
|
|
def test_cli_lldp_missing_topology_file_returns_1(tmp_path, capsys):
|
|
rc = cli.main(["lldp", str(tmp_path / "does-not-exist.yaml")])
|
|
assert rc == 1
|