mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Huawei NEs still tagged zte_zxros were getting show lldp neighbor brief and timing out; use display lldp neighbor and align UME inventory vendor before the jump. Co-authored-by: Cursor <cursoragent@cursor.com>
402 lines
12 KiB
Python
402 lines
12 KiB
Python
"""LLDP neighbor command profiles and TextFSM parsers (per vendor).
|
|
|
|
Multi-vendor fabrics default to LLDP. Resolve profile primarily from Netmiko
|
|
``device_type`` (managed NE / UME already store it), then fall back to vendor label.
|
|
|
|
Parsing is TextFSM-only via ``ntc_parse`` (custom ``cli_templates/`` then community).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from .ntc_parse import parse_cli, resolve_cli_platform, row_get
|
|
|
|
|
|
@dataclass
|
|
class NeighborHit:
|
|
remote_name: str = ""
|
|
remote_ip: str = ""
|
|
local_port: str = ""
|
|
remote_port: str = ""
|
|
protocol: str = "lldp" # lldp | cdp
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VendorLldpProfile:
|
|
"""One vendor's LLDP (and optional CDP) discovery profile."""
|
|
|
|
key: str
|
|
lldp_command: str
|
|
cdp_command: str = ""
|
|
notes: str = ""
|
|
|
|
|
|
# device_type (Netmiko) -> profile key. Prefer inventory device_type over fuzzy text.
|
|
# Keep aligned with netx_api.device_types.SUPPORTED_DEVICE_TYPES families.
|
|
_DEVICE_TYPE_PREFIX_RULES: tuple[tuple[str, str], ...] = (
|
|
("cisco_", "cisco"),
|
|
("huawei", "huawei"), # huawei, huawei_vrp, huawei_olt, ...
|
|
("zte_", "zte"),
|
|
("juniper", "juniper"), # juniper, juniper_junos, ...
|
|
("nokia_", "nokia"),
|
|
("alcatel_sros", "nokia"),
|
|
("alcatel_", "nokia"),
|
|
("ericsson_", "ericsson"),
|
|
("hp_comware", "h3c"),
|
|
("h3c_", "h3c"),
|
|
)
|
|
|
|
_VENDOR_LABEL_TO_KEY: dict[str, str] = {
|
|
"cisco": "cisco",
|
|
"huawei": "huawei",
|
|
"华为": "huawei",
|
|
"hw": "huawei",
|
|
"h3c": "h3c",
|
|
"zte": "zte",
|
|
"中兴": "zte",
|
|
"juniper": "juniper",
|
|
"nokia": "nokia",
|
|
"ericsson": "ericsson",
|
|
"alcatel": "nokia",
|
|
"alcatel-lucent": "nokia",
|
|
}
|
|
|
|
|
|
VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
|
|
"cisco": VendorLldpProfile(
|
|
key="cisco",
|
|
lldp_command="show lldp neighbors detail",
|
|
cdp_command="show cdp neighbors detail",
|
|
notes="device_type cisco_*; prefer detail for System Name / Port id / mgmt IP.",
|
|
),
|
|
"huawei": VendorLldpProfile(
|
|
key="huawei",
|
|
lldp_command="display lldp neighbor",
|
|
notes="device_type huawei*; per-interface neighbor blocks.",
|
|
),
|
|
"h3c": VendorLldpProfile(
|
|
key="h3c",
|
|
lldp_command="display lldp neighbor-information list",
|
|
notes="Community hp_comware TextFSM (list; verbose also available).",
|
|
),
|
|
"zte": VendorLldpProfile(
|
|
key="zte",
|
|
lldp_command="show lldp neighbor brief",
|
|
notes="device_type zte_*; NetX custom TextFSM.",
|
|
),
|
|
"juniper": VendorLldpProfile(
|
|
key="juniper",
|
|
lldp_command="show lldp neighbors",
|
|
notes="Community juniper_junos TextFSM.",
|
|
),
|
|
"nokia": VendorLldpProfile(
|
|
key="nokia",
|
|
lldp_command="show system lldp neighbor",
|
|
notes="SROS: no community LLDP template yet. alcatel_aos uses show lldp remote-system.",
|
|
),
|
|
"ericsson": VendorLldpProfile(
|
|
key="ericsson",
|
|
lldp_command="show lldp neighbors",
|
|
notes="No community ericsson_ipos LLDP TextFSM yet.",
|
|
),
|
|
"generic": VendorLldpProfile(
|
|
key="generic",
|
|
lldp_command="show lldp neighbors",
|
|
notes="Fallback when device_type/vendor unknown.",
|
|
),
|
|
}
|
|
|
|
# Vendors without a working TextFSM path yet (custom or community).
|
|
STUB_PARSER_KEYS = frozenset({"nokia", "ericsson", "generic"})
|
|
|
|
|
|
def _key_from_device_type(device_type: str) -> str:
|
|
dtype = str(device_type or "").strip().lower()
|
|
if not dtype:
|
|
return ""
|
|
for prefix, key in _DEVICE_TYPE_PREFIX_RULES:
|
|
if dtype == prefix.rstrip("_") or dtype.startswith(prefix):
|
|
return key
|
|
if dtype in VENDOR_LLDP_PROFILES and dtype != "generic":
|
|
return dtype
|
|
return ""
|
|
|
|
|
|
def _key_from_vendor_label(vendor: str) -> str:
|
|
label = str(vendor or "").strip().lower()
|
|
if not label:
|
|
return ""
|
|
if label in _VENDOR_LABEL_TO_KEY:
|
|
return _VENDOR_LABEL_TO_KEY[label]
|
|
for token, key in _VENDOR_LABEL_TO_KEY.items():
|
|
if label == token or label.startswith(f"{token} ") or label.startswith(f"{token}-"):
|
|
return key
|
|
# Substring for values like "Huawei Technologies"
|
|
if len(token) >= 3 and token in label:
|
|
return key
|
|
return ""
|
|
|
|
|
|
def resolve_vendor_key(vendor: str = "", device_type: str = "") -> str:
|
|
"""Map inventory vendor / device_type -> LLDP profile key.
|
|
|
|
Prefer Netmiko ``device_type`` when it is specific. When vendor label and
|
|
device_type disagree (e.g. vendor=Huawei but dtype still zte_zxros from a
|
|
default profile), prefer the vendor label so Huawei gets ``display lldp``
|
|
instead of ZTE's ``show lldp neighbor brief``.
|
|
"""
|
|
dtype_key = _key_from_device_type(device_type)
|
|
label_key = _key_from_vendor_label(vendor)
|
|
if label_key and dtype_key and label_key != dtype_key:
|
|
return label_key
|
|
if dtype_key:
|
|
return dtype_key
|
|
if label_key:
|
|
return label_key
|
|
return "generic"
|
|
|
|
|
|
def get_vendor_profile(vendor: str = "", device_type: str = "") -> VendorLldpProfile:
|
|
key = resolve_vendor_key(vendor, device_type)
|
|
return VENDOR_LLDP_PROFILES.get(key) or VENDOR_LLDP_PROFILES["generic"]
|
|
|
|
|
|
def lldp_command_for_vendor(vendor: str = "", device_type: str = "") -> str:
|
|
dtype = str(device_type or "").strip().lower()
|
|
# AOS has a community template; SROS profile command differs.
|
|
# Keep AOS command even when vendor label says Nokia/Alcatel.
|
|
if dtype.startswith("alcatel_aos"):
|
|
return "show lldp remote-system"
|
|
return get_vendor_profile(vendor, device_type).lldp_command
|
|
|
|
|
|
def cdp_command_for_vendor(vendor: str = "", device_type: str = "") -> str:
|
|
"""Deprecated: CDP is not used for fabric discovery (LLDP only)."""
|
|
return get_vendor_profile(vendor, device_type).cdp_command
|
|
|
|
|
|
def pick_neighbor_command(
|
|
*,
|
|
protocol: str = "lldp",
|
|
vendor: str = "",
|
|
device_type: str = "",
|
|
) -> tuple[str, str]:
|
|
"""Return (lldp_command, \"lldp\"). Physical discovery is LLDP-only (CDP ignored)."""
|
|
_ = protocol # accepted for call-site compat; always 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]:
|
|
"""Return (parser_key, is_stub)."""
|
|
key = resolve_vendor_key(vendor, device_type)
|
|
dtype = str(device_type or "").strip().lower()
|
|
if dtype.startswith("alcatel_aos"):
|
|
return key, False
|
|
return key, key in STUB_PARSER_KEYS
|
|
|
|
|
|
def _map_lldp_rows(rows: list[dict[str, Any]]) -> list[NeighborHit]:
|
|
hits: list[NeighborHit] = []
|
|
for row in rows:
|
|
local_port = row_get(row, "local_interface", "local_intf", "local_port")
|
|
remote_name = row_get(row, "neighbor_name", "system_name", "neighbor", "device_id")
|
|
remote_port = row_get(
|
|
row,
|
|
"neighbor_interface",
|
|
"neighbor_port_id",
|
|
"port_id",
|
|
"neighbor_port",
|
|
"remote_port",
|
|
)
|
|
remote_ip = row_get(
|
|
row, "mgmt_address", "management_address", "management_ip", "neighbor_ip"
|
|
)
|
|
name = remote_name.strip()
|
|
if name.lower() in {"", "-", "not advertised"}:
|
|
name = ""
|
|
# Require a remote identity; skip filldown-only leftovers.
|
|
if not name and not remote_port and not remote_ip:
|
|
continue
|
|
hits.append(
|
|
NeighborHit(
|
|
remote_name=name,
|
|
remote_ip=remote_ip,
|
|
local_port=local_port,
|
|
remote_port=remote_port,
|
|
protocol="lldp",
|
|
)
|
|
)
|
|
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(
|
|
text: str,
|
|
*,
|
|
vendor: str = "",
|
|
device_type: str = "",
|
|
command: str = "",
|
|
) -> list[NeighborHit]:
|
|
plat = resolve_cli_platform(vendor=vendor, device_type=device_type)
|
|
cmd = (command or "").strip() or lldp_command_for_vendor(vendor, device_type)
|
|
if not plat or not cmd:
|
|
return []
|
|
rows = parse_cli(platform=plat, command=cmd, text=text)
|
|
return _map_lldp_rows(rows)
|
|
|
|
|
|
def parse_neighbor_output(
|
|
text: str,
|
|
*,
|
|
protocol: str = "lldp",
|
|
vendor: str = "",
|
|
device_type: str = "",
|
|
command: str = "",
|
|
) -> list[NeighborHit]:
|
|
"""Parse neighbor CLI via TextFSM only (custom then community)."""
|
|
raw = _strip_collect_wrapper(text)
|
|
if not raw.strip():
|
|
return []
|
|
_ = 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(
|
|
raw,
|
|
vendor=vendor,
|
|
device_type=device_type,
|
|
command=cmd,
|
|
)
|
|
|
|
|
|
def parse_cisco_lldp(text: str) -> list[NeighborHit]:
|
|
"""Cisco LLDP: try detail command mapping first, then brief table."""
|
|
for cmd in ("show lldp neighbors detail", "show lldp neighbors"):
|
|
hits = _parse_lldp_via_ntc(
|
|
text, vendor="cisco", device_type="cisco_ios", command=cmd
|
|
)
|
|
if hits:
|
|
return hits
|
|
return []
|
|
|
|
|
|
def parse_cisco_cdp(text: str) -> list[NeighborHit]:
|
|
"""CDP discovery disabled; kept for API compat."""
|
|
_ = text
|
|
return []
|
|
|
|
|
|
def parse_huawei_lldp(text: str) -> list[NeighborHit]:
|
|
return _parse_lldp_via_ntc(
|
|
text, vendor="huawei", device_type="huawei", command="display lldp neighbor"
|
|
)
|
|
|
|
|
|
def parse_h3c_lldp(text: str) -> list[NeighborHit]:
|
|
for cmd in (
|
|
"display lldp neighbor-information list",
|
|
"display lldp neighbor-information verbose",
|
|
):
|
|
hits = _parse_lldp_via_ntc(
|
|
text, vendor="h3c", device_type="hp_comware", command=cmd
|
|
)
|
|
if hits:
|
|
return hits
|
|
return []
|
|
|
|
|
|
def parse_zte_lldp(text: str) -> list[NeighborHit]:
|
|
return _parse_lldp_via_ntc(
|
|
text, vendor="zte", device_type="zte_zxros", command="show lldp neighbor brief"
|
|
)
|
|
|
|
|
|
def parse_juniper_lldp(text: str) -> list[NeighborHit]:
|
|
return _parse_lldp_via_ntc(
|
|
text,
|
|
vendor="juniper",
|
|
device_type="juniper_junos",
|
|
command="show lldp neighbors",
|
|
)
|
|
|
|
|
|
def parse_nokia_lldp(text: str, *, device_type: str = "") -> list[NeighborHit]:
|
|
dtype = str(device_type or "").strip() or "nokia_sros"
|
|
cmd = lldp_command_for_vendor(vendor="nokia", device_type=dtype)
|
|
return _parse_lldp_via_ntc(text, vendor="nokia", device_type=dtype, command=cmd)
|
|
|
|
|
|
def parse_ericsson_lldp(text: str) -> list[NeighborHit]:
|
|
_ = text
|
|
return []
|
|
|
|
|
|
def parse_generic_lldp(text: str) -> list[NeighborHit]:
|
|
"""Unknown vendor: no heuristic regex; require an explicit platform template."""
|
|
_ = text
|
|
return []
|
|
|
|
|
|
# Long media names → short canonical form (case-insensitive prefix).
|
|
_IFNAME_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
("tengigabitethernet", "te"),
|
|
("ten-gigabitethernet", "te"),
|
|
("gigabitethernet", "gi"),
|
|
("fastethernet", "fa"),
|
|
("ethernet", "eth"),
|
|
("xgigabitethernet", "xge"),
|
|
("100ge", "100ge"),
|
|
("40ge", "40ge"),
|
|
("25ge", "25ge"),
|
|
("10ge", "10ge"),
|
|
("ge-trunk", "ge-trunk"),
|
|
("eth-trunk", "eth-trunk"),
|
|
("port-channel", "po"),
|
|
("portchannel", "po"),
|
|
("loopback", "lo"),
|
|
("vlanif", "vlanif"),
|
|
("vlan", "vlan"),
|
|
("mgmteth", "mgmt"),
|
|
("management", "mgmt"),
|
|
("hundredgige", "hu"),
|
|
("fiftygige", "fi"),
|
|
("fortygige", "fo"),
|
|
("twentyfivegige", "twe"),
|
|
("twogigabitethernet", "tw"),
|
|
)
|
|
|
|
|
|
def normalize_ifname(name: str) -> str:
|
|
"""Canonicalize interface names so Gi0/0 and GigabitEthernet0/0 share a key."""
|
|
raw = str(name or "").strip()
|
|
if not raw:
|
|
return ""
|
|
s = re.sub(r"\s+", "", raw).lower()
|
|
s = s.replace("_", "/")
|
|
for long, short in _IFNAME_PREFIXES:
|
|
if s.startswith(long):
|
|
rest = s[len(long) :]
|
|
if rest.startswith((":", "/", "-")) or rest == "" or rest[0].isdigit():
|
|
if rest.startswith(":"):
|
|
rest = rest[1:]
|
|
return f"{short}{rest}"
|
|
break
|
|
return s
|