From 47f013fb886dda4fbbd0930e23f82fac01b95cda Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 11 Nov 2026 22:38:16 +0100 Subject: [PATCH 1/4] Collect LLDP neighbours on SONiC switches The cabling check needs the neighbours of the switches as well as those of the nodes. The SONiC collector gains the section lldp. It dumps LLDP_ENTRY_TABLE from APPL_DB. On Enterprise SONiC 4.4 and 4.5 the table lists the management port next to the front-panel ports. lldpctl inside the lldp container is only run when the table does not list eth0: it is the fallback for a switch without the table, or with one that only has front-panel ports. The key of an entry is the port name on Enterprise SONiC 4.4 and the port name followed by the source MAC address of the neighbour on 4.5, which keeps several neighbours per port. Both are read, in the collector and in the parser. parse_lldp turns the table into the same neighbour records as lldpctl JSON, translating the numeric LLDP-MIB subtypes into the names lldpctl uses. The records have no age: lldp_rem_time_mark is the uptime of lldpd at which the neighbour was learned on Enterprise SONiC, not the time since then. Ports the table does not list are filled in from lldpctl. A switch where neither source can be read raises an error with the reason, so that it is reported as a failed device and not as a switch without neighbours. The switch fixture gains its LLDP data, and a capture of the second leaf of the pair, running Enterprise SONiC 4.4.1, is added. Tests: the collector section with its fallbacks, and the parser for both key formats, the merge with lldpctl, the failure messages, and both ends of a captured cable. Part of #2877. Assisted-by: Claude:claude-fable-5-2 Signed-off-by: Christian Berendt --- osism/utils/hardware/parse_lldp.py | 110 ++ osism/utils/hardware/remote_sonic.py | 21 + tests/unit/utils/hardware/fixtures/README.md | 6 +- .../fixtures/sonic-enterprise-4.4.json | 1196 +++++++++++++++++ .../hardware/fixtures/sonic-enterprise.json | 220 ++- tests/unit/utils/hardware/test_parse_lldp.py | 281 ++++ .../unit/utils/hardware/test_remote_sonic.py | 107 ++ 7 files changed, 1939 insertions(+), 2 deletions(-) create mode 100644 tests/unit/utils/hardware/fixtures/sonic-enterprise-4.4.json diff --git a/osism/utils/hardware/parse_lldp.py b/osism/utils/hardware/parse_lldp.py index 01564407b..6cd40d8f2 100644 --- a/osism/utils/hardware/parse_lldp.py +++ b/osism/utils/hardware/parse_lldp.py @@ -10,10 +10,37 @@ """ import json +import re from typing import Any, Dict, List, Optional +from osism.utils.hardware.parse_sonic import table + Neighbours = Dict[str, List[Dict[str, Any]]] +# LLDP-MIB subtypes as stored in LLDP_ENTRY_TABLE, named as lldpctl does +CHASSIS_ID_SUBTYPES = { + "1": "chassis-component", + "2": "ifalias", + "3": "port-component", + "4": "mac", + "5": "ip", + "6": "ifname", + "7": "local", +} +PORT_ID_SUBTYPES = { + "1": "ifalias", + "2": "port-component", + "3": "mac", + "4": "ip", + "5": "ifname", + "6": "agent-circuit-id", + "7": "local", +} + + +# Key of an LLDP_ENTRY_TABLE entry that names the neighbour after the port +_ENTRY_KEY = re.compile(r"^(.+):((?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2})$") + class LldpError(Exception): """LLDP data is not available on a device.""" @@ -142,3 +169,86 @@ def linux_neighbours(document: Dict[str, Any]) -> Neighbours: return parse_lldpctl(result.get("out") or "") except ValueError: raise LldpError("unparsable lldpctl output") + + +def entry_port(key: str) -> str: + """Return the local port an ``LLDP_ENTRY_TABLE`` key belongs to. + + The key is the port name (``Ethernet0``). Enterprise SONiC 4.5 appends + the source MAC address of the neighbour (``Ethernet0:aa:bb:cc:dd:ee:ff``) + and so keeps several neighbours per port. + """ + match = _ENTRY_KEY.match(key) + return match.group(1) if match else key + + +def parse_lldp_entry_table(entries: Dict[str, Dict[str, Any]]) -> Neighbours: + """Parse the entries of ``LLDP_ENTRY_TABLE`` (APPL_DB) of a SONiC switch. + + The records have no ``age``. ``lldp_rem_time_mark`` is not one on every + release: Enterprise SONiC stores the uptime of ``lldpd`` at which the + neighbour was learned, so it is large for a neighbour that just came + up and small for one that has been there since boot. + + Args: + entries: Fields per key without the table prefix, as + ``parse_sonic.table`` returns them + """ + neighbours: Neighbours = {} + for key, fields in entries.items(): + addresses = [ + address.strip() + for address in str(fields.get("lldp_rem_man_addr") or "").split(",") + if address.strip() + ] + neighbours.setdefault(entry_port(key), []).append( + neighbour( + system_name=fields.get("lldp_rem_sys_name"), + chassis_id=fields.get("lldp_rem_chassis_id"), + chassis_id_type=CHASSIS_ID_SUBTYPES.get( + str(fields.get("lldp_rem_chassis_id_subtype")) + ), + port_id=fields.get("lldp_rem_port_id"), + port_id_type=PORT_ID_SUBTYPES.get( + str(fields.get("lldp_rem_port_id_subtype")) + ), + port_description=fields.get("lldp_rem_port_desc"), + management_addresses=addresses, + ) + ) + return neighbours + + +def sonic_neighbours(document: Dict[str, Any]) -> Neighbours: + """Return the neighbours found in a SONiC collector document. + + ``LLDP_ENTRY_TABLE`` is the primary source. Ports it does not list are + filled in from ``lldpctl``, when the collector ran it. + + Raises: + LldpError: Neither source could be read + """ + collected = "LLDP_ENTRY_TABLE" in ((document.get("db") or {}).get("APPL_DB") or {}) + neighbours = parse_lldp_entry_table(table(document, "APPL_DB", "LLDP_ENTRY_TABLE")) + + result = document.get("lldpctl") + if isinstance(result, dict) and result.get("rc") == 0: + try: + for name, found in parse_lldpctl(result.get("out") or "").items(): + neighbours.setdefault(name, found) + except ValueError: + if not collected: + raise LldpError("unparsable lldpctl output") + elif not collected: + lines: List[str] = [] + if isinstance(result, dict): + lines = (result.get("err") or "").strip().splitlines() + errors = document.get("errors") or {} + reason = ( + (lines[-1] if lines else "") + or errors.get("APPL_DB LLDP_ENTRY_TABLE") + or "no LLDP data" + ) + raise LldpError(f"LLDP neighbours not readable: {reason}") + + return neighbours diff --git a/osism/utils/hardware/remote_sonic.py b/osism/utils/hardware/remote_sonic.py index baeb7c423..5dd50f556 100644 --- a/osism/utils/hardware/remote_sonic.py +++ b/osism/utils/hardware/remote_sonic.py @@ -48,6 +48,9 @@ "system": [ ("STATE_DB", "EEPROM_INFO", "EEPROM_INFO|*", False), ], + "lldp": [ + ("APPL_DB", "LLDP_ENTRY_TABLE", "LLDP_ENTRY_TABLE:*", False), + ], } @@ -134,9 +137,27 @@ def extra_system(document, sys_net, runner): document["commands"] = commands +def extra_lldp(document, sys_net, runner): + # lldpctl is the fallback for a switch without LLDP_ENTRY_TABLE, and the + # second source for the management port in case the table only lists + # front-panel ports. lldpd runs in the lldp container. + entries = (document["db"].get("APPL_DB") or {}).get("LLDP_ENTRY_TABLE") or {} + # Enterprise SONiC 4.5 appends the source MAC address of the neighbour + # to the key: LLDP_ENTRY_TABLE:eth0:aa:bb:cc:dd:ee:ff + key = "LLDP_ENTRY_TABLE:" + MGMT_PORT + if any(name == key or name.startswith(key + ":") for name in entries): + return + command = ["docker", "exec", "lldp", "lldpctl", "-f", "json"] + result = runner(command) + if result["rc"] != 0: + result = runner(["sudo", "-n"] + command) + document["lldpctl"] = result + + EXTRAS = { "ports": extra_ports, "system": extra_system, + "lldp": extra_lldp, } diff --git a/tests/unit/utils/hardware/fixtures/README.md b/tests/unit/utils/hardware/fixtures/README.md index 8c7eb6fa0..09b981dad 100644 --- a/tests/unit/utils/hardware/fixtures/README.md +++ b/tests/unit/utils/hardware/fixtures/README.md @@ -11,7 +11,8 @@ the `parse_*` modules. | `linux-connectx4.json` | captured | Node with an Intel I350 (`igb`) and a Mellanox ConnectX-4 Lx (`mlx5_core`) on 25G passive copper cables, Ubuntu 24.04 | | `linux-bcm57414.json` | captured | Node with a Broadcom BCM57414 (`bnxt_en`) on 25G passive copper cables, an Intel X710 10GBASE-T (`i40e`) and the USB host interface of its BMC | | `linux-82599.json` | captured | Node with an Intel 82599ES (`ixgbe`) on 10G, cabled with 25G passive copper cables, and the USB host interface of its BMC | -| `sonic-enterprise.json` | captured | Accton AS7726-32X with Enterprise SONiC 4.5.2: QSFP28 cages broken out into 4x25G and 4x10G with passive copper cables, 100G SR4 optics with diagnostics, empty cages | +| `sonic-enterprise.json` | captured | Accton AS7726-32X with Enterprise SONiC 4.5.2: QSFP28 cages broken out into 4x25G and 4x10G with passive copper cables, 100G SR4 optics with diagnostics, empty cages. Keys of `LLDP_ENTRY_TABLE` with the MAC address of the neighbour | +| `sonic-enterprise-4.4.json` | captured | The second leaf of the same pair with Enterprise SONiC 4.4.1. Keys of `LLDP_ENTRY_TABLE` without MAC address | | `linux-mellanox.json` | synthetic | Node with a bond, an optical module with diagnostics and an empty cage | | `sonic-synthetic.json` | synthetic | Switch `leaf-1a`, the cable peer of `linux-mellanox.json` in the tests of the report commands | | `linux-intel.json` | synthetic | Node with a multi-lane optical module and a CMIS module | @@ -28,6 +29,9 @@ then changed in two ways: and VLAN IDs are replaced. The same value is replaced by the same replacement in all files, so the two ends of a cable still point at each other: `leaf-1` sees `node-1` on the port that `node-1` sees `leaf-1` on. + `linux-connectx4.json` is `node-1`, `linux-82599.json` is `node-5`, and + `linux-bcm57414.json` is a node whose host name, `deploy-1`, is not its + device name. The switches are `leaf-1` and `leaf-2`. - It is reduced. A switch document keeps a selection of its ports, and the `ip link` output of a node keeps the physical ports and `lo`. diff --git a/tests/unit/utils/hardware/fixtures/sonic-enterprise-4.4.json b/tests/unit/utils/hardware/fixtures/sonic-enterprise-4.4.json new file mode 100644 index 000000000..6d01051da --- /dev/null +++ b/tests/unit/utils/hardware/fixtures/sonic-enterprise-4.4.json @@ -0,0 +1,1196 @@ +{ + "collector": "sonic", + "commands": { + "show platform firmware status": { + "err": "", + "out": "Chassis Module Component Version Description\n--------------- -------- ----------- ------------ -------------------------\n7726-32X-O-AC-B N/A CPLD1 14 CPLD 1\n CPLD2 142 CPLD 2\n CPLD3 142 CPLD 3\n BIOS v40.01.01.01 Basic Input/Output System\n PCIE D102_08 ASIC PCIe Firmware\n", + "rc": 0 + } + }, + "db": { + "APPL_DB": { + "LLDP_ENTRY_TABLE": { + "LLDP_ENTRY_TABLE:Ethernet0": { + "expireat": 1794564831.836356, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:19", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "41", + "lldp_rem_man_addr": "192.0.2.4,2001:db8::1", + "lldp_rem_port_desc": "ens4f1np1", + "lldp_rem_port_id": "02:00:00:00:00:02", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "Ubuntu 24.04.5 LTS Linux 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:53:05 UTC 2026 x86_64", + "lldp_rem_sys_name": "node-1", + "lldp_rem_time_mark": "2961214", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet120": { + "expireat": 1794564831.8363047, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1d", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "21", + "lldp_rem_man_addr": "192.0.2.2", + "lldp_rem_port_desc": "Ethernet120", + "lldp_rem_port_id": "Eth1/31", + "lldp_rem_port_id_subtype": "7", + "lldp_rem_sys_cap_enabled": "08 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "SONiC Software Version: SONiC.4.5.2-Enterprise_Base - HwSku: Accton-AS7726-32X - Distribution: Debian 11.11 - Kernel: 5.10.0-39-amd64", + "lldp_rem_sys_name": "leaf-1", + "lldp_rem_time_mark": "1771", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet124": { + "expireat": 1794564831.8363721, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1d", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "21", + "lldp_rem_man_addr": "192.0.2.2", + "lldp_rem_port_desc": "Ethernet124", + "lldp_rem_port_id": "Eth1/32", + "lldp_rem_port_id_subtype": "7", + "lldp_rem_sys_cap_enabled": "08 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "SONiC Software Version: SONiC.4.5.2-Enterprise_Base - HwSku: Accton-AS7726-32X - Distribution: Debian 11.11 - Kernel: 5.10.0-39-amd64", + "lldp_rem_sys_name": "leaf-1", + "lldp_rem_time_mark": "1782", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet22": { + "expireat": 1794564831.8363206, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:07", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "74", + "lldp_rem_man_addr": "172.17.0.1,2001:db8::5", + "lldp_rem_port_desc": "data1", + "lldp_rem_port_id": "02:00:00:00:00:07", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "Ubuntu 24.04.4 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64", + "lldp_rem_sys_name": "node-5", + "lldp_rem_time_mark": "3032022", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet6": { + "expireat": 1794564831.836441, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:04", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "19", + "lldp_rem_man_addr": "", + "lldp_rem_port_desc": "NIC 1/10/25Gb ", + "lldp_rem_port_id": "02:00:00:00:00:04", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "00 00", + "lldp_rem_sys_cap_supported": "00 00", + "lldp_rem_sys_desc": "216.0.333.11 fw_version:AFW_214.4.42.1", + "lldp_rem_sys_name": "Broadcom P225p NetXtreme-E Dual-port 10Gb/25Gb Ethernet PCIe AdaBroadcom fw_version:AFW_214.4.42.1", + "lldp_rem_time_mark": "91", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:eth0": { + "expireat": 1794564831.8364508, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1c", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "16", + "lldp_rem_man_addr": "192.0.2.1", + "lldp_rem_port_desc": "", + "lldp_rem_port_id": "bridge/ether8", + "lldp_rem_port_id_subtype": "5", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "MikroTik RouterOS 6.49.1 (stable) CRS354-48G-4S+2Q+", + "lldp_rem_sys_name": "oob-1", + "lldp_rem_time_mark": "68", + "lldp_rem_ttl": "120" + } + } + }, + "PORT_TABLE": { + "PORT_TABLE:Ethernet0": { + "expireat": 1794564828.6708624, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/1/1", + "auto_fec": "rs", + "auto_unreliable_los": "off", + "autoneg": "off", + "description": "", + "index": "1", + "lanes": "1", + "last_change": "1794075220", + "link_training": "off", + "medium": "copper", + "mtu": "9100", + "oper_speed": "25000", + "oper_status": "up", + "oper_status_change_uptime": "2960258", + "port_load_interval": "10", + "preemphasis": "0x1a4a00", + "reason": "OPER_UP", + "speed": "25000", + "tagged_vlans": "105,107,104", + "unreliable_los": "auto", + "valid_speeds": "25000" + } + }, + "PORT_TABLE:Ethernet120": { + "expireat": 1794564828.6707838, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/31", + "auto_fec": "rs", + "auto_unreliable_los": "off", + "autoneg": "off", + "description": "", + "index": "31", + "lanes": "121,122,123,124", + "last_change": "1791116715", + "link_training": "off", + "medium": "backplane", + "mtu": "9100", + "oper_speed": "100000", + "oper_status": "up", + "oper_status_change_uptime": "1753", + "port_load_interval": "10", + "preemphasis": "0x184804,0x184804,0x184804,0x184804", + "reason": "OPER_UP", + "speed": "100000", + "unreliable_los": "auto", + "valid_speeds": "100000, 40000" + } + }, + "PORT_TABLE:Ethernet124": { + "expireat": 1794564828.670838, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/32", + "auto_fec": "rs", + "auto_unreliable_los": "off", + "autoneg": "off", + "description": "", + "index": "32", + "lanes": "125,126,127,128", + "last_change": "1791116715", + "link_training": "off", + "medium": "backplane", + "mtu": "9100", + "oper_speed": "100000", + "oper_status": "up", + "oper_status_change_uptime": "1753", + "port_load_interval": "10", + "preemphasis": "0x184804,0x184804,0x184804,0x184804", + "reason": "OPER_UP", + "speed": "100000", + "unreliable_los": "auto", + "valid_speeds": "100000, 40000" + } + }, + "PORT_TABLE:Ethernet22": { + "expireat": 1794564828.6709437, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/6/3", + "auto_fec": "none", + "auto_unreliable_los": "off", + "autoneg": "off", + "description": "", + "index": "6", + "lanes": "23", + "last_change": "1794146532", + "link_training": "off", + "medium": "copper", + "mtu": "9100", + "oper_speed": "10000", + "oper_status": "up", + "oper_status_change_uptime": "3031570", + "port_load_interval": "10", + "preemphasis": "0x0e4104", + "reason": "OPER_UP", + "speed": "10000", + "unreliable_los": "auto", + "valid_speeds": "10000" + } + }, + "PORT_TABLE:Ethernet24": { + "expireat": 1794564828.671084, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "down", + "adv_speeds": "all", + "alias": "Eth1/7", + "autoneg": "off", + "description": "", + "index": "7", + "lanes": "25,26,27,28", + "link_training": "off", + "mtu": "9100", + "oper_status": "down", + "oper_status_change_uptime": "0", + "port_load_interval": "10", + "reason": "ADMIN_DOWN", + "speed": "100000", + "unreliable_los": "auto", + "valid_speeds": "100000, 40000" + } + }, + "PORT_TABLE:Ethernet4": { + "expireat": 1794564828.6711886, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/2/1", + "auto_fec": "rs", + "auto_unreliable_los": "off", + "autoneg": "off", + "description": "", + "index": "2", + "lanes": "5", + "link_training": "off", + "medium": "copper", + "mtu": "9100", + "oper_status": "down", + "oper_status_change_uptime": "0", + "port_load_interval": "10", + "preemphasis": "0x1a4a00", + "reason": "PHY_LINK_DOWN", + "speed": "25000", + "unreliable_los": "auto", + "valid_speeds": "25000" + } + }, + "PORT_TABLE:Ethernet6": { + "expireat": 1794564828.6711352, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/2/3", + "auto_fec": "rs", + "auto_unreliable_los": "off", + "autoneg": "off", + "description": "", + "index": "2", + "lanes": "7", + "last_change": "1791115021", + "link_training": "off", + "medium": "copper", + "mtu": "9100", + "oper_speed": "25000", + "oper_status": "up", + "oper_status_change_uptime": "59", + "port_load_interval": "10", + "preemphasis": "0x1a4a00", + "reason": "OPER_UP", + "speed": "25000", + "unreliable_los": "auto", + "valid_speeds": "25000" + } + }, + "PORT_TABLE:PortConfigDone": { + "expireat": 1794564828.6707094, + "ttl": -0.001, + "type": "hash", + "value": { + "count": "7" + } + }, + "PORT_TABLE:PortInitDone": { + "expireat": 1794564828.6707067, + "ttl": -0.001, + "type": "hash", + "value": { + "lanes": "0" + } + } + } + }, + "CONFIG_DB": { + "DEVICE_METADATA": { + "DEVICE_METADATA|localhost": { + "expireat": 1794564828.3888955, + "ttl": -0.001, + "type": "hash", + "value": { + "default_config_profile": "l3", + "frr_mgmt_framework_config": "true", + "hostname": "leaf-2", + "hwsku": "Accton-AS7726-32X", + "intf_naming_mode": "standard", + "mac": "02:00:00:00:00:22", + "platform": "x86_64-accton_as7726_32x-r0", + "type": "LeafRouter" + } + } + }, + "PORT": { + "PORT|Ethernet0": { + "expireat": 1794564828.1048253, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/1/1", + "autoneg": "off", + "index": "1", + "lanes": "1", + "link_training": "off", + "mtu": "9100", + "speed": "25000", + "tagged_vlans@": "105,107,104", + "unreliable_los": "auto", + "valid_speeds": "25000" + } + }, + "PORT|Ethernet120": { + "expireat": 1794564828.1045732, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/31", + "autoneg": "off", + "index": "31", + "lanes": "121,122,123,124", + "link_training": "off", + "mtu": "9100", + "speed": "100000", + "unreliable_los": "auto", + "valid_speeds": "100000, 40000" + } + }, + "PORT|Ethernet124": { + "expireat": 1794564828.1046479, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/32", + "autoneg": "off", + "index": "32", + "lanes": "125,126,127,128", + "link_training": "off", + "mtu": "9100", + "speed": "100000", + "unreliable_los": "auto", + "valid_speeds": "100000, 40000" + } + }, + "PORT|Ethernet22": { + "expireat": 1794564828.1046937, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/6/3", + "autoneg": "off", + "index": "6", + "lanes": "23", + "link_training": "off", + "mtu": "9100", + "speed": "10000", + "unreliable_los": "auto", + "valid_speeds": "10000" + } + }, + "PORT|Ethernet24": { + "expireat": 1794564828.104536, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "down", + "adv_speeds": "all", + "alias": "Eth1/7", + "autoneg": "off", + "index": "7", + "lanes": "25,26,27,28", + "link_training": "off", + "mtu": "9100", + "speed": "100000", + "unreliable_los": "auto", + "valid_speeds": "100000, 40000" + } + }, + "PORT|Ethernet4": { + "expireat": 1794564828.104876, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/2/1", + "autoneg": "off", + "index": "2", + "lanes": "5", + "link_training": "off", + "mtu": "9100", + "speed": "25000", + "unreliable_los": "auto", + "valid_speeds": "25000" + } + }, + "PORT|Ethernet6": { + "expireat": 1794564828.1048117, + "ttl": -0.001, + "type": "hash", + "value": { + "admin_status": "up", + "adv_speeds": "all", + "alias": "Eth1/2/3", + "autoneg": "off", + "index": "2", + "lanes": "7", + "link_training": "off", + "mtu": "9100", + "speed": "25000", + "unreliable_los": "auto", + "valid_speeds": "25000" + } + } + } + }, + "STATE_DB": { + "EEPROM_INFO": { + "EEPROM_INFO|0x21": { + "expireat": 1794564829.7741764, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "15", + "Name": "Product Name", + "Value": "7726-32X-O-AC-B" + } + }, + "EEPROM_INFO|0x22": { + "expireat": 1794564829.7741728, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "13", + "Name": "Part Number", + "Value": "FP3ZZ7632076A" + } + }, + "EEPROM_INFO|0x23": { + "expireat": 1794564829.7741656, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "14", + "Name": "Serial Number", + "Value": "SN00000002" + } + }, + "EEPROM_INFO|0x24": { + "expireat": 1794564829.7741814, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "6", + "Name": "Base MAC Address", + "Value": "02:00:00:00:00:1E" + } + }, + "EEPROM_INFO|0x25": { + "expireat": 1794564829.7741556, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "19", + "Name": "Manufacture Date", + "Value": "01/01/2023 00:00:00" + } + }, + "EEPROM_INFO|0x27": { + "expireat": 1794564829.774159, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "4", + "Name": "Label Revision", + "Value": "R01H" + } + }, + "EEPROM_INFO|0x28": { + "expireat": 1794564829.7741623, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "27", + "Name": "Platform Name", + "Value": "x86_64-accton_as7726_32x-r0" + } + }, + "EEPROM_INFO|0x29": { + "expireat": 1794564829.7741356, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "13", + "Name": "ONIE Version", + "Value": "2017.11.00.05" + } + }, + "EEPROM_INFO|0x2a": { + "expireat": 1794564829.7741492, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "2", + "Name": "MAC Addresses", + "Value": "256" + } + }, + "EEPROM_INFO|0x2b": { + "expireat": 1794564829.7741692, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "6", + "Name": "Manufacturer", + "Value": "Accton" + } + }, + "EEPROM_INFO|0x2c": { + "expireat": 1794564829.774191, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "2", + "Name": "Manufacture Country", + "Value": "TW" + } + }, + "EEPROM_INFO|0x2d": { + "expireat": 1794564829.7741847, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "8", + "Name": "Vendor Name", + "Value": "Edgecore" + } + }, + "EEPROM_INFO|0x2e": { + "expireat": 1794564829.7741878, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "11", + "Name": "Diag Version", + "Value": "01.01.01.09" + } + }, + "EEPROM_INFO|0xfe": { + "expireat": 1794564829.7741318, + "ttl": -0.001, + "type": "hash", + "value": { + "Len": "4", + "Name": "CRC-32", + "Value": "0x00000000" + } + }, + "EEPROM_INFO|Checksum": { + "expireat": 1794564829.7741518, + "ttl": -0.001, + "type": "hash", + "value": { + "Valid": "1" + } + }, + "EEPROM_INFO|State": { + "expireat": 1794564829.7741935, + "ttl": -0.001, + "type": "hash", + "value": { + "Initialized": "1" + } + }, + "EEPROM_INFO|TlvHeader": { + "expireat": 1794564829.7741234, + "ttl": -0.001, + "type": "hash", + "value": { + "Id String": "TlvInfo", + "Total Length": "172", + "Version": "1" + } + } + }, + "TRANSCEIVER_DOM_SENSOR": { + "TRANSCEIVER_DOM_SENSOR|Ethernet0": { + "expireat": 1794564829.4989445, + "ttl": -0.001, + "type": "hash", + "value": { + "rx1power": "N/A", + "rx2power": "N/A", + "rx3power": "N/A", + "rx4power": "N/A", + "temperature": "N/A", + "tx1bias": "N/A", + "tx1power": "N/A", + "tx2bias": "N/A", + "tx2power": "N/A", + "tx3bias": "N/A", + "tx3power": "N/A", + "tx4bias": "N/A", + "tx4power": "N/A", + "voltage": "N/A" + } + }, + "TRANSCEIVER_DOM_SENSOR|Ethernet120": { + "expireat": 1794564829.4990568, + "ttl": -0.001, + "type": "hash", + "value": { + "rx1power": "0.1641", + "rx2power": "0.1991", + "rx3power": "0.4618", + "rx4power": "0.2338", + "rxpowerhighalarm": "3.4001", + "rxpowerhighwarning": "2.4000", + "rxpowerlowalarm": "-11.9997", + "rxpowerlowwarning": "-8.9997", + "temperature": "38.6719", + "temphighalarm": "75.0000", + "temphighwarning": "70.0000", + "templowalarm": "-5.0000", + "templowwarning": "0.0000", + "tx1bias": "6.4940", + "tx1power": "0.1982", + "tx2bias": "6.4860", + "tx2power": "0.2111", + "tx3bias": "6.4800", + "tx3power": "0.2991", + "tx4bias": "6.4060", + "tx4power": "0.1862", + "txbiashighalarm": "11.0000", + "txbiashighwarning": "10.0000", + "txbiaslowalarm": "2.0000", + "txbiaslowwarning": "3.0000", + "txpowerhighalarm": "4.4000", + "txpowerhighwarning": "3.4001", + "txpowerlowalarm": "-10.4001", + "txpowerlowwarning": "-8.4013", + "vcchighalarm": "3.6300", + "vcchighwarning": "3.4650", + "vcclowalarm": "2.9700", + "vcclowwarning": "3.1350", + "voltage": "3.3040" + } + }, + "TRANSCEIVER_DOM_SENSOR|Ethernet124": { + "expireat": 1794564829.4989264, + "ttl": -0.001, + "type": "hash", + "value": { + "rx1power": "0.2942", + "rx2power": "0.4454", + "rx3power": "0.4945", + "rx4power": "0.2686", + "rxpowerhighalarm": "3.4001", + "rxpowerhighwarning": "2.4000", + "rxpowerlowalarm": "-11.9997", + "rxpowerlowwarning": "-8.9997", + "temperature": "40.2188", + "temphighalarm": "75.0000", + "temphighwarning": "70.0000", + "templowalarm": "-5.0000", + "templowwarning": "0.0000", + "tx1bias": "6.9860", + "tx1power": "0.3519", + "tx2bias": "6.9800", + "tx2power": "0.4442", + "tx3bias": "7.0060", + "tx3power": "0.4497", + "tx4bias": "6.6000", + "tx4power": "0.3902", + "txbiashighalarm": "11.0000", + "txbiashighwarning": "10.0000", + "txbiaslowalarm": "2.0000", + "txbiaslowwarning": "3.0000", + "txpowerhighalarm": "4.4000", + "txpowerhighwarning": "3.4001", + "txpowerlowalarm": "-10.4001", + "txpowerlowwarning": "-8.4013", + "vcchighalarm": "3.6300", + "vcchighwarning": "3.4650", + "vcclowalarm": "2.9700", + "vcclowwarning": "3.1350", + "voltage": "3.3130" + } + }, + "TRANSCEIVER_DOM_SENSOR|Ethernet22": { + "expireat": 1794564829.498898, + "ttl": -0.001, + "type": "hash", + "value": { + "rx1power": "N/A", + "rx2power": "N/A", + "rx3power": "N/A", + "rx4power": "N/A", + "temperature": "N/A", + "tx1bias": "N/A", + "tx1power": "N/A", + "tx2bias": "N/A", + "tx2power": "N/A", + "tx3bias": "N/A", + "tx3power": "N/A", + "tx4bias": "N/A", + "tx4power": "N/A", + "voltage": "N/A" + } + }, + "TRANSCEIVER_DOM_SENSOR|Ethernet4": { + "expireat": 1794564829.4990919, + "ttl": -0.001, + "type": "hash", + "value": { + "rx1power": "N/A", + "rx2power": "N/A", + "rx3power": "N/A", + "rx4power": "N/A", + "temperature": "N/A", + "tx1bias": "N/A", + "tx1power": "N/A", + "tx2bias": "N/A", + "tx2power": "N/A", + "tx3bias": "N/A", + "tx3power": "N/A", + "tx4bias": "N/A", + "tx4power": "N/A", + "voltage": "N/A" + } + }, + "TRANSCEIVER_DOM_SENSOR|Ethernet6": { + "expireat": 1794564829.4990027, + "ttl": -0.001, + "type": "hash", + "value": { + "rx1power": "N/A", + "rx2power": "N/A", + "rx3power": "N/A", + "rx4power": "N/A", + "temperature": "N/A", + "tx1bias": "N/A", + "tx1power": "N/A", + "tx2bias": "N/A", + "tx2power": "N/A", + "tx3bias": "N/A", + "tx3power": "N/A", + "tx4bias": "N/A", + "tx4power": "N/A", + "voltage": "N/A" + } + } + }, + "TRANSCEIVER_FIRMWARE_INFO": {}, + "TRANSCEIVER_INFO": { + "TRANSCEIVER_INFO|Ethernet0": { + "expireat": 1794564828.9515586, + "ttl": -0.001, + "type": "hash", + "value": { + "application_advertisement": "N/A", + "cable_breakout": "1x1", + "cable_class": "DAC", + "cable_length": "2.0", + "cable_length_detailed": "2.0", + "cable_type": "Length Cable Assembly(m)", + "connector": "No separable connector", + "connector_type": "No separable connector", + "display_name": "QSFP28 100GBASE-CR4-DAC-2.0M", + "encoding": "Unspecified", + "ext_identifier": "Power Class 1(1.5W max)", + "ext_rateselect_compliance": "QSFP+ Rate Select Version 1", + "firmware_revision": "N/A", + "form_factor": "QSFP28", + "hardware_rev": "A", + "host_electrical_lane_count": "N/A", + "host_lane_count": "4", + "is_qualified": "False", + "is_replaceable": "False", + "lane_count": "4", + "laser_capabilities": "N/A", + "link_length": "N/A", + "manufacturer": "FS", + "max_port_power": "5.0", + "media_interface": "CR", + "media_lane_count": "4", + "memory_type": "Flat", + "model": "Q-4S28PC02", + "module_lane_count": "N/A", + "nominal_bit_rate": "255", + "power_rating_max": "1.5", + "qsa_adapter": "N/A", + "serial": "SN00000005", + "special_fields": "N/A", + "specification_compliance": "{'10/40G Ethernet Compliance Code': '100GBASE-CR4'}", + "supported_ctrls": "N/A", + "type": "QSFP28", + "type_abbrv_name": "QSFP28", + "vendor_date": "2022-09-07T00:00:00+00:00", + "vendor_date_code": "2022-09-07T00:00:00+00:00", + "vendor_name": "FS", + "vendor_oui": "64-9D-99", + "vendor_part_number": "Q-4S28PC02", + "vendor_revision": "A", + "vendor_serial_number": "SN00000005", + "vendorrev": "A", + "xcvr_speed_max": "100000" + } + }, + "TRANSCEIVER_INFO|Ethernet120": { + "expireat": 1794564828.952059, + "ttl": -0.001, + "type": "hash", + "value": { + "application_advertisement": "N/A", + "cable_breakout": "1x1", + "cable_class": "FIBER", + "cable_length": "0.0", + "cable_length_detailed": "0.0", + "cable_type": "Length OM3(2m)", + "connector": "MPOx12", + "connector_type": "MPOx12", + "display_name": "QSFP28 100GBASE-SR4", + "encoding": "NRZ", + "ext_identifier": "Power Class 4(3.5W max), CDR present in Rx Tx", + "ext_rateselect_compliance": "Unknown", + "firmware_revision": "N/A", + "form_factor": "QSFP28", + "hardware_rev": "1A", + "host_electrical_lane_count": "N/A", + "host_lane_count": "4", + "is_qualified": "False", + "is_replaceable": "False", + "lane_count": "4", + "laser_capabilities": "N/A", + "link_length": "N/A", + "manufacturer": "FS", + "max_port_power": "5.0", + "media_interface": "SR", + "media_lane_count": "4", + "memory_type": "Paged", + "model": "QSFP28-SR4-100G", + "module_lane_count": "N/A", + "nominal_bit_rate": "255", + "power_rating_max": "3.5", + "qsa_adapter": "N/A", + "serial": "SN00000020", + "special_fields": "N/A", + "specification_compliance": "{'10/40G Ethernet Compliance Code': '100GBASE-SR4', 'Gigabit Ethernet Compliant codes': '1000BASE-SX', 'SAS/SATA compliance codes': 'SAS 3.0G'}", + "supported_ctrls": "N/A", + "type": "QSFP28", + "type_abbrv_name": "QSFP28", + "vendor_date": "2022-03-10T00:00:00+00:00", + "vendor_date_code": "2022-03-10T00:00:00+00:00", + "vendor_name": "FS", + "vendor_oui": "64-9D-99", + "vendor_part_number": "QSFP28-SR4-100G", + "vendor_revision": "1A", + "vendor_serial_number": "SN00000020", + "vendorrev": "1A", + "wavelength": "850.00000000", + "xcvr_speed_max": "100000" + } + }, + "TRANSCEIVER_INFO|Ethernet124": { + "expireat": 1794564828.951938, + "ttl": -0.001, + "type": "hash", + "value": { + "application_advertisement": "N/A", + "cable_breakout": "1x1", + "cable_class": "FIBER", + "cable_length": "0.0", + "cable_length_detailed": "0.0", + "cable_type": "Length OM3(2m)", + "connector": "MPOx12", + "connector_type": "MPOx12", + "display_name": "QSFP28 100GBASE-SR4", + "encoding": "NRZ", + "ext_identifier": "Power Class 4(3.5W max), CDR present in Rx Tx", + "ext_rateselect_compliance": "Unknown", + "firmware_revision": "N/A", + "form_factor": "QSFP28", + "hardware_rev": "1A", + "host_electrical_lane_count": "N/A", + "host_lane_count": "4", + "is_qualified": "False", + "is_replaceable": "False", + "lane_count": "4", + "laser_capabilities": "N/A", + "link_length": "N/A", + "manufacturer": "FS", + "max_port_power": "5.0", + "media_interface": "SR", + "media_lane_count": "4", + "memory_type": "Paged", + "model": "QSFP28-SR4-100G", + "module_lane_count": "N/A", + "nominal_bit_rate": "255", + "power_rating_max": "3.5", + "qsa_adapter": "N/A", + "serial": "SN00000021", + "special_fields": "N/A", + "specification_compliance": "{'10/40G Ethernet Compliance Code': '100GBASE-SR4', 'Gigabit Ethernet Compliant codes': '1000BASE-SX', 'SAS/SATA compliance codes': 'SAS 3.0G'}", + "supported_ctrls": "N/A", + "type": "QSFP28", + "type_abbrv_name": "QSFP28", + "vendor_date": "2022-03-10T00:00:00+00:00", + "vendor_date_code": "2022-03-10T00:00:00+00:00", + "vendor_name": "FS", + "vendor_oui": "64-9D-99", + "vendor_part_number": "QSFP28-SR4-100G", + "vendor_revision": "1A", + "vendor_serial_number": "SN00000021", + "vendorrev": "1A", + "wavelength": "850.00000000", + "xcvr_speed_max": "100000" + } + }, + "TRANSCEIVER_INFO|Ethernet22": { + "expireat": 1794564828.9521332, + "ttl": -0.001, + "type": "hash", + "value": { + "application_advertisement": "N/A", + "cable_breakout": "1x1", + "cable_class": "DAC", + "cable_length": "2.0", + "cable_length_detailed": "2.0", + "cable_type": "Length Cable Assembly(m)", + "connector": "No separable connector", + "connector_type": "No separable connector", + "display_name": "QSFP28 100GBASE-CR4-DAC-2.0M", + "encoding": "Unspecified", + "ext_identifier": "Power Class 1(1.5W max)", + "ext_rateselect_compliance": "QSFP+ Rate Select Version 1", + "firmware_revision": "N/A", + "form_factor": "QSFP28", + "hardware_rev": "A", + "host_electrical_lane_count": "N/A", + "host_lane_count": "4", + "is_qualified": "False", + "is_replaceable": "False", + "lane_count": "4", + "laser_capabilities": "N/A", + "link_length": "N/A", + "manufacturer": "FS", + "max_port_power": "5.0", + "media_interface": "CR", + "media_lane_count": "4", + "memory_type": "Flat", + "model": "Q-4S28PC02", + "module_lane_count": "N/A", + "nominal_bit_rate": "255", + "power_rating_max": "1.5", + "qsa_adapter": "N/A", + "serial": "SN00000013", + "special_fields": "N/A", + "specification_compliance": "{'10/40G Ethernet Compliance Code': '100GBASE-CR4'}", + "supported_ctrls": "N/A", + "type": "QSFP28", + "type_abbrv_name": "QSFP28", + "vendor_date": "2024-07-04T00:00:00+00:00", + "vendor_date_code": "2024-07-04T00:00:00+00:00", + "vendor_name": "FS", + "vendor_oui": "64-9D-99", + "vendor_part_number": "Q-4S28PC02", + "vendor_revision": "A", + "vendor_serial_number": "SN00000013", + "vendorrev": "A", + "xcvr_speed_max": "100000" + } + }, + "TRANSCEIVER_INFO|Ethernet4": { + "expireat": 1794564828.9520352, + "ttl": -0.001, + "type": "hash", + "value": { + "application_advertisement": "N/A", + "cable_breakout": "1x1", + "cable_class": "DAC", + "cable_length": "3.0", + "cable_length_detailed": "3.0", + "cable_type": "Length Cable Assembly(m)", + "connector": "No separable connector", + "connector_type": "No separable connector", + "display_name": "QSFP28 100GBASE-CR4-DAC-3.0M", + "encoding": "Unspecified", + "ext_identifier": "Power Class 1(1.5W max)", + "ext_rateselect_compliance": "QSFP+ Rate Select Version 1", + "firmware_revision": "N/A", + "form_factor": "QSFP28", + "hardware_rev": "A", + "host_electrical_lane_count": "N/A", + "host_lane_count": "4", + "is_qualified": "False", + "is_replaceable": "False", + "lane_count": "4", + "laser_capabilities": "N/A", + "link_length": "N/A", + "manufacturer": "FS", + "max_port_power": "5.0", + "media_interface": "CR", + "media_lane_count": "4", + "memory_type": "Flat", + "model": "Q-4S28PC03", + "module_lane_count": "N/A", + "nominal_bit_rate": "255", + "power_rating_max": "1.5", + "qsa_adapter": "N/A", + "serial": "SN00000010", + "special_fields": "N/A", + "specification_compliance": "{'10/40G Ethernet Compliance Code': '100GBASE-CR4'}", + "supported_ctrls": "N/A", + "type": "QSFP28", + "type_abbrv_name": "QSFP28", + "vendor_date": "2022-03-10T00:00:00+00:00", + "vendor_date_code": "2022-03-10T00:00:00+00:00", + "vendor_name": "FS", + "vendor_oui": "64-9D-99", + "vendor_part_number": "Q-4S28PC03", + "vendor_revision": "A", + "vendor_serial_number": "SN00000010", + "vendorrev": "A", + "xcvr_speed_max": "100000" + } + }, + "TRANSCEIVER_INFO|Ethernet6": { + "expireat": 1794564828.9518116, + "ttl": -0.001, + "type": "hash", + "value": { + "application_advertisement": "N/A", + "cable_breakout": "1x1", + "cable_class": "DAC", + "cable_length": "3.0", + "cable_length_detailed": "3.0", + "cable_type": "Length Cable Assembly(m)", + "connector": "No separable connector", + "connector_type": "No separable connector", + "display_name": "QSFP28 100GBASE-CR4-DAC-3.0M", + "encoding": "Unspecified", + "ext_identifier": "Power Class 1(1.5W max)", + "ext_rateselect_compliance": "QSFP+ Rate Select Version 1", + "firmware_revision": "N/A", + "form_factor": "QSFP28", + "hardware_rev": "A", + "host_electrical_lane_count": "N/A", + "host_lane_count": "4", + "is_qualified": "False", + "is_replaceable": "False", + "lane_count": "4", + "laser_capabilities": "N/A", + "link_length": "N/A", + "manufacturer": "FS", + "max_port_power": "5.0", + "media_interface": "CR", + "media_lane_count": "4", + "memory_type": "Flat", + "model": "Q-4S28PC03", + "module_lane_count": "N/A", + "nominal_bit_rate": "255", + "power_rating_max": "1.5", + "qsa_adapter": "N/A", + "serial": "SN00000010", + "special_fields": "N/A", + "specification_compliance": "{'10/40G Ethernet Compliance Code': '100GBASE-CR4'}", + "supported_ctrls": "N/A", + "type": "QSFP28", + "type_abbrv_name": "QSFP28", + "vendor_date": "2022-03-10T00:00:00+00:00", + "vendor_date_code": "2022-03-10T00:00:00+00:00", + "vendor_name": "FS", + "vendor_oui": "64-9D-99", + "vendor_part_number": "Q-4S28PC03", + "vendor_revision": "A", + "vendor_serial_number": "SN00000010", + "vendorrev": "A", + "xcvr_speed_max": "100000" + } + }, + "TRANSCEIVER_INFO|XcvrInitDone": { + "expireat": 1794564828.9519646, + "ttl": -0.001, + "type": "hash", + "value": { + "status": "yes" + } + } + } + } + }, + "db_access": "user", + "errors": {}, + "files": { + "sonic_version": "build_version: '4.4.1-Enterprise_Base'\ndebian_version: '11.11'\nkernel_version: '5.10.0-21-amd64'\nasic_type: broadcom\ncommit_id: '2ef8ceaf10'\nbuild_date: Thu Nov 21 00:37:57 UTC 2024\nbuild_number: 0\nbuilt_by: sonicbld@bld-lvn-csg-08\nconfig_db_version: version_4_4_1" + }, + "hostname": "leaf-2", + "mgmt": { + "eth0": { + "address": "02:00:00:00:00:1e", + "carrier": "1", + "mtu": "1500", + "operstate": "up", + "speed": "1000" + } + }, + "sections": [ + "ports", + "transceiver", + "dom", + "system", + "lldp" + ], + "version": 1 +} diff --git a/tests/unit/utils/hardware/fixtures/sonic-enterprise.json b/tests/unit/utils/hardware/fixtures/sonic-enterprise.json index 373ea28e3..6bcdd87a9 100644 --- a/tests/unit/utils/hardware/fixtures/sonic-enterprise.json +++ b/tests/unit/utils/hardware/fixtures/sonic-enterprise.json @@ -9,6 +9,218 @@ }, "db": { "APPL_DB": { + "LLDP_ENTRY_TABLE": { + "LLDP_ENTRY_TABLE:Ethernet0:02:00:00:00:00:01": { + "expireat": 1794564783.0885222, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:19", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "37", + "lldp_rem_man_addr": "192.0.2.4,2001:db8::1", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "ens4f0np0", + "lldp_rem_port_id": "02:00:00:00:00:01", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "Ubuntu 24.04.5 LTS Linux 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:53:05 UTC 2026 x86_64", + "lldp_rem_sys_name": "node-1", + "lldp_rem_time_mark": "2959518", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet120:02:00:00:00:00:09": { + "expireat": 1794564783.0883687, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1e", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "4", + "lldp_rem_man_addr": "192.0.2.3", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "Ethernet120", + "lldp_rem_port_id": "Eth1/31", + "lldp_rem_port_id_subtype": "7", + "lldp_rem_sys_cap_enabled": "08 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "SONiC Software Version: SONiC.4.4.1-Enterprise_Base - HwSku: Accton-AS7726-32X - Distribution: Debian 11.11 - Kernel: 5.10.0-21-amd64", + "lldp_rem_sys_name": "leaf-2", + "lldp_rem_time_mark": "3024560", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet124:02:00:00:00:00:09": { + "expireat": 1794564783.0884378, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1e", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "4", + "lldp_rem_man_addr": "192.0.2.3", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "Ethernet124", + "lldp_rem_port_id": "Eth1/32", + "lldp_rem_port_id_subtype": "7", + "lldp_rem_sys_cap_enabled": "08 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "SONiC Software Version: SONiC.4.4.1-Enterprise_Base - HwSku: Accton-AS7726-32X - Distribution: Debian 11.11 - Kernel: 5.10.0-21-amd64", + "lldp_rem_sys_name": "leaf-2", + "lldp_rem_time_mark": "3024560", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet12:02:00:00:00:00:0a": { + "expireat": 1794564783.0884604, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1f", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "35", + "lldp_rem_man_addr": "172.17.0.1,2001:db8::2", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "data2", + "lldp_rem_port_id": "02:00:00:00:00:0a", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "", + "lldp_rem_sys_cap_supported": "", + "lldp_rem_sys_desc": "Ubuntu 24.04.4 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64", + "lldp_rem_sys_name": "node-2", + "lldp_rem_time_mark": "2941043", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet13:02:00:00:00:00:0b": { + "expireat": 1794564783.0883977, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1f", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "35", + "lldp_rem_man_addr": "172.17.0.1,2001:db8::3", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "data2", + "lldp_rem_port_id": "02:00:00:00:00:0b", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "", + "lldp_rem_sys_cap_supported": "", + "lldp_rem_sys_desc": "Ubuntu 24.04.4 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64", + "lldp_rem_sys_name": "node-3", + "lldp_rem_time_mark": "2963270", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet1:02:00:00:00:00:10": { + "expireat": 1794564783.0884452, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:21", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "41", + "lldp_rem_man_addr": "192.0.2.5,2001:db8::4", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "ens4f0np0", + "lldp_rem_port_id": "02:00:00:00:00:10", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "Ubuntu 24.04.5 LTS Linux 6.8.0-149-generic #149-Ubuntu SMP PREEMPT_DYNAMIC Fri Oct 23 13:21:10 UTC 2026 x86_64", + "lldp_rem_sys_name": "node-4", + "lldp_rem_time_mark": "2961814", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet22:02:00:00:00:00:08": { + "expireat": 1794564783.0884254, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:07", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "68", + "lldp_rem_man_addr": "172.17.0.1,2001:db8::5", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "data2", + "lldp_rem_port_id": "02:00:00:00:00:08", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "Ubuntu 24.04.4 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64", + "lldp_rem_sys_name": "node-5", + "lldp_rem_time_mark": "3030325", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet6:02:00:00:00:00:03": { + "expireat": 1794564783.0884082, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:03", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "8", + "lldp_rem_man_addr": "172.17.0.1,2001:db8::6", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "data1", + "lldp_rem_port_id": "02:00:00:00:00:03", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "Ubuntu 24.04.5 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64", + "lldp_rem_sys_name": "deploy-1", + "lldp_rem_time_mark": "3448113", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:Ethernet9:02:00:00:00:00:16": { + "expireat": 1794564783.0884726, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:16", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "10", + "lldp_rem_man_addr": "", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "NIC 1/10/25Gb ", + "lldp_rem_port_id": "02:00:00:00:00:16", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_sys_cap_enabled": "00 00", + "lldp_rem_sys_cap_supported": "00 00", + "lldp_rem_sys_desc": "216.0.333.11 fw_version:AFW_214.4.42.1", + "lldp_rem_sys_name": "Broadcom P225p NetXtreme-E Dual-port 10Gb/25Gb Ethernet PCIe AdaBroadcom fw_version:AFW_214.4.42.1", + "lldp_rem_time_mark": "3448121", + "lldp_rem_ttl": "120" + } + }, + "LLDP_ENTRY_TABLE:eth0:02:00:00:00:00:17": { + "expireat": 1794564783.0884533, + "ttl": -0.001, + "type": "hash", + "value": { + "lldp_rem_chassis_id": "02:00:00:00:00:1c", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_index": "16", + "lldp_rem_man_addr": "192.0.2.1", + "lldp_rem_man_addr_type": "ipv4", + "lldp_rem_port_desc": "", + "lldp_rem_port_id": "bridge/ether9", + "lldp_rem_port_id_subtype": "5", + "lldp_rem_sys_cap_enabled": "28 00", + "lldp_rem_sys_cap_supported": "28 00", + "lldp_rem_sys_desc": "MikroTik RouterOS 6.49.1 (stable) CRS354-48G-4S+2Q+", + "lldp_rem_sys_name": "oob-1", + "lldp_rem_time_mark": "101", + "lldp_rem_ttl": "120" + } + } + }, "PORT_TABLE": { "PORT_TABLE:Ethernet0": { "expireat": 1794564779.5969937, @@ -1607,6 +1819,11 @@ "sonic_version": "build_version: '4.5.2-Enterprise_Base'\ndebian_version: '11.11'\nkernel_version: '5.10.0-39-amd64'\nasic_type: broadcom\ncommit_id: '3cd846de9f'\nbuild_date: Sat Apr 25 01:30:35 UTC 2026\nbuild_number: 0\nbuilt_by: sonicbld@bld-lvn-csg-10\nconfig_db_version: version_4_5_4" }, "hostname": "leaf-1", + "lldpctl": { + "err": "", + "out": "{\n \"lldp\": {\n \"interface\": [\n {\n \"eth0\": {\n \"via\": \"LLDP\",\n \"rid\": \"16\",\n \"age\": \"39 days, 21:47:02\",\n \"lastupdate\": \"0 day, 00:00:38\",\n \"chassis\": {\n \"oob-1\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:1c\"\n },\n \"descr\": \"MikroTik RouterOS 6.49.1 (stable) CRS354-48G-4S+2Q+\",\n \"mgmt-ip\": \"192.0.2.1\",\n \"src-mac\": \"02:00:00:00:00:17\",\n \"capability\": [\n {\n \"type\": \"Bridge\",\n \"enabled\": true\n },\n {\n \"type\": \"Router\",\n \"enabled\": true\n }\n ]\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"ifname\",\n \"value\": \"bridge/ether9\"\n },\n \"srcmac\": \"02:00:00:00:00:17\",\n \"ttl\": \"120\"\n },\n \"lldp-med\": {\n \"device-type\": \"Network Connectivity Device\",\n \"capability\": [\n {\n \"type\": \"Capabilities\",\n \"available\": true\n },\n {\n \"type\": \"MDI/PSE\",\n \"available\": true\n }\n ]\n }\n }\n },\n {\n \"Ethernet0\": {\n \"via\": \"LLDP\",\n \"rid\": \"37\",\n \"age\": \"5 days, 15:43:25\",\n \"lastupdate\": \"0 day, 00:00:18\",\n \"chassis\": {\n \"node-1\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:19\"\n },\n \"descr\": \"Ubuntu 24.04.5 LTS Linux 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:53:05 UTC 2026 x86_64\",\n \"mgmt-ip\": [\n \"192.0.2.4\",\n \"2001:db8::1\"\n ],\n \"src-mac\": \"02:00:00:00:00:01\",\n \"capability\": [\n {\n \"type\": \"Bridge\",\n \"enabled\": true\n },\n {\n \"type\": \"Router\",\n \"enabled\": true\n },\n {\n \"type\": \"Wlan\",\n \"enabled\": false\n },\n {\n \"type\": \"Station\",\n \"enabled\": false\n }\n ]\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:01\"\n },\n \"srcmac\": \"02:00:00:00:00:01\",\n \"descr\": \"ens4f0np0\",\n \"ttl\": \"120\",\n \"auto-negotiation\": {\n \"supported\": true,\n \"enabled\": true,\n \"advertised\": {\n \"type\": \"1000Base-X\",\n \"hd\": false,\n \"fd\": true\n },\n \"current\": \"unknown\"\n }\n },\n \"vlan\": [\n {\n \"vlan-id\": \"103\",\n \"value\": \"Vlan_103\"\n },\n {\n \"vlan-id\": \"106\",\n \"value\": \"Vlan_106\"\n },\n {\n \"vlan-id\": \"101\",\n \"value\": \"Vlan_101\"\n }\n ]\n }\n },\n {\n \"Ethernet1\": {\n \"via\": \"LLDP\",\n \"rid\": \"41\",\n \"age\": \"5 days, 15:05:08\",\n \"lastupdate\": \"0 day, 00:00:29\",\n \"chassis\": {\n \"node-4\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:21\"\n },\n \"descr\": \"Ubuntu 24.04.5 LTS Linux 6.8.0-149-generic #149-Ubuntu SMP PREEMPT_DYNAMIC Fri Oct 23 13:21:10 UTC 2026 x86_64\",\n \"mgmt-ip\": [\n \"192.0.2.5\",\n \"2001:db8::4\"\n ],\n \"src-mac\": \"02:00:00:00:00:10\",\n \"capability\": [\n {\n \"type\": \"Bridge\",\n \"enabled\": true\n },\n {\n \"type\": \"Router\",\n \"enabled\": true\n },\n {\n \"type\": \"Wlan\",\n \"enabled\": false\n },\n {\n \"type\": \"Station\",\n \"enabled\": false\n }\n ]\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:10\"\n },\n \"srcmac\": \"02:00:00:00:00:10\",\n \"descr\": \"ens4f0np0\",\n \"ttl\": \"120\",\n \"auto-negotiation\": {\n \"supported\": true,\n \"enabled\": true,\n \"advertised\": {\n \"type\": \"1000Base-X\",\n \"hd\": false,\n \"fd\": true\n },\n \"current\": \"unknown\"\n }\n },\n \"vlan\": [\n {\n \"vlan-id\": \"103\",\n \"value\": \"Vlan_103\"\n },\n {\n \"vlan-id\": \"101\",\n \"value\": \"Vlan_101\"\n },\n {\n \"vlan-id\": \"106\",\n \"value\": \"Vlan_106\"\n }\n ]\n }\n },\n {\n \"Ethernet6\": {\n \"via\": \"LLDP\",\n \"rid\": \"8\",\n \"age\": \"0 day, 00:00:09\",\n \"lastupdate\": \"0 day, 00:00:09\",\n \"chassis\": {\n \"deploy-1\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:03\"\n },\n \"descr\": \"Ubuntu 24.04.5 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64\",\n \"mgmt-ip\": [\n \"172.17.0.1\",\n \"2001:db8::6\"\n ],\n \"src-mac\": \"02:00:00:00:00:03\",\n \"capability\": [\n {\n \"type\": \"Bridge\",\n \"enabled\": true\n },\n {\n \"type\": \"Router\",\n \"enabled\": true\n },\n {\n \"type\": \"Wlan\",\n \"enabled\": false\n },\n {\n \"type\": \"Station\",\n \"enabled\": false\n }\n ]\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:03\"\n },\n \"srcmac\": \"02:00:00:00:00:03\",\n \"descr\": \"data1\",\n \"ttl\": \"120\",\n \"auto-negotiation\": {\n \"supported\": true,\n \"enabled\": true,\n \"current\": \"unknown\"\n }\n }\n }\n },\n {\n \"Ethernet9\": {\n \"via\": \"LLDP\",\n \"rid\": \"10\",\n \"age\": \"0 day, 00:00:02\",\n \"lastupdate\": \"0 day, 00:00:02\",\n \"chassis\": {\n \"Broadcom P225p NetXtreme-E Dual-port 10Gb/25Gb Ethernet PCIe AdaBroadcom fw_version:AFW_214.4.42.1\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:16\"\n },\n \"descr\": \"216.0.333.11 fw_version:AFW_214.4.42.1\",\n \"capability\": {\n \"type\": \"Station\",\n \"enabled\": true\n }\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:16\"\n },\n \"srcmac\": \"02:00:00:00:00:16\",\n \"descr\": \"NIC 1/10/25Gb \",\n \"ttl\": \"120\"\n }\n }\n },\n {\n \"Ethernet12\": {\n \"via\": \"LLDP\",\n \"rid\": \"35\",\n \"age\": \"5 days, 20:51:20\",\n \"lastupdate\": \"0 day, 00:00:27\",\n \"chassis\": {\n \"node-2\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:1f\"\n },\n \"descr\": \"Ubuntu 24.04.4 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64\",\n \"mgmt-ip\": [\n \"172.17.0.1\",\n \"2001:db8::2\"\n ],\n \"src-mac\": \"02:00:00:00:00:0a\"\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:0a\"\n },\n \"srcmac\": \"02:00:00:00:00:0a\",\n \"descr\": \"data2\",\n \"ttl\": \"120\",\n \"auto-negotiation\": {\n \"supported\": false,\n \"enabled\": false,\n \"current\": \"10GigBaseCX4 - X copper over 8 pair 100-Ohm balanced cable\"\n }\n }\n }\n },\n {\n \"Ethernet13\": {\n \"via\": \"LLDP\",\n \"rid\": \"35\",\n \"age\": \"5 days, 14:40:53\",\n \"lastupdate\": \"0 day, 00:00:23\",\n \"chassis\": {\n \"node-3\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:1f\"\n },\n \"descr\": \"Ubuntu 24.04.4 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64\",\n \"mgmt-ip\": [\n \"172.17.0.1\",\n \"2001:db8::3\"\n ],\n \"src-mac\": \"02:00:00:00:00:0b\"\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:0b\"\n },\n \"srcmac\": \"02:00:00:00:00:0b\",\n \"descr\": \"data2\",\n \"ttl\": \"120\",\n \"auto-negotiation\": {\n \"supported\": false,\n \"enabled\": false,\n \"current\": \"10GigBaseCX4 - X copper over 8 pair 100-Ohm balanced cable\"\n }\n }\n }\n },\n {\n \"Ethernet22\": {\n \"via\": \"LLDP\",\n \"rid\": \"68\",\n \"age\": \"4 days, 20:03:18\",\n \"lastupdate\": \"0 day, 00:00:10\",\n \"chassis\": {\n \"node-5\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:07\"\n },\n \"descr\": \"Ubuntu 24.04.4 LTS Linux 7.0.0-31-generic #31~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Aug 10 09:38:02 UTC 2 x86_64\",\n \"mgmt-ip\": [\n \"172.17.0.1\",\n \"2001:db8::5\"\n ],\n \"src-mac\": \"02:00:00:00:00:08\",\n \"capability\": [\n {\n \"type\": \"Bridge\",\n \"enabled\": true\n },\n {\n \"type\": \"Router\",\n \"enabled\": true\n },\n {\n \"type\": \"Wlan\",\n \"enabled\": false\n },\n {\n \"type\": \"Station\",\n \"enabled\": false\n }\n ]\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:08\"\n },\n \"srcmac\": \"02:00:00:00:00:08\",\n \"descr\": \"data2\",\n \"ttl\": \"120\",\n \"auto-negotiation\": {\n \"supported\": false,\n \"enabled\": false,\n \"current\": \"10GigBaseCX4 - X copper over 8 pair 100-Ohm balanced cable\"\n }\n }\n }\n },\n {\n \"Ethernet120\": {\n \"via\": \"LLDP\",\n \"rid\": \"4\",\n \"age\": \"4 days, 21:39:23\",\n \"lastupdate\": \"0 day, 00:00:10\",\n \"chassis\": {\n \"leaf-2\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:1e\"\n },\n \"descr\": \"SONiC Software Version: SONiC.4.4.1-Enterprise_Base - HwSku: Accton-AS7726-32X - Distribution: Debian 11.11 - Kernel: 5.10.0-21-amd64\",\n \"mgmt-ip\": \"192.0.2.3\",\n \"src-mac\": \"02:00:00:00:00:09\",\n \"capability\": [\n {\n \"type\": \"Bridge\",\n \"enabled\": false\n },\n {\n \"type\": \"Router\",\n \"enabled\": true\n },\n {\n \"type\": \"Wlan\",\n \"enabled\": false\n },\n {\n \"type\": \"Station\",\n \"enabled\": false\n }\n ]\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"local\",\n \"value\": \"Eth1/31\"\n },\n \"srcmac\": \"02:00:00:00:00:09\",\n \"descr\": \"Ethernet120\",\n \"ttl\": \"120\",\n \"aggregation\": \"1\",\n \"mfs\": \"9100\",\n \"auto-negotiation\": {\n \"supported\": false,\n \"enabled\": false,\n \"current\": \"100GbaseCR10 - 100GBASE-R PCS/PMA over 10 lane shielded copper balanced cable\"\n }\n },\n \"lldp-med\": {\n \"device-type\": \"Network Connectivity Device\",\n \"capability\": [\n {\n \"type\": \"Capabilities\",\n \"available\": true\n },\n {\n \"type\": \"Policy\",\n \"available\": true\n },\n {\n \"type\": \"Location\",\n \"available\": true\n },\n {\n \"type\": \"MDI/PSE\",\n \"available\": true\n },\n {\n \"type\": \"MDI/PD\",\n \"available\": true\n },\n {\n \"type\": \"Inventory\",\n \"available\": true\n }\n ],\n \"inventory\": {\n \"hardware\": \"R01H\",\n \"software\": \"5.10.0-21-amd64\",\n \"firmware\": \"v40.01.01.01\",\n \"serial\": \"SN00000002\",\n \"manufacturer\": \"Accton\",\n \"model\": \"AS7726-32X\",\n \"asset\": \"None\"\n }\n }\n }\n },\n {\n \"Ethernet124\": {\n \"via\": \"LLDP\",\n \"rid\": \"4\",\n \"age\": \"4 days, 21:39:23\",\n \"lastupdate\": \"0 day, 00:00:10\",\n \"chassis\": {\n \"leaf-2\": {\n \"id\": {\n \"type\": \"mac\",\n \"value\": \"02:00:00:00:00:1e\"\n },\n \"descr\": \"SONiC Software Version: SONiC.4.4.1-Enterprise_Base - HwSku: Accton-AS7726-32X - Distribution: Debian 11.11 - Kernel: 5.10.0-21-amd64\",\n \"mgmt-ip\": \"192.0.2.3\",\n \"src-mac\": \"02:00:00:00:00:09\",\n \"capability\": [\n {\n \"type\": \"Bridge\",\n \"enabled\": false\n },\n {\n \"type\": \"Router\",\n \"enabled\": true\n },\n {\n \"type\": \"Wlan\",\n \"enabled\": false\n },\n {\n \"type\": \"Station\",\n \"enabled\": false\n }\n ]\n }\n },\n \"port\": {\n \"id\": {\n \"type\": \"local\",\n \"value\": \"Eth1/32\"\n },\n \"srcmac\": \"02:00:00:00:00:09\",\n \"descr\": \"Ethernet124\",\n \"ttl\": \"120\",\n \"aggregation\": \"1\",\n \"mfs\": \"9100\",\n \"auto-negotiation\": {\n \"supported\": false,\n \"enabled\": false,\n \"current\": \"100GbaseCR10 - 100GBASE-R PCS/PMA over 10 lane shielded copper balanced cable\"\n }\n },\n \"lldp-med\": {\n \"device-type\": \"Network Connectivity Device\",\n \"capability\": [\n {\n \"type\": \"Capabilities\",\n \"available\": true\n },\n {\n \"type\": \"Policy\",\n \"available\": true\n },\n {\n \"type\": \"Location\",\n \"available\": true\n },\n {\n \"type\": \"MDI/PSE\",\n \"available\": true\n },\n {\n \"type\": \"MDI/PD\",\n \"available\": true\n },\n {\n \"type\": \"Inventory\",\n \"available\": true\n }\n ],\n \"inventory\": {\n \"hardware\": \"R01H\",\n \"software\": \"5.10.0-21-amd64\",\n \"firmware\": \"v40.01.01.01\",\n \"serial\": \"SN00000002\",\n \"manufacturer\": \"Accton\",\n \"model\": \"AS7726-32X\",\n \"asset\": \"None\"\n }\n }\n }\n }\n ]\n }\n}\n", + "rc": 0 + }, "mgmt": { "eth0": { "address": "02:00:00:00:00:1d", @@ -1620,7 +1837,8 @@ "ports", "transceiver", "dom", - "system" + "system", + "lldp" ], "version": 1 } diff --git a/tests/unit/utils/hardware/test_parse_lldp.py b/tests/unit/utils/hardware/test_parse_lldp.py index 6fe7cbbc7..4b6f020dc 100644 --- a/tests/unit/utils/hardware/test_parse_lldp.py +++ b/tests/unit/utils/hardware/test_parse_lldp.py @@ -8,9 +8,12 @@ from osism.utils.hardware.parse_lldp import ( LldpError, + entry_port, linux_neighbours, neighbour, + parse_lldp_entry_table, parse_lldpctl, + sonic_neighbours, ) IFACE_ETH0 = { @@ -172,3 +175,281 @@ def test_linux_neighbours_failures_have_a_clear_message(result, message): linux_neighbours(document) assert str(excinfo.value) == message + + +# --------------------------------------------------------------------------- +# SONiC +# --------------------------------------------------------------------------- + +ENTRY_ETH11 = { + "lldp_rem_chassis_id": "b8:ce:f6:aa:00:10", + "lldp_rem_chassis_id_subtype": "4", + "lldp_rem_port_id": "b8:ce:f6:aa:00:10", + "lldp_rem_port_id_subtype": "3", + "lldp_rem_port_desc": "ens1f0np0", + "lldp_rem_sys_name": "compute-042", + "lldp_rem_sys_desc": "Ubuntu 24.04", + "lldp_rem_man_addr": "10.10.1.42,fe80::1", + "lldp_rem_time_mark": "1234", + "lldp_rem_index": "1", +} + + +def _sonic(entries=None, lldpctl=None, errors=None): + document = {"db": {}, "errors": errors or {}} + if entries is not None: + document["db"]["APPL_DB"] = { + "LLDP_ENTRY_TABLE": { + f"LLDP_ENTRY_TABLE:{name}": {"type": "hash", "value": fields} + for name, fields in entries.items() + } + } + if lldpctl is not None: + document["lldpctl"] = lldpctl + return document + + +def test_lldp_entry_table_is_normalised(): + neighbours = parse_lldp_entry_table({"Ethernet11": ENTRY_ETH11}) + + assert neighbours == { + "Ethernet11": [ + neighbour( + system_name="compute-042", + chassis_id="b8:ce:f6:aa:00:10", + chassis_id_type="mac", + port_id="b8:ce:f6:aa:00:10", + port_id_type="mac", + port_description="ens1f0np0", + management_addresses=["10.10.1.42", "fe80::1"], + # lldp_rem_time_mark is not an age on every release + age=None, + ) + ] + } + + +def test_lldp_entry_table_with_sparse_and_unknown_fields(): + neighbours = parse_lldp_entry_table( + { + "Ethernet0": { + "lldp_rem_port_id": "Eth1/1", + "lldp_rem_port_id_subtype": "7", + }, + "eth0": {"lldp_rem_port_id_subtype": "99"}, + } + ) + + assert neighbours["Ethernet0"][0]["port_id_type"] == "local" + assert neighbours["Ethernet0"][0]["age"] is None + assert neighbours["eth0"] == [neighbour()] + assert parse_lldp_entry_table({}) == {} + + +@pytest.mark.parametrize( + "key,port", + [ + ("Ethernet0", "Ethernet0"), + ("eth0", "eth0"), + # Enterprise SONiC 4.5 appends the source MAC of the neighbour + ("Ethernet0:02:00:00:00:00:01", "Ethernet0"), + ("Ethernet124:02:00:00:00:00:09", "Ethernet124"), + ("eth0:02:00:00:00:00:17", "eth0"), + ("Ethernet0:AA:BB:CC:DD:EE:FF", "Ethernet0"), + # Not a MAC address: part of the name + ("Ethernet0:1", "Ethernet0:1"), + ], +) +def test_entry_port(key, port): + assert entry_port(key) == port + + +def test_lldp_entry_table_keeps_several_neighbours_of_a_port(): + """A NIC with an LLDP agent of its own answers next to the host.""" + firmware = dict(ENTRY_ETH11, lldp_rem_sys_name="NIC firmware") + neighbours = parse_lldp_entry_table( + { + "Ethernet11:b8:ce:f6:aa:00:10": ENTRY_ETH11, + "Ethernet11:b8:ce:f6:aa:00:12": firmware, + } + ) + + assert sorted(neighbours) == ["Ethernet11"] + assert [found["system_name"] for found in neighbours["Ethernet11"]] == [ + "compute-042", + "NIC firmware", + ] + + +def test_sonic_neighbours_of_enterprise_sonic_4_5(load_fixture): + """The keys of LLDP_ENTRY_TABLE carry the MAC address of the neighbour.""" + neighbours = sonic_neighbours(load_fixture("sonic-enterprise.json")) + + assert sorted(neighbours) == [ + "Ethernet0", + "Ethernet1", + "Ethernet12", + "Ethernet120", + "Ethernet124", + "Ethernet13", + "Ethernet22", + "Ethernet6", + "Ethernet9", + "eth0", + ] + assert all(len(found) == 1 for found in neighbours.values()) + + # lldpd on a node advertises the MAC address of the port as port ID + # and the name of the port as description + node = neighbours["Ethernet0"][0] + assert node == neighbour( + system_name="node-1", + chassis_id="02:00:00:00:00:19", + chassis_id_type="mac", + port_id="02:00:00:00:00:01", + port_id_type="mac", + port_description="ens4f0np0", + management_addresses=["192.0.2.4", "2001:db8::1"], + ) + + # The other leaf: alias as port ID, SONiC name as description + leaf = neighbours["Ethernet120"][0] + assert leaf["system_name"] == "leaf-2" + assert (leaf["port_id"], leaf["port_id_type"]) == ("Eth1/31", "local") + assert leaf["port_description"] == "Ethernet120" + + # The management port is part of the table + oob = neighbours["eth0"][0] + assert oob["system_name"] == "oob-1" + assert (oob["port_id"], oob["port_id_type"]) == ("bridge/ether9", "ifname") + assert oob["port_description"] is None + + +def test_what_a_node_announces_is_not_always_its_name_in_netbox(load_fixture): + neighbours = sonic_neighbours(load_fixture("sonic-enterprise.json")) + + # A host name that differs from the device name + assert neighbours["Ethernet6"][0]["system_name"] == "deploy-1" + # The LLDP agent of a NIC, answering instead of the host: no host name, no + # management address, but the MAC address of the port + agent = neighbours["Ethernet9"][0] + assert agent["system_name"].startswith("Broadcom P225p NetXtreme-E") + assert agent["management_addresses"] == [] + assert agent["port_id_type"] == "mac" + # Two nodes with the same chassis ID, the MAC address of the USB host + # interface of their BMC, and with the address of docker0 + first, second = neighbours["Ethernet12"][0], neighbours["Ethernet13"][0] + assert first["system_name"] != second["system_name"] + assert first["chassis_id"] == second["chassis_id"] == "02:00:00:00:00:1f" + assert first["port_id"] != second["port_id"] + assert "172.17.0.1" in first["management_addresses"] + assert "172.17.0.1" in second["management_addresses"] + + +def test_sonic_neighbours_of_enterprise_sonic_4_4(load_fixture): + """The keys of LLDP_ENTRY_TABLE are the port names.""" + document = load_fixture("sonic-enterprise-4.4.json") + neighbours = sonic_neighbours(document) + + assert "lldpctl" not in document + assert sorted(neighbours) == [ + "Ethernet0", + "Ethernet120", + "Ethernet124", + "Ethernet22", + "Ethernet6", + "eth0", + ] + assert neighbours["Ethernet0"][0]["system_name"] == "node-1" + assert neighbours["Ethernet0"][0]["port_description"] == "ens4f1np1" + assert neighbours["Ethernet120"][0]["system_name"] == "leaf-1" + assert neighbours["Ethernet120"][0]["port_id"] == "Eth1/31" + assert neighbours["eth0"][0]["port_id"] == "bridge/ether8" + assert neighbours["Ethernet0"][0]["age"] is None + + +def test_both_ends_of_a_cable_name_each_other(load_fixture): + """node-1 and leaf-1 were captured at both ends of the same cable.""" + node = linux_neighbours(load_fixture("linux-connectx4.json")) + switch = sonic_neighbours(load_fixture("sonic-enterprise.json")) + + seen_by_node = node["ens4f0np0"][0] + seen_by_switch = switch["Ethernet0"][0] + assert seen_by_node["system_name"] == "leaf-1" + assert seen_by_node["port_description"] == "Ethernet0" + assert seen_by_switch["system_name"] == "node-1" + assert seen_by_switch["port_description"] == "ens4f0np0" + + +def test_sonic_neighbours_from_the_table(): + neighbours = sonic_neighbours(_sonic({"Ethernet11": ENTRY_ETH11})) + + assert sorted(neighbours) == ["Ethernet11"] + assert neighbours["Ethernet11"][0]["system_name"] == "compute-042" + + +def test_sonic_neighbours_of_an_empty_table_are_empty(): + assert sonic_neighbours(_sonic({})) == {} + + +def test_lldpctl_fills_in_ports_the_table_does_not_list(): + """The management port, in case the table only has front-panel ports.""" + lldpctl = { + "rc": 0, + "out": _lldp([{"eth0": IFACE_ETH0}, {"Ethernet11": IFACE_ETH1}]), + "err": "", + } + + neighbours = sonic_neighbours(_sonic({"Ethernet11": ENTRY_ETH11}, lldpctl)) + + assert sorted(neighbours) == ["Ethernet11", "eth0"] + assert neighbours["eth0"][0]["system_name"] == "switch-1" + # The table stays the source for the ports it lists + assert neighbours["Ethernet11"][0]["system_name"] == "compute-042" + + +def test_sonic_neighbours_fall_back_to_lldpctl_without_the_table(): + lldpctl = {"rc": 0, "out": _lldp({"Ethernet0": IFACE_ETH0}), "err": ""} + + neighbours = sonic_neighbours(_sonic(lldpctl=lldpctl)) + + assert neighbours["Ethernet0"][0]["port_id"] == "Ethernet10" + + +def test_failing_lldpctl_is_ignored_when_the_table_was_read(): + lldpctl = {"rc": 1, "out": "", "err": "Error: No such container: lldp"} + garbage = {"rc": 0, "out": "not json", "err": ""} + + assert sorted(sonic_neighbours(_sonic({"Ethernet11": ENTRY_ETH11}, lldpctl))) == [ + "Ethernet11" + ] + assert sorted(sonic_neighbours(_sonic({"Ethernet11": ENTRY_ETH11}, garbage))) == [ + "Ethernet11" + ] + + +@pytest.mark.parametrize( + "document,message", + [ + ( + _sonic( + lldpctl={"rc": 1, "out": "", "err": "x\nError: No such container: lldp"} + ), + "LLDP neighbours not readable: Error: No such container: lldp", + ), + ( + _sonic(errors={"APPL_DB LLDP_ENTRY_TABLE": "permission denied"}), + "LLDP neighbours not readable: permission denied", + ), + (_sonic(), "LLDP neighbours not readable: no LLDP data"), + ( + _sonic(lldpctl={"rc": 0, "out": "not json", "err": ""}), + "unparsable lldpctl output", + ), + ], +) +def test_sonic_neighbours_failures_have_a_clear_message(document, message): + with pytest.raises(LldpError) as excinfo: + sonic_neighbours(document) + + assert str(excinfo.value) == message diff --git a/tests/unit/utils/hardware/test_remote_sonic.py b/tests/unit/utils/hardware/test_remote_sonic.py index ae327bb8e..c13eb11a3 100644 --- a/tests/unit/utils/hardware/test_remote_sonic.py +++ b/tests/unit/utils/hardware/test_remote_sonic.py @@ -228,3 +228,110 @@ def test_main_accepts_the_hardware_sections(tmp_path): "dom", "system", ] + + +# --------------------------------------------------------------------------- +# LLDP section +# --------------------------------------------------------------------------- + + +def _lldp_runner(entries, calls, lldpctl_rc=0): + """Serve LLDP_ENTRY_TABLE and record the lldpctl calls.""" + dump = _runner({(False, "APPL_DB", "LLDP_ENTRY_TABLE:*"): entries}) + + def run(argv, timeout=30): + if "lldpctl" in argv: + calls.append(argv) + if argv[0] != "sudo" and lldpctl_rc: + return {"rc": lldpctl_rc, "out": "", "err": "permission denied"} + return {"rc": 0, "out": '{"lldp": {}}', "err": ""} + return dump(argv, timeout) + + return run + + +def test_lldp_section_dumps_the_entry_table(tmp_path): + entries = { + "LLDP_ENTRY_TABLE:Ethernet0": {"value": {"lldp_rem_sys_name": "a"}}, + "LLDP_ENTRY_TABLE:eth0": {"value": {"lldp_rem_sys_name": "mgmt-1"}}, + } + calls = [] + + document = remote_sonic.collect( + ["lldp"], str(tmp_path), _lldp_runner(entries, calls) + ) + + assert document["db"]["APPL_DB"]["LLDP_ENTRY_TABLE"] == entries + # The table lists the management port, so lldpctl is not needed + assert calls == [] + assert "lldpctl" not in document + + +def test_lldp_section_finds_the_management_port_under_a_key_with_mac(tmp_path): + """Enterprise SONiC 4.5 appends the MAC address of the neighbour.""" + entries = { + "LLDP_ENTRY_TABLE:Ethernet0:02:00:00:00:00:01": {"value": {}}, + "LLDP_ENTRY_TABLE:eth0:02:00:00:00:00:17": {"value": {}}, + } + calls = [] + + document = remote_sonic.collect( + ["lldp"], str(tmp_path), _lldp_runner(entries, calls) + ) + + assert calls == [] + assert "lldpctl" not in document + + +def test_lldp_section_does_not_take_another_port_for_the_management_port(tmp_path): + entries = {"LLDP_ENTRY_TABLE:eth01": {"value": {}}} + calls = [] + + document = remote_sonic.collect( + ["lldp"], str(tmp_path), _lldp_runner(entries, calls) + ) + + assert calls == [["docker", "exec", "lldp", "lldpctl", "-f", "json"]] + assert document["lldpctl"]["rc"] == 0 + + +def test_lldp_section_asks_lldpctl_when_the_management_port_is_not_listed(tmp_path): + entries = {"LLDP_ENTRY_TABLE:Ethernet0": {"value": {"lldp_rem_sys_name": "a"}}} + calls = [] + + document = remote_sonic.collect( + ["lldp"], str(tmp_path), _lldp_runner(entries, calls) + ) + + assert calls == [["docker", "exec", "lldp", "lldpctl", "-f", "json"]] + assert document["lldpctl"]["rc"] == 0 + + +def test_lldp_section_retries_lldpctl_with_sudo(tmp_path): + calls = [] + + document = remote_sonic.collect( + ["lldp"], str(tmp_path), _lldp_runner({}, calls, lldpctl_rc=1) + ) + + assert calls == [ + ["docker", "exec", "lldp", "lldpctl", "-f", "json"], + ["sudo", "-n", "docker", "exec", "lldp", "lldpctl", "-f", "json"], + ] + assert document["lldpctl"] == {"rc": 0, "out": '{"lldp": {}}', "err": ""} + + +def test_lldp_section_keeps_going_without_the_table(tmp_path): + calls = [] + + def run(argv, timeout=30): + if "lldpctl" in argv: + calls.append(argv) + return {"rc": 0, "out": '{"lldp": {}}', "err": ""} + return {"rc": 1, "out": "", "err": "no such table"} + + document = remote_sonic.collect(["lldp"], str(tmp_path), run) + + assert document["errors"] == {"APPL_DB LLDP_ENTRY_TABLE": "no such table"} + assert document["lldpctl"]["rc"] == 0 + assert len(calls) == 1 From a9018ef36251f79830b70654b42068046ef3855a Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 11 Nov 2026 22:38:16 +0100 Subject: [PATCH 2/4] Add NetBox device lookups and the vendor alias index Resolving an LLDP neighbour to NetBox needs devices the report did not select: the documented peers of the selected interfaces, and devices a neighbour announces by name. fetch_devices() loads devices by ID in chunks. find_devices() looks them up by name, ignoring case, and also tries the name without its domain, because a device can announce itself fully qualified while NetBox holds the short name. sonic_alias_index() maps the vendor aliases of a HWSKU port config to SONiC port names, for switches that advertise twentyFiveGigE1 instead of the NetBox name. Tests: chunking, the name variants, and the alias index against the port config files of the repository. Part of #2877. Assisted-by: Claude:claude-fable-5-2 Signed-off-by: Christian Berendt --- osism/utils/hardware/mapping.py | 54 +++++++++++++++++++++++ tests/unit/utils/hardware/test_mapping.py | 51 +++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/osism/utils/hardware/mapping.py b/osism/utils/hardware/mapping.py index 0909dc193..2c979b818 100644 --- a/osism/utils/hardware/mapping.py +++ b/osism/utils/hardware/mapping.py @@ -151,6 +151,41 @@ def fetch_interfaces( return interfaces +def fetch_devices( + nb, device_ids: Iterable[int], chunk_size: int = 50 +) -> List[Dict[str, Any]]: + """Fetch devices by ID with few requests and return them as plain data.""" + ids = sorted(set(device_ids)) + devices: List[Dict[str, Any]] = [] + for start in range(0, len(ids), chunk_size): + chunk = ids[start : start + chunk_size] + devices.extend(dict(record) for record in nb.dcim.devices.filter(id=chunk)) + return devices + + +def find_devices( + nb, names: Iterable[str], chunk_size: int = 50 +) -> List[Dict[str, Any]]: + """Find devices by name, ignoring case, and return them as plain data. + + Each name is also tried without its domain, because a device can + announce itself fully qualified while NetBox holds the short name. + """ + wanted: List[str] = [] + for name in names: + for candidate in (name, name.split(".")[0]): + if candidate and candidate not in wanted: + wanted.append(candidate) + + devices: Dict[Any, Dict[str, Any]] = {} + for start in range(0, len(wanted), chunk_size): + chunk = wanted[start : start + chunk_size] + for record in nb.dcim.devices.filter(name__ie=chunk): + device = dict(record) + devices.setdefault(device.get("id"), device) + return list(devices.values()) + + def physical_interfaces( interfaces: Iterable[NetboxInterface], include_mgmt: bool = True ) -> List[NetboxInterface]: @@ -217,6 +252,25 @@ def sonic_name_index( return index +def sonic_alias_index(hwsku: Optional[str]) -> Dict[str, str]: + """Return ``{vendor alias: SONiC port name}`` of a HWSKU port config. + + A switch that was not configured by OSISM advertises the vendor alias + of the port config file (``twentyFiveGigE1``) instead of the NetBox + name. Empty without a HWSKU or without its port config file. + """ + if not hwsku: + return {} + + from osism.tasks.conductor.sonic.interface import get_port_config + + return { + fields["alias"]: name + for name, fields in get_port_config(hwsku).items() + if fields.get("alias") + } + + class _Matcher: """Keeps a NetBox interface from being matched to two ports.""" diff --git a/tests/unit/utils/hardware/test_mapping.py b/tests/unit/utils/hardware/test_mapping.py index bf4beb45b..47d784c8e 100644 --- a/tests/unit/utils/hardware/test_mapping.py +++ b/tests/unit/utils/hardware/test_mapping.py @@ -15,7 +15,9 @@ Endpoint, Match, NetboxInterface, + fetch_devices, fetch_interfaces, + find_devices, interface_from_netbox, map_node_ports, map_switch_ports, @@ -23,6 +25,7 @@ neighbour_port_names, physical_interfaces, same_system, + sonic_alias_index, sonic_name_index, ) from osism.utils.hardware.parse_lldp import neighbour @@ -321,6 +324,54 @@ def test_sonic_name_index_needs_hwsku_and_port_config(port_config, loguru_logs): assert not any("No HWSKU found" in r["message"] for r in loguru_logs) +def test_sonic_alias_index_maps_vendor_aliases_to_port_names(port_config): + index = sonic_alias_index("Accton-AS7326-56X") + + assert index["twentyFiveGigE1"] == "Ethernet0" + assert index["twentyFiveGigE12"] == "Ethernet11" + assert sonic_alias_index(None) == {} + assert sonic_alias_index("No-Such-HWSKU") == {} + + +# --------------------------------------------------------------------------- +# NetBox devices +# --------------------------------------------------------------------------- + + +def test_fetch_devices_queries_by_id_in_chunks(): + nb = MagicMock() + nb.dcim.devices.filter.side_effect = [ + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + [{"id": 3, "name": "c"}], + ] + + devices = fetch_devices(nb, [3, 1, 2, 1], chunk_size=2) + + assert [c.kwargs for c in nb.dcim.devices.filter.call_args_list] == [ + {"id": [1, 2]}, + {"id": [3]}, + ] + assert [device["name"] for device in devices] == ["a", "b", "c"] + assert fetch_devices(nb, []) == [] + + +def test_find_devices_ignores_case_and_tries_the_short_name(): + nb = MagicMock() + nb.dcim.devices.filter.return_value = [ + {"id": 1, "name": "leaf-1c"}, + {"id": 1, "name": "leaf-1c"}, + {"id": 2, "name": "tor-9"}, + ] + + devices = find_devices(nb, ["Leaf-1C.example.com", "tor-9", "tor-9"]) + + nb.dcim.devices.filter.assert_called_once_with( + name__ie=["Leaf-1C.example.com", "Leaf-1C", "tor-9"] + ) + assert [device["id"] for device in devices] == [1, 2] + assert find_devices(MagicMock(), []) == [] + + # --------------------------------------------------------------------------- # Switch mapping # --------------------------------------------------------------------------- From 564c71db40fdffc2eabcfec536d608afc99d0c26 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 11 Nov 2026 22:38:16 +0100 Subject: [PATCH 3/4] Add the comparison of LLDP neighbours with NetBox cabling NetBox holds the intended cabling and the devices know their neighbours. cabling brings the two together, as a pure function over plain data without SSH or pynetbox. Directory resolves what a neighbour announces. The device is found by system name, case-insensitively and without the domain. A node does not always announce its device name: its host name can differ, it can still have the default name of its image, and the LLDP agent of a NIC can answer in place of the host, with the name of the NIC. The fallbacks are therefore the port ID when it is a MAC address, which is what lldpd sends and which names one interface, then the management addresses, then the chassis ID. A MAC address that NetBox documents on several devices identifies none of them: the USB host interface of a BMC has the same address on every node of a model, and lldpd uses it as chassis ID on some. The port is found by interface name or label, through the HWSKU port config for a SONiC port name or vendor alias, by MAC address, or through the live interfaces of a device that was queried as well. RouterOS announces a bridge port as bridge/ether9; it is compared by the name of the port. compare() then gives every port a result: ok, missing, no-lldp, wrong-port, wrong-device, unexpected, unmapped or stale. The expected peer is the far end of the cable path, so a cable through a patch panel is compared with the device behind it. A management interface without a live counterpart is the BMC of a node and is skipped; its cable is checked from the switch it is plugged into. Two passes look across devices. A port that sees its documented peer becomes one-sided when the peer was queried and sees something else. Mismatches are searched for exchanged cables: when the port that owns the cable seen here in turn sees a cable owned by another mismatch, and the chain leads back, all its members are swapped and name their partners. That covers two ports crossed on one node, the cables of two nodes crossed on a switch, and longer rotations. neighbour_listing() is the variant without NetBox. It keeps the two checks that need no source of truth, no-lldp and one-sided. Tests: the result matrix for single ports, swaps within one device, across devices and as a rotation, both passes with and without a queried peer, management interfaces, and every resolution rule. The captures of a node and of the two switches it is cabled to are compared from both ends. Part of #2877. Assisted-by: Claude:claude-fable-5-2 Signed-off-by: Christian Berendt --- osism/utils/hardware/cabling.py | 660 ++++++++++++ tests/unit/utils/hardware/test_cabling.py | 1175 +++++++++++++++++++++ 2 files changed, 1835 insertions(+) create mode 100644 osism/utils/hardware/cabling.py create mode 100644 tests/unit/utils/hardware/test_cabling.py diff --git a/osism/utils/hardware/cabling.py b/osism/utils/hardware/cabling.py new file mode 100644 index 000000000..febd969e3 --- /dev/null +++ b/osism/utils/hardware/cabling.py @@ -0,0 +1,660 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Comparison of live LLDP neighbours with the cabling documented in NetBox. + +Everything in here works on plain data: the ports and neighbours collected +from the devices, and NetBox interfaces with the far end of their cable +path. There is no SSH and no pynetbox, so the whole result matrix can be +tested without either. + +The check runs in three steps. Every port of a queried device first gets a +result of its own. Ports that look fine are then checked from the other +side, when the peer was queried as well. Finally the mismatches are searched +for cables that were exchanged with each other. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from osism.utils.hardware import normalize_mac +from osism.utils.hardware.mapping import ( + Endpoint, + Match, + NetboxInterface, + neighbour_is_endpoint, + neighbour_port_names, + same_system, +) +from osism.utils.hardware.parse_lldp import Neighbours + +RESULT_OK = "ok" +RESULT_MISSING = "missing" +RESULT_NO_LLDP = "no-lldp" +RESULT_WRONG_PORT = "wrong-port" +RESULT_WRONG_DEVICE = "wrong-device" +RESULT_SWAPPED = "swapped" +RESULT_UNEXPECTED = "unexpected" +RESULT_ONE_SIDED = "one-sided" +RESULT_UNMAPPED = "unmapped" +RESULT_STALE = "stale" + +RESULTS = [ + RESULT_OK, + RESULT_MISSING, + RESULT_NO_LLDP, + RESULT_WRONG_PORT, + RESULT_WRONG_DEVICE, + RESULT_SWAPPED, + RESULT_UNEXPECTED, + RESULT_ONE_SIDED, + RESULT_UNMAPPED, + RESULT_STALE, +] + + +@dataclass +class NetboxDevice: + """A NetBox device reduced to what identifies it to its neighbours.""" + + id: Optional[int] + name: str + hostname: str = "" + addresses: List[str] = field(default_factory=list) + + +@dataclass +class LiveDevice: + """What was collected from one queried device. + + Attributes: + name: Device name as used in the report + kind: ``node`` or ``switch`` + hostname: Inventory hostname, when it differs from the name + device_id: NetBox device ID, ``None`` without NetBox + ports: Live ports that are subject to the check + neighbours: LLDP neighbours per live port name + matches: NetBox interface per live port name + unmatched: Documented interfaces without a live port + """ + + name: str + kind: str + hostname: str = "" + device_id: Optional[int] = None + ports: List[Dict[str, Any]] = field(default_factory=list) + neighbours: Neighbours = field(default_factory=dict) + matches: Dict[str, Match] = field(default_factory=dict) + unmatched: List[NetboxInterface] = field(default_factory=list) + + +@dataclass +class Seen: + """An LLDP neighbour, resolved to NetBox as far as possible. + + ``device`` and ``interface`` hold the NetBox names when the neighbour + was resolved, and what the neighbour announced otherwise. + """ + + device: str + interface: str + neighbour: Dict[str, Any] + device_id: Optional[int] = None + netbox_interface: Optional[NetboxInterface] = None + + +def netbox_device(data: Dict[str, Any]) -> NetboxDevice: + """Build a ``NetboxDevice`` from the plain data of an API record.""" + addresses = [] + for key in ("primary_ip4", "primary_ip6", "oob_ip"): + value = data.get(key) + if isinstance(value, dict): + value = value.get("address") + if value: + addresses.append(str(value).split("/")[0]) + custom_fields = data.get("custom_fields") or {} + return NetboxDevice( + id=data.get("id"), + name=data.get("name") or "", + hostname=custom_fields.get("inventory_hostname") or "", + addresses=addresses, + ) + + +def find_live_port( + device: LiveDevice, neighbour: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + """Find the live port of a queried device a neighbour record points at. + + The port ID or the port description has to equal the name, an altname + or the alias of the port, or the port ID has to be its MAC address. + """ + for value in neighbour_port_names(neighbour): + for port in device.ports: + if value == port["name"] or value == port.get("alias"): + return port + if value in (port.get("altnames") or []): + return port + mac = normalize_mac(neighbour.get("port_id")) + if mac: + for port in device.ports: + if mac in (port.get("permanent_mac"), port.get("mac")): + return port + return None + + +class Directory: + """Resolves LLDP identities to NetBox devices and interfaces. + + Args: + devices: NetBox devices a neighbour may turn out to be + interfaces: NetBox interfaces per device ID + live: The queried devices + sonic_names: Per device ID, NetBox interface by SONiC port name + sonic_aliases: Per device ID, SONiC port name by vendor alias + """ + + def __init__( + self, + devices: Iterable[NetboxDevice] = (), + interfaces: Optional[Dict[int, List[NetboxInterface]]] = None, + live: Iterable[LiveDevice] = (), + sonic_names: Optional[Dict[int, Dict[str, NetboxInterface]]] = None, + sonic_aliases: Optional[Dict[int, Dict[str, str]]] = None, + ): + self.interfaces = interfaces or {} + self.sonic_names = sonic_names or {} + self.sonic_aliases = sonic_aliases or {} + live = list(live) + self.live_by_id = {d.device_id: d for d in live if d.device_id is not None} + self.live_by_name = {d.name: d for d in live} + + self._exact: Dict[str, NetboxDevice] = {} + self._short: Dict[str, NetboxDevice] = {} + self._by_address: Dict[str, NetboxDevice] = {} + self._by_id: Dict[int, NetboxDevice] = {} + for device in devices: + if device.id is not None: + self._by_id[device.id] = device + for name in (device.name, device.hostname): + if name: + self._exact.setdefault(name.lower(), device) + self._short.setdefault(name.lower().split(".")[0], device) + for address in device.addresses: + self._by_address.setdefault(address, device) + + owners: Dict[str, Dict[int, NetboxDevice]] = {} + for device_id, device_interfaces in self.interfaces.items(): + owner = self._by_id.get(device_id) + if owner is None: + continue + for interface in device_interfaces: + for mac in interface.macs: + owners.setdefault(mac, {})[device_id] = owner + # A MAC address documented on several devices identifies none of + # them. The USB host interface of a BMC is one: it has the same + # address on every node of a model. + self._by_mac: Dict[str, NetboxDevice] = { + mac: next(iter(found.values())) + for mac, found in owners.items() + if len(found) == 1 + } + + def resolve_device(self, neighbour: Dict[str, Any]) -> Optional[NetboxDevice]: + """Find the NetBox device a neighbour announces itself as. + + The system name is compared case-insensitively, as given and with + the domain stripped, against device names and inventory hostnames. + + The name does not always lead to the device: a node can have a host + name that differs from its device name, or still have the default + one of its image, and the LLDP agent of a NIC announces the name of + the NIC instead of the host. The fallbacks are, in this order, + the port ID when it is a MAC address, the management addresses and + the chassis ID. ``lldpd`` announces the MAC address of the port as + port ID, which names exactly one interface. Its chassis ID is the + MAC address of some interface of the node, and its management + addresses include the one of ``docker0``; neither is unique among + the nodes. + """ + name = (neighbour.get("system_name") or "").lower() + if name: + device = self._exact.get(name) or self._short.get(name.split(".")[0]) + if device is not None: + return device + mac = normalize_mac(neighbour.get("port_id")) + if mac and mac in self._by_mac: + return self._by_mac[mac] + for address in neighbour.get("management_addresses") or []: + if address in self._by_address: + return self._by_address[address] + mac = normalize_mac(neighbour.get("chassis_id")) + if mac: + return self._by_mac.get(mac) + return None + + def resolve_port( + self, device: NetboxDevice, neighbour: Dict[str, Any] + ) -> Optional[NetboxInterface]: + """Find the NetBox interface of a device a neighbour record names. + + First match wins: the port ID or description is the name or label + of an interface; it is a SONiC port name or vendor alias that the + HWSKU port config translates; the port ID is the MAC address of an + interface; or the device was queried and one of its live ports + carries that name, alias or MAC. + """ + if device.id is None: + return None + interfaces = self.interfaces.get(device.id) or [] + values = neighbour_port_names(neighbour) + + for value in values: + for interface in interfaces: + if value == interface.name or ( + interface.label and value == interface.label + ): + return interface + + index = self.sonic_names.get(device.id) or {} + aliases = self.sonic_aliases.get(device.id) or {} + for value in values: + sonic_name = value if value in index else aliases.get(value) + if sonic_name in index: + return index[sonic_name] + + mac = normalize_mac(neighbour.get("port_id")) + if mac: + for interface in interfaces: + if mac in interface.macs: + return interface + + live = self.live_by_id.get(device.id) + if live is not None: + port = find_live_port(live, neighbour) + if port is not None: + match = live.matches.get(port["name"]) + if match is not None and match.interface is not None: + return match.interface + return None + + def resolve(self, neighbour: Dict[str, Any]) -> Seen: + """Resolve a neighbour record as far as NetBox knows it.""" + announced_device = ( + neighbour.get("system_name") or neighbour.get("chassis_id") or "?" + ) + announced_port = ( + neighbour.get("port_id") or neighbour.get("port_description") or "?" + ) + device = self.resolve_device(neighbour) + if device is None: + return Seen(announced_device, announced_port, neighbour) + interface = self.resolve_port(device, neighbour) + return Seen( + device=device.name, + interface=interface.name if interface else announced_port, + neighbour=neighbour, + device_id=device.id, + netbox_interface=interface, + ) + + def live_device(self, endpoint: Endpoint) -> Optional[LiveDevice]: + """Return the queried device an endpoint belongs to, if it was queried.""" + if endpoint.device_id is not None: + return self.live_by_id.get(endpoint.device_id) + return self.live_by_name.get(endpoint.device) + + +def _row( + device: LiveDevice, + interface: str, + oper_status: Optional[str] = None, + match: Optional[Match] = None, + seen: Sequence[Seen] = (), + result: str = "", + hint: str = "", +) -> Dict[str, Any]: + netbox = match.interface if match else None + return { + "device": device.name, + "kind": device.kind, + "interface": interface, + "netbox_name": netbox.name if netbox else None, + "mapping": match.method if match else None, + "oper_status": oper_status, + "expected": [ + {"device": endpoint.device, "interface": endpoint.interface} + for endpoint in (netbox.endpoints if netbox else []) + ], + "seen": [ + {"device": item.device, "interface": item.interface, **item.neighbour} + for item in seen + ], + "result": result, + "hint": hint, + # Working data of the later passes, dropped by public() + "_device": device, + "_interface": netbox, + "_seen": list(seen), + } + + +def public(row: Dict[str, Any]) -> Dict[str, Any]: + """Return a row without the working data of the comparison.""" + return {key: value for key, value in row.items() if not key.startswith("_")} + + +def _is_endpoint(seen: Seen, endpoint: Endpoint) -> bool: + """Return True when a neighbour is the documented far end.""" + if seen.device_id is None: + return neighbour_is_endpoint(seen.neighbour, endpoint) + if not _same_device(seen, endpoint): + return False + if seen.netbox_interface is not None: + return seen.netbox_interface.name == endpoint.interface + return endpoint.interface in neighbour_port_names(seen.neighbour) + + +def _same_device(seen: Seen, endpoint: Endpoint) -> bool: + if seen.device_id is not None and endpoint.device_id is not None: + return seen.device_id == endpoint.device_id + return same_system(seen.device, endpoint.device) + + +def _documented_at(seen: Seen) -> str: + """Say where NetBox documents the cable that was seen.""" + if seen.device_id is None: + return "neighbour unknown to NetBox" + if seen.netbox_interface is None: + return "neighbour port not found in NetBox" + endpoints = seen.netbox_interface.endpoints + if not endpoints: + return "seen port has no cable in NetBox" + places = ", ".join(f"{e.device}:{e.interface}" for e in endpoints) + return f"documented at {places}" + + +def _check_port( + device: LiveDevice, port: Dict[str, Any], directory: Directory +) -> Dict[str, Any]: + """Give one live port its result, before the cross-device passes.""" + name = port["name"] + link = port.get("oper_status") + match = device.matches.get(name) or Match() + seen = [directory.resolve(n) for n in device.neighbours.get(name) or []] + + def row(result: str = "", hint: str = "") -> Dict[str, Any]: + return _row(device, name, link, match, seen, result, hint) + + if seen and link == "down": + return row(RESULT_STALE, "link down, neighbour not aged out") + + interface = match.interface + if interface is None: + if seen: + return row(RESULT_UNMAPPED, "port not found in NetBox") + # A port NetBox does not know and that has no neighbour says nothing + # about the cabling: the USB NIC of a BMC is one of these. + return row(hint="port not found in NetBox") + + expected = interface.endpoints + if expected: + if not seen: + if link == "down": + return row(RESULT_MISSING, "link down") + return row(RESULT_NO_LLDP, "link up, no neighbour") + if any(_is_endpoint(s, e) for s in seen for e in expected): + return row(RESULT_OK) + on_expected_device = [ + s for s in seen if any(_same_device(s, e) for e in expected) + ] + if on_expected_device: + return row(RESULT_WRONG_PORT, _documented_at(on_expected_device[0])) + return row(RESULT_WRONG_DEVICE, _documented_at(seen[0])) + + reason = ( + "incomplete cable path in NetBox" + if interface.path_incomplete + else "no cable in NetBox" + ) + if seen: + return row(RESULT_UNEXPECTED, reason) + if link == "up" and not interface.path_incomplete: + return row(RESULT_UNEXPECTED, "link up, no neighbour, no cable in NetBox") + return row(hint=reason if interface.path_incomplete else "") + + +def _disagrees(seen_by_peer: Sequence[Seen], back: Endpoint) -> bool: + """Return True when what a peer port sees rules out the other side. + + A neighbour on the right device whose port cannot be identified is not + a disagreement: it shows that the two are connected, just not where. + """ + for seen in seen_by_peer: + if _is_endpoint(seen, back): + return False + if _same_device(seen, back) and seen.netbox_interface is None: + return False + return True + + +def _mark_one_sided(rows: Sequence[Dict[str, Any]], directory: Directory) -> None: + """Turn ``ok`` into ``one-sided`` when a queried peer disagrees. + + This side sees the documented peer. When the peer was queried too, its + port has to see this side in return. + """ + for row in rows: + if row["result"] != RESULT_OK: + continue + device: LiveDevice = row["_device"] + interface: NetboxInterface = row["_interface"] + for endpoint in interface.endpoints: + if not any(_is_endpoint(seen, endpoint) for seen in row["_seen"]): + continue + peer = directory.live_device(endpoint) + if peer is None: + continue + peer_port = next( + ( + name + for name, match in peer.matches.items() + if match.interface is not None + and match.interface.name == endpoint.interface + ), + None, + ) + if peer_port is None: + continue + back = Endpoint( + device=device.name, interface=interface.name, device_id=device.device_id + ) + seen_by_peer = [ + directory.resolve(n) for n in peer.neighbours.get(peer_port) or [] + ] + if _disagrees(seen_by_peer, back): + row["result"] = RESULT_ONE_SIDED + row["hint"] = ( + f"{endpoint.device}:{endpoint.interface} does not see this port" + ) + break + + +def _label(row: Dict[str, Any], other: Dict[str, Any]) -> str: + """Name another port from the point of view of a row.""" + if other["device"] == row["device"]: + return other["interface"] + return f"{other['device']}:{other['interface']}" + + +def _mark_swapped(rows: Sequence[Dict[str, Any]], directory: Directory) -> None: + """Turn mismatches into ``swapped`` when cables were exchanged. + + A mismatching interface I sees an endpoint N. NetBox documents N for + some interface J. When J in turn sees the endpoint documented for I, + the two cables are swapped. Following the same step from J finds + longer rotations: the chain is a swap when it leads back to I. + """ + candidates = { + (row["device"], row["netbox_name"]): row + for row in rows + if row["result"] in (RESULT_WRONG_PORT, RESULT_WRONG_DEVICE) + and row["_interface"] is not None + } + + def owner(row: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return the mismatching port that owns the cable this one sees.""" + for seen in row["_seen"]: + if seen.netbox_interface is None: + continue + for endpoint in seen.netbox_interface.endpoints: + other = candidates.get((endpoint.device, endpoint.interface)) + if other is not None and other is not row: + return other + return None + + done: set = set() + for start in candidates.values(): + if id(start) in done: + continue + chain = [start] + current = owner(start) + while current is not None and all(current is not item for item in chain): + chain.append(current) + current = owner(current) + if current is not start or len(chain) < 2: + continue + for position, row in enumerate(chain): + # Named in the order of the chain: the first partner is the port + # where this port's own cable is documented to be seen + others = chain[position + 1 :] + chain[:position] + partners = [_label(row, other) for other in others] + row["result"] = RESULT_SWAPPED + row["hint"] = f"swapped with {', '.join(partners)}" + done.add(id(row)) + + +def compare( + devices: Sequence[LiveDevice], directory: Directory, include_idle: bool = False +) -> List[Dict[str, Any]]: + """Compare the neighbours of all queried devices with NetBox. + + Args: + devices: The queried devices + directory: Resolver for LLDP identities, holding the NetBox data + include_idle: Also return ports with nothing to report: no cable in + NetBox, no neighbour and no link + + Returns: + One row per port, with ``result`` and ``hint``. Rows of idle ports + have an empty result. + """ + rows: List[Dict[str, Any]] = [] + for device in devices: + for port in device.ports: + rows.append(_check_port(device, port, directory)) + for interface in device.unmatched: + # A management interface without a live counterpart is the BMC + # port of a node. Its cable is checked from the switch side. + if interface.mgmt_only: + continue + if interface.endpoints or interface.path_incomplete: + rows.append( + _row( + device, + interface.name, + match=Match(interface, "none"), + result=RESULT_UNMAPPED, + hint="no live port for this NetBox interface", + ) + ) + + _mark_one_sided(rows, directory) + _mark_swapped(rows, directory) + + return [public(row) for row in rows if include_idle or row["result"]] + + +def neighbour_listing(devices: Sequence[LiveDevice]) -> List[Dict[str, Any]]: + """List the neighbours of all queried devices without NetBox cabling. + + Two checks need no source of truth and stay active: a port with link + but without neighbour is ``no-lldp``, and a neighbour that was queried + as well and does not see this port in return makes it ``one-sided``. + + Returns: + One row per neighbour, plus one per ``no-lldp`` port. Rows without + a finding have an empty result. + """ + by_name: Dict[str, LiveDevice] = {} + for device in devices: + for name in (device.name, device.hostname): + if name: + by_name.setdefault(name.lower(), device) + by_name.setdefault(name.lower().split(".")[0], device) + + def peer_of(neighbour: Dict[str, Any]) -> Optional[LiveDevice]: + name = (neighbour.get("system_name") or "").lower() + if not name: + return None + return by_name.get(name) or by_name.get(name.split(".")[0]) + + def sees(peer: LiveDevice, peer_port: str, device: LiveDevice, port: str) -> bool: + for neighbour in peer.neighbours.get(peer_port) or []: + if peer_of(neighbour) is not device: + continue + found = find_live_port(device, neighbour) + # A port that cannot be identified is not a disagreement + if found is None or found["name"] == port: + return True + return False + + rows: List[Dict[str, Any]] = [] + for device in devices: + ports = {port["name"]: port for port in device.ports} + # lldpd also reports neighbours on interfaces that are not physical + # ports of their own, such as a bridge. They are listed as well. + names = list(ports) + [n for n in device.neighbours if n not in ports] + for name in names: + link = (ports.get(name) or {}).get("oper_status") + neighbours = device.neighbours.get(name) or [] + if not neighbours: + if link == "up": + rows.append( + _row( + device, + name, + link, + result=RESULT_NO_LLDP, + hint="link up, no neighbour", + ) + ) + continue + for neighbour in neighbours: + seen = Seen( + neighbour.get("system_name") or neighbour.get("chassis_id") or "", + neighbour.get("port_id") or "", + neighbour, + ) + row = _row(device, name, link, seen=[seen]) + peer = peer_of(neighbour) + if peer is not None and peer is not device: + peer_port = find_live_port(peer, neighbour) + if peer_port is not None and not sees( + peer, peer_port["name"], device, name + ): + row["result"] = RESULT_ONE_SIDED + row["hint"] = ( + f"{peer.name}:{peer_port['name']} does not see this port" + ) + rows.append(row) + + return [public(row) for row in rows] + + +def summary(rows: Iterable[Dict[str, Any]]) -> Dict[str, int]: + """Count the rows per result, in the order of ``RESULTS``.""" + counts: Dict[str, int] = {} + for row in rows: + if row["result"]: + counts[row["result"]] = counts.get(row["result"], 0) + 1 + return {result: counts[result] for result in RESULTS if result in counts} diff --git a/tests/unit/utils/hardware/test_cabling.py b/tests/unit/utils/hardware/test_cabling.py new file mode 100644 index 000000000..c7684e9e6 --- /dev/null +++ b/tests/unit/utils/hardware/test_cabling.py @@ -0,0 +1,1175 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for ``osism.utils.hardware.cabling``. + +The comparison is a pure function over plain data, so the tests describe a +small rack twice: once as NetBox documents it (``Lab.cable``) and once as +the devices see it (``Lab.live``). Unless a test says otherwise, a live port +carries the name of its NetBox interface, and a neighbour announces itself +with its NetBox device name and interface name. +""" + +import itertools + +import pytest + +from osism.utils.hardware.cabling import ( + RESULTS, + Directory, + LiveDevice, + NetboxDevice, + compare, + find_live_port, + neighbour_listing, + netbox_device, + summary, +) +from osism.utils.hardware import parse_linux, parse_sonic +from osism.utils.hardware.mapping import ( + Endpoint, + Match, + NetboxInterface, + interface_from_netbox, + map_node_ports, + map_switch_ports, +) +from osism.utils.hardware.parse_lldp import ( + linux_neighbours, + neighbour, + sonic_neighbours, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _seen(text): + """Neighbour record for ``device:interface``.""" + device, _, interface = text.partition(":") + return neighbour(system_name=device, port_id=interface) + + +class Lab: + """The documented and the live side of a small topology.""" + + def __init__(self, *devices): + self.ids = {} + self.kinds = {} + self.netbox = {} + self._interface_ids = itertools.count(100) + for device in devices: + self.add(device) + + def add(self, name, kind=None): + self.ids[name] = len(self.ids) + 1 + self.kinds[name] = kind or ( + "switch" if name.startswith(("leaf", "mgmt")) else "node" + ) + self.netbox[self.ids[name]] = {} + return self + + def interface(self, device, name, **kwargs): + if device not in self.ids: + self.add(device) + interfaces = self.netbox[self.ids[device]] + if name not in interfaces: + interfaces[name] = NetboxInterface( + id=next(self._interface_ids), + device_id=self.ids[device], + device=device, + name=name, + **kwargs, + ) + return interfaces[name] + + def cable(self, left, right): + """Document a cable between ``device:interface`` and another one.""" + (left_device, left_name), (right_device, right_name) = ( + left.split(":"), + right.split(":"), + ) + a = self.interface(left_device, left_name) + b = self.interface(right_device, right_name) + a.has_cable = b.has_cable = True + a.endpoints.append( + Endpoint(right_device, right_name, self.ids[right_device], b.id) + ) + b.endpoints.append( + Endpoint(left_device, left_name, self.ids[left_device], a.id) + ) + return self + + def live(self, device, ports): + """Live view of a device. + + Args: + ports: ``{port name: (link state, [seen "device:interface"])}`` + """ + documented = self.netbox[self.ids[device]] + return LiveDevice( + name=device, + kind=self.kinds[device], + device_id=self.ids[device], + ports=[ + {"name": name, "kind": "physical", "oper_status": link, "altnames": []} + for name, (link, _) in ports.items() + ], + neighbours={ + name: [_seen(item) for item in seen] + for name, (_, seen) in ports.items() + if seen + }, + matches={ + name: Match(documented[name], "name") if name in documented else Match() + for name in ports + }, + unmatched=[ + interface for name, interface in documented.items() if name not in ports + ], + ) + + def directory(self, live=(), **kwargs): + return Directory( + [NetboxDevice(device_id, name) for name, device_id in self.ids.items()], + { + device_id: list(found.values()) + for device_id, found in self.netbox.items() + }, + live, + **kwargs, + ) + + def check(self, *live, **kwargs): + """Compare and return ``{(device, interface): (result, hint)}``.""" + rows = compare(list(live), self.directory(live), **kwargs) + return { + (row["device"], row["interface"]): (row["result"], row["hint"]) + for row in rows + } + + +@pytest.fixture +def rack(): + """Two compute nodes, each with one port on each of two leaf switches.""" + return ( + Lab("leaf-1a", "leaf-1b", "compute-041", "compute-042") + .cable("compute-041:ens1f0", "leaf-1a:Eth1/11") + .cable("compute-041:ens1f1", "leaf-1b:Eth1/11") + .cable("compute-042:ens1f0", "leaf-1a:Eth1/12") + .cable("compute-042:ens1f1", "leaf-1b:Eth1/12") + ) + + +# --------------------------------------------------------------------------- +# One port against its documentation +# --------------------------------------------------------------------------- + + +def test_documented_neighbour_is_ok(rack): + live = rack.live( + "compute-041", + {"ens1f0": ("up", ["leaf-1a:Eth1/11"]), "ens1f1": ("up", ["leaf-1b:Eth1/11"])}, + ) + + assert rack.check(live) == { + ("compute-041", "ens1f0"): ("ok", ""), + ("compute-041", "ens1f1"): ("ok", ""), + } + + +def test_removed_cable_is_missing(rack): + live = rack.live( + "compute-041", {"ens1f0": ("down", []), "ens1f1": ("up", ["leaf-1b:Eth1/11"])} + ) + + assert rack.check(live)[("compute-041", "ens1f0")] == ("missing", "link down") + + +@pytest.mark.parametrize("link", ["up", None]) +def test_silent_peer_is_no_lldp(rack, link): + live = rack.live("compute-041", {"ens1f0": (link, [])}) + + assert rack.check(live)[("compute-041", "ens1f0")] == ( + "no-lldp", + "link up, no neighbour", + ) + + +def test_cable_in_another_port_of_the_expected_device_is_wrong_port(rack): + rack.cable("compute-044:ens1f0", "leaf-1a:Eth1/14") + rack.interface("leaf-1a", "Eth1/20") + + documented = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/14"])}) + uncabled = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/20"])}) + unknown = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/99"])}) + + assert rack.check(documented)[("compute-041", "ens1f0")] == ( + "wrong-port", + "documented at compute-044:ens1f0", + ) + assert rack.check(uncabled)[("compute-041", "ens1f0")] == ( + "wrong-port", + "seen port has no cable in NetBox", + ) + assert rack.check(unknown)[("compute-041", "ens1f0")] == ( + "wrong-port", + "neighbour port not found in NetBox", + ) + + +def test_cable_to_another_device_is_wrong_device(rack): + rack.cable("compute-044:ens1f0", "leaf-1c:Eth1/11") + + other = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1c:Eth1/11"])}) + stranger = rack.live("compute-041", {"ens1f0": ("up", ["tor-9:xe-0/0/1"])}) + + assert rack.check(other)[("compute-041", "ens1f0")] == ( + "wrong-device", + "documented at compute-044:ens1f0", + ) + assert rack.check(stranger)[("compute-041", "ens1f0")] == ( + "wrong-device", + "neighbour unknown to NetBox", + ) + + +def test_undocumented_cable_is_unexpected(rack): + rack.interface("compute-041", "ens2f0") + rack.interface("compute-041", "ens2f1") + + live = rack.live( + "compute-041", + {"ens2f0": ("up", ["leaf-1a:Eth1/20"]), "ens2f1": ("up", [])}, + ) + + assert rack.check(live) == { + ("compute-041", "ens2f0"): ("unexpected", "no cable in NetBox"), + ("compute-041", "ens2f1"): ( + "unexpected", + "link up, no neighbour, no cable in NetBox", + ), + # The documented ports were not seen on the node at all + ("compute-041", "ens1f0"): ( + "unmapped", + "no live port for this NetBox interface", + ), + ("compute-041", "ens1f1"): ( + "unmapped", + "no live port for this NetBox interface", + ), + } + + +def test_incomplete_cable_path_gives_no_expectation(rack): + rack.interface("compute-041", "ens2f0", has_cable=True) + rack.interface("compute-041", "ens2f1", has_cable=True) + + live = rack.live( + "compute-041", + { + "ens1f0": ("up", ["leaf-1a:Eth1/11"]), + "ens1f1": ("up", ["leaf-1b:Eth1/11"]), + "ens2f0": ("up", ["leaf-1a:Eth1/20"]), + "ens2f1": ("up", []), + }, + ) + results = rack.check(live, include_idle=True) + + assert results[("compute-041", "ens2f0")] == ( + "unexpected", + "incomplete cable path in NetBox", + ) + # Nothing to compare with and nothing seen: only shown on request + assert results[("compute-041", "ens2f1")] == ("", "incomplete cable path in NetBox") + assert ("compute-041", "ens2f1") not in rack.check(live) + + +def test_port_with_nothing_documented_and_nothing_seen_is_not_listed(rack): + rack.interface("compute-041", "ens2f0") + live = rack.live( + "compute-041", + { + "ens1f0": ("up", ["leaf-1a:Eth1/11"]), + "ens1f1": ("up", ["leaf-1b:Eth1/11"]), + "ens2f0": ("down", []), + "usb0": ("up", []), + }, + ) + + assert sorted(rack.check(live)) == [ + ("compute-041", "ens1f0"), + ("compute-041", "ens1f1"), + ] + + with_idle = rack.check(live, include_idle=True) + assert with_idle[("compute-041", "ens2f0")] == ("", "") + # Unknown to NetBox and silent: says nothing about the cabling + assert with_idle[("compute-041", "usb0")] == ("", "port not found in NetBox") + + +def test_port_that_cannot_be_matched_is_unmapped(rack): + live = rack.live( + "compute-041", + { + "ens1f0": ("up", ["leaf-1a:Eth1/11"]), + "enp9s0": ("up", ["leaf-1b:Eth1/11"]), + }, + ) + + assert rack.check(live) == { + ("compute-041", "ens1f0"): ("ok", ""), + ("compute-041", "enp9s0"): ("unmapped", "port not found in NetBox"), + ("compute-041", "ens1f1"): ( + "unmapped", + "no live port for this NetBox interface", + ), + } + + +def test_documented_interface_without_cable_and_live_port_is_not_listed(rack): + rack.interface("compute-041", "ens9f0") + live = rack.live( + "compute-041", + {"ens1f0": ("up", ["leaf-1a:Eth1/11"]), "ens1f1": ("up", ["leaf-1b:Eth1/11"])}, + ) + + assert ("compute-041", "ens9f0") not in rack.check(live, include_idle=True) + + +@pytest.mark.parametrize("seen", ["leaf-1a:Eth1/11", "leaf-1b:Eth1/40"]) +def test_neighbour_on_a_port_without_link_is_stale(rack, seen): + live = rack.live("compute-041", {"ens1f0": ("down", [seen])}) + + assert rack.check(live)[("compute-041", "ens1f0")] == ( + "stale", + "link down, neighbour not aged out", + ) + + +def test_one_of_several_neighbours_is_enough(rack): + live = rack.live( + "compute-041", + {"ens1f0": ("up", ["hypervisor-bridge:vnet3", "leaf-1a:Eth1/11"])}, + ) + + assert rack.check(live)[("compute-041", "ens1f0")] == ("ok", "") + + +def test_every_endpoint_of_a_breakout_cable_is_accepted(): + lab = ( + Lab("leaf-1a", "compute-041", "compute-042") + .cable("leaf-1a:Eth1/49", "compute-041:ens1f0") + .cable("leaf-1a:Eth1/49", "compute-042:ens1f0") + ) + + first = lab.live("leaf-1a", {"Eth1/49": ("up", ["compute-041:ens1f0"])}) + second = lab.live("leaf-1a", {"Eth1/49": ("up", ["compute-042:ens1f0"])}) + wrong = lab.live("leaf-1a", {"Eth1/49": ("up", ["compute-042:ens1f1"])}) + + assert lab.check(first)[("leaf-1a", "Eth1/49")] == ("ok", "") + assert lab.check(second)[("leaf-1a", "Eth1/49")] == ("ok", "") + assert lab.check(wrong)[("leaf-1a", "Eth1/49")][0] == "wrong-port" + + +def test_cable_through_a_patch_panel_is_compared_with_the_far_end(): + """NetBox reports the panel as link peer and the switch as endpoint.""" + interface = interface_from_netbox( + { + "id": 100, + "name": "ens1f0", + "device": {"id": 1, "name": "compute-041"}, + "type": {"value": "25gbase-x-sfp28"}, + "cable": {"id": 7}, + "link_peers": [ + {"id": 500, "name": "1", "device": {"id": 9, "name": "panel-a"}} + ], + "link_peers_type": "dcim.frontport", + "connected_endpoints": [ + {"id": 200, "name": "Eth1/11", "device": {"id": 2, "name": "leaf-1a"}} + ], + "connected_endpoints_type": "dcim.interface", + "connected_endpoints_reachable": True, + } + ) + directory = Directory( + [ + NetboxDevice(1, "compute-041"), + NetboxDevice(2, "leaf-1a"), + NetboxDevice(9, "panel-a"), + ], + { + 1: [interface], + 2: [NetboxInterface(id=200, device_id=2, device="leaf-1a", name="Eth1/11")], + }, + ) + + def result(seen): + live = LiveDevice( + name="compute-041", + kind="node", + device_id=1, + ports=[{"name": "ens1f0", "oper_status": "up"}], + neighbours={"ens1f0": [_seen(seen)]}, + matches={"ens1f0": Match(interface, "mac")}, + ) + (row,) = compare([live], directory) + return row + + row = result("leaf-1a:Eth1/11") + assert row["result"] == "ok" + assert row["expected"] == [{"device": "leaf-1a", "interface": "Eth1/11"}] + assert result("panel-a:1")["result"] == "wrong-device" + + +# --------------------------------------------------------------------------- +# Exchanged cables +# --------------------------------------------------------------------------- + + +def test_two_cables_swapped_on_one_node(rack): + live = rack.live( + "compute-042", + {"ens1f0": ("up", ["leaf-1b:Eth1/12"]), "ens1f1": ("up", ["leaf-1a:Eth1/12"])}, + ) + + assert rack.check(live) == { + ("compute-042", "ens1f0"): ("swapped", "swapped with ens1f1"), + ("compute-042", "ens1f1"): ("swapped", "swapped with ens1f0"), + } + + +def test_cables_of_two_nodes_swapped_on_the_switch(rack): + first = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/12"])}) + second = rack.live("compute-042", {"ens1f0": ("up", ["leaf-1a:Eth1/11"])}) + + results = rack.check(first, second) + + assert results[("compute-041", "ens1f0")] == ( + "swapped", + "swapped with compute-042:ens1f0", + ) + assert results[("compute-042", "ens1f0")] == ( + "swapped", + "swapped with compute-041:ens1f0", + ) + + +def test_swap_is_found_from_the_switch_side_as_well(rack): + switch = rack.live( + "leaf-1a", + { + "Eth1/11": ("up", ["compute-042:ens1f0"]), + "Eth1/12": ("up", ["compute-041:ens1f0"]), + }, + ) + + assert rack.check(switch) == { + ("leaf-1a", "Eth1/11"): ("swapped", "swapped with Eth1/12"), + ("leaf-1a", "Eth1/12"): ("swapped", "swapped with Eth1/11"), + } + + +def test_swap_with_a_port_that_was_not_queried_stays_a_mismatch(rack): + first = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/12"])}) + + assert rack.check(first)[("compute-041", "ens1f0")] == ( + "wrong-port", + "documented at compute-042:ens1f0", + ) + + +def test_mismatch_whose_partner_is_fine_is_not_a_swap(rack): + first = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/12"])}) + second = rack.live("compute-042", {"ens1f0": ("up", ["leaf-1a:Eth1/12"])}) + + results = rack.check(first, second) + + assert results[("compute-041", "ens1f0")][0] == "wrong-port" + assert results[("compute-042", "ens1f0")] == ("ok", "") + + +def test_rotation_of_three_cables(): + lab = ( + Lab("leaf-1a", "compute-041", "compute-042", "compute-043") + .cable("compute-041:ens1f0", "leaf-1a:Eth1/11") + .cable("compute-042:ens1f0", "leaf-1a:Eth1/12") + .cable("compute-043:ens1f0", "leaf-1a:Eth1/13") + ) + live = [ + lab.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/12"])}), + lab.live("compute-042", {"ens1f0": ("up", ["leaf-1a:Eth1/13"])}), + lab.live("compute-043", {"ens1f0": ("up", ["leaf-1a:Eth1/11"])}), + ] + + assert lab.check(*live) == { + ("compute-041", "ens1f0"): ( + "swapped", + "swapped with compute-042:ens1f0, compute-043:ens1f0", + ), + ("compute-042", "ens1f0"): ( + "swapped", + "swapped with compute-043:ens1f0, compute-041:ens1f0", + ), + ("compute-043", "ens1f0"): ( + "swapped", + "swapped with compute-041:ens1f0, compute-042:ens1f0", + ), + } + + +def test_chain_that_does_not_close_is_not_a_swap(): + lab = ( + Lab("leaf-1a", "compute-041", "compute-042", "compute-043") + .cable("compute-041:ens1f0", "leaf-1a:Eth1/11") + .cable("compute-042:ens1f0", "leaf-1a:Eth1/12") + .cable("compute-043:ens1f0", "leaf-1a:Eth1/13") + ) + lab.interface("leaf-1a", "Eth1/20") + live = [ + lab.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/12"])}), + lab.live("compute-042", {"ens1f0": ("up", ["leaf-1a:Eth1/13"])}), + lab.live("compute-043", {"ens1f0": ("up", ["leaf-1a:Eth1/20"])}), + ] + + results = lab.check(*live) + + assert {result for result, _ in results.values()} == {"wrong-port"} + assert results[("compute-041", "ens1f0")][1] == "documented at compute-042:ens1f0" + + +# --------------------------------------------------------------------------- +# Both ends queried +# --------------------------------------------------------------------------- + + +def test_peer_that_sees_this_side_keeps_ok(rack): + node = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/11"])}) + switch = rack.live("leaf-1a", {"Eth1/11": ("up", ["compute-041:ens1f0"])}) + + results = rack.check(node, switch) + + assert results[("compute-041", "ens1f0")] == ("ok", "") + assert results[("leaf-1a", "Eth1/11")] == ("ok", "") + + +@pytest.mark.parametrize("seen_by_switch", [[], ["compute-041:ens1f1"], ["tor-9:x"]]) +def test_peer_that_does_not_see_this_side_makes_it_one_sided(rack, seen_by_switch): + node = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/11"])}) + switch = rack.live("leaf-1a", {"Eth1/11": ("up", seen_by_switch)}) + + results = rack.check(node, switch) + + assert results[("compute-041", "ens1f0")] == ( + "one-sided", + "leaf-1a:Eth1/11 does not see this port", + ) + + +def test_peer_that_sees_the_node_on_an_unidentified_port_does_not_disagree(rack): + """The switch sees the node, but not under a port name NetBox knows.""" + node = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/11"])}) + switch = rack.live("leaf-1a", {"Eth1/11": ("up", ["compute-041:bond0-slave"])}) + + assert rack.check(node, switch)[("compute-041", "ens1f0")] == ("ok", "") + + +def test_peer_that_was_not_queried_cannot_disagree(rack): + node = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/11"])}) + + assert rack.check(node)[("compute-041", "ens1f0")] == ("ok", "") + + +def test_peer_port_that_is_not_live_cannot_disagree(rack): + node = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Eth1/11"])}) + switch = rack.live("leaf-1a", {"Eth1/12": ("up", ["compute-042:ens1f0"])}) + + assert rack.check(node, switch)[("compute-041", "ens1f0")] == ("ok", "") + + +# --------------------------------------------------------------------------- +# Management interfaces +# --------------------------------------------------------------------------- + + +def test_bmc_port_of_a_node_produces_no_finding_on_the_node(): + lab = Lab("mgmt-1", "compute-041").cable("compute-041:bmc", "mgmt-1:Eth1/5") + lab.netbox[lab.ids["compute-041"]]["bmc"].mgmt_only = True + node = lab.live("compute-041", {}) + + assert lab.check(node, include_idle=True) == {} + + +def test_bmc_cable_is_checked_from_the_oob_switch(): + lab = Lab("mgmt-1", "compute-041").cable("compute-041:bmc", "mgmt-1:Eth1/5") + bmc = lab.netbox[lab.ids["compute-041"]]["bmc"] + bmc.mgmt_only = True + bmc.macs = ["3c:ec:ef:00:00:99"] + + def check(port_id): + switch = lab.live("mgmt-1", {"Eth1/5": ("up", [])}) + # A BMC announces its MAC address, not an interface name + switch.neighbours["Eth1/5"] = [ + neighbour(system_name="compute-041", port_id=port_id, port_id_type="mac") + ] + return lab.check(switch)[("mgmt-1", "Eth1/5")][0] + + assert check("3c:ec:ef:00:00:99") == "ok" + assert check("3c:ec:ef:00:00:11") == "wrong-port" + + +def test_management_port_of_a_switch_is_checked(): + lab = Lab("mgmt-1", "leaf-1a").cable("leaf-1a:eth0", "mgmt-1:Eth1/1") + lab.netbox[lab.ids["leaf-1a"]]["eth0"].mgmt_only = True + + def check(link, seen): + switch = lab.live("leaf-1a", {"eth0": (link, seen)}) + switch.ports[0]["kind"] = "mgmt" + return lab.check(switch)[("leaf-1a", "eth0")][0] + + assert check("up", ["mgmt-1:Eth1/1"]) == "ok" + assert check("down", []) == "missing" + assert check("up", ["mgmt-1:Eth1/2"]) == "wrong-port" + + +# --------------------------------------------------------------------------- +# Resolving LLDP identities +# --------------------------------------------------------------------------- + + +@pytest.fixture +def directory(): + interfaces = { + 1: [ + NetboxInterface( + id=11, device_id=1, device="leaf-1a", name="Eth1/11", label="port 11" + ), + NetboxInterface(id=12, device_id=1, device="leaf-1a", name="Eth1/12"), + ], + 2: [ + NetboxInterface( + id=21, + device_id=2, + device="sto-1", + name="nic1", + macs=["aa:aa:aa:00:00:21"], + ), + NetboxInterface(id=22, device_id=2, device="sto-1", name="nic2"), + ], + } + live = LiveDevice( + name="sto-1", + kind="node", + device_id=2, + ports=[ + { + "name": "ens1f0", + "altnames": ["enp59s0f0"], + "permanent_mac": "aa:aa:aa:00:00:21", + }, + { + "name": "ens1f1", + "altnames": [], + "permanent_mac": "aa:aa:aa:00:00:22", + "alias": "uplink-b", + }, + ], + matches={ + "ens1f0": Match(interfaces[2][0], "mac"), + "ens1f1": Match(interfaces[2][1], "name"), + }, + ) + return Directory( + [ + NetboxDevice(1, "leaf-1a", addresses=["10.10.0.11"]), + NetboxDevice(2, "sto-1", hostname="storage-001"), + ], + interfaces, + [live], + sonic_names={ + 1: {"Ethernet10": interfaces[1][0], "Ethernet11": interfaces[1][1]} + }, + sonic_aliases={1: {"twentyFiveGigE12": "Ethernet11"}}, + ) + + +@pytest.mark.parametrize( + "record,expected", + [ + (neighbour(system_name="leaf-1a"), "leaf-1a"), + (neighbour(system_name="LEAF-1A"), "leaf-1a"), + (neighbour(system_name="leaf-1a.example.com"), "leaf-1a"), + (neighbour(system_name="storage-001"), "sto-1"), + (neighbour(system_name="Storage-001.example.com"), "sto-1"), + (neighbour(system_name="x", management_addresses=["10.10.0.11"]), "leaf-1a"), + (neighbour(chassis_id="AA:AA:AA:00:00:21", chassis_id_type="mac"), "sto-1"), + # The port ID names one interface and wins over address and chassis + ( + neighbour( + system_name="x", + port_id="aa:aa:aa:00:00:21", + port_id_type="mac", + management_addresses=["10.10.0.11"], + ), + "sto-1", + ), + (neighbour(system_name="x", port_id="de:ad:be:ef:00:02"), None), + (neighbour(system_name="tor-9", chassis_id="de:ad:be:ef:00:01"), None), + (neighbour(), None), + ], +) +def test_neighbour_device_resolution(directory, record, expected): + device = directory.resolve_device(record) + + assert (device.name if device else None) == expected + + +@pytest.mark.parametrize( + "device,record,expected", + [ + # 1. Name or label of a NetBox interface + ("leaf-1a", neighbour(port_id="Eth1/12"), "Eth1/12"), + ("leaf-1a", neighbour(port_id="x", port_description="Eth1/12"), "Eth1/12"), + ("leaf-1a", neighbour(port_id="port 11"), "Eth1/11"), + # 2. SONiC port name or vendor alias, through the port config + ("leaf-1a", neighbour(port_id="Ethernet10"), "Eth1/11"), + ("leaf-1a", neighbour(port_id="x", port_description="Ethernet11"), "Eth1/12"), + ("leaf-1a", neighbour(port_id="twentyFiveGigE12"), "Eth1/12"), + # 3. MAC address of a NetBox interface + ("sto-1", neighbour(port_id="AA:AA:AA:00:00:21", port_id_type="mac"), "nic1"), + # 4. Live interface of a queried device: name, altname, MAC, alias + ("sto-1", neighbour(port_id="ens1f1"), "nic2"), + ("sto-1", neighbour(port_id="enp59s0f0"), "nic1"), + ("sto-1", neighbour(port_id="aa:aa:aa:00:00:22"), "nic2"), + ("sto-1", neighbour(port_id="x", port_description="uplink-b"), "nic2"), + # Nothing matches + ("leaf-1a", neighbour(port_id="Ethernet99"), None), + ("sto-1", neighbour(port_id="eth7"), None), + ], +) +def test_neighbour_port_resolution(directory, device, record, expected): + netbox = directory.resolve_device(neighbour(system_name=device)) + + interface = directory.resolve_port(netbox, record) + + assert (interface.name if interface else None) == expected + + +@pytest.mark.parametrize( + "system_name", + [ + # A host name that is not the device name + "deploy-1", + # The default host name of an image + "ubuntu", + # The LLDP agent of a NIC, which answers instead of the host + "Broadcom P225p NetXtreme-E Dual-port 10Gb/25Gb Ethernet PCIe Ada", + ], +) +def test_port_id_identifies_a_neighbour_that_announces_another_name( + directory, system_name +): + seen = directory.resolve( + neighbour( + system_name=system_name, + chassis_id="de:ad:be:ef:00:01", + chassis_id_type="mac", + port_id="aa:aa:aa:00:00:21", + port_id_type="mac", + port_description="data1", + management_addresses=["172.17.0.1"], + ) + ) + + assert (seen.device, seen.interface, seen.device_id) == ("sto-1", "nic1", 2) + + +def test_mac_address_documented_on_several_devices_identifies_none(): + """The USB host interface of a BMC has the same address on every node. + + ``lldpd`` picks it as chassis ID on some of them. + """ + shared = "02:00:00:00:00:1f" + interfaces = { + device_id: [ + NetboxInterface( + id=device_id * 10, + device_id=device_id, + device=name, + name="usb0", + macs=[shared], + ), + NetboxInterface( + id=device_id * 10 + 1, + device_id=device_id, + device=name, + name="data2", + macs=[mac], + ), + ] + for device_id, name, mac in ( + (1, "node-2", "02:00:00:00:00:0a"), + (2, "node-3", "02:00:00:00:00:0b"), + ) + } + directory = Directory( + [NetboxDevice(1, "node-2"), NetboxDevice(2, "node-3")], interfaces + ) + + by_chassis = neighbour(system_name="ubuntu", chassis_id=shared) + by_port = neighbour( + system_name="ubuntu", chassis_id=shared, port_id="02:00:00:00:00:0b" + ) + + assert directory.resolve_device(by_chassis) is None + assert directory.resolve_device(by_port).name == "node-3" + assert directory.resolve(by_port).interface == "data2" + + +def test_bridge_port_of_routeros_is_compared_by_the_name_of_the_port(): + """RouterOS announces ``bridge/ether9`` for the port ``ether9``.""" + lab = Lab("leaf-1a", "mgmt-1").cable("leaf-1a:eth0", "mgmt-1:ether9") + leaf = lab.live("leaf-1a", {"eth0": ("up", [])}) + leaf.neighbours["eth0"] = [ + neighbour(system_name="mgmt-1", port_id="bridge/ether9", port_id_type="ifname") + ] + + assert lab.check(leaf) == {("leaf-1a", "eth0"): ("ok", "")} + + # In another port of the bridge it is still the wrong port + leaf.neighbours["eth0"][0]["port_id"] = "bridge/ether10" + assert lab.check(leaf)[("leaf-1a", "eth0")][0] == "wrong-port" + + +def test_port_name_wins_over_a_sonic_translation(directory): + """``Ethernet10`` would translate, but an interface of that name exists.""" + directory.interfaces[1].append( + NetboxInterface(id=13, device_id=1, device="leaf-1a", name="Ethernet10") + ) + netbox = directory.resolve_device(neighbour(system_name="leaf-1a")) + + assert directory.resolve_port(netbox, neighbour(port_id="Ethernet10")).id == 13 + + +def test_resolve_keeps_what_an_unknown_neighbour_announced(directory): + stranger = directory.resolve(neighbour(system_name="tor-9", port_id="xe-0/0/1")) + nameless = directory.resolve(neighbour(chassis_id="de:ad:be:ef:00:01")) + half = directory.resolve(neighbour(system_name="leaf-1a", port_id="Ethernet99")) + + assert (stranger.device, stranger.interface, stranger.device_id) == ( + "tor-9", + "xe-0/0/1", + None, + ) + assert (nameless.device, nameless.interface) == ("de:ad:be:ef:00:01", "?") + assert (half.device, half.interface, half.device_id) == ("leaf-1a", "Ethernet99", 1) + assert half.netbox_interface is None + + +def test_switch_that_advertises_sonic_names_is_compared_through_the_port_config(rack): + """The node sees ``Ethernet10``; NetBox calls that port ``Eth1/11``.""" + node = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1a:Ethernet10"])}) + leaf = rack.netbox[rack.ids["leaf-1a"]] + directory = rack.directory( + [node], sonic_names={rack.ids["leaf-1a"]: {"Ethernet10": leaf["Eth1/11"]}} + ) + + (row,) = [r for r in compare([node], directory) if r["interface"] == "ens1f0"] + + assert row["result"] == "ok" + assert row["seen"][0]["interface"] == "Eth1/11" + assert row["seen"][0]["port_id"] == "Ethernet10" + + +def test_netbox_device_collects_names_and_addresses(): + device = netbox_device( + { + "id": 7, + "name": "sto-1", + "custom_fields": {"inventory_hostname": "storage-001"}, + "primary_ip4": {"address": "192.0.2.7/24"}, + "primary_ip6": None, + "oob_ip": {"address": "198.51.100.7/24"}, + } + ) + + assert device == NetboxDevice( + 7, "sto-1", hostname="storage-001", addresses=["192.0.2.7", "198.51.100.7"] + ) + assert netbox_device({"id": 8, "name": "x"}) == NetboxDevice(8, "x") + + +def test_find_live_port_without_a_match(): + device = LiveDevice(name="n", kind="node", ports=[{"name": "eth0", "altnames": []}]) + + assert find_live_port(device, neighbour(port_id="eth9")) is None + assert find_live_port(device, neighbour()) is None + + +# --------------------------------------------------------------------------- +# Captured devices +# --------------------------------------------------------------------------- + + +def _captured_node(lab, name, document): + ports = parse_linux.parse_ports(document) + matches, unmatched = map_node_ports(ports, list(lab.netbox[lab.ids[name]].values())) + return LiveDevice( + name=name, + kind="node", + device_id=lab.ids[name], + ports=ports, + neighbours=linux_neighbours(document), + matches=matches, + unmatched=unmatched, + ) + + +def _captured_switch(lab, name, document): + ports = parse_sonic.parse_ports(document) + [parse_sonic.parse_mgmt_port(document)] + matches, unmatched = map_switch_ports( + ports, list(lab.netbox[lab.ids[name]].values()) + ) + return LiveDevice( + name=name, + kind="switch", + device_id=lab.ids[name], + ports=ports, + neighbours=sonic_neighbours(document), + matches=matches, + unmatched=unmatched, + ) + + +def test_captured_cables_are_ok_from_both_ends(load_fixture): + """A node and the two leaf switches it is cabled to, as captured. + + NetBox names the switch ports by their alias. The switches announce the + alias, the node the MAC address and the name of its port, and the + RouterOS switch of the management network a bridge port. + """ + lab = Lab("leaf-1", "leaf-2", "node-1", "oob-1") + lab.interface("leaf-1", "eth0", mgmt_only=True) + lab.interface("leaf-2", "eth0", mgmt_only=True) + lab.cable("node-1:ens4f0np0", "leaf-1:Eth1/1/1") + lab.cable("node-1:ens4f1np1", "leaf-2:Eth1/1/1") + lab.cable("node-1:enp4s0f0", "oob-1:ether14") + lab.cable("leaf-1:eth0", "oob-1:ether9") + lab.cable("leaf-2:eth0", "oob-1:ether8") + lab.cable("leaf-1:Eth1/31", "leaf-2:Eth1/31") + lab.cable("leaf-1:Eth1/32", "leaf-2:Eth1/32") + + node = _captured_node(lab, "node-1", load_fixture("linux-connectx4.json")) + leaf_1 = _captured_switch(lab, "leaf-1", load_fixture("sonic-enterprise.json")) + leaf_2 = _captured_switch(lab, "leaf-2", load_fixture("sonic-enterprise-4.4.json")) + results = lab.check(node, leaf_1, leaf_2) + + for port in ( + ("node-1", "ens4f0np0"), + ("node-1", "ens4f1np1"), + ("node-1", "enp4s0f0"), + ("leaf-1", "Ethernet0"), + ("leaf-1", "Ethernet120"), + ("leaf-1", "Ethernet124"), + ("leaf-1", "eth0"), + ("leaf-2", "Ethernet0"), + ("leaf-2", "Ethernet120"), + ("leaf-2", "Ethernet124"), + ("leaf-2", "eth0"), + ): + assert results[port] == ("ok", ""), port + # The unused onboard port of the node has nothing to report + assert ("node-1", "enp4s0f1") not in results + + +def test_captured_neighbours_with_another_name_are_found_by_their_port(load_fixture): + """Ethernet6 sees a host name that is not a device name, Ethernet9 the + LLDP agent of a NIC. NetBox documents the MAC addresses of the ports.""" + lab = Lab("leaf-1", "node-17", "node-20") + lab.cable("leaf-1:Eth1/2/3", "node-17:data1") + lab.cable("leaf-1:Eth1/3/2", "node-20:data1") + lab.interface("node-17", "data1").macs.append("02:00:00:00:00:03") + lab.interface("node-20", "data1").macs.append("02:00:00:00:00:16") + + leaf = _captured_switch(lab, "leaf-1", load_fixture("sonic-enterprise.json")) + results = lab.check(leaf) + + assert results[("leaf-1", "Ethernet6")] == ("ok", "") + assert results[("leaf-1", "Ethernet9")] == ("ok", "") + + +def test_captured_cable_in_the_wrong_cage_is_found(load_fixture): + """NetBox documents node-1 in the cage next to the one it is plugged in.""" + lab = Lab("leaf-1", "node-1").cable("node-1:ens4f0np0", "leaf-1:Eth1/2/1") + + node = _captured_node(lab, "node-1", load_fixture("linux-connectx4.json")) + leaf = _captured_switch(lab, "leaf-1", load_fixture("sonic-enterprise.json")) + results = lab.check(node, leaf) + + assert results[("node-1", "ens4f0np0")][0] == "wrong-port" + # Ethernet4 is the documented port: no link, no neighbour + assert results[("leaf-1", "Ethernet4")] == ("missing", "link down") + # Ethernet0 is where the node is: NetBox does not know that port + assert results[("leaf-1", "Ethernet0")] == ("unmapped", "port not found in NetBox") + + +# --------------------------------------------------------------------------- +# Rows, listing without NetBox, summary +# --------------------------------------------------------------------------- + + +def test_rows_carry_expected_and_seen_without_working_data(rack): + live = rack.live("compute-041", {"ens1f0": ("up", ["leaf-1b:Eth1/11"])}) + + rows = compare([live], rack.directory([live])) + (row,) = [r for r in rows if r["interface"] == "ens1f0"] + + assert row == { + "device": "compute-041", + "kind": "node", + "interface": "ens1f0", + "netbox_name": "ens1f0", + "mapping": "name", + "oper_status": "up", + "expected": [{"device": "leaf-1a", "interface": "Eth1/11"}], + "seen": [ + dict( + {"device": "leaf-1b", "interface": "Eth1/11"}, + **_seen("leaf-1b:Eth1/11"), + ) + ], + "result": "wrong-device", + "hint": "documented at compute-041:ens1f1", + } + + +def _plain(name, ports, kind="node", hostname=""): + return LiveDevice( + name=name, + kind=kind, + hostname=hostname, + ports=[ + {"name": port, "oper_status": link, "altnames": [], "alias": alias} + for port, (link, alias, _) in ports.items() + ], + neighbours={port: seen for port, (_, _, seen) in ports.items() if seen}, + ) + + +def test_listing_has_one_row_per_neighbour(): + node = _plain( + "compute-041", + { + "ens1f0": ("up", None, [_seen("leaf-1a:Eth1/11"), _seen("bridge:vnet0")]), + "ens1f1": ("down", None, []), + }, + ) + node.neighbours["br0"] = [_seen("leaf-1b:Eth1/11")] + + rows = neighbour_listing([node]) + + assert [ + (r["interface"], r["seen"][0]["system_name"], r["result"]) for r in rows + ] == [ + ("ens1f0", "leaf-1a", ""), + ("ens1f0", "bridge", ""), + ("br0", "leaf-1b", ""), + ] + assert rows[0]["expected"] == [] + assert rows[0]["netbox_name"] is None + + +def test_listing_flags_a_port_with_link_but_without_neighbour(): + node = _plain( + "compute-041", {"ens1f0": ("up", None, []), "ens1f1": (None, None, [])} + ) + + (row,) = neighbour_listing([node]) + + assert (row["interface"], row["result"], row["hint"]) == ( + "ens1f0", + "no-lldp", + "link up, no neighbour", + ) + assert row["seen"] == [] + + +def test_listing_flags_a_queried_neighbour_that_does_not_see_this_port(): + node = _plain( + "sto-1", + { + "ens1f0": ("up", None, [_seen("leaf-1a:Eth1/11")]), + "ens1f1": ("down", None, []), + }, + hostname="storage-001", + ) + agrees = _plain( + "leaf-1a", + {"Ethernet10": ("up", "Eth1/11", [_seen("storage-001.example.com:ens1f0")])}, + kind="switch", + ) + silent = _plain("leaf-1a", {"Ethernet10": ("up", "Eth1/11", [])}, kind="switch") + elsewhere = _plain( + "leaf-1a", + {"Ethernet10": ("up", "Eth1/11", [_seen("storage-001:ens1f1")])}, + kind="switch", + ) + + def result(switch): + rows = neighbour_listing([node, switch]) + return [(r["result"], r["hint"]) for r in rows if r["device"] == "sto-1"] + + assert result(agrees) == [("", "")] + assert result(silent) == [ + ("one-sided", "leaf-1a:Ethernet10 does not see this port") + ] + assert result(elsewhere) == [ + ("one-sided", "leaf-1a:Ethernet10 does not see this port") + ] + + +def test_listing_accepts_a_neighbour_that_sees_this_device_on_an_unknown_port(): + node = _plain("sto-1", {"ens1f0": ("up", None, [_seen("leaf-1a:Eth1/11")])}) + switch = _plain( + "leaf-1a", + {"Ethernet10": ("up", "Eth1/11", [_seen("sto-1:aa:bb:cc:00:00:01")])}, + kind="switch", + ) + + rows = neighbour_listing([node, switch]) + + assert [r["result"] for r in rows if r["device"] == "sto-1"] == [""] + + +def test_listing_does_not_judge_neighbours_that_were_not_queried(): + node = _plain("compute-041", {"ens1f0": ("up", None, [_seen("leaf-1a:Eth1/11")])}) + other = _plain("leaf-1b", {"Ethernet10": ("up", "Eth1/11", [])}, kind="switch") + + rows = neighbour_listing([node, other]) + + assert [r["result"] for r in rows if r["device"] == "compute-041"] == [""] + + +def test_summary_counts_results_in_a_fixed_order(): + rows = [ + {"result": "swapped"}, + {"result": "ok"}, + {"result": ""}, + {"result": "swapped"}, + {"result": "missing"}, + ] + + assert list(summary(rows).items()) == [("ok", 1), ("missing", 1), ("swapped", 2)] + assert summary([]) == {} + assert RESULTS == [ + "ok", + "missing", + "no-lldp", + "wrong-port", + "wrong-device", + "swapped", + "unexpected", + "one-sided", + "unmapped", + "stale", + ] From b358370fb1bc6c589d5652bd5fa45a43e38dbc47 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 11 Nov 2026 22:38:16 +0100 Subject: [PATCH 4/4] Extend report lldp into a cabling check Cabling errors are the most common fault after racking and after hardware replacement, and they are slow to find: two swapped cables still give link on both ports. osism report lldp printed the raw neighbours of the nodes, to be read next to NetBox by hand. The command now collects the neighbours of nodes and SONiC switches in parallel and compares them with the cabling in NetBox. Every port with a cable in NetBox or a live neighbour gets a result and a hint, so a missing cable, a cable in the wrong port and swapped cables are visible at a glance. Devices are selected with the options of report transceiver, and only selected devices are logged in to; documented peers outside the selection are read from NetBox. The management port of a switch is part of the check unless --no-mgmt is given. --problems, --result and --interface filter the rows, and the output is a table, CSV or JSON. --strict exits with 1 when a device failed or a port has a result other than ok, whatever the filters hide. --no-compare, and a setup without NetBox, give the neighbour listing with the columns the command had before, now also for switches. The command exits with 1 when nothing was selected, where it used to return success for an empty inventory. A node without a running lldpd is reported as a failed device instead of as a host without neighbours. Tests: the existing lldp tests run against the new collection path and keep their assertions; the failure summary names devices instead of hosts. New tests cover the comparison, the swap across nodes, the management port, the filters and formats, --strict, failed devices, the lookup of unknown neighbours and the listing without NetBox. Closes #2877 Assisted-by: Claude:claude-fable-5-2 Signed-off-by: Christian Berendt --- osism/commands/report.py | 538 ++++++++++++----- tests/unit/commands/test_report.py | 917 ++++++++++++++++++++++++++++- 2 files changed, 1280 insertions(+), 175 deletions(-) diff --git a/osism/commands/report.py b/osism/commands/report.py index 2ab308f32..40c48a324 100644 --- a/osism/commands/report.py +++ b/osism/commands/report.py @@ -12,7 +12,7 @@ from osism import settings, utils from osism.commands.console import resolve_host_with_fallback -from osism.utils.hardware import parse_linux, parse_sonic +from osism.utils.hardware import cabling, parse_linux, parse_sonic from osism.utils.hardware.collect import ( Request, add_collection_arguments, @@ -20,13 +20,20 @@ dump_raw, ) from osism.utils.hardware.mapping import ( + fetch_devices, fetch_interfaces, + find_devices, map_node_ports, map_switch_ports, physical_interfaces, + sonic_alias_index, sonic_name_index, ) -from osism.utils.hardware.parse_lldp import LldpError, linux_neighbours +from osism.utils.hardware.parse_lldp import ( + LldpError, + linux_neighbours, + sonic_neighbours, +) from osism.utils.hardware.render import ( Column, RenderError, @@ -189,158 +196,6 @@ def take_action(self, parsed_args): ) -class Lldp(Command): - def get_parser(self, prog_name): - parser = super(Lldp, self).get_parser(prog_name) - parser.add_argument( - "-l", - "--limit", - type=str, - help="Limit selected hosts to an additional pattern", - ) - return parser - - def take_action(self, parsed_args): - if not ensure_known_hosts_file(): - logger.warning( - f"Could not initialize {KNOWN_HOSTS_PATH}, SSH may show warnings" - ) - - try: - command = [ - "ansible-inventory", - "-i", - get_inventory_path("/ansible/inventory/hosts.yml"), - "--list", - ] - if parsed_args.limit: - command.extend(["--limit", parsed_args.limit]) - - result = subprocess.run( - command, - capture_output=True, - text=True, - timeout=30, - ) - - if result.returncode != 0: - logger.error("Error loading inventory.") - return 1 - except subprocess.TimeoutExpired: - logger.error("Timeout loading inventory.") - return 1 - - data = json.loads(result.stdout) - hosts = get_hosts_from_inventory(data) - - if not hosts: - logger.error("No hosts found in inventory.") - return - - ssh_base = [ - "/usr/bin/ssh", - "-i", - "/ansible/secrets/id_rsa.operator", - "-o", - "StrictHostKeyChecking=no", - "-o", - "LogLevel=ERROR", - "-o", - f"UserKnownHostsFile={KNOWN_HOSTS_PATH}", - "-o", - "ConnectTimeout=10", - ] - lldp_command = "lldpctl -f json" - - table = [] - failed_hosts = [] - - for host in hosts: - resolved_host = resolve_host_with_fallback(host) - - try: - lldp_result = subprocess.run( - [ - *ssh_base, - f"{settings.OPERATOR_USER}@{resolved_host}", - lldp_command, - ], - capture_output=True, - text=True, - timeout=30, - ) - - if lldp_result.returncode != 0: - logger.warning( - f"Failed to get LLDP info from {host}: {lldp_result.stderr.strip()}" - ) - failed_hosts.append(host) - continue - - lldp_data = json.loads(lldp_result.stdout) - interfaces = lldp_data.get("lldp", {}).get("interface", {}) - - # lldpctl returns a list of dicts for multiple interfaces, - # but a single dict for one interface. Normalize to list. - if isinstance(interfaces, dict): - interfaces = [{k: v} for k, v in interfaces.items()] - - for iface_entry in interfaces: - for local_iface, iface_data in iface_entry.items(): - chassis = iface_data.get("chassis", {}) - remote_switch = next(iter(chassis), "n/a") - - port = iface_data.get("port", {}) - remote_port = port.get("id", {}).get("value", "n/a") - port_descr = port.get("descr", "") - - age = iface_data.get("age", "n/a") - - table.append( - [ - host, - local_iface, - remote_switch, - remote_port, - port_descr, - age, - ] - ) - - except subprocess.TimeoutExpired: - logger.warning(f"Timeout connecting to {host}.") - failed_hosts.append(host) - except (ValueError, json.JSONDecodeError): - logger.warning(f"Could not parse LLDP info from {host}.") - failed_hosts.append(host) - - if table: - print( - tabulate( - table, - headers=[ - "Host", - "Local Interface", - "Remote Switch", - "Remote Port", - "Port Description", - "Age", - ], - tablefmt="psql", - ) - ) - print() - print(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print(f"Hosts: {len(set(row[0] for row in table))}") - print(f"Neighbors: {len(table)}") - - if failed_hosts: - print() - logger.warning( - f"Failed to query {len(failed_hosts)} host(s): {', '.join(failed_hosts)}" - ) - - class Bgp(Command): def get_parser(self, prog_name): parser = super(Bgp, self).get_parser(prog_name) @@ -1054,3 +909,378 @@ def take_action(self, parsed_args): _warn_failed(failed) if parsed_args.strict and failed: return 1 + + +LLDP_COMPARE_COLUMNS = [ + Column("device", "Device"), + Column("interface", "Interface"), + Column("netbox_interface", "NetBox Interface", default=False), + Column("mapping", "Mapping", default=False), + Column("oper_status", "Link", default=False), + Column("expected", "Expected"), + Column("seen", "Seen"), + Column("result", "Result"), + Column("hint", "Hint"), + Column("remote_system", "Remote Switch", default=False), + Column("remote_port", "Remote Port", default=False), + Column("port_description", "Port Description", default=False), + Column("chassis_id", "Chassis ID", default=False), + Column("management_addresses", "Management Address", default=False), + Column("age", "Age", default=False), +] + +# Without the comparison the report is the neighbour listing it used to be, +# with the two checks that need no NetBox data appended. +LLDP_LISTING_COLUMNS = [ + Column("device", "Host"), + Column("interface", "Local Interface"), + Column("remote_system", "Remote Switch"), + Column("remote_port", "Remote Port"), + Column("port_description", "Port Description"), + Column("age", "Age"), + Column("result", "Result"), + Column("hint", "Hint"), + Column("oper_status", "Link", default=False), + Column("chassis_id", "Chassis ID", default=False), + Column("management_addresses", "Management Address", default=False), +] + + +def _lldp_device(target, document, netbox_interfaces, compare, no_mgmt): + """Build the live view of one device from its collector document. + + Raises: + LldpError: The device has no usable LLDP data + """ + if target.kind == KIND_SWITCH: + ports = parse_sonic.parse_ports(document) + neighbours = sonic_neighbours(document) + mgmt_port = parse_sonic.parse_mgmt_port(document) + if no_mgmt: + neighbours.pop(parse_sonic.MGMT_PORT, None) + elif mgmt_port is not None: + ports.append(mgmt_port) + else: + ports = parse_linux.parse_ports(document) + neighbours = linux_neighbours(document) + + device = cabling.LiveDevice( + name=target.name, + kind=target.kind, + hostname=target.hostname, + device_id=target.device_id, + ports=ports, + neighbours=neighbours, + ) + if not compare: + return device + + # LLDP runs on the member ports, and NetBox cables are attached to them + documented = physical_interfaces(netbox_interfaces, include_mgmt=not no_mgmt) + if target.kind == KIND_SWITCH: + hwsku = target.hwsku or parse_sonic.device_metadata(document).get("hwsku") + index = sonic_name_index( + target.device_id, target.name, hwsku, netbox_interfaces + ) + device.matches, device.unmatched = map_switch_ports(ports, documented, index) + else: + # Not matched by LLDP neighbour here: a port that is only identified + # by what it is plugged into can never be found in the wrong place. + device.matches, device.unmatched = map_node_ports(ports, documented) + return device + + +def _lldp_directory(targets, devices, netbox_interfaces): + """Load what is needed to resolve LLDP identities and build the resolver. + + Besides the selected devices, NetBox data is loaded for the documented + peers of their interfaces and for neighbours that announce a name NetBox + knows. None of those devices is logged in to. + """ + nb = utils.nb + netbox_devices = {t.device_id: t.device for t in targets if t.device_id} + interfaces = dict(netbox_interfaces) + + peer_ids = { + endpoint.device_id + for device_interfaces in interfaces.values() + for interface in device_interfaces + for endpoint in interface.endpoints + if endpoint.device_id is not None + } - set(netbox_devices) + if peer_ids: + for device in fetch_devices(nb, peer_ids): + netbox_devices[device["id"]] = device + interfaces.update(fetch_interfaces(nb, peer_ids)) + + known = cabling.Directory( + [cabling.netbox_device(device) for device in netbox_devices.values()], + interfaces, + ) + unknown = sorted( + { + neighbour["system_name"] + for device in devices + for neighbours in device.neighbours.values() + for neighbour in neighbours + if neighbour.get("system_name") and known.resolve_device(neighbour) is None + } + ) + if unknown: + found = [ + device + for device in find_devices(nb, unknown) + if device.get("id") not in netbox_devices + ] + if found: + for device in found: + netbox_devices[device["id"]] = device + interfaces.update(fetch_interfaces(nb, [d["id"] for d in found])) + + sonic_names = {} + sonic_aliases = {} + for device_id, device in netbox_devices.items(): + custom_fields = device.get("custom_fields") or {} + hwsku = (custom_fields.get("sonic_parameters") or {}).get("hwsku") + if not hwsku: + continue + sonic_names[device_id] = sonic_name_index( + device_id, device.get("name") or "", hwsku, interfaces.get(device_id) or [] + ) + sonic_aliases[device_id] = sonic_alias_index(hwsku) + + return cabling.Directory( + [cabling.netbox_device(device) for device in netbox_devices.values()], + interfaces, + devices, + sonic_names, + sonic_aliases, + ) + + +def _lldp_flat(row, listing): + """Flatten a result row for the table and CSV.""" + seen = row["seen"] + # A neighbour that announces no name or port shows as n/a in the + # listing, as it always did + missing = "n/a" if listing and seen else "" + + def join(key, default=""): + values = [str(item[key]) for item in seen if item.get(key)] + return ", ".join(values) or default + + return { + "device": row["device"], + "interface": row["interface"], + "netbox_interface": row["netbox_name"], + "mapping": row["mapping"], + "oper_status": row["oper_status"], + "expected": ", ".join( + f"{item['device']}:{item['interface']}" for item in row["expected"] + ), + "seen": ", ".join(f"{item['device']}:{item['interface']}" for item in seen), + "result": row["result"], + "hint": row["hint"], + "remote_system": join("system_name", missing), + "remote_port": join("port_id", missing), + "port_description": join("port_description"), + "chassis_id": join("chassis_id"), + "management_addresses": ", ".join( + address + for item in seen + for address in item.get("management_addresses") or [] + ), + "age": join("age", missing), + } + + +def _lldp_json(rows, devices, failed, compare, counts): + """Build the JSON document, nested per device.""" + interfaces = {} + for row in rows: + expected = row["expected"] + interfaces.setdefault(row["device"], []).append( + { + "name": row["interface"], + "netbox_name": row["netbox_name"], + "mapping": row["mapping"], + "oper_status": row["oper_status"], + # One endpoint is the rule; a breakout cable modelled on + # one interface has several + "expected": expected[0] if len(expected) == 1 else expected or None, + "seen": row["seen"], + "result": row["result"] or None, + "hint": row["hint"] or None, + } + ) + return { + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "compared_with_netbox": compare, + "summary": counts, + "devices": [ + { + "name": device.name, + "kind": device.kind, + "interfaces": interfaces.get(device.name, []), + } + for device in devices + ], + "failed": failed, + } + + +class Lldp(Command): + """Check the cabling: compare live LLDP neighbours with NetBox.""" + + def get_parser(self, prog_name): + parser = super(Lldp, self).get_parser(prog_name) + add_selection_arguments(parser) + parser.add_argument( + "--no-compare", + action="store_true", + help="List the neighbours without comparing them with the cabling in NetBox", + ) + parser.add_argument( + "--no-mgmt", + action="store_true", + help="Leave management interfaces out of the check", + ) + parser.add_argument( + "--interface", + action="append", + metavar="PATTERN", + help="Only show ports whose name on the device or in NetBox matches the pattern (can be used multiple times)", + ) + parser.add_argument( + "--result", + action="append", + choices=cabling.RESULTS, + metavar="RESULT", + help=f"Only show ports with this result (can be used multiple times): {', '.join(cabling.RESULTS)}", + ) + parser.add_argument( + "--problems", + action="store_true", + help="Only show ports with a finding", + ) + add_output_arguments(parser) + add_collection_arguments(parser) + parser.add_argument( + "--strict", + action="store_true", + help="Exit with 1 when a device could not be queried or a port has a result other than ok", + ) + return parser + + def take_action(self, parsed_args): + # Without NetBox there is nothing to compare with + compare = not parsed_args.no_compare and bool(utils.nb) + try: + columns = select_columns( + LLDP_COMPARE_COLUMNS if compare else LLDP_LISTING_COLUMNS, + parsed_args.column, + parsed_args.long, + ) + except RenderError as exc: + logger.error(str(exc)) + return 1 + + # Reading neighbours and link state needs no root + requests = { + KIND_NODE: Request(sections=["ports", "lldp"]), + KIND_SWITCH: Request(sections=["ports", "lldp"]), + } + try: + targets, collection = _collect_hardware(parsed_args, requests) + except SelectionError as exc: + logger.error(str(exc)) + return 1 + + netbox_interfaces = {} + if compare: + try: + netbox_interfaces = fetch_interfaces( + utils.nb, [t.device_id for t in targets if t.device_id] + ) + except Exception as exc: + logger.error(f"Could not load the cabling from NetBox: {exc}") + return 1 + + failed = list(collection.failed) + devices = [] + for target in targets: + document = collection.documents.get(target.name) + if document is None: + continue + try: + devices.append( + _lldp_device( + target, + document, + netbox_interfaces.get(target.device_id) or [], + compare, + parsed_args.no_mgmt, + ) + ) + except LldpError as exc: + failed.append({"name": target.name, "error": str(exc)}) + except Exception as exc: + logger.debug(f"{target.name}: {type(exc).__name__}: {exc}") + failed.append({"name": target.name, "error": "unparsable output"}) + + if compare: + try: + directory = _lldp_directory(targets, devices, netbox_interfaces) + except Exception as exc: + logger.error(f"Could not load the cabling from NetBox: {exc}") + return 1 + rows = cabling.compare(devices, directory, include_idle=parsed_args.long) + else: + rows = cabling.neighbour_listing(devices) + + counts = cabling.summary(rows) + findings = sum( + count for result, count in counts.items() if result != cabling.RESULT_OK + ) + + shown = [ + row + for row in rows + if _matches_interface( + parsed_args.interface, row["interface"], row["netbox_name"] + ) + and (not parsed_args.result or row["result"] in parsed_args.result) + and ( + not parsed_args.problems or row["result"] not in ("", cabling.RESULT_OK) + ) + ] + shown = sort_rows(shown, "device", "interface") + + if parsed_args.format == "json": + document = _lldp_json(shown, devices, failed, compare, counts) + write_output(render_json(document), parsed_args.output) + else: + flat = [_lldp_flat(row, not compare) for row in shown] + if parsed_args.format == "csv": + write_output(render_csv(flat, columns), parsed_args.output) + elif devices: + footer = [f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"] + if compare: + footer.append(f"Devices: {len(devices)} Ports: {len(rows)}") + else: + footer.append(f"Devices: {len(devices)}") + neighbors = sum(1 for row in rows if row["seen"]) + footer.append(f"Neighbors: {neighbors}") + if counts: + footer.append( + " ".join(f"{name}: {count}" for name, count in counts.items()) + ) + if flat: + text = render_table(flat, columns, footer) + else: + text = "\n".join(footer) + write_output(text, parsed_args.output) + + _warn_failed(failed) + if parsed_args.strict and (failed or findings): + return 1 diff --git a/tests/unit/commands/test_report.py b/tests/unit/commands/test_report.py index 343aca865..47539901d 100644 --- a/tests/unit/commands/test_report.py +++ b/tests/unit/commands/test_report.py @@ -21,6 +21,7 @@ import csv import io +import itertools import json import subprocess from contextlib import contextmanager @@ -35,7 +36,9 @@ from osism.utils.hardware.mapping import Endpoint, NetboxInterface from osism.utils.hardware.selection import Target -COMMANDS = [report.Memory, report.Lldp, report.Bgp, report.Status] +# Lldp is not part of this list: it selects its devices through +# osism.utils.hardware.selection and has tests of its own further down. +COMMANDS = [report.Memory, report.Bgp, report.Status] # Status requires a positional "type"; the others take no required args. ARGS = {report.Status: ["bootstrap"]} @@ -243,11 +246,31 @@ def _lldp_json(interface): return json.dumps({"lldp": {"interface": interface}}) -def test_lldp_normalizes_single_interface_dict(capsys): - cmd, parsed_args = _make(report.Lldp) +def _listing_document(lldpctl_stdout, ports=("eth0",)): + """Document of the Linux collector for a node with the given lldpctl output.""" + return { + "collector": "linux", + "ports": { + name: {"kind": "physical", "sysfs": {"operstate": "up"}} for name in ports + }, + "ip_link": {"rc": 0, "out": "[]", "err": ""}, + "lldpctl": {"rc": 0, "out": lldpctl_stdout, "err": ""}, + } - with _ssh_env(["host-a"], [_proc(stdout=_lldp_json({"eth0": IFACE_ETH0}))]): - result = cmd.take_action(parsed_args) + +def _run_listing(lldpctl_stdout, ports=("eth0",)): + """Run ``report lldp`` for one node without NetBox, as a plain listing.""" + return _run( + report.Lldp, + [], + [_target("host-a", "node")], + {"host-a": _listing_document(lldpctl_stdout, ports)}, + netbox=False, + ) + + +def test_lldp_normalizes_single_interface_dict(capsys): + result, _ = _run_listing(_lldp_json({"eth0": IFACE_ETH0})) assert not result out = capsys.readouterr().out @@ -258,11 +281,9 @@ def test_lldp_normalizes_single_interface_dict(capsys): def test_lldp_handles_interface_list(capsys): - cmd, parsed_args = _make(report.Lldp) interfaces = [{"eth0": IFACE_ETH0}, {"eth1": IFACE_ETH1}] - with _ssh_env(["host-a"], [_proc(stdout=_lldp_json(interfaces))]): - result = cmd.take_action(parsed_args) + result, _ = _run_listing(_lldp_json(interfaces), ports=("eth0", "eth1")) assert not result out = capsys.readouterr().out @@ -274,10 +295,7 @@ def test_lldp_handles_interface_list(capsys): def test_lldp_defaults_missing_neighbor_details(capsys): - cmd, parsed_args = _make(report.Lldp) - - with _ssh_env(["host-a"], [_proc(stdout=_lldp_json({"eth0": {}}))]): - result = cmd.take_action(parsed_args) + result, _ = _run_listing(_lldp_json({"eth0": {}})) assert not result out = capsys.readouterr().out @@ -286,16 +304,77 @@ def test_lldp_defaults_missing_neighbor_details(capsys): assert out.count("n/a") >= 3 -def test_lldp_marks_host_failed_on_invalid_json(loguru_logs): +def test_lldp_marks_host_failed_on_invalid_json(capsys, loguru_logs): + result, _ = _run_listing("not json") + + assert not result + assert "eth0" not in capsys.readouterr().out + messages = [record["message"] for record in loguru_logs] + assert any( + "Failed to query 1 device(s): host-a (unparsable lldpctl output)" in m + for m in messages + ) + + +@contextmanager +def _lldp_inventory_env(inventory_result): + """Let ``report lldp`` select from an inventory, without NetBox.""" + with patch( + "osism.commands.report.ensure_known_hosts_file", return_value=True + ), patch("osism.utils.nb", new=None, create=True), patch( + "osism.utils.hardware.selection.get_inventory_path", + return_value="/ansible/inventory/hosts.yml", + ), patch( + "osism.utils.hardware.selection.subprocess.run", + side_effect=[inventory_result], + ) as run, patch( + "osism.commands.report.collect", return_value=Collection() + ) as collect: + yield SimpleNamespace(collect=collect, run=run) + + +@pytest.mark.parametrize( + "inventory_result,message", + [ + (_proc(returncode=1), "Error loading inventory."), + ( + subprocess.TimeoutExpired(cmd="ansible-inventory", timeout=30), + "Timeout loading inventory.", + ), + # Nothing to check is a failed selection for the cabling check + (_proc(stdout="{}"), "No devices matched the selection."), + ], +) +def test_lldp_returns_nonzero_when_nothing_can_be_selected( + inventory_result, message, loguru_logs +): cmd, parsed_args = _make(report.Lldp) - with _ssh_env(["host-a"], [_proc(stdout="not json")]): + with _lldp_inventory_env(inventory_result) as mocks: result = cmd.take_action(parsed_args) - assert not result - messages = [record["message"] for record in loguru_logs] - assert any("Could not parse LLDP info from host-a." in m for m in messages) - assert any("Failed to query 1 host(s): host-a" in m for m in messages) + assert result == 1 + mocks.collect.assert_not_called() + assert _logged(loguru_logs, "ERROR", message) + + +def test_lldp_limit_keeps_selecting_nodes_from_the_inventory(): + cmd = report.Lldp(MagicMock(), MagicMock()) + parsed_args = cmd.get_parser("test").parse_args(["-l", "compute"]) + inventory = _proc(stdout=json.dumps({"compute": {"hosts": ["host-a", "host-b"]}})) + + with _lldp_inventory_env(inventory) as mocks, patch( + "osism.commands.report.resolve_host_with_fallback", + side_effect=lambda host: host, + ): + cmd.take_action(parsed_args) + + assert mocks.run.call_args.args[0][-2:] == ["--limit", "compute"] + targets = mocks.collect.call_args.args[0] + assert [(t.name, t.kind, t.address) for t in targets] == [ + ("host-a", "node", "host-a"), + ("host-b", "node", "host-b"), + ] # --- Bgp --- @@ -471,12 +550,35 @@ def _netbox_interface(interface_id, device, name, peer=None, **kwargs): @contextmanager -def _hardware_env(targets, documents, failed=(), interfaces=None, netbox=True): +def _hardware_env( + targets, + documents, + failed=(), + interfaces=None, + netbox=True, + netbox_devices=(), + found_devices=(), +): """Replace selection, collection and NetBox for a hardware report. + Args: + interfaces: NetBox interfaces per device ID + netbox_devices: Devices NetBox returns when asked by ID + found_devices: Devices NetBox returns when asked by name + Yields the mocks, so that a test can look at what was requested. """ collection = Collection(documents=dict(documents), failed=list(failed)) + interfaces = interfaces or {} + + def fetch_interfaces(nb, device_ids): + return { + device_id: list(interfaces.get(device_id, [])) for device_id in device_ids + } + + def fetch_devices(nb, device_ids): + return [dict(d) for d in netbox_devices if d["id"] in set(device_ids)] + with patch( "osism.commands.report.ensure_known_hosts_file", return_value=True ), patch( @@ -486,14 +588,26 @@ def _hardware_env(targets, documents, failed=(), interfaces=None, netbox=True): ) as resolve, patch( "osism.commands.report.collect", return_value=collection ) as collect, patch( - "osism.commands.report.fetch_interfaces", return_value=interfaces or {} + "osism.commands.report.fetch_interfaces", side_effect=fetch_interfaces ) as fetch, patch( + "osism.commands.report.fetch_devices", side_effect=fetch_devices + ) as devices, patch( + "osism.commands.report.find_devices", + return_value=[dict(d) for d in found_devices], + ) as find, patch( "osism.commands.report.sonic_name_index", return_value={} + ), patch( + "osism.commands.report.sonic_alias_index", return_value={} ), patch( "osism.utils.nb", new=MagicMock() if netbox else None, create=True ): yield SimpleNamespace( - select=select, resolve=resolve, collect=collect, fetch=fetch + select=select, + resolve=resolve, + collect=collect, + fetch=fetch, + devices=devices, + find=find, ) @@ -1012,3 +1126,764 @@ def test_transceiver_is_registered_as_a_command(): "report transceiver = osism.commands.report:Transceiver" in config["options.entry_points"]["osism.commands"] ) + + +# --------------------------------------------------------------------------- +# report lldp: comparison with NetBox +# --------------------------------------------------------------------------- + +RACK_IDS = { + "leaf-1a": 1, + "leaf-1b": 2, + "mgmt-1": 3, + "leaf-1c": 4, + "compute-041": 10, + "compute-042": 11, +} +RACK_DEVICES = [{"id": device_id, "name": name} for name, device_id in RACK_IDS.items()] + + +def _rack(): + """NetBox interfaces of a small rack, per device ID. + + Two compute nodes with one port on each of two leaf switches, the BMC of + compute-041 and the management port of leaf-1a on an OOB switch, and a + port of leaf-1c that is documented for a node outside the rack. + """ + counter = itertools.count(1000) + interfaces = {device_id: {} for device_id in RACK_IDS.values()} + + def get(device, name, **kwargs): + found = interfaces[RACK_IDS[device]] + if name not in found: + found[name] = NetboxInterface( + id=next(counter), + device_id=RACK_IDS[device], + device=device, + name=name, + **kwargs, + ) + return found[name] + + def cable(left, right, **kwargs): + (left_device, left_name), (right_device, right_name) = ( + left.split(":"), + right.split(":"), + ) + a = get(left_device, left_name, **kwargs) + b = get(right_device, right_name) + a.has_cable = b.has_cable = True + a.endpoints.append( + Endpoint(right_device, right_name, RACK_IDS[right_device], b.id) + ) + b.endpoints.append( + Endpoint(left_device, left_name, RACK_IDS[left_device], a.id) + ) + + cable("compute-041:ens1f0", "leaf-1a:Eth1/11") + cable("compute-041:ens1f1", "leaf-1b:Eth1/11") + cable("compute-042:ens1f0", "leaf-1a:Eth1/12") + cable("compute-042:ens1f1", "leaf-1b:Eth1/12") + cable("compute-041:bmc", "mgmt-1:Eth1/5", mgmt_only=True) + cable("leaf-1a:eth0", "mgmt-1:Eth1/1", mgmt_only=True) + get("compute-041", "bond0", type="lag") + get("leaf-1c", "Eth1/11") + return {device_id: list(found.values()) for device_id, found in interfaces.items()} + + +def _ok(text): + return {"rc": 0, "out": text, "err": ""} + + +def _node_document(ports): + """Linux collector document. + + Args: + ports: ``{name: (link state, "device:port" seen or None)}`` + """ + links = [] + lldp = [] + for name, (link, seen) in ports.items(): + links.append( + { + "ifname": name, + "flags": ["UP"], + "operstate": link.upper(), + "address": "02:00:00:00:00:01", + } + ) + if seen: + system, _, port = seen.partition(":") + lldp.append( + { + name: { + "chassis": {system: {"id": {"type": "mac", "value": "x"}}}, + "port": {"id": {"type": "local", "value": port}}, + "age": "1 day", + } + } + ) + return { + "collector": "linux", + "ports": {name: {"kind": "physical", "sysfs": {}} for name in ports}, + "ip_link": _ok(json.dumps(links)), + "lldpctl": _ok(json.dumps({"lldp": {"interface": lldp}})), + } + + +def _switch_document(ports, mgmt=None): + """SONiC collector document. + + Args: + ports: ``{SONiC name: (alias, link state, "device:port" seen or None)}`` + mgmt: ``(link state, "device:port" seen or None)`` of ``eth0`` + """ + config, state, entries = {}, {}, {} + + def entry(seen): + system, _, port = seen.partition(":") + return { + "value": { + "lldp_rem_sys_name": system, + "lldp_rem_port_id": port, + "lldp_rem_port_id_subtype": "5", + } + } + + for name, (alias, link, seen) in ports.items(): + config[f"PORT|{name}"] = {"value": {"alias": alias, "admin_status": "up"}} + state[f"PORT_TABLE:{name}"] = {"value": {"oper_status": link}} + if seen: + entries[f"LLDP_ENTRY_TABLE:{name}"] = entry(seen) + document = { + "collector": "sonic", + "db": { + "CONFIG_DB": {"PORT": config}, + "APPL_DB": {"PORT_TABLE": state, "LLDP_ENTRY_TABLE": entries}, + }, + } + if mgmt: + link, seen = mgmt + document["mgmt"] = {"eth0": {"operstate": link, "address": "0c:00:00:00:00:01"}} + if seen: + entries["LLDP_ENTRY_TABLE:eth0"] = entry(seen) + return document + + +def _rack_target(name): + kind = "node" if name.startswith("compute") else "switch" + return _target(name, kind, device_id=RACK_IDS[name]) + + +HEALTHY_041 = _node_document( + {"ens1f0": ("up", "leaf-1a:Eth1/11"), "ens1f1": ("up", "leaf-1b:Eth1/11")} +) +SWAPPED_042 = _node_document( + {"ens1f0": ("up", "leaf-1b:Eth1/12"), "ens1f1": ("up", "leaf-1a:Eth1/12")} +) +HEALTHY_LEAF_1A = _switch_document( + { + "Ethernet10": ("Eth1/11", "up", "compute-041:ens1f0"), + "Ethernet11": ("Eth1/12", "up", "compute-042:ens1f0"), + }, + mgmt=("up", "mgmt-1:Eth1/1"), +) + + +def _lldp(argv, documents, **kwargs): + """Run ``report lldp`` against the rack; return ``(result, mocks)``.""" + kwargs.setdefault("interfaces", _rack()) + kwargs.setdefault("netbox_devices", RACK_DEVICES) + targets = kwargs.pop("targets", None) or [_rack_target(name) for name in documents] + return _run(report.Lldp, list(argv), targets, documents, **kwargs) + + +def _lldp_json_report(argv, documents, **kwargs): + stdout = io.StringIO() + with patch("sys.stdout", stdout): + result, mocks = _lldp(["--format", "json", *argv], documents, **kwargs) + return result, json.loads(stdout.getvalue()), mocks + + +def _results(document): + """``{(device, interface): result}`` of a JSON report.""" + return { + (device["name"], interface["name"]): interface["result"] + for device in document["devices"] + for interface in device["interfaces"] + } + + +def test_lldp_compares_neighbours_with_netbox(capsys): + documents = {"compute-041": HEALTHY_041, "compute-042": SWAPPED_042} + + result, _ = _lldp([], documents) + + assert not result + lines = capsys.readouterr().out.splitlines() + header = [cell.strip() for cell in lines[1].strip("|").split("|")] + assert header == ["Device", "Interface", "Expected", "Seen", "Result", "Hint"] + rows = [ + [cell.strip() for cell in line.strip("|").split("|")] for line in lines[3:7] + ] + assert rows == [ + ["compute-041", "ens1f0", "leaf-1a:Eth1/11", "leaf-1a:Eth1/11", "ok", ""], + ["compute-041", "ens1f1", "leaf-1b:Eth1/11", "leaf-1b:Eth1/11", "ok", ""], + [ + "compute-042", + "ens1f0", + "leaf-1a:Eth1/12", + "leaf-1b:Eth1/12", + "swapped", + "swapped with ens1f1", + ], + [ + "compute-042", + "ens1f1", + "leaf-1b:Eth1/12", + "leaf-1a:Eth1/12", + "swapped", + "swapped with ens1f0", + ], + ] + assert "Devices: 2 Ports: 4" in lines + assert "ok: 2 swapped: 2" in lines + + +def test_lldp_collects_without_root_from_the_selected_devices_only(): + documents = {"compute-041": HEALTHY_041, "leaf-1a": HEALTHY_LEAF_1A} + + _, mocks = _lldp([], documents) + + targets, requests = mocks.collect.call_args.args + # leaf-1b, mgmt-1 and compute-042 are documented peers, but they were + # not selected + assert [target.name for target in targets] == ["compute-041", "leaf-1a"] + for kind in ("node", "switch"): + assert requests[kind].sections == ["ports", "lldp"] + assert requests[kind].sudo is False + # Their cabling is read from NetBox instead + assert sorted(mocks.devices.call_args.args[1]) == [2, 3, 11] + assert sorted(mocks.fetch.call_args_list[1].args[1]) == [2, 3, 11] + + +def test_lldp_reports_the_findings_of_a_miscabled_rack(): + documents = { + "compute-041": _node_document( + {"ens1f0": ("down", None), "ens1f1": ("up", "leaf-1b:Eth1/14")} + ), + "compute-042": _node_document( + { + "ens1f0": ("up", "leaf-1c:Eth1/11"), + "ens1f1": ("up", None), + "ens2f0": ("up", "leaf-1a:Eth1/20"), + } + ), + } + interfaces = _rack() + interfaces[11].append( + NetboxInterface(id=5000, device_id=11, device="compute-042", name="ens2f0") + ) + + _, document, _ = _lldp_json_report([], documents, interfaces=interfaces) + + assert _results(document) == { + ("compute-041", "ens1f0"): "missing", + ("compute-041", "ens1f1"): "wrong-port", + ("compute-042", "ens1f0"): "wrong-device", + ("compute-042", "ens1f1"): "no-lldp", + ("compute-042", "ens2f0"): "unexpected", + } + assert document["summary"] == { + "missing": 1, + "no-lldp": 1, + "wrong-port": 1, + "wrong-device": 1, + "unexpected": 1, + } + + +def test_lldp_finds_cables_swapped_between_two_nodes(): + documents = { + "compute-041": _node_document({"ens1f0": ("up", "leaf-1a:Eth1/12")}), + "compute-042": _node_document({"ens1f0": ("up", "leaf-1a:Eth1/11")}), + } + + _, document, _ = _lldp_json_report(["--result", "swapped"], documents) + + hints = { + device["name"]: device["interfaces"][0]["hint"] + for device in document["devices"] + } + assert hints == { + "compute-041": "swapped with compute-042:ens1f0", + "compute-042": "swapped with compute-041:ens1f0", + } + + +def test_lldp_json_is_nested_per_device(): + documents = {"compute-041": HEALTHY_041, "compute-042": SWAPPED_042} + + _, document, _ = _lldp_json_report([], documents) + + assert sorted(document) == [ + "compared_with_netbox", + "devices", + "failed", + "generated_at", + "summary", + ] + assert document["compared_with_netbox"] is True + assert document["summary"] == {"ok": 2, "swapped": 2} + assert [(d["name"], d["kind"]) for d in document["devices"]] == [ + ("compute-041", "node"), + ("compute-042", "node"), + ] + interface = document["devices"][1]["interfaces"][0] + assert interface["name"] == "ens1f0" + assert interface["netbox_name"] == "ens1f0" + assert interface["mapping"] == "name" + assert interface["oper_status"] == "up" + assert interface["expected"] == {"device": "leaf-1a", "interface": "Eth1/12"} + assert interface["result"] == "swapped" + assert interface["hint"] == "swapped with ens1f1" + (seen,) = interface["seen"] + assert (seen["device"], seen["interface"]) == ("leaf-1b", "Eth1/12") + assert (seen["system_name"], seen["port_id"], seen["age"]) == ( + "leaf-1b", + "Eth1/12", + "1 day", + ) + assert document["devices"][0]["interfaces"][0]["hint"] is None + + +def test_lldp_checks_the_management_port_of_a_switch_by_default(): + documents = {"leaf-1a": HEALTHY_LEAF_1A} + + _, document, _ = _lldp_json_report([], documents) + + assert _results(document) == { + ("leaf-1a", "Ethernet10"): "ok", + ("leaf-1a", "Ethernet11"): "ok", + ("leaf-1a", "eth0"): "ok", + } + by_name = {i["name"]: i for i in document["devices"][0]["interfaces"]} + assert by_name["eth0"]["netbox_name"] == "eth0" + assert by_name["eth0"]["expected"] == {"device": "mgmt-1", "interface": "Eth1/1"} + assert by_name["Ethernet10"]["netbox_name"] == "Eth1/11" + assert by_name["Ethernet10"]["mapping"] == "alias" + + +def test_lldp_reports_an_unplugged_management_port(): + documents = { + "leaf-1a": _switch_document( + {"Ethernet10": ("Eth1/11", "up", "compute-041:ens1f0")}, + mgmt=("down", None), + ) + } + + _, document, _ = _lldp_json_report(["--interface", "eth0"], documents) + + assert _results(document) == {("leaf-1a", "eth0"): "missing"} + + +def test_lldp_no_mgmt_leaves_management_interfaces_out(): + documents = {"leaf-1a": HEALTHY_LEAF_1A, "compute-041": HEALTHY_041} + + _, document, _ = _lldp_json_report(["--no-mgmt", "--long"], documents) + + assert ("leaf-1a", "eth0") not in _results(document) + assert ("compute-041", "bmc") not in _results(document) + + +def test_lldp_bmc_port_produces_no_finding_on_the_node(): + """The BMC port is documented, cabled, and absent in the operating system.""" + documents = {"compute-041": HEALTHY_041} + + result, document, _ = _lldp_json_report(["--long", "--strict"], documents) + + assert _results(document) == { + ("compute-041", "ens1f0"): "ok", + ("compute-041", "ens1f1"): "ok", + } + assert not result + + +def test_lldp_one_sided_when_the_queried_peer_does_not_see_the_node(): + documents = { + "compute-041": HEALTHY_041, + "leaf-1a": _switch_document( + { + "Ethernet10": ("Eth1/11", "up", None), + "Ethernet11": ("Eth1/12", "up", "compute-042:ens1f0"), + }, + mgmt=("up", "mgmt-1:Eth1/1"), + ), + } + + _, document, _ = _lldp_json_report([], documents) + + results = _results(document) + assert results[("compute-041", "ens1f0")] == "one-sided" + assert results[("compute-041", "ens1f1")] == "ok" + assert results[("leaf-1a", "Ethernet10")] == "no-lldp" + + +def test_lldp_looks_up_unknown_neighbours_by_name(): + documents = { + "compute-041": _node_document( + { + "ens1f0": ("up", "leaf-1c.example.com:Eth1/11"), + "ens1f1": ("up", "leaf-1b:Eth1/11"), + } + ) + } + + _, document, mocks = _lldp_json_report( + [], documents, found_devices=[{"id": 4, "name": "leaf-1c"}] + ) + + assert mocks.find.call_args.args[1] == ["leaf-1c.example.com"] + interface = document["devices"][0]["interfaces"][0] + assert interface["result"] == "wrong-device" + assert interface["hint"] == "seen port has no cable in NetBox" + assert interface["seen"][0]["device"] == "leaf-1c" + + +def test_lldp_neighbour_unknown_to_netbox_is_reported_as_announced(): + documents = { + "compute-041": _node_document( + {"ens1f0": ("up", "tor-9:xe-0/0/1"), "ens1f1": ("up", "leaf-1b:Eth1/11")} + ) + } + + _, document, _ = _lldp_json_report([], documents) + + interface = document["devices"][0]["interfaces"][0] + assert interface["result"] == "wrong-device" + assert interface["hint"] == "neighbour unknown to NetBox" + assert (interface["seen"][0]["device"], interface["seen"][0]["interface"]) == ( + "tor-9", + "xe-0/0/1", + ) + + +@pytest.mark.parametrize( + "argv,expected", + [ + (["--problems"], [("compute-042", "ens1f0"), ("compute-042", "ens1f1")]), + (["--result", "ok"], [("compute-041", "ens1f0"), ("compute-041", "ens1f1")]), + ( + ["--result", "ok", "--result", "swapped", "--interface", "ens1f0"], + [("compute-041", "ens1f0"), ("compute-042", "ens1f0")], + ), + ( + ["--interface", "*f1"], + [("compute-041", "ens1f1"), ("compute-042", "ens1f1")], + ), + (["--result", "missing"], []), + ], +) +def test_lldp_row_filters(argv, expected): + documents = {"compute-041": HEALTHY_041, "compute-042": SWAPPED_042} + + _, document, _ = _lldp_json_report(argv, documents) + + assert sorted(_results(document)) == expected + # The summary keeps counting everything that was checked + assert document["summary"] == {"ok": 2, "swapped": 2} + + +def test_lldp_problems_with_nothing_wrong_still_prints_the_summary(capsys): + result, _ = _lldp(["--problems"], {"compute-041": HEALTHY_041}) + + assert not result + out = capsys.readouterr().out + assert "ens1f0" not in out + assert "Devices: 1 Ports: 2" in out + assert "ok: 2" in out + + +def test_lldp_long_lists_ports_with_nothing_to_report(): + documents = { + "compute-041": _node_document( + { + "ens1f0": ("up", "leaf-1a:Eth1/11"), + "ens1f1": ("up", "leaf-1b:Eth1/11"), + "usb0": ("up", None), + } + ) + } + + _, default, _ = _lldp_json_report([], documents) + _, long, _ = _lldp_json_report(["--long"], documents) + + assert ("compute-041", "usb0") not in _results(default) + assert _results(long)[("compute-041", "usb0")] is None + + +def test_lldp_csv_and_columns(capsys, tmp_path): + documents = {"compute-041": HEALTHY_041, "compute-042": SWAPPED_042} + + _lldp(["--format", "csv"], documents) + rows = list(csv.reader(io.StringIO(capsys.readouterr().out))) + assert rows[0] == ["device", "interface", "expected", "seen", "result", "hint"] + assert rows[3] == [ + "compute-042", + "ens1f0", + "leaf-1a:Eth1/12", + "leaf-1b:Eth1/12", + "swapped", + "swapped with ens1f1", + ] + assert len(rows) == 5 + + _lldp(["--format", "csv", "--column", "result", "--column", "Device"], documents) + rows = list(csv.reader(io.StringIO(capsys.readouterr().out))) + assert rows[0] == ["result", "device"] + + _lldp(["--format", "csv", "--long"], documents) + header = list(csv.reader(io.StringIO(capsys.readouterr().out)))[0] + assert header == [column.key for column in report.LLDP_COMPARE_COLUMNS] + + path = tmp_path / "cabling.csv" + _lldp(["--format", "csv", "--output", str(path)], documents) + assert capsys.readouterr().out == "" + assert path.read_text().startswith("device,interface,expected,seen,result,hint\n") + + +def test_lldp_json_on_stdout_is_one_document(capsys, loguru_logs): + documents = {"compute-041": HEALTHY_041} + failed = [{"name": "compute-042", "error": "timeout"}] + targets = [_rack_target("compute-041"), _rack_target("compute-042")] + + _lldp(["--format", "json"], documents, failed=failed, targets=targets) + + document = json.loads(capsys.readouterr().out) + assert document["failed"] == failed + assert _logged(loguru_logs, "WARNING", "Failed to query 1 device(s)") + + +@pytest.mark.parametrize( + "documents,failed,expected", + [ + ({"compute-041": HEALTHY_041}, [], None), + ({"compute-042": SWAPPED_042}, [], 1), + ( + {"compute-041": HEALTHY_041}, + [{"name": "compute-042", "error": "timeout"}], + 1, + ), + ], +) +def test_lldp_strict_fails_on_findings_and_failed_devices(documents, failed, expected): + targets = [_rack_target("compute-041"), _rack_target("compute-042")] + + result, _ = _lldp(["--strict"], documents, failed=failed, targets=targets) + assert result == expected + + result, _ = _lldp([], documents, failed=failed, targets=targets) + assert result is None + + +def test_lldp_strict_ignores_the_row_filters(): + """Hiding a finding must not turn a failed check into a passed one.""" + result, _ = _lldp(["--strict", "--result", "ok"], {"compute-042": SWAPPED_042}) + + assert result == 1 + + +def test_lldp_unreachable_device_is_seen_from_the_other_side(loguru_logs): + documents = {"leaf-1a": HEALTHY_LEAF_1A} + failed = [{"name": "compute-041", "error": "timeout"}] + targets = [_rack_target("compute-041"), _rack_target("leaf-1a")] + + result, document, _ = _lldp_json_report( + [], documents, failed=failed, targets=targets + ) + + assert not result + assert document["failed"] == failed + assert [device["name"] for device in document["devices"]] == ["leaf-1a"] + assert _results(document)[("leaf-1a", "Ethernet10")] == "ok" + + +def test_lldp_missing_lldpd_is_a_failed_device_with_a_clear_message(loguru_logs): + broken = dict(HEALTHY_041) + broken["lldpctl"] = {"rc": 1, "out": "", "err": "unable to connect to socket"} + documents = {"compute-041": broken, "compute-042": SWAPPED_042} + + result, document, _ = _lldp_json_report([], documents) + + assert not result + assert document["failed"] == [ + { + "name": "compute-041", + "error": "lldpctl failed: unable to connect to socket", + } + ] + assert [device["name"] for device in document["devices"]] == ["compute-042"] + assert _logged( + loguru_logs, + "WARNING", + "compute-041 (lldpctl failed: unable to connect to socket)", + ) + + +def test_lldp_fails_when_the_cabling_cannot_be_loaded(capsys, loguru_logs): + cmd = report.Lldp(MagicMock(), MagicMock()) + parsed_args = cmd.get_parser("test").parse_args([]) + + with _hardware_env( + [_rack_target("compute-041")], {"compute-041": HEALTHY_041} + ), patch("osism.commands.report.fetch_interfaces", side_effect=RuntimeError("503")): + result = cmd.take_action(parsed_args) + + assert result == 1 + assert capsys.readouterr().out == "" + assert _logged(loguru_logs, "ERROR", "Could not load the cabling from NetBox: 503") + + +def test_lldp_no_compare_lists_neighbours_without_cabling_data(capsys): + documents = {"compute-041": HEALTHY_041, "compute-042": SWAPPED_042} + + result, mocks = _lldp(["--no-compare"], documents) + + assert not result + mocks.fetch.assert_not_called() + mocks.devices.assert_not_called() + lines = capsys.readouterr().out.splitlines() + header = [cell.strip() for cell in lines[1].strip("|").split("|")] + assert header == [ + "Host", + "Local Interface", + "Remote Switch", + "Remote Port", + "Port Description", + "Age", + "Result", + "Hint", + ] + first = [cell.strip() for cell in lines[3].strip("|").split("|")] + assert first == ["compute-041", "ens1f0", "leaf-1a", "Eth1/11", "", "1 day", "", ""] + assert "Devices: 2" in lines + assert "Neighbors: 4" in lines + # Swapped cables are not a finding without the documentation + assert not any("swapped" in line for line in lines) + + +def test_lldp_no_compare_keeps_the_checks_that_need_no_netbox(): + documents = { + "compute-041": _node_document( + {"ens1f0": ("up", "leaf-1a:Ethernet10"), "ens1f1": ("up", None)} + ), + "leaf-1a": _switch_document({"Ethernet10": ("Eth1/11", "up", None)}), + } + + result, document, _ = _lldp_json_report(["--no-compare", "--strict"], documents) + + assert document["compared_with_netbox"] is False + assert _results(document) == { + ("compute-041", "ens1f0"): "one-sided", + ("compute-041", "ens1f1"): "no-lldp", + ("leaf-1a", "Ethernet10"): "no-lldp", + } + assert document["devices"][0]["interfaces"][0]["expected"] is None + assert result == 1 + + +def test_lldp_without_netbox_is_a_listing_automatically(): + target = _target("compute-041", "node") + stdout = io.StringIO() + + with patch("sys.stdout", stdout): + result, mocks = _run( + report.Lldp, + ["--format", "json"], + [target], + {"compute-041": HEALTHY_041}, + netbox=False, + ) + + document = json.loads(stdout.getvalue()) + assert not result + assert document["compared_with_netbox"] is False + assert _results(document) == { + ("compute-041", "ens1f0"): None, + ("compute-041", "ens1f1"): None, + } + mocks.fetch.assert_not_called() + + +def test_lldp_selection_options_are_those_of_report_transceiver(): + argv = [ + "leaf-*", + "--type", + "switch", + "--include-unmanaged", + "--site", + "fra1", + "--role", + "leaf", + "--rack", + "R12", + "--location", + "Hall 1", + "--region", + "eu", + "--site-group", + "prod", + "--tenant", + "t", + "--tag", + "x", + "--manufacturer", + "dell", + "--device-type", + "s5248f-on", + "--platform", + "sonic", + "--status", + "planned", + "--filter", + "cf_x=1", + "-l", + "compute", + ] + selection = [ + "names", + "type", + "include_unmanaged", + "limit", + "site", + "role", + "rack", + "location", + "region", + "site_group", + "tenant", + "tag", + "manufacturer", + "device_type", + "platform", + "status", + "netbox_filter", + ] + + parsed = [ + cls(MagicMock(), MagicMock()).get_parser("test").parse_args(argv) + for cls in (report.Lldp, report.Transceiver) + ] + + assert [getattr(parsed[0], name) for name in selection] == [ + getattr(parsed[1], name) for name in selection + ] + assert parsed[0].site == ["fra1"] and parsed[0].limit == "compute" + + +def test_lldp_passes_the_selection_to_select_targets(): + _, mocks = _lldp(["leaf-*", "--site", "fra1"], {"compute-041": HEALTHY_041}) + + parsed_args = mocks.select.call_args.args[0] + assert parsed_args.names == ["leaf-*"] + assert parsed_args.site == ["fra1"]