Switch CLI parsing to TextFSM and polish topology/workbench UX.

Use custom cli_templates plus community ntc-templates for LLDP and port traffic across ZTE/Huawei/Cisco/H3C/Juniper/Nokia, drop regex parsers, and surface Admin/Phy/Prot on port discover. Also batch-add topology NEs and tighten workbench branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 23:06:40 +08:00
parent 60186a12e3
commit 393fd0e0b4
41 changed files with 1771 additions and 1075 deletions

View file

@ -0,0 +1,57 @@
# NetX CLI TextFSM templates
Custom TextFSM templates that complement (and can **override**) community `ntc-templates`.
Organize by **vendor folder**; prefer adding templates here for every new CLI parse path.
## Layout
```text
cli_templates/
index # global index (paths relative to this dir)
README.md
zte/ # ZTE ZXROS / ROSNG, ...
cisco/ # overrides / extras beyond community cisco_*
huawei/
h3c/
juniper/
```
## Lookup order
1. **This tree** (`index` → `vendor/*.textfsm`)
2. Community `ntc-templates` package
No regex CLI parsers. If both miss, the call returns empty — fix or add a template.
## Vendor coverage (blind fill from community + NetX)
| NetX vendor_key | ntc platform | LLDP command | Port brief | Port detail | Notes |
|-----------------|--------------|--------------|------------|-------------|-------|
| `zte` | `zte_zxros` | `show lldp neighbor brief` | `show interface brief` | `show interface {if}` | NetX custom (community has iface only) |
| `huawei` | `huawei_vrp` | `display lldp neighbor` | `display interface brief` | `display interface {if}` | NetX custom (community prompt-fragile) |
| `cisco` | `cisco_ios` / `nxos` / `xr` | `show lldp neighbors detail` | `show ip interface brief` | `show interfaces {if}` | Community + NetX iface override |
| `h3c` | `hp_comware` | `display lldp neighbor-information list` | `display interface brief` | `display interface {if}` | Community |
| `juniper` | `juniper_junos` | `show lldp neighbors` | `show interfaces` | `show interfaces {if}` | Community (rates often sparse) |
| `nokia` | `alcatel_sros` | *(stub / no SROS LLDP tpl)* | `show port` | `show port {if}` | Community port status; AOS LLDP via `alcatel_aos` |
| `mikrotik` | `mikrotik_routeros` | — | `/interface print brief` | `/interface print detail …` | Community; no LLDP path |
| `ericsson` | `ericsson_ipos` | stub | — | — | No community LLDP/iface templates |
`alcatel_aos` device_type uses community `show lldp remote-system` (mapped to platform `alcatel_aos`).
## Adding a template
1. Put the file under the vendor folder, e.g. `zte/zte_zxros_show_xxx.textfsm`
2. Name: `{platform}_{command_slug}.textfsm` (same convention as ntc-templates)
3. Register in root `index` with a **relative path**:
`zte/zte_zxros_show_xxx.textfsm, .*, zte_zxros, sh[[ow]] ...`
4. Align Value names with community templates when possible
5. Add unit tests under `tests/`
When community already works, no file is required.
When community is wrong/incomplete for our lab, add an override under the vendor folder
with the same Platform+Command so NetX wins.
## Concurrency
Template files are read-only and safe across processes. Parsing goes through
`netx_api.ntc_parse` (fresh `CliTable` + process lock; never share instances across threads).

View file

View file

@ -0,0 +1,19 @@
Value Required INTERFACE (\S+)
Value LINK_STATUS (.+?)
Value PROTOCOL_STATUS (.+?)
Value DESCRIPTION (.+?)
Value BANDWIDTH (\d+\s+\w+)
Value RATE_PERIOD (\d+)
Value RATE_PERIOD_UNIT (second|minute|hour)
Value INPUT_RATE (\d+)
Value OUTPUT_RATE (\d+)
Start
^${INTERFACE}\s+is\s+${LINK_STATUS},\s*line\s+protocol\s+is\s+${PROTOCOL_STATUS}\s*$$
^\s+Description:\s+${DESCRIPTION}\s*$$
^\s+MTU\s+\d+\s+bytes,\s+BW\s+${BANDWIDTH}(?:/s(?:ec)?)?,
^\s+${RATE_PERIOD}\s+${RATE_PERIOD_UNIT}s?\s+input\s+rate\s+${INPUT_RATE}\s+bits/sec
^\s+\d+\s+(?:second|minute|hour)s?\s+output\s+rate\s+${OUTPUT_RATE}\s+bits/sec -> Record
^\s*$$
^\S+#\s*$$ -> End
^.* -> Start

View file

View file

View file

@ -0,0 +1,20 @@
Value Filldown INTERFACE (\S+)
Value Filldown ADMIN_OPER (UP|DOWN|Administratively\s+DOWN)
Value Filldown DESCRIPTION (.*)
Value Filldown RATE_PERIOD (\d+)
Value Filldown INPUT_BPS ([\d.]+)
Value Filldown OUTPUT_BPS ([\d.]+)
Value Filldown IN_UTIL ([\d.]+)
Value OUT_UTIL ([\d.]+)
Start
^${INTERFACE}\s+current\s+state\s*:\s*${ADMIN_OPER}
^Description:\s*${DESCRIPTION}\s*$$
^\s*Last\s+${RATE_PERIOD}\s+seconds\s+input\s+rate:\s*${INPUT_BPS}\s*bits/sec
^\s*Last\s+\d+\s+seconds\s+output\s+rate:\s*${OUTPUT_BPS}\s*bits/sec
^\s*Last\s+\d+\s+seconds\s+input\s+utility\s+rate:\s*${IN_UTIL}\s*%
^\s*Last\s+\d+\s+seconds\s+output\s+utility\s+rate:\s*${OUT_UTIL}\s*% -> Record
^\s*$$
^\s*<.*>\s*$$
^.*#\s*$$
^.* -> Start

View file

@ -0,0 +1,14 @@
Value INTERFACE (\S+)
Value PHY (down|[\*\^]down|up|up\(\w+\))
Value PROTOCOL (down|[\*\^]down|up|up\(\w+\))
Value INUTI (\d*\.?\d*%|\-\-)
Value OUTUTI (\d*\.?\d*%|\-\-)
Value INERRORS (\d+)
Value OUTERRORS (\d+)
Start
^\s*${INTERFACE}\s+${PHY}\s+${PROTOCOL}\s+${INUTI}\s+${OUTUTI}\s+${INERRORS}\s+${OUTERRORS}\s*$$ -> Record
^\s*$$
^\s*<.*>\s*$$
^.*#\s*$$
^.* -> Start

View file

@ -0,0 +1,23 @@
Value Filldown LOCAL_INTERFACE (\S+)
Value CHASSIS_ID (\S+)
Value NEIGHBOR_PORT_ID (\S+)
Value NEIGHBOR_NAME (\S+)
Value MGMT_ADDRESS (\S+)
Start
^${LOCAL_INTERFACE}\s+has\s+\d+\s+neighbor -> Neighbor
^\s*$$
^.* -> Start
Neighbor
^Chassis\s+ID\s*:\s*${CHASSIS_ID}
^Port\s+ID\s*:\s*${NEIGHBOR_PORT_ID}
^System\s+name\s*:\s*${NEIGHBOR_NAME}
^Management\s+address\s*:\s*${MGMT_ADDRESS} -> Record
^Local\s+Interface\s*:\s*${LOCAL_INTERFACE}
^Neighbor\s+index\s*:
^${LOCAL_INTERFACE}\s+has\s+\d+\s+neighbor -> Neighbor
^\s*$$
^\s*<.*>\s*$$
^.*#\s*$$
^.* -> Neighbor

View file

@ -0,0 +1,25 @@
# NetX custom TextFSM index (checked before community ntc-templates).
# Templates live under vendor folders; paths are relative to this directory.
Template, Hostname, Platform, Command
# --- zte ---
zte/zte_zxros_show_lldp_neighbor_brief.textfsm, .*, zte_zxros, sh[[ow]] lld[[p]] nei[[ghbor]] br[[ief]]
zte/zte_zxros_show_interface_brief.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] br[[ief]]
zte/zte_zxros_show_interface.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] .+
# --- cisco ---
cisco/cisco_ios_show_interfaces.textfsm, .*, cisco_ios, sh[[ow]] int[[erfaces]] .+
# --- huawei ---
huawei/huawei_vrp_display_lldp_neighbor.textfsm, .*, huawei_vrp, dis[[play]] lldp nei[[ghbor]]
huawei/huawei_vrp_display_interface_brief.textfsm, .*, huawei_vrp, dis[[play]] inter[[face]] br[[ief]]
huawei/huawei_vrp_display_interface.textfsm, .*, huawei_vrp, dis[[play]] inter[[face]]\s*((?!brief|counters|description).)*$
# --- h3c (community hp_comware covers list/brief/detail; override here if lab differs) ---
# h3c/hp_comware_display_lldp_neighbor-information_list.textfsm, .*, hp_comware, ...
# --- juniper (community juniper_junos covers lldp + show interfaces) ---
# juniper/juniper_junos_....textfsm, .*, juniper_junos, ...
# --- nokia / alcatel (community alcatel_sros show port; aos lldp in community) ---
# (add overrides when SROS LLDP / rates are captured from lab)

View file

View file

@ -0,0 +1,30 @@
Value Filldown IFNAME (\S+)
Value Filldown ADMIN_OPER (up|down)
Value Filldown DESCRIPTION (.+?)
Value Filldown BW_RAW (\d+(?:\.\d+)?\s*[kKmMgGtT]?\s*(?:G?bit|bit)/s(?:ec)?)
Value Filldown RATE_PERIOD (\d+)
Value Filldown INPUT_BPS ([\d.]+)
Value Filldown OUTPUT_BPS ([\d.]+)
Value IN_UTIL ([\d.]+)
Value OUT_UTIL ([\d.]+)
Start
^${IFNAME}\s+is\s+${ADMIN_OPER}\b
^\s*Description:\s*${DESCRIPTION}\s*$$
^\s*BW\s+${BW_RAW}
^\s*Rate\s+period\s*:\s*${RATE_PERIOD}\s*s -> Rates
^\s*$$
^.* -> Start
Rates
^\s*Input\s*:\s*${INPUT_BPS}\s*bit/s
^\s*Output\s*:\s*${OUTPUT_BPS}\s*bit/s
^\s*Peak\s+rate: -> AfterPeak
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record
^\s*$$
^.* -> Rates
AfterPeak
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record
^\s*$$
^.* -> AfterPeak

View file

@ -0,0 +1,21 @@
Value INTERFACE (\S+)
Value ATTRIBUTE (\S+)
Value MODE (\S+)
Value BW (\S+)
Value ADMIN (up|down)
Value PHY (up|down)
Value PROT (up|down)
Value DESCRIPTION (.*)
Start
^Interface\s+Attribute\s+Mode -> Ports
^\s*$$
^.* -> Start
Ports
^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${BW}\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record
^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Ports

View file

@ -0,0 +1,15 @@
Value LOCAL_INTERFACE (\S+)
Value CHASSIS_ID (\S+)
Value NEIGHBOR_PORT_ID (\S+)
Value NEIGHBOR_NAME (\S+.*)
Start
^Local\s+Interface\s+Chassis\s+ID -> Neighbors
^\s*$$
^.* -> Start
Neighbors
^${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_PORT_ID}\s+${NEIGHBOR_NAME}\s*$$ -> Record
^\s*$$
^Local\s+Interface -> Neighbors
^.* -> Start

View file

@ -28,6 +28,8 @@ def normalize_vendor_key(vendor: str, device_type: str = "") -> str:
return "h3c"
if "zte" in blob or "zxros" in blob:
return "zte"
if "mikrotik" in blob or "routeros" in blob or "switchos" in blob:
return "mikrotik"
if "cisco" in blob or "ios" in blob or "nx-os" in blob or "xr" in blob:
return "cisco"
return "other"

View file

@ -39,6 +39,7 @@ SUPPORTED_VENDORS: tuple[str, ...] = (
"ZTE",
"Huawei",
"Cisco",
"H3C",
"Juniper",
"Nokia",
"Other",

176
netx_api/ntc_parse.py Normal file
View file

@ -0,0 +1,176 @@
"""Thread-safe CLI parse: NetX cli_templates -> community ntc-templates.
CliTable/ParseCmd is not safe to share across threads. Each call builds a fresh
CliTable under a process-wide lock. Template files on disk are read-only and
safe for multi-process workers.
"""
from __future__ import annotations
import logging
import threading
from functools import lru_cache
from pathlib import Path
from typing import Any, Callable, TypeVar
_log = logging.getLogger("netx.ntc_parse")
_PARSE_LOCK = threading.Lock()
CUSTOM_TEMPLATE_DIR = Path(__file__).resolve().parent / "cli_templates"
T = TypeVar("T")
@lru_cache(maxsize=1)
def _community_template_dir() -> str:
import ntc_templates
return str(Path(ntc_templates.__file__).resolve().parent / "templates")
def resolve_cli_platform(*, vendor: str = "", device_type: str = "", vendor_key: str = "") -> str:
"""Map NetX inventory fields to an ntc / NetX TextFSM platform id."""
dt = str(device_type or "").strip().lower()
key = str(vendor_key or "").strip().lower()
vend = str(vendor or "").strip().lower()
if dt.startswith("cisco_nxos") or dt == "cisco_nxos":
return "cisco_nxos"
if dt.startswith("cisco_xr") or dt == "cisco_xr":
return "cisco_xr"
if dt.startswith("cisco_"):
return "cisco_ios"
if dt.startswith("huawei"):
return "huawei_vrp"
if dt.startswith("hp_comware") or dt.startswith("h3c_"):
return "hp_comware"
if dt.startswith("juniper"):
return "juniper_junos"
if dt.startswith("zte_"):
return "zte_zxros"
if dt.startswith("alcatel_aos"):
return "alcatel_aos"
if dt.startswith("alcatel_sros") or dt.startswith("nokia_sros"):
return "alcatel_sros"
if dt.startswith("nokia_"):
# SRL etc.: best-effort SROS templates until dedicated ones exist.
return "alcatel_sros"
if dt.startswith("alcatel"):
return "alcatel_sros"
if dt.startswith("ericsson_"):
return "ericsson_ipos"
if dt.startswith("mikrotik"):
return "mikrotik_routeros"
blob = key or vend
if "nxos" in blob:
return "cisco_nxos"
if "cisco" in blob:
return "cisco_ios"
if "huawei" in blob:
return "huawei_vrp"
if "h3c" in blob or "comware" in blob:
return "hp_comware"
if "juniper" in blob or "junos" in blob:
return "juniper_junos"
if "zte" in blob:
return "zte_zxros"
if "aos" in blob and "alcatel" in blob:
return "alcatel_aos"
if "nokia" in blob or "alcatel" in blob or "sros" in blob:
return "alcatel_sros"
if "ericsson" in blob:
return "ericsson_ipos"
if "mikrotik" in blob or "routeros" in blob:
return "mikrotik_routeros"
return ""
def row_get(row: dict[str, Any], *names: str) -> str:
if not row:
return ""
lower_map = {str(k).lower(): v for k, v in row.items()}
for name in names:
val = lower_map.get(name.lower())
if val is None:
continue
text = str(val).strip()
if text:
return text
return ""
def parse_cli(
*,
platform: str,
command: str,
text: str,
) -> list[dict[str, Any]]:
"""Parse CLI text: custom templates first, then community ntc-templates."""
plat = str(platform or "").strip()
cmd = str(command or "").strip()
raw = str(text or "")
if not plat or not cmd or not raw.strip():
return []
with _PARSE_LOCK:
rows = _parse_dir(CUSTOM_TEMPLATE_DIR, plat, cmd, raw)
if rows:
return rows
try:
community = Path(_community_template_dir())
except Exception:
_log.debug("community ntc template dir unavailable", exc_info=True)
return []
return _parse_dir(community, plat, cmd, raw)
def _parse_dir(template_dir: Path, platform: str, command: str, text: str) -> list[dict[str, Any]]:
index_path = template_dir / "index"
if not index_path.is_file():
return []
try:
from ntc_templates.parse import ParsingException, parse_output
except Exception:
_log.debug("ntc_templates unavailable", exc_info=True)
return []
try:
# Fresh CliTable inside parse_output each call; we still hold _PARSE_LOCK.
rows = parse_output(
platform=platform,
command=command,
data=text,
template_dir=str(template_dir),
try_fallback=False,
)
except ParsingException:
return []
except Exception:
_log.debug(
"cli parse failed platform=%s command=%s dir=%s",
platform,
command,
template_dir,
exc_info=True,
)
return []
if not isinstance(rows, list):
return []
return [r for r in rows if isinstance(r, dict)]
def parse_cli_mapped(
*,
platform: str,
command: str,
text: str,
map_rows: Callable[[list[dict[str, Any]]], list[T]],
) -> list[T]:
"""Parse CLI and map rows; empty list if no TextFSM match."""
rows = parse_cli(platform=platform, command=command, text=text)
if not rows:
return []
return map_rows(rows)

View file

@ -1,4 +1,4 @@
"""Vendor → port traffic CLI command matrix (ZTE / Huawei / Cisco)."""
"""Vendor → port traffic CLI command matrix (TextFSM-backed vendors)."""
from __future__ import annotations
@ -12,27 +12,60 @@ class PortTrafficCommands:
brief: str
detail_template: str # format with ifname=
vendor_key: str = "other"
notes: str = ""
def commands_for_vendor(vendor: str, device_type: str = "") -> PortTrafficCommands | None:
"""Return brief/detail commands when a TextFSM path exists (custom or community)."""
key = normalize_vendor_key(vendor, device_type)
if key == "zte":
return PortTrafficCommands(
brief="show interface brief",
detail_template="show interface {ifname}",
vendor_key=key,
notes="NetX custom TextFSM (+ community zte_zxros).",
)
if key == "huawei":
return PortTrafficCommands(
brief="display interface brief",
detail_template="display interface {ifname}",
vendor_key=key,
notes="NetX custom TextFSM (community prompt-sensitive).",
)
if key == "cisco":
return PortTrafficCommands(
brief="show ip interface brief",
detail_template="show interfaces {ifname}",
vendor_key=key,
notes="Community ntc (ios/nxos/xr via device_type platform).",
)
if key == "h3c":
return PortTrafficCommands(
brief="display interface brief",
detail_template="display interface {ifname}",
vendor_key=key,
notes="Community hp_comware TextFSM.",
)
if key == "juniper":
return PortTrafficCommands(
brief="show interfaces",
detail_template="show interfaces {ifname}",
vendor_key=key,
notes="Community juniper_junos show interfaces (rates may be sparse).",
)
if key == "nokia":
return PortTrafficCommands(
brief="show port",
detail_template="show port {ifname}",
vendor_key=key,
notes="Community alcatel_sros show port (status; rates usually absent).",
)
if key == "mikrotik":
return PortTrafficCommands(
brief="/interface print brief",
detail_template="/interface print detail where name={ifname}",
vendor_key=key,
notes="Community mikrotik_routeros interface print*.",
)
return None

View file

@ -490,7 +490,12 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp
ports = [
DiscoverPortItem(**brief_port_to_dict(p))
for p in parse_interface_brief(raw, cmds.vendor_key)
for p in parse_interface_brief(
raw,
cmds.vendor_key,
command=cmds.brief,
device_type=device_type,
)
]
return DiscoverPortsResponse(
source=body.source,

View file

@ -1,4 +1,7 @@
"""Parsers for ZTE / Huawei / Cisco interface brief & detail (rate bit/s)."""
"""Interface brief/detail parsers via TextFSM only (``ntc_parse``).
No regex CLI parsers — add / fix templates under ``cli_templates/{vendor}/``.
"""
from __future__ import annotations
@ -6,6 +9,8 @@ import re
from dataclasses import dataclass
from typing import Any
from .ntc_parse import parse_cli, resolve_cli_platform, row_get
_BW_UNIT = {
"k": 1_000,
@ -15,79 +20,10 @@ _BW_UNIT = {
}
_RE_BW_COMPACT = re.compile(r"^(\d+(?:\.\d+)?)\s*([kKmMgGtT])(?:bit)?s?$", re.I)
# ZTE "BW 1 Gbit/s" and Cisco "BW 1000000 Kbit/sec"
_RE_BW_DETAIL = re.compile(
r"\bBW\s+(\d+(?:\.\d+)?)\s*([kKmMgGtT])?\s*(?:G?bit|bit)/s(?:ec)?\b",
re.I,
)
_RE_RATE_PERIOD = re.compile(r"Rate\s+period\s*:\s*(\d+)\s*s", re.I)
_RE_INPUT_BPS = re.compile(r"^\s*Input\s*:\s*([\d.]+)\s*bit/s", re.I | re.M)
_RE_OUTPUT_BPS = re.compile(r"^\s*Output\s*:\s*([\d.]+)\s*bit/s", re.I | re.M)
_RE_UTIL = re.compile(
r"Intf\s+utilization\s*:\s*input\s*([\d.]+)%\s*output\s*([\d.]+)%",
re.I,
)
_RE_IF_UP = re.compile(r"^(\S+)\s+is\s+(up|down)\b", re.I | re.M)
_RE_DESC = re.compile(r"^\s*Description:\s*(.+?)\s*$", re.I | re.M)
_RE_PROMPT_LINE = re.compile(r"[#>]\s*$")
_RE_UPDOWN = re.compile(r"^(up|down)$", re.I)
_RE_UPDOWN_TOKEN = re.compile(r"^(up|down)\b", re.I)
# ZTE / Huawei / Cisco / common logical iface names.
_RE_IFNAME = re.compile(
r"^(?:"
r"xxvgei|xgei|cgei|gei|fei|qli|smartgroup|bvi|vlan|loopback|mgmt|"
r"null|pos|atm|tunnel|irb|pw|eth|ethernet|port-channel|bundle|"
r"gigabitethernet|xgigabitethernet|fastethernet|tengigabitethernet|"
r"hundredgige|fivegige|fortygige|serial|dialer|cellular|multilink|"
r"10ge|25ge|40ge|100ge|eth-trunk|vlanif|meth|loopback"
r")[\w./:-]*$",
re.I,
)
_RE_CISCO_BRIEF_ROW = re.compile(
r"^(\S+)\s+(\S+)\s+(YES|NO)\s+(\S+)\s+(.+?)\s+(up|down)\s*$",
re.I,
)
_RE_CISCO_IF_STATE = re.compile(
r"^(\S+)\s+is\s+(administratively\s+)?(up|down),\s*line\s+protocol\s+is\s+(up|down)\b",
re.I | re.M,
)
_RE_CISCO_RATE = re.compile(
r"(\d+)\s+(second|minute|hour)s?\s+(input|output)\s+rate\s+([\d.]+)\s*bits/sec",
re.I,
)
_RE_HW_BRIEF_ROW = re.compile(
r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)\s*$"
)
_RE_HW_STATE = re.compile(
r"^(\S+)\s+current\s+state\s*:\s*(UP|DOWN|Administratively\s+DOWN)\b",
re.I | re.M,
)
_RE_HW_IN_RATE = re.compile(
r"Last\s+(\d+)\s+seconds\s+input\s+rate\s*:\s*([\d.]+)\s*bits/sec",
re.I,
)
_RE_HW_OUT_RATE = re.compile(
r"Last\s+(\d+)\s+seconds\s+output\s+rate\s*:\s*([\d.]+)\s*bits/sec",
re.I,
)
_RE_HW_IN_UTIL = re.compile(
r"Last\s+(\d+)\s+seconds\s+input\s+utility\s+rate\s*:\s*([\d.]+)\s*%",
re.I,
)
_RE_HW_OUT_UTIL = re.compile(
r"Last\s+(\d+)\s+seconds\s+output\s+utility\s+rate\s*:\s*([\d.]+)\s*%",
re.I,
)
# Fixed-width columns from ZTE `show interface brief` header.
_COL_IF = (0, 24)
_COL_ATTR = (24, 35)
_COL_MODE = (35, 48)
_COL_BW = (48, 54)
_COL_ADMIN = (54, 60)
_COL_PHY = (60, 66)
_COL_PROT = (66, 72)
_COL_DESC = (72, None)
@dataclass(frozen=True)
@ -129,28 +65,24 @@ def resolve_util_pct(vendor_util: float, bps: float, bw_bps: int) -> float:
def parse_bw_to_bps(raw: str) -> int:
"""Normalize bandwidth field values from TextFSM (``1G``, ``BW 1 Gbit/s``, …)."""
text = (raw or "").strip()
if not text or text.upper() == "N/A":
return 0
m = _RE_BW_COMPACT.match(text.replace(" ", ""))
if m:
value = float(m.group(1))
mult = _BW_UNIT[m.group(2).lower()]
return int(value * mult)
return int(float(m.group(1)) * _BW_UNIT[m.group(2).lower()])
m2 = _RE_BW_DETAIL.search(text)
if m2:
value = float(m2.group(1))
unit = (m2.group(2) or "g").lower()
# "BW 1 Gbit/s" — unit letter may be in "Gbit" when group2 empty after "1 "
if m2.group(2) is None and "gbit" in text.lower():
unit = "g"
elif m2.group(2) is None and "mbit" in text.lower():
unit = "m"
elif m2.group(2) is None and "kbit" in text.lower():
unit = "k"
mult = _BW_UNIT.get(unit, 1_000_000_000)
return int(value * mult)
# Plain "BW 1000000000" unlikely; try digits only
return int(value * _BW_UNIT.get(unit, 1_000_000_000))
digits = re.sub(r"[^\d.]", "", text)
if digits:
try:
@ -160,149 +92,6 @@ def parse_bw_to_bps(raw: str) -> int:
return 0
def _slice(line: str, start: int, end: int | None) -> str:
if end is None:
return line[start:].rstrip() if len(line) > start else ""
if len(line) <= start:
return ""
return line[start:end].strip()
def _looks_like_brief_port(ifname: str, admin: str, phy: str, prot: str) -> bool:
"""Reject prompts / hostname lines that leak after the brief table."""
name = (ifname or "").strip()
if not name or name.lower() == "interface":
return False
if "#" in name or ">" in name:
return False
if _RE_PROMPT_LINE.search(name):
return False
# Real brief rows always have Admin/Phy/Prot as up|down.
if not (
_RE_UPDOWN.match(admin or "")
and _RE_UPDOWN.match(phy or "")
and _RE_UPDOWN.match(prot or "")
):
return False
# Prefer known iface prefixes; still allow smartgroup/bvi style names.
if not _RE_IFNAME.match(name):
return False
return True
def parse_zte_interface_brief(text: str) -> list[BriefPort]:
"""Parse ZTE `show interface brief` into port rows."""
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
out: list[BriefPort] = []
started = False
for raw in lines:
line = raw.rstrip()
if not line.strip():
continue
if line.lstrip().startswith("Interface") and "Admin" in line and "Description" in line:
started = True
continue
if not started:
continue
stripped = line.strip()
# Trailing device prompt after the table (Netmiko often leaves hostname#).
if "#" in stripped or (stripped.endswith(">") and " " not in stripped):
admin_probe = _slice(line, *_COL_ADMIN).lower()
if not _RE_UPDOWN.match(admin_probe):
break
if " is " in line and "ifindex" in line.lower():
break
if_field = _slice(line, *_COL_IF)
ifname = if_field.split()[0] if if_field else ""
admin = _slice(line, *_COL_ADMIN).lower()
phy = _slice(line, *_COL_PHY).lower()
prot = _slice(line, *_COL_PROT).lower()
if not _looks_like_brief_port(ifname, admin, phy, prot):
continue
bw_raw = _slice(line, *_COL_BW)
out.append(
BriefPort(
ifname=ifname,
attribute=_slice(line, *_COL_ATTR),
mode=_slice(line, *_COL_MODE),
bw_raw=bw_raw,
bw_bps=parse_bw_to_bps(bw_raw),
admin=admin,
phy=phy,
prot=prot,
description=_slice(line, *_COL_DESC).strip(),
)
)
return out
def parse_zte_interface_detail(text: str) -> DetailRates:
"""Parse ZTE `show interface {ifname}` rate / util / BW."""
blob = text or ""
ifname = ""
admin_oper = ""
m_if = _RE_IF_UP.search(blob)
if m_if:
ifname = m_if.group(1)
admin_oper = m_if.group(2).lower()
desc = ""
m_desc = _RE_DESC.search(blob)
if m_desc:
desc = m_desc.group(1).strip()
bw_bps = 0
m_bw = _RE_BW_DETAIL.search(blob)
if m_bw:
bw_bps = parse_bw_to_bps(m_bw.group(0))
else:
# Fallback line scan
for line in blob.splitlines():
if re.search(r"\bBW\b", line, re.I):
bw_bps = parse_bw_to_bps(line)
if bw_bps:
break
rate_period = 0
m_rp = _RE_RATE_PERIOD.search(blob)
if m_rp:
rate_period = int(m_rp.group(1))
# Prefer Rate period block: first Input/Output after "Rate period"
in_bps = 0.0
out_bps = 0.0
rp_idx = blob.lower().find("rate period")
rate_blob = blob[rp_idx:] if rp_idx >= 0 else blob
# Stop before Peak rate to avoid peak Input/Output
peak_idx = rate_blob.lower().find("peak rate")
if peak_idx >= 0:
rate_blob = rate_blob[:peak_idx]
m_in = _RE_INPUT_BPS.search(rate_blob)
m_out = _RE_OUTPUT_BPS.search(rate_blob)
if m_in:
in_bps = float(m_in.group(1))
if m_out:
out_bps = float(m_out.group(1))
in_util = 0.0
out_util = 0.0
m_util = _RE_UTIL.search(blob)
if m_util:
in_util = float(m_util.group(1))
out_util = float(m_util.group(2))
return DetailRates(
ifname=ifname,
admin_oper=admin_oper,
description=desc,
bw_bps=bw_bps,
rate_period_sec=rate_period,
in_bps=in_bps,
out_bps=out_bps,
in_util_pct=in_util,
out_util_pct=out_util,
)
def brief_port_to_dict(row: BriefPort) -> dict[str, Any]:
return {
"ifname": row.ifname,
@ -331,138 +120,137 @@ def detail_to_dict(row: DetailRates) -> dict[str, Any]:
}
def _brief_command_for_vendor(vendor_key: str) -> str:
from .port_traffic_commands import commands_for_vendor
key = str(vendor_key or "zte").strip().lower()
cmds = commands_for_vendor(key, key)
return cmds.brief if cmds else "show interface brief"
def _detail_command_for_vendor(vendor_key: str, ifname: str = "") -> str:
from .port_traffic_commands import commands_for_vendor, detail_command
key = str(vendor_key or "zte").strip().lower()
name = (ifname or "IFACE").strip() or "IFACE"
cmds = commands_for_vendor(key, key)
if cmds:
return detail_command(cmds, name)
return f"show interface {name}"
def _bw_field_to_bps(raw: str) -> int:
"""Normalize TextFSM bandwidth fields (e.g. ``1000000 Kbit``, ``1 Gbit/s``)."""
text = (raw or "").strip()
if not text:
return 0
m = re.search(r"(?i)(\d+(?:\.\d+)?)\s*([kKmMgGtT])\s*bit\b", text)
if m:
return int(float(m.group(1)) * _BW_UNIT[m.group(2).lower()])
prefixed = text if text.upper().startswith("BW") else f"BW {text}"
got = parse_bw_to_bps(prefixed)
if got:
return got
if text.isdigit():
try:
return int(text)
except ValueError:
return 0
return parse_bw_to_bps(text)
def _norm_updown(token: str) -> str:
"""Normalize Huawei tokens like up(s) / *down to up|down|''."""
text = (token or "").strip().lower()
if "administratively" in text:
return "down"
if text.startswith("*"):
text = text[1:]
m = _RE_UPDOWN_TOKEN.match(text)
return m.group(1).lower() if m else ""
if text.startswith("up"):
return "up"
if text.startswith("down"):
return "down"
return text if text in {"up", "down"} else ""
def parse_huawei_interface_brief(text: str) -> list[BriefPort]:
"""Parse Huawei `display interface brief` into port rows (BW ignored / 0)."""
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
def _yes_no_updown(token: str) -> str:
text = (token or "").strip().lower()
if text in {"yes", "up", "true", "1"}:
return "up"
if text in {"no", "down", "false", "0", "ghost"}:
return "down"
return _norm_updown(text)
def _map_brief_rows(rows: list[dict[str, Any]], *, vendor_key: str) -> list[BriefPort]:
key = str(vendor_key or "zte").strip().lower()
out: list[BriefPort] = []
started = False
for raw in lines:
line = raw.rstrip()
if not line.strip():
for row in rows:
ifname = row_get(row, "interface", "ifname", "port", "intf", "port_id", "name")
if not ifname or "#" in ifname or ">" in ifname:
continue
low = line.lstrip().lower()
if low.startswith("interface") and "phy" in low and ("inuti" in low or "protocol" in low):
started = True
continue
if not started:
continue
stripped = line.strip()
if stripped.startswith("<") and stripped.endswith(">"):
break
if "#" in stripped and " " not in stripped.split("#", 1)[0]:
break
m = _RE_HW_BRIEF_ROW.match(stripped)
if not m:
continue
ifname = m.group(1)
phy = _norm_updown(m.group(2))
prot = _norm_updown(m.group(3))
if not ifname or ifname.lower() == "interface":
continue
if "#" in ifname or ">" in ifname:
continue
if not _RE_IFNAME.match(ifname):
continue
if not phy or not prot:
admin = _norm_updown(
row_get(row, "admin", "admin_state", "status", "phy", "link", "link_status")
)
phy = _norm_updown(row_get(row, "phy", "status", "link_status", "port_state", "link"))
prot = _norm_updown(row_get(row, "prot", "protocol", "proto", "protocol_status"))
if key == "cisco":
admin = _norm_updown(row_get(row, "status", "admin", "link_status")) or admin
phy = admin
prot = _norm_updown(row_get(row, "proto", "protocol", "prot")) or prot
elif key == "huawei":
phy = _norm_updown(row_get(row, "phy", "status")) or phy
prot = _norm_updown(row_get(row, "protocol", "prot", "proto")) or prot
admin = phy or admin
elif key == "h3c":
# Community: LINK / PROTOCOL (route mode) or LINK only (bridge mode).
link = _norm_updown(row_get(row, "link", "admin", "phy"))
admin = link or admin
phy = link or phy
prot = _norm_updown(row_get(row, "protocol", "prot", "proto")) or prot or phy
elif key == "juniper":
admin = _norm_updown(row_get(row, "admin_state", "admin", "status")) or admin
phy = _norm_updown(row_get(row, "link_status", "phy", "status")) or phy
prot = phy or prot or admin
elif key == "nokia":
admin = _norm_updown(row_get(row, "admin_state", "admin")) or admin
link = _yes_no_updown(row_get(row, "link"))
phy = _norm_updown(row_get(row, "port_state", "phy")) or link or phy
prot = phy or prot or admin
elif key == "mikrotik":
# print brief often exposes flags / name; tolerate missing proto.
admin = _norm_updown(row_get(row, "status", "admin", "link")) or admin or "up"
phy = _norm_updown(row_get(row, "link", "status", "phy")) or phy or admin
prot = _norm_updown(row_get(row, "protocol", "prot")) or prot or phy
if admin not in {"up", "down"} and phy in {"up", "down"}:
admin = phy
if phy not in {"up", "down"}:
phy = admin if admin in {"up", "down"} else phy
if prot not in {"up", "down"}:
# Prefer inferring prot from phy when vendor brief has no protocol column.
if phy in {"up", "down"} and key in {"juniper", "nokia", "mikrotik", "h3c"}:
prot = phy
else:
continue
if admin not in {"up", "down"} or phy not in {"up", "down"}:
continue
bw_raw = row_get(row, "bw", "bandwidth", "bw_raw", "speed")
out.append(
BriefPort(
ifname=ifname,
attribute="",
mode="",
bw_raw="",
bw_bps=0,
admin=phy, # Huawei brief has no separate Admin; PHY is closest.
attribute=row_get(row, "attribute", "attr", "port_type", "type"),
mode=row_get(row, "mode", "port_mode", "duplex"),
bw_raw=bw_raw,
bw_bps=parse_bw_to_bps(bw_raw),
admin=admin,
phy=phy,
prot=prot,
description="",
description=row_get(row, "description", "descrip", "desc"),
)
)
return out
def parse_huawei_interface_detail(text: str) -> DetailRates:
"""Parse Huawei `display interface {if}` Last N seconds rate / utility."""
blob = text or ""
ifname = ""
admin_oper = ""
m_state = _RE_HW_STATE.search(blob)
if m_state:
ifname = m_state.group(1)
st = m_state.group(2).lower()
admin_oper = "down" if "down" in st else "up"
desc = ""
m_desc = _RE_DESC.search(blob)
if m_desc:
desc = m_desc.group(1).strip()
rate_period = 0
in_bps = 0.0
out_bps = 0.0
m_in = _RE_HW_IN_RATE.search(blob)
if m_in:
rate_period = int(m_in.group(1))
in_bps = float(m_in.group(2))
m_out = _RE_HW_OUT_RATE.search(blob)
if m_out:
if not rate_period:
rate_period = int(m_out.group(1))
out_bps = float(m_out.group(2))
in_util = 0.0
out_util = 0.0
m_iu = _RE_HW_IN_UTIL.search(blob)
if m_iu:
if not rate_period:
rate_period = int(m_iu.group(1))
in_util = float(m_iu.group(2))
m_ou = _RE_HW_OUT_UTIL.search(blob)
if m_ou:
if not rate_period:
rate_period = int(m_ou.group(1))
out_util = float(m_ou.group(2))
return DetailRates(
ifname=ifname,
admin_oper=admin_oper,
description=desc,
bw_bps=0, # sample has no BW; leave 0 for now
rate_period_sec=rate_period,
in_bps=in_bps,
out_bps=out_bps,
in_util_pct=in_util,
out_util_pct=out_util,
)
def parse_interface_brief(text: str, vendor_key: str = "zte") -> list[BriefPort]:
key = str(vendor_key or "zte").strip().lower()
if key == "huawei":
return parse_huawei_interface_brief(text)
if key == "cisco":
return parse_cisco_interface_brief(text)
return parse_zte_interface_brief(text)
def parse_interface_detail(text: str, vendor_key: str = "zte") -> DetailRates:
key = str(vendor_key or "zte").strip().lower()
if key == "huawei":
return parse_huawei_interface_detail(text)
if key == "cisco":
return parse_cisco_interface_detail(text)
return parse_zte_interface_detail(text)
def _cisco_period_to_sec(n: int, unit: str) -> int:
def _period_to_sec(n: int, unit: str) -> int:
u = (unit or "").lower()
if u.startswith("second"):
return int(n)
@ -473,109 +261,149 @@ def _cisco_period_to_sec(n: int, unit: str) -> int:
return int(n)
def parse_cisco_interface_brief(text: str) -> list[BriefPort]:
"""Parse Cisco `show ip interface brief` into port rows."""
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
out: list[BriefPort] = []
started = False
for raw in lines:
line = raw.rstrip()
if not line.strip():
continue
low = line.lstrip().lower()
if low.startswith("interface") and "status" in low and "protocol" in low:
started = True
continue
if not started:
continue
stripped = line.strip()
if stripped.endswith("#") or (stripped.endswith(">") and stripped.startswith("<")):
break
if "#" in stripped and " " not in stripped.split("#", 1)[0]:
break
m = _RE_CISCO_BRIEF_ROW.match(stripped)
if not m:
continue
ifname = m.group(1)
status = (m.group(5) or "").strip().lower()
prot = (m.group(6) or "").strip().lower()
if not _RE_IFNAME.match(ifname):
continue
if prot not in ("up", "down"):
continue
admin_down = "administratively" in status
if "down" in status:
phy = "down"
elif "up" in status:
phy = "up"
else:
continue
admin = "down" if admin_down else phy
out.append(
BriefPort(
ifname=ifname,
attribute="",
mode="",
bw_raw="",
bw_bps=0,
admin=admin,
phy=phy,
prot=prot,
description="",
def _map_detail_rows(rows: list[dict[str, Any]]) -> list[DetailRates]:
best: dict[str, DetailRates] = {}
for row in rows:
ifname = row_get(row, "ifname", "interface", "port")
in_bps_s = row_get(row, "input_bps", "in_bps", "input_rate", "input")
out_bps_s = row_get(row, "output_bps", "out_bps", "output_rate", "output")
try:
in_bps = float(in_bps_s or 0)
except ValueError:
in_bps = 0.0
try:
out_bps = float(out_bps_s or 0)
except ValueError:
out_bps = 0.0
bw_raw = row_get(row, "bw_raw", "bandwidth", "bw")
bw_bps = _bw_field_to_bps(bw_raw)
period_s = row_get(row, "rate_period", "period", "rate_period_sec")
period_unit = row_get(row, "rate_period_unit", "period_unit")
try:
rate_period_sec = _period_to_sec(int(float(period_s or 0)), period_unit)
except ValueError:
rate_period_sec = 0
in_util_s = row_get(row, "in_util", "input_util", "inuti")
out_util_s = row_get(row, "out_util", "output_util", "oututi")
try:
in_util = float((in_util_s or "0").rstrip("%"))
except ValueError:
in_util = 0.0
try:
out_util = float((out_util_s or "0").rstrip("%"))
except ValueError:
out_util = 0.0
admin = _norm_updown(
row_get(
row,
"admin_oper",
"status",
"admin",
"link_status",
"line_status",
"admin_state",
)
)
return out
ifname = ifname or row_get(row, "port_id", "name")
if not ifname and in_bps <= 0 and out_bps <= 0 and not bw_bps:
continue
cand = DetailRates(
ifname=ifname,
admin_oper=admin,
description=row_get(row, "description", "descrip", "interface_description"),
bw_bps=bw_bps,
rate_period_sec=rate_period_sec,
in_bps=in_bps,
out_bps=out_bps,
in_util_pct=in_util,
out_util_pct=out_util,
)
key = ifname or "_"
prev = best.get(key)
if prev is None or (in_util_s or out_util_s or in_bps or out_bps):
best[key] = cand
return list(best.values())
def parse_cisco_interface_detail(text: str) -> DetailRates:
"""Parse Cisco `show interfaces {if}` BW + N minute/second rate (util ignored)."""
blob = text or ""
ifname = ""
admin_oper = ""
m_state = _RE_CISCO_IF_STATE.search(blob)
if m_state:
ifname = m_state.group(1)
admin_oper = "down" if m_state.group(2) or m_state.group(3).lower() == "down" else "up"
if not ifname:
m_up = _RE_IF_UP.search(blob)
if m_up:
ifname = m_up.group(1)
admin_oper = m_up.group(2).lower()
def parse_interface_brief(
text: str,
vendor_key: str = "zte",
*,
command: str = "",
device_type: str = "",
) -> list[BriefPort]:
"""Parse brief CLI via custom/community TextFSM. Empty list if no template match."""
key = str(vendor_key or "zte").strip().lower()
plat = resolve_cli_platform(vendor_key=key, device_type=device_type)
cmd = (command or _brief_command_for_vendor(key)).strip()
if not plat or not cmd:
return []
rows = parse_cli(platform=plat, command=cmd, text=text)
return _map_brief_rows(rows, vendor_key=key)
desc = ""
m_desc = _RE_DESC.search(blob)
if m_desc:
desc = m_desc.group(1).strip()
bw_bps = 0
for line in blob.splitlines():
if "BW" in line.upper() and ("bit" in line.lower()):
bw_bps = parse_bw_to_bps(line)
if bw_bps:
break
def parse_interface_detail(
text: str,
vendor_key: str = "zte",
*,
command: str = "",
device_type: str = "",
ifname: str = "",
) -> DetailRates:
"""Parse detail CLI via custom/community TextFSM. Empty DetailRates if no match."""
key = str(vendor_key or "zte").strip().lower()
plat = resolve_cli_platform(vendor_key=key, device_type=device_type)
cmd = (command or _detail_command_for_vendor(key, ifname=ifname)).strip()
if not plat or not cmd:
return DetailRates()
rows = parse_cli(platform=plat, command=cmd, text=text)
mapped = _map_detail_rows(rows)
return mapped[0] if mapped else DetailRates()
rate_period = 0
in_bps = 0.0
out_bps = 0.0
for m in _RE_CISCO_RATE.finditer(blob):
period = _cisco_period_to_sec(int(m.group(1)), m.group(2))
direction = m.group(3).lower()
rate = float(m.group(4))
if not rate_period:
rate_period = period
if direction == "input":
in_bps = rate
else:
out_bps = rate
return DetailRates(
ifname=ifname,
admin_oper=admin_oper,
description=desc,
bw_bps=bw_bps,
rate_period_sec=rate_period,
in_bps=in_bps,
out_bps=out_bps,
in_util_pct=0.0, # sample has no util %; ignore for now
out_util_pct=0.0,
)
def parse_zte_interface_brief(text: str) -> list[BriefPort]:
return parse_interface_brief(text, "zte", device_type="zte_zxros")
def parse_zte_interface_detail(text: str, *, ifname: str = "") -> DetailRates:
return parse_interface_detail(text, "zte", device_type="zte_zxros", ifname=ifname)
def parse_huawei_interface_brief(text: str) -> list[BriefPort]:
return parse_interface_brief(text, "huawei", device_type="huawei_vrp")
def parse_huawei_interface_detail(text: str, *, ifname: str = "") -> DetailRates:
return parse_interface_detail(text, "huawei", device_type="huawei_vrp", ifname=ifname)
def parse_cisco_interface_brief(text: str) -> list[BriefPort]:
return parse_interface_brief(text, "cisco", device_type="cisco_ios")
def parse_cisco_interface_detail(text: str, *, ifname: str = "") -> DetailRates:
return parse_interface_detail(text, "cisco", device_type="cisco_ios", ifname=ifname)
def parse_h3c_interface_brief(text: str) -> list[BriefPort]:
return parse_interface_brief(text, "h3c", device_type="hp_comware")
def parse_h3c_interface_detail(text: str, *, ifname: str = "") -> DetailRates:
return parse_interface_detail(text, "h3c", device_type="hp_comware", ifname=ifname)
def parse_juniper_interface_brief(text: str) -> list[BriefPort]:
return parse_interface_brief(text, "juniper", device_type="juniper_junos")
def parse_juniper_interface_detail(text: str, *, ifname: str = "") -> DetailRates:
return parse_interface_detail(text, "juniper", device_type="juniper_junos", ifname=ifname)
def parse_nokia_interface_brief(text: str) -> list[BriefPort]:
return parse_interface_brief(text, "nokia", device_type="nokia_sros")
def parse_nokia_interface_detail(text: str, *, ifname: str = "") -> DetailRates:
return parse_interface_detail(text, "nokia", device_type="nokia_sros", ifname=ifname)

View file

@ -263,7 +263,12 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> tup
try:
cmd = detail_command(cmds, ifname)
raw = send_show_command(conn, cmd, read_timeout=per_cmd)
parsed = parse_interface_detail(raw, vendor_key)
parsed = parse_interface_detail(
raw,
vendor_key,
command=cmd,
ifname=ifname,
)
_save_sample(tid, parsed)
except Exception as exc:
local_errors += 1

View file

@ -1,18 +1,18 @@
"""LLDP/CDP neighbor command templates and output parsers (per vendor).
"""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.
Each vendor has:
- a show/display command
- a dedicated parse_* stub (fill with real lab echoes later)
Parsing is TextFSM-only via ``ntc_parse`` (custom ``cli_templates/`` then community).
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from .ntc_parse import parse_cli, resolve_cli_platform, row_get
@dataclass
@ -34,12 +34,6 @@ class VendorLldpProfile:
notes: str = ""
ParserFn = Callable[[str], list[NeighborHit]]
_IPV4_RE = re.compile(
r"(?<![\d.])(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?![\d.])"
)
# 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], ...] = (
@ -68,10 +62,6 @@ _VENDOR_LABEL_TO_KEY: dict[str, str] = {
}
# ---------------------------------------------------------------------------
# Vendor registry — command templates (edit / refine with lab echoes)
# ---------------------------------------------------------------------------
VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
"cisco": VendorLldpProfile(
key="cisco",
@ -87,27 +77,27 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
"h3c": VendorLldpProfile(
key="h3c",
lldp_command="display lldp neighbor-information list",
notes="Placeholder Comware command; confirm on lab.",
notes="Community hp_comware TextFSM (list; verbose also available).",
),
"zte": VendorLldpProfile(
key="zte",
lldp_command="show lldp neighbor brief",
notes="device_type zte_*; ZXROS brief table (Local Interface / Port ID / System Name).",
notes="device_type zte_*; NetX custom TextFSM.",
),
"juniper": VendorLldpProfile(
key="juniper",
lldp_command="show lldp neighbors",
notes="device_type juniper*; detail form TBD.",
notes="Community juniper_junos TextFSM.",
),
"nokia": VendorLldpProfile(
key="nokia",
lldp_command="show system lldp neighbor",
notes="device_type nokia_* / alcatel_*; SRL may differ.",
notes="SROS: no community LLDP template yet. alcatel_aos uses show lldp remote-system.",
),
"ericsson": VendorLldpProfile(
key="ericsson",
lldp_command="show lldp neighbors",
notes="device_type ericsson_*; confirm IPOS/SEOS on lab.",
notes="No community ericsson_ipos LLDP TextFSM yet.",
),
"generic": VendorLldpProfile(
key="generic",
@ -116,6 +106,9 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
),
}
# Vendors without a working TextFSM path yet (custom or community).
STUB_PARSER_KEYS = frozenset({"nokia", "ericsson", "generic"})
def resolve_vendor_key(vendor: str = "", device_type: str = "") -> str:
"""Map inventory device_type (preferred) or vendor label -> profile key."""
@ -143,6 +136,10 @@ def get_vendor_profile(vendor: str = "", device_type: str = "") -> VendorLldpPro
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.
if dtype.startswith("alcatel_aos"):
return "show lldp remote-system"
return get_vendor_profile(vendor, device_type).lldp_command
@ -159,397 +156,154 @@ def pick_neighbor_command(
) -> tuple[str, str]:
"""Return (lldp_command, \"lldp\"). Physical discovery is LLDP-only (CDP ignored)."""
_ = protocol # accepted for call-site compat; always LLDP
profile = get_vendor_profile(vendor, device_type)
return profile.lldp_command, "lldp"
# ---------------------------------------------------------------------------
# Parsers — keep working ones; stubs return [] until lab echoes are added
# ---------------------------------------------------------------------------
def parse_cisco_lldp(text: str) -> list[NeighborHit]:
"""Cisco `show lldp neighbors detail` (preferred); brief table as fallback."""
hits = _parse_cisco_lldp_detail(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
def parse_cisco_cdp(text: str) -> list[NeighborHit]:
"""Cisco `show cdp neighbors detail`."""
return _parse_cdp_detail(text)
def parse_huawei_lldp(text: str) -> list[NeighborHit]:
"""Huawei `display lldp neighbor`."""
hits = _parse_huawei_lldp_neighbor(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
def parse_h3c_lldp(text: str) -> list[NeighborHit]:
"""H3C Comware LLDP — placeholder until lab echo is captured."""
# TODO: replace with Comware-specific parser using real `display lldp ...` output.
_ = text
return []
def parse_zte_lldp(text: str) -> list[NeighborHit]:
"""ZTE ZXROS `show lldp neighbor brief` table."""
hits = _parse_zte_lldp_brief(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
def parse_juniper_lldp(text: str) -> list[NeighborHit]:
"""Juniper Junos LLDP — placeholder until lab echo is captured."""
# TODO: parse `show lldp neighbors` / detail from Junos sample.
_ = text
return []
def parse_nokia_lldp(text: str) -> list[NeighborHit]:
"""Nokia SROS/SRL LLDP — placeholder until lab echo is captured."""
# TODO: parse `show system lldp neighbor` (SROS) / SRL equivalent.
_ = text
return []
def parse_ericsson_lldp(text: str) -> list[NeighborHit]:
"""Ericsson IPOS/SEOS LLDP — placeholder until lab echo is captured."""
# TODO: parse vendor show output from lab.
_ = text
return []
def parse_generic_lldp(text: str) -> list[NeighborHit]:
"""Best-effort fallback when vendor is unknown."""
hits = _parse_cisco_lldp_detail(text)
if hits:
return hits
hits = _parse_huawei_lldp_neighbor(text)
if hits:
return hits
return _parse_lldp_brief_table(text)
_VENDOR_PARSERS: dict[str, ParserFn] = {
"cisco": parse_cisco_lldp,
"huawei": parse_huawei_lldp,
"h3c": parse_h3c_lldp,
"zte": parse_zte_lldp,
"juniper": parse_juniper_lldp,
"nokia": parse_nokia_lldp,
"ericsson": parse_ericsson_lldp,
"generic": parse_generic_lldp,
}
# Parsers that intentionally return [] until lab samples are added.
STUB_PARSER_KEYS = frozenset({"h3c", "juniper", "nokia", "ericsson"})
return lldp_command_for_vendor(vendor, device_type), "lldp"
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 _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 output using the device_type/vendor-specific parser."""
"""Parse neighbor CLI via TextFSM only (custom then community)."""
raw = str(text or "")
if not raw.strip():
return []
_ = protocol # CDP discovery removed; always parse as LLDP
key = resolve_vendor_key(vendor, device_type)
return _parse_lldp_via_ntc(
raw,
vendor=vendor,
device_type=device_type,
command=command or lldp_command_for_vendor(vendor, device_type),
)
parser = _VENDOR_PARSERS.get(key) or parse_generic_lldp
hits = parser(raw)
if hits:
return hits
# Soft fallbacks so an early/wrong tag still yields something useful.
if key != "cisco":
hits = parse_cisco_lldp(raw)
if hits:
return hits
if key != "huawei":
hits = parse_huawei_lldp(raw)
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 []
# ---------------------------------------------------------------------------
# Shared low-level helpers
# ---------------------------------------------------------------------------
def parse_cisco_cdp(text: str) -> list[NeighborHit]:
"""CDP discovery disabled; kept for API compat."""
_ = text
return []
def _parse_cisco_lldp_detail(text: str) -> list[NeighborHit]:
"""Cisco IOS `show lldp neighbors detail` blocks starting at Local Intf."""
raw = str(text or "")
if not re.search(r"(?i)Local\s+Intf\s*:", raw):
return []
chunks = re.split(r"(?i)(?=Local\s+Intf\s*:)", raw)
hits: list[NeighborHit] = []
for chunk in chunks:
if not re.search(r"(?i)Local\s+Intf\s*:", chunk):
continue
local_port = _kv(chunk, r"Local\s+Intf\s*:\s*(.+)")
remote_port = _kv(chunk, r"Port\s+id\s*:\s*(.+)")
sys_name = _kv(chunk, r"System\s+Name\s*:\s*(.+)")
# Prefer IPv4 under Management Addresses; skip OID / MAC "Other:" lines.
ip = ""
m = re.search(
r"(?is)Management\s+Addresses?\s*:(.*?)(?:\n\s*\n|Auto Negotiation|Total entries|$)",
chunk,
)
if m:
mgmt_lines = []
for ln in (m.group(1) or "").splitlines():
low = ln.lower()
if "oid" in low or re.search(r"(?i)^\s*other\s*:", ln):
continue
mgmt_lines.append(ln)
ip_m = _IPV4_RE.search("\n".join(mgmt_lines))
if ip_m:
ip = ip_m.group(0)
if not sys_name and not remote_port and not local_port:
continue
# Skip empty / not-advertised system names
name = (sys_name or "").strip()
if name.lower() in {"", "-", "not advertised"}:
name = ""
hits.append(
NeighborHit(
remote_name=name,
remote_ip=ip,
local_port=(local_port or "").strip(),
remote_port=(remote_port or "").strip(),
protocol="lldp",
)
)
return hits
def _parse_zte_lldp_brief(text: str) -> list[NeighborHit]:
"""ZTE ZXROS `show lldp neighbor brief`.
Columns: Local Interface | Scope | Chassis ID | Port ID | Holdtime | System Name
Example:
cgei-1/1/0/34 NB 744a.a42d.8970 cgei-1/1/0/36 91 KND-VKAU-EN1-Z20HS
"""
raw = str(text or "")
if not re.search(r"(?i)Local\s+Interface", raw) or not re.search(r"(?i)System\s+Name", raw):
return []
# Scope codes seen on ZXROS: NB / NC / NTPMR (and possibly others).
row_re = re.compile(
r"^(?P<local>\S+)\s+"
r"(?P<scope>[A-Za-z]{2,8})\s+"
r"(?P<chassis>\S+)\s+"
r"(?P<port>\S+)\s+"
r"(?P<hold>\d+)\s+"
r"(?P<name>\S.*?)\s*$"
def parse_huawei_lldp(text: str) -> list[NeighborHit]:
return _parse_lldp_via_ntc(
text, vendor="huawei", device_type="huawei", command="display lldp neighbor"
)
hits: list[NeighborHit] = []
for ln in raw.splitlines():
s = ln.strip()
if not s or set(s) <= {"-", "="}:
continue
low = s.lower()
if "local interface" in low or low.startswith(("total", "scope", "capability", "---")):
continue
if s.endswith("#") or "show lldp" in low:
continue
m = row_re.match(s)
if not m:
continue
scope = m.group("scope").upper()
# Reject rows that clearly aren't neighbor entries (e.g. mis-split header leftovers).
if scope in {"INTERFACE", "CHASSIS", "PORT", "HOLDTIME", "SYSTEM"}:
continue
name = (m.group("name") or "").strip()
local_port = (m.group("local") or "").strip()
remote_port = (m.group("port") or "").strip()
if not name and not remote_port and not local_port:
continue
hits.append(
NeighborHit(
remote_name=name,
local_port=local_port,
remote_port=remote_port,
protocol="lldp",
)
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
)
return hits
if hits:
return hits
return []
def _parse_lldp_brief_table(text: str) -> list[NeighborHit]:
"""Cisco/ZTE-style brief table: Device ID / Local Intf / ... / Port ID."""
lines = [ln.rstrip() for ln in str(text or "").splitlines()]
start = -1
for i, ln in enumerate(lines):
low = ln.lower()
if "device id" in low and ("local" in low or "intf" in low or "port" in low):
start = i + 1
break
if "system name" in low and "local" in low:
start = i + 1
break
if start < 0:
return []
hits: list[NeighborHit] = []
for ln in lines[start:]:
s = ln.strip()
if not s or set(s) <= {"-", "="}:
continue
if s.lower().startswith(("total", "capability", "---")):
continue
parts = s.split()
if len(parts) < 2:
continue
remote = parts[0]
local_port = parts[1] if len(parts) >= 2 else ""
remote_port = parts[-1] if len(parts) >= 4 else ""
if remote.lower() in {"device", "system", "chassis"}:
continue
hits.append(
NeighborHit(
remote_name=remote,
local_port=local_port,
remote_port=remote_port,
protocol="lldp",
)
)
return hits
def _parse_huawei_lldp_neighbor(text: str) -> list[NeighborHit]:
"""Huawei VRP `display lldp neighbor` — per-interface sections."""
raw = str(text or "")
# Split on "<ifname> has N neighbor(s):"
header_re = re.compile(
r"(?im)^(\S+)\s+has\s+(\d+)\s+neighbor\(s\)\s*:\s*$"
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"
)
hits: list[NeighborHit] = []
matches = list(header_re.finditer(raw))
if not matches:
# Older compact sample with Local Interface: field
return _parse_huawei_lldp_blocks_legacy(raw)
for i, m in enumerate(matches):
local_if = m.group(1).strip()
count = int(m.group(2))
if count <= 0:
continue
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw)
section = raw[start:end]
# One section may contain multiple neighbors; split on Neighbor index
sub_chunks = re.split(r"(?im)(?=^Neighbor\s+index\s*:)", section)
for chunk in sub_chunks:
if not re.search(r"(?i)Neighbor\s+index\s*:", chunk):
# Sometimes fields appear without explicit index; still try once.
if not re.search(r"(?i)System\s+name\s*:", chunk):
continue
sys_name = _kv(chunk, r"System\s+name\s*:\s*(.+)")
port_id = _kv(chunk, r"Port\s+ID\s*:\s*(.+)")
mgmt = _kv(chunk, r"Management\s+address\s*:\s*(.+)")
ip = ""
if mgmt:
ip_m = _IPV4_RE.search(mgmt)
if ip_m:
ip = ip_m.group(0)
name = (sys_name or "").strip()
# Hostname may be FQDN — keep as-is; matcher strips domain.
if not name and not port_id and not ip:
continue
hits.append(
NeighborHit(
remote_name=name,
remote_ip=ip,
local_port=local_if,
remote_port=(port_id or "").strip(),
protocol="lldp",
)
)
return hits
def _parse_huawei_lldp_blocks_legacy(text: str) -> list[NeighborHit]:
"""Older/compact Huawei block with Local Interface field."""
hits: list[NeighborHit] = []
blocks = re.split(r"\n\s*\n", str(text or ""))
for block in blocks:
if not block.strip():
continue
sys_name = _kv(block, r"System\s+name\s*[:=]\s*(.+)")
local_if = _kv(block, r"Local\s+(?:Interface|Port)\s*[:=]\s*(.+)")
port_id = _kv(block, r"Port\s+ID\s*[:=]\s*(.+)")
mgmt = _kv(block, r"Management\s+address\s*[:=]\s*(.+)")
if not sys_name and not port_id:
continue
ip = ""
if mgmt:
m = _IPV4_RE.search(mgmt)
if m:
ip = m.group(0)
hits.append(
NeighborHit(
remote_name=(sys_name or "").strip(),
remote_ip=ip,
local_port=(local_if or "").strip(),
remote_port=(port_id or "").strip(),
protocol="lldp",
)
)
return hits
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_cdp_detail(text: str) -> list[NeighborHit]:
"""Cisco `show cdp neighbors detail`."""
hits: list[NeighborHit] = []
chunks = re.split(r"(?i)\n(?=Device ID\s*:)", str(text or ""))
for chunk in chunks:
if not re.search(r"(?i)Device\s+ID\s*:", chunk):
continue
device_id = _kv(chunk, r"Device\s+ID\s*:\s*(.+)")
ip = ""
ip_line = _kv(chunk, r"IP(?:v4)?\s+address\s*:\s*(.+)")
if ip_line:
m = _IPV4_RE.search(ip_line)
if m:
ip = m.group(0)
local_port = _kv(chunk, r"Interface\s*:\s*([^,\n]+)")
remote_port = _kv(chunk, r"Port ID\s*(?:\(outgoing port\))?\s*:\s*(.+)")
if not device_id and not ip:
continue
hits.append(
NeighborHit(
remote_name=(device_id or "").strip(),
remote_ip=ip,
local_port=(local_port or "").strip().rstrip(","),
remote_port=(remote_port or "").strip(),
protocol="cdp",
)
)
return hits
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 _kv(text: str, pattern: str) -> str:
m = re.search(pattern, text, flags=re.IGNORECASE | re.MULTILINE)
if not m:
return ""
return str(m.group(1) or "").strip()
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).
@ -596,5 +350,4 @@ def normalize_ifname(name: str) -> str:
rest = rest[1:]
return f"{short}{rest}"
break
# Already-short forms: gi0/0, te1/0/1, xge0/0/1, 10ge1/0/1
return s