mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Fix ZTE LLDP Scope table parse and skip discover without vendor.
Match ZXROS Scope/Holdtime columns, disable Netmiko cmd_verify on show commands, and do not SSH LLDP when vendor/device_type cannot be resolved. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
393fd0e0b4
commit
d2fb33ec0d
9 changed files with 146 additions and 19 deletions
|
|
@ -1,15 +1,32 @@
|
||||||
Value LOCAL_INTERFACE (\S+)
|
Value LOCAL_INTERFACE (\S+)
|
||||||
|
Value SCOPE (\S+)
|
||||||
Value CHASSIS_ID (\S+)
|
Value CHASSIS_ID (\S+)
|
||||||
Value NEIGHBOR_PORT_ID (\S+)
|
Value NEIGHBOR_PORT_ID (\S+)
|
||||||
Value NEIGHBOR_NAME (\S+.*)
|
Value HOLDTIME (\d+)
|
||||||
|
Value NEIGHBOR_NAME (\S.*?)
|
||||||
|
|
||||||
Start
|
Start
|
||||||
^Local\s+Interface\s+Chassis\s+ID -> Neighbors
|
^Local\s+Interface\s+Scope\s+Chassis\s+ID -> Neighbors
|
||||||
|
^Local\s+Interface\s+Chassis\s+ID -> NeighborsSimple
|
||||||
|
^Total\s+neighbors
|
||||||
|
^-+
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^.* -> Start
|
^.* -> Start
|
||||||
|
|
||||||
Neighbors
|
Neighbors
|
||||||
^${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_PORT_ID}\s+${NEIGHBOR_NAME}\s*$$ -> Record
|
^${LOCAL_INTERFACE}\s+${SCOPE}\s+${CHASSIS_ID}\s+${NEIGHBOR_PORT_ID}\s+${HOLDTIME}\s+${NEIGHBOR_NAME}\s*$$ -> Record
|
||||||
|
^-+
|
||||||
|
^Total\s+neighbors
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^Local\s+Interface -> Neighbors
|
^\S+\s*#\s*$$ -> End
|
||||||
^.* -> Start
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Neighbors
|
||||||
|
|
||||||
|
NeighborsSimple
|
||||||
|
^${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_PORT_ID}\s+${NEIGHBOR_NAME}\s*$$ -> Record
|
||||||
|
^-+
|
||||||
|
^Total\s+neighbors
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> NeighborsSimple
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,22 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
|
||||||
|
|
||||||
Do not use ``send_command_timing`` for Cisco config collection: long idle during
|
Do not use ``send_command_timing`` for Cisco config collection: long idle during
|
||||||
``Building configuration...`` is treated as end-of-output and truncates the config.
|
``Building configuration...`` is treated as end-of-output and truncates the config.
|
||||||
|
|
||||||
|
``cmd_verify=False``: Netmiko's default echo check often raises
|
||||||
|
``Pattern not detected: 'show\\ lldp\\ ...'`` on IOSv / hop / slow echo paths.
|
||||||
"""
|
"""
|
||||||
cmd = str(command or "").strip()
|
cmd = str(command or "").strip()
|
||||||
if not cmd:
|
if not cmd:
|
||||||
return ""
|
return ""
|
||||||
return str(conn.send_command(command_string=cmd, read_timeout=read_timeout) or "")
|
try:
|
||||||
|
return str(
|
||||||
|
conn.send_command(
|
||||||
|
command_string=cmd,
|
||||||
|
read_timeout=read_timeout,
|
||||||
|
cmd_verify=False,
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
# Older Netmiko without cmd_verify kwarg.
|
||||||
|
return str(conn.send_command(command_string=cmd, read_timeout=read_timeout) or "")
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,13 @@ from .topology_fabric import (
|
||||||
ensure_fabric_node_for_ume,
|
ensure_fabric_node_for_ume,
|
||||||
upsert_fabric_edge,
|
upsert_fabric_edge,
|
||||||
)
|
)
|
||||||
from .topology_lldp import NeighborHit, parse_neighbor_output, parser_meta, pick_neighbor_command
|
from .topology_lldp import (
|
||||||
|
NeighborHit,
|
||||||
|
can_discover_lldp,
|
||||||
|
parse_neighbor_output,
|
||||||
|
parser_meta,
|
||||||
|
pick_neighbor_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _discover_one_target(
|
def _discover_one_target(
|
||||||
|
|
@ -70,10 +76,21 @@ def _discover_one_target(
|
||||||
# Release unique-index locks before slow SSH.
|
# Release unique-index locks before slow SSH.
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
cmd, _proto = pick_neighbor_command(
|
vendor = target.get("vendor") or ""
|
||||||
vendor=target.get("vendor") or "",
|
device_type = target.get("device_type") or ""
|
||||||
device_type=target.get("device_type") or "",
|
pkey, is_stub = parser_meta(vendor=vendor, device_type=device_type)
|
||||||
)
|
if not can_discover_lldp(vendor=vendor, device_type=device_type):
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"ok": False,
|
||||||
|
"command": "",
|
||||||
|
"parser_key": pkey,
|
||||||
|
"parser_stub": True,
|
||||||
|
"error": "vendor_or_device_type_required",
|
||||||
|
"raw_preview": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, _proto = pick_neighbor_command(vendor=vendor, device_type=device_type)
|
||||||
exec_kwargs: dict[str, Any] = {"read_timeout_sec": 60}
|
exec_kwargs: dict[str, Any] = {"read_timeout_sec": 60}
|
||||||
if target.get("ume_ne_id") and not db.get(ManagedNE, target["ne_id"]):
|
if target.get("ume_ne_id") and not db.get(ManagedNE, target["ne_id"]):
|
||||||
exec_kwargs["ume_ne_id"] = target["ume_ne_id"]
|
exec_kwargs["ume_ne_id"] = target["ume_ne_id"]
|
||||||
|
|
@ -102,14 +119,12 @@ def _discover_one_target(
|
||||||
}
|
}
|
||||||
|
|
||||||
raw = str(exec_out.get("output") or "")
|
raw = str(exec_out.get("output") or "")
|
||||||
pkey, is_stub = parser_meta(
|
|
||||||
vendor=target.get("vendor") or "", device_type=target.get("device_type") or ""
|
|
||||||
)
|
|
||||||
hits = parse_neighbor_output(
|
hits = parse_neighbor_output(
|
||||||
raw,
|
raw,
|
||||||
protocol="lldp",
|
protocol="lldp",
|
||||||
vendor=target.get("vendor") or "",
|
vendor=vendor,
|
||||||
device_type=target.get("device_type") or "",
|
device_type=device_type,
|
||||||
|
command=cmd,
|
||||||
)
|
)
|
||||||
stub_flag = bool(is_stub and raw.strip() and not hits)
|
stub_flag = bool(is_stub and raw.strip() and not hits)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,11 @@ def pick_neighbor_command(
|
||||||
return lldp_command_for_vendor(vendor, device_type), "lldp"
|
return lldp_command_for_vendor(vendor, device_type), "lldp"
|
||||||
|
|
||||||
|
|
||||||
|
def can_discover_lldp(*, vendor: str = "", device_type: str = "") -> bool:
|
||||||
|
"""False when inventory has no usable vendor/device_type (do not SSH a guess command)."""
|
||||||
|
return resolve_vendor_key(vendor, device_type) != "generic"
|
||||||
|
|
||||||
|
|
||||||
def parser_meta(*, vendor: str = "", device_type: str = "") -> tuple[str, bool]:
|
def parser_meta(*, vendor: str = "", device_type: str = "") -> tuple[str, bool]:
|
||||||
"""Return (parser_key, is_stub)."""
|
"""Return (parser_key, is_stub)."""
|
||||||
key = resolve_vendor_key(vendor, device_type)
|
key = resolve_vendor_key(vendor, device_type)
|
||||||
|
|
@ -202,6 +207,17 @@ def _map_lldp_rows(rows: list[dict[str, Any]]) -> list[NeighborHit]:
|
||||||
return hits
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_collect_wrapper(text: str) -> str:
|
||||||
|
"""Drop SecureCRT-style headers injected by ``_collect_on_device``."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for ln in str(text or "").splitlines():
|
||||||
|
s = ln.strip()
|
||||||
|
if s.startswith(">>>") and '"String"' in s:
|
||||||
|
continue
|
||||||
|
lines.append(ln)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _parse_lldp_via_ntc(
|
def _parse_lldp_via_ntc(
|
||||||
text: str,
|
text: str,
|
||||||
*,
|
*,
|
||||||
|
|
@ -226,15 +242,18 @@ def parse_neighbor_output(
|
||||||
command: str = "",
|
command: str = "",
|
||||||
) -> list[NeighborHit]:
|
) -> list[NeighborHit]:
|
||||||
"""Parse neighbor CLI via TextFSM only (custom then community)."""
|
"""Parse neighbor CLI via TextFSM only (custom then community)."""
|
||||||
raw = str(text or "")
|
raw = _strip_collect_wrapper(text)
|
||||||
if not raw.strip():
|
if not raw.strip():
|
||||||
return []
|
return []
|
||||||
_ = protocol # CDP discovery removed; always parse as LLDP
|
_ = protocol # CDP discovery removed; always parse as LLDP
|
||||||
|
if not can_discover_lldp(vendor=vendor, device_type=device_type):
|
||||||
|
return []
|
||||||
|
cmd = (command or "").strip() or lldp_command_for_vendor(vendor, device_type)
|
||||||
return _parse_lldp_via_ntc(
|
return _parse_lldp_via_ntc(
|
||||||
raw,
|
raw,
|
||||||
vendor=vendor,
|
vendor=vendor,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
command=command or lldp_command_for_vendor(vendor, device_type),
|
command=cmd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ Local Interface Chassis ID Port ID System Name
|
||||||
gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1
|
gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ZTE_LLDP_SCOPE = """\
|
||||||
|
Total neighbors: 12
|
||||||
|
Local Interface Scope Chassis ID Port ID Holdtime System Name
|
||||||
|
-----------------------------------------------------------------------------------------
|
||||||
|
xgei-1/1/0/1 NB 026e.8219.bc57 xgei-1/1/0/26 100 CSR1_6120HSC
|
||||||
|
xgei-1/1/0/3 NB 00d0.0000.081f gei-1/2/0/3 113 OLT/CPE_6180H
|
||||||
|
xxvgei-1/1/0/16 NB 026e.8219.bc57 xxvgei-1/1/0/32 100 CSR1_6120HSC
|
||||||
|
cgei-1/1/0/34 NB 0022.9354.6e60 cgei-0/3/0/34 110 AG5
|
||||||
|
"""
|
||||||
|
|
||||||
ZTE_BRIEF = """\
|
ZTE_BRIEF = """\
|
||||||
Interface Attribute Mode BW Admin Phy Prot Description
|
Interface Attribute Mode BW Admin Phy Prot Description
|
||||||
xgei-1/1/0/1 optical Duplex/full 1G up up up C2930L100-EQ2
|
xgei-1/1/0/1 optical Duplex/full 1G up up up C2930L100-EQ2
|
||||||
|
|
@ -67,6 +77,19 @@ class CustomTemplateTests(unittest.TestCase):
|
||||||
self.assertEqual(rows[0].get("local_interface"), "gei-0/1/0/1")
|
self.assertEqual(rows[0].get("local_interface"), "gei-0/1/0/1")
|
||||||
self.assertEqual(rows[0].get("neighbor_name"), "R1")
|
self.assertEqual(rows[0].get("neighbor_name"), "R1")
|
||||||
|
|
||||||
|
def test_zte_lldp_scope_holdtime(self) -> None:
|
||||||
|
rows = parse_cli(
|
||||||
|
platform="zte_zxros",
|
||||||
|
command="show lldp neighbor brief",
|
||||||
|
text=ZTE_LLDP_SCOPE,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), 4)
|
||||||
|
by_local = {r.get("local_interface"): r for r in rows}
|
||||||
|
self.assertEqual(by_local["xgei-1/1/0/1"].get("neighbor_name"), "CSR1_6120HSC")
|
||||||
|
self.assertEqual(by_local["xgei-1/1/0/3"].get("neighbor_name"), "OLT/CPE_6180H")
|
||||||
|
self.assertEqual(by_local["xgei-1/1/0/3"].get("neighbor_port_id"), "gei-1/2/0/3")
|
||||||
|
self.assertEqual(by_local["cgei-1/1/0/34"].get("scope"), "NB")
|
||||||
|
|
||||||
def test_zte_brief_custom(self) -> None:
|
def test_zte_brief_custom(self) -> None:
|
||||||
rows = parse_cli(
|
rows = parse_cli(
|
||||||
platform="zte_zxros",
|
platform="zte_zxros",
|
||||||
|
|
@ -94,6 +117,13 @@ class WiredParserTests(unittest.TestCase):
|
||||||
self.assertEqual(hits[0].remote_name, "R1")
|
self.assertEqual(hits[0].remote_name, "R1")
|
||||||
self.assertEqual(hits[0].local_port, "gei-0/1/0/1")
|
self.assertEqual(hits[0].local_port, "gei-0/1/0/1")
|
||||||
|
|
||||||
|
def test_zte_lldp_scope_parser(self) -> None:
|
||||||
|
hits = lldp.parse_zte_lldp(ZTE_LLDP_SCOPE)
|
||||||
|
self.assertEqual(len(hits), 4)
|
||||||
|
by_local = {h.local_port: h for h in hits}
|
||||||
|
self.assertEqual(by_local["xgei-1/1/0/3"].remote_name, "OLT/CPE_6180H")
|
||||||
|
self.assertEqual(by_local["xxvgei-1/1/0/16"].remote_port, "xxvgei-1/1/0/32")
|
||||||
|
|
||||||
def test_cisco_lldp_community(self) -> None:
|
def test_cisco_lldp_community(self) -> None:
|
||||||
hits = lldp.parse_cisco_lldp(CISCO_LLDP_DETAIL)
|
hits = lldp.parse_cisco_lldp(CISCO_LLDP_DETAIL)
|
||||||
self.assertEqual(len(hits), 1)
|
self.assertEqual(len(hits), 1)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,15 @@ Local Interface Chassis ID Port ID System Name
|
||||||
gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1
|
gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ZTE_LLDP_SCOPE = """
|
||||||
|
Total neighbors: 12
|
||||||
|
Local Interface Scope Chassis ID Port ID Holdtime System Name
|
||||||
|
-----------------------------------------------------------------------------------------
|
||||||
|
xgei-1/1/0/1 NB 026e.8219.bc57 xgei-1/1/0/26 100 CSR1_6120HSC
|
||||||
|
xgei-1/1/0/3 NB 00d0.0000.081f gei-1/2/0/3 113 OLT/CPE_6180H
|
||||||
|
cgei-1/1/0/34 NB 0022.9354.6e60 cgei-0/3/0/34 110 AG5
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class LldpParserTests(unittest.TestCase):
|
class LldpParserTests(unittest.TestCase):
|
||||||
def test_cisco_brief(self) -> None:
|
def test_cisco_brief(self) -> None:
|
||||||
|
|
@ -89,6 +98,18 @@ class LldpParserTests(unittest.TestCase):
|
||||||
self.assertEqual(hits[0].remote_name.lower(), "r1")
|
self.assertEqual(hits[0].remote_name.lower(), "r1")
|
||||||
self.assertEqual(hits[0].remote_ip, "192.168.0.1")
|
self.assertEqual(hits[0].remote_ip, "192.168.0.1")
|
||||||
|
|
||||||
|
def test_no_vendor_skips_discover(self) -> None:
|
||||||
|
self.assertFalse(lldp.can_discover_lldp(vendor="", device_type=""))
|
||||||
|
self.assertFalse(lldp.can_discover_lldp(vendor="", device_type="generic"))
|
||||||
|
self.assertTrue(lldp.can_discover_lldp(vendor="Cisco", device_type="cisco_ios"))
|
||||||
|
hits = lldp.parse_neighbor_output(
|
||||||
|
CISCO_LLDP_BRIEF,
|
||||||
|
vendor="",
|
||||||
|
device_type="generic",
|
||||||
|
command="show lldp neighbors",
|
||||||
|
)
|
||||||
|
self.assertEqual(hits, [])
|
||||||
|
|
||||||
def test_huawei(self) -> None:
|
def test_huawei(self) -> None:
|
||||||
hits = lldp.parse_huawei_lldp(HUAWEI_LLDP)
|
hits = lldp.parse_huawei_lldp(HUAWEI_LLDP)
|
||||||
self.assertGreaterEqual(len(hits), 1)
|
self.assertGreaterEqual(len(hits), 1)
|
||||||
|
|
@ -100,6 +121,13 @@ class LldpParserTests(unittest.TestCase):
|
||||||
self.assertEqual(len(hits), 1)
|
self.assertEqual(len(hits), 1)
|
||||||
self.assertEqual(hits[0].remote_name, "R1")
|
self.assertEqual(hits[0].remote_name, "R1")
|
||||||
|
|
||||||
|
def test_zte_scope_holdtime(self) -> None:
|
||||||
|
hits = lldp.parse_zte_lldp(ZTE_LLDP_SCOPE)
|
||||||
|
self.assertEqual(len(hits), 3)
|
||||||
|
self.assertEqual(hits[0].remote_name, "CSR1_6120HSC")
|
||||||
|
self.assertEqual(hits[1].remote_name, "OLT/CPE_6180H")
|
||||||
|
self.assertEqual(hits[1].remote_port, "gei-1/2/0/3")
|
||||||
|
|
||||||
def test_pick_command_lldp_only(self) -> None:
|
def test_pick_command_lldp_only(self) -> None:
|
||||||
cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
|
cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
|
||||||
self.assertEqual(tag, "lldp")
|
self.assertEqual(tag, "lldp")
|
||||||
|
|
|
||||||
|
|
@ -1395,6 +1395,7 @@ const en = {
|
||||||
discoverNeFail: "Collect failed",
|
discoverNeFail: "Collect failed",
|
||||||
discoverUnmatched: "unmatched {{count}}",
|
discoverUnmatched: "unmatched {{count}}",
|
||||||
discoverUnmatchedMore: "{{count}} more not listed",
|
discoverUnmatchedMore: "{{count}} more not listed",
|
||||||
|
discoverVendorRequired: "Vendor/device type missing; skipped LLDP command",
|
||||||
discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed",
|
discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed",
|
||||||
discoverNoTargets:
|
discoverNoTargets:
|
||||||
"No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.",
|
"No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.",
|
||||||
|
|
|
||||||
|
|
@ -1389,6 +1389,7 @@ const zh = {
|
||||||
discoverNeFail: "采集失败",
|
discoverNeFail: "采集失败",
|
||||||
discoverUnmatched: "未匹配 {{count}}",
|
discoverUnmatched: "未匹配 {{count}}",
|
||||||
discoverUnmatchedMore: "另有 {{count}} 条未列出",
|
discoverUnmatchedMore: "另有 {{count}} 条未列出",
|
||||||
|
discoverVendorRequired: "未设置厂商/设备类型,已跳过下发 LLDP 命令",
|
||||||
discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居",
|
discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居",
|
||||||
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
|
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
|
||||||
discoverFilter: "结果筛选",
|
discoverFilter: "结果筛选",
|
||||||
|
|
|
||||||
|
|
@ -685,7 +685,9 @@ export function LldpLinksPage() {
|
||||||
|
|
||||||
{!itemDetail.ok ? (
|
{!itemDetail.ok ? (
|
||||||
<p className="topo-discover__error">
|
<p className="topo-discover__error">
|
||||||
{itemDetail.error || t("topology.discoverNeFail")}
|
{itemDetail.error === "vendor_or_device_type_required"
|
||||||
|
? t("topology.discoverVendorRequired")
|
||||||
|
: itemDetail.error || t("topology.discoverNeFail")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{itemDetail.parser_stub ? (
|
{itemDetail.parser_stub ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue