Files
aci-sim/tests/test_lldp_cdp_fidelity.py
T
dtzp555-max f6c95a0105 feat(isn): VLAN-4 routed sub-interface + addr on spine ISN ospfIf; ISN-CSW LLDP neighbor (v0.19.0)
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.
2026-07-07 08:17:23 +10:00

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