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/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/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/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/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"] 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_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", + ] 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 # --------------------------------------------------------------------------- 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