"""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 | one-line", 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