From 23f814a1c7e1c210ec851488d8162667c92ccf99 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 22 Aug 2026 15:00:48 +0300 Subject: [PATCH 1/2] feat(VMware): Create NetBox cables from ESXi CDP/LLDP neighbors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During host sync, read physical NIC neighbor hints (CDP connectedSwitchPort / LLDP lldpInfo), match switch device and port in NetBox inventory, and create dcim.cable objects (server pNIC ↔ switch port) when both ends resolve. --- module/netbox/__init__.py | 3 +- module/netbox/object_classes.py | 41 ++++ module/sources/vmware/connection.py | 365 ++++++++++++++++++++++++++-- 3 files changed, 385 insertions(+), 24 deletions(-) diff --git a/module/netbox/__init__.py b/module/netbox/__init__.py index b2cbb2bc..5d69fcb2 100644 --- a/module/netbox/__init__.py +++ b/module/netbox/__init__.py @@ -38,7 +38,8 @@ NBMACAddress, NBFHRPGroupItem, NBInventoryItem, - NBPowerPort + NBPowerPort, + NBCable ) primary_tag_name = "NetBox-synced" diff --git a/module/netbox/object_classes.py b/module/netbox/object_classes.py index dae2113a..de240b44 100644 --- a/module/netbox/object_classes.py +++ b/module/netbox/object_classes.py @@ -2417,4 +2417,45 @@ def update(self, data=None, read_from_netbox=False, source=None): super().update(data=data, read_from_netbox=read_from_netbox, source=source) + +class NBCable(NetBoxObject): + name = "cable" + api_path = "dcim/cables" + object_type = "dcim.cable" + primary_key = "label" + prune = True + + def __init__(self, *args, **kwargs): + self.data_model = { + "label": 100, + "a_terminations": list, + "b_terminations": list, + "status": ["connected", "planned", "decommissioning"], + "type": [ + "cat3", "cat5", "cat5e", "cat6", "cat6a", "cat7", "cat7a", "cat8", + "dac-active", "dac-passive", + "mmf", "mmf-om1", "mmf-om2", "mmf-om3", "mmf-om4", "mmf-om5", + "smf", "smf-os1", "smf-os2", "aoc", "power", "usb", "coaxial", + ], + "description": 200, + "color": str, + "length": float, + "length_unit": ["km", "m", "cm", "mi", "ft", "in"], + "tags": NBTagList, + } + super().__init__(*args, **kwargs) + + def get_display_name(self, data=None, including_second_key=False): + this_data = data if data is not None else self.data + if not this_data: + return "Cable" + label = this_data.get("label") + if label: + return str(label) + a = (this_data.get("a_terminations") or [{}])[0] + b = (this_data.get("b_terminations") or [{}])[0] + a_id = a.get("object_id") if isinstance(a, dict) else None + b_id = b.get("object_id") if isinstance(b, dict) else None + return f"Cable a={a_id} ↔ b={b_id}" + # EOF diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index e63763c9..22877631 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2020 - 2026 Ricardo Bartels. All rights reserved. # # netbox-sync.py @@ -75,7 +74,43 @@ class VMWareHandler(SourceBase): NBVLANGroup, NBCustomField, NBVirtualDisk, - NBMACAddress + NBMACAddress, + NBCable + ] + + IFACE_PREFIX_MAP = [ + # 100G + ("HundredGigabitEthernet", "HundredGigE"), + ("HundredGigabitEthernet", "Hu"), + ("HundredGigE", "Hu"), + # 50G + ("FiftyGigabitEthernet", "FiftyGigE"), + ("FiftyGigabitEthernet", "Fi"), + ("FiftyGigE", "Fi"), + # 40G + ("FortyGigabitEthernet", "Fo"), + ("FortyGigabitEthernet", "FortyGigE"), + # 25G + ("TwentyFiveGigabitEthernet", "TwentyFiveGigE"), + ("TwentyFiveGigabitEthernet", "Twe"), + ("TwentyFiveGigabitEthernet", "TF"), + ("TwentyFiveGigabitEthernet", "25GigE"), + ("TwentyFiveGigE", "Twe"), + ("TwentyFiveGigE", "TF"), + ("TwentyFiveGigE", "25GigE"), + # 10G (Te после Twe!) + ("TenGigabitEthernet", "Te"), + ("TenGigabitEthernet", "TenGigE"), + # Huawei-style 10G + ("XGigabitEthernet", "XGi"), + ("XGigabitEthernet", "XGE"), + # 1G + ("GigabitEthernet", "Gi"), + ("GigabitEthernet", "GE"), + # 100M + ("FastEthernet", "Fa"), + # generic + ("Ethernet", "Eth"), ] source_type = "vmware" @@ -135,6 +170,246 @@ def __init__(self, name=None): self.objects_to_reevaluate = list() self.parsing_objects_to_reevaluate = False + def _expand_interface_names(self, name): + """Generates interface name variants (Fa0/16 ↔ FastEthernet0/16)""" + if not name: + return [] + variants = [name] + name_l = name.lower() + for long_form, short_form in self.IFACE_PREFIX_MAP: + long_l = long_form.lower() + short_l = short_form.lower() + if name_l.startswith(long_l): + rest = name[len(long_form):] + variants.extend([short_form + rest, short_form.lower() + rest, short_form.upper() + rest]) + if name_l.startswith(short_l) and not name_l.startswith(long_l): + rest = name[len(short_form):] + if rest and (rest[0].isdigit() or rest[0] in "/-"): + variants.append(long_form + rest) + seen = set() + unique = [] + for v in variants: + if v and v not in seen: + seen.add(v) + unique.append(v) + return unique + + def _get_pnic_neighbor_info(self, host_obj, pnic_name, host_name): + """ + CDP/LLDP neighbor для pNIC. + Returns dict: system_name, port_id, port_description, protocol + or None. + """ + try: + hints = host_obj.configManager.networkSystem.QueryNetworkHint(pnic_name) + if not hints: + return None + hint = hints[0] + + # --- CDP (priority) --- + cdp = grab(hint, "connectedSwitchPort") + if cdp is not None: + sys_name = grab(cdp, "systemName") or grab(cdp, "devId") + port_id = grab(cdp, "portId") + if sys_name: + return { + "system_name": str(sys_name).strip(), + "port_id": str(port_id).strip() if port_id else None, + "port_description": str(port_id).strip() if port_id else None, + "protocol": "CDP", + } + + # --- LLDP --- + lldp = grab(hint, "lldpInfo") + if lldp is not None: + params = {} + for param in grab(lldp, "parameter", fallback=list()) or []: + key = grab(param, "key") + value = grab(param, "value") + if key is not None and value is not None: + params[str(key).strip().lower()] = str(value).strip() + + sys_name = ( + params.get("system name") + or params.get("systemname") + or grab(lldp, "chassisId") + ) + # Port ID — real name port (ex: XGigabitEthernet0/0/14) + port_id = ( + params.get("port id") + or params.get("portid") + or grab(lldp, "portId") + ) + # Port Description — description from Network Switch (ex: MAIN-DETAIL12/Eth1) + port_desc = ( + params.get("port description") + or params.get("portdescription") + ) + + if sys_name: + return { + "system_name": str(sys_name).strip(), + "port_id": str(port_id).strip() if port_id else None, + "port_description": str(port_desc).strip() if port_desc else None, + "protocol": "LLDP", + } + except Exception as e: + log.debug2(f"[{host_name}/{pnic_name}] QueryNetworkHint failed: {e}") + return None + + def _cable_type_for_port(self, port_name): + """FastEthernet/Fa → cat5, others → dac-active. (for next optional edit)""" + if not port_name: + return "dac-active" + n = port_name.lower() + if n.startswith("fastethernet") or n.startswith("fa") and ( + len(n) == 2 or (len(n) > 2 and n[2] in "0123456789/-") + ): + return "cat5" + return "dac-active" + + def _find_device_by_name(self, name): + if not name: + return None + name_l = name.strip().lower() + name_short = name_l.split(".")[0] + for dev in self.inventory.get_all_items(NBDevice): + dname = (grab(dev, "data.name") or "").strip() + if not dname: + continue + dname_l = dname.lower() + if dname_l == name_l: + return dev + if dname_l == name_short or dname_l.split(".")[0] == name_short: + return dev + if dname_l.split(".")[0] == name_short: + return dev + return None + + def _find_iface_on_device(self, device, port_candidates): + """Find interface on Switch/Device (with alias Fa/Gi/...).""" + if device is None or not port_candidates: + return None + cand_l = {c.lower() for c in port_candidates if c} + for iface in self.inventory.get_all_items(NBInterface): + if grab(iface, "data.device") is not device and grab(iface, "data.device") != device: + dev_ref = grab(iface, "data.device") + if dev_ref is None: + continue + if getattr(dev_ref, "nb_id", None) != getattr(device, "nb_id", None): + if grab(dev_ref, "data.name") != grab(device, "data.name"): + continue + iname = (grab(iface, "data.name") or "") + if iname.lower() in cand_l: + return iface + return None + + def _find_switch_interface(self, system_name, port_id, port_description): + """ + Find switch and interface in inventory + Returns (NBDevice|None, NBInterface|None) + """ + if not system_name: + return None, None + + switch = self._find_device_by_name(system_name) + if switch is None: + return None, None + + candidates = [] + if port_id: + candidates.extend(self._expand_interface_names(port_id)) + if port_description and port_description != port_id: + candidates.extend(self._expand_interface_names(port_description)) + seen = set() + uniq = [] + for c in candidates: + if c and c not in seen: + seen.add(c) + uniq.append(c) + + iface = self._find_iface_on_device(switch, uniq) + return switch, iface + + def _find_existing_cable(self, iface_a, iface_b): + """Valid existing сables.""" + id_a = getattr(iface_a, "nb_id", 0) or 0 + id_b = getattr(iface_b, "nb_id", 0) or 0 + if id_a == 0 or id_b == 0: + return None + for cable in self.inventory.get_all_items(NBCable): + terms = [] + for side in ("a_terminations", "b_terminations"): + for t in grab(cable, f"data.{side}", fallback=[]) or []: + if isinstance(t, dict): + terms.append(t.get("object_id")) + if id_a in terms and id_b in terms: + return cable + return None + + def _create_cable_if_possible(self, server_iface, neighbor, host_name, pnic_name): + """ + Creates an NBCable server_iface ↔ switch_iface if both ends + resolve and both have an nb_id (otherwise, it’s handled in the next sync). + """ + if neighbor is None or server_iface is None: + return + + sys_name = neighbor.get("system_name") + port_id = neighbor.get("port_id") + port_desc = neighbor.get("port_description") + + switch, switch_iface = self._find_switch_interface(sys_name, port_id, port_desc) + if switch is None: + log.debug2(f"[{host_name}/{pnic_name}] Switch '{sys_name}' not in inventory, skip cable") + return + if switch_iface is None: + log.debug2( + f"[{host_name}/{pnic_name}] Port '{port_id or port_desc}' " + f"not found on '{sys_name}', skip cable" + ) + return + + srv_id = getattr(server_iface, "nb_id", 0) or 0 + sw_id = getattr(switch_iface, "nb_id", 0) or 0 + if srv_id == 0 or sw_id == 0: + log.debug2( + f"[{host_name}/{pnic_name}] Interface(s) not yet in NetBox " + f"(server_id={srv_id}, switch_id={sw_id}), cable on next sync" + ) + return + + if self._find_existing_cable(server_iface, switch_iface) is not None: + log.debug2(f"[{host_name}/{pnic_name}] Cable already exists, skip") + return + + cable_type = self._cable_type_for_port(port_id or port_desc or "") + desc = port_desc or "" + + label = f"{srv_id}:{sw_id}" # ex: "20538:1869" + + cable_data = { + "label": label, + "a_terminations": [ + {"object_type": "dcim.interface", "object_id": srv_id} + ], + "b_terminations": [ + {"object_type": "dcim.interface", "object_id": sw_id} + ], + "status": "connected", + "type": cable_type, + "description": (desc[:200] if desc else None), + "tags": [{"name": self.source_tag}] if getattr(self, "source_tag", None) else None, + } + cable_data = {k: v for k, v in cable_data.items() if v is not None} + + self.inventory.add_object(NBCable, data=cable_data, source=self) + log.info( + f"Cable queued: [{host_name}:{pnic_name}] ↔ " + f"[{grab(switch, 'data.name')}:{grab(switch_iface, 'data.name')}] " + f"type={cable_type} label={label}" + ) + def create_sdk_session(self): """ Initialize SDK session with vCenter @@ -1769,13 +2044,6 @@ def add_host(self, obj): # now iterate over all physical interfaces and collect data pnic_data_dict = dict() - pnic_hints = dict() - # noinspection PyBroadException - try: - for hint in obj.configManager.networkSystem.QueryNetworkHint(""): - pnic_hints[hint.device] = hint - except Exception: - pass for pnic in grab(obj, "config.network.pnic", fallback=list()): @@ -1807,30 +2075,25 @@ def add_host(self, obj): pnic_description = f"{pnic_description} pNIC" pnic_mtu = None - pnic_mode = None # check virtual switches for interface data for vs_name, vs_data in self.network_data["vswitch"][name].items(): - if pnic_key in vs_data.get("pnics", list()): pnic_description = f"{pnic_description} ({vs_name})" pnic_mtu = vs_data.get("mtu") # check proxy switches for interface data for ps_uuid, ps_data in self.network_data["pswitch"][name].items(): - if pnic_key in ps_data.get("pnics", list()): ps_name = ps_data.get("name") pnic_description = f"{pnic_description} ({ps_name})" pnic_mtu = ps_data.get("mtu") - pnic_mode = "tagged-all" # check vlans on this pnic pnic_vlans = list() for pg_name, pg_data in self.network_data["host_pgroup"][name].items(): - if pnic_name in pg_data.get("nics", list()): pnic_vlans.append({ "name": pg_name, @@ -1839,14 +2102,16 @@ def add_host(self, obj): pnic_mac_address = normalize_mac_address(grab(pnic, "mac")) - if pnic_hints.get(pnic_name) is not None: - pnic_switch_port = grab(pnic_hints.get(pnic_name), 'connectedSwitchPort') - if pnic_switch_port is not None: - pnic_sp_sys_name = grab(pnic_switch_port, 'systemName') - if pnic_sp_sys_name is None: - pnic_sp_sys_name = grab(pnic_switch_port, 'devId') - if pnic_sp_sys_name is not None: - pnic_description += f" (conn: {pnic_sp_sys_name} - {grab(pnic_switch_port, 'portId')})" + # --- CDP / LLDP: structured neighbor + description --- + neighbor = self._get_pnic_neighbor_info(obj, pnic_name, name) + if neighbor: + sys_name = neighbor.get("system_name") + port_show = neighbor.get("port_id") or neighbor.get("port_description") + if sys_name: + if port_show: + pnic_description += f" (conn: {sys_name} - {port_show})" + else: + pnic_description += f" (conn: {sys_name})" if self.settings.host_nic_exclude_by_mac_list is not None and \ pnic_mac_address in self.settings.host_nic_exclude_by_mac_list: @@ -1906,6 +2171,7 @@ def add_host(self, obj): if len(tagged_vlan_list) > 0: pnic_data["tagged_vlans"] = tagged_vlan_list + pnic_data["_neighbor"] = neighbor pnic_data_dict[pnic_name] = pnic_data host_primary_ip4 = None @@ -2053,11 +2319,64 @@ def add_host(self, obj): host_primary_ip6 = int_v6 # add host to inventory + pending_cables = {} + for pnic_name, pnic_data in list(pnic_data_dict.items()): + neighbor = pnic_data.pop("_neighbor", None) + if neighbor: + pending_cables[pnic_name] = neighbor + + log.info(f"[{name}] pending_cables={len(pending_cables)} keys={list(pending_cables.keys())}") + self.add_device_vm_to_inventory(NBDevice, object_data=host_data, pnic_data=pnic_data_dict, vnic_data=vnic_data_dict, nic_ips=vnic_ips, p_ipv4=host_primary_ip4, p_ipv6=host_primary_ip6, vmware_object=obj) - return + device_object = self.inventory.get_by_data( + NBDevice, data={"name": name, "site": {"name": site_name}} + ) + + if device_object is None: + device_object = self.inventory.get_by_data(NBDevice, data={"name": name}) + + log.info( + f"[{name}] device_object=" + f"{None if device_object is None else (device_object.nb_id, grab(device_object, 'data.name'))}" + ) + + if device_object is None: + log.warning(f"[{name}] device not found in inventory after add — skip cables") + return + + if not pending_cables: + log.info(f"[{name}] no neighbors on pNICs — skip cables") + return + + for pnic_name, neighbor in pending_cables.items(): + log.info( + f"[{name}/{pnic_name}] neighbor=" + f"{neighbor.get('system_name')} / " + f"port_id={neighbor.get('port_id')} / " + f"port_desc={neighbor.get('port_description')} / " + f"proto={neighbor.get('protocol')}" + ) + + server_iface = self.inventory.get_by_data( + NBInterface, + data={"name": unquote(pnic_name), "device": device_object} + ) + if server_iface is None: + for iface in self.inventory.get_all_items(NBInterface): + if grab(iface, "data.device") is device_object and \ + grab(iface, "data.name") == unquote(pnic_name): + server_iface = iface + break + + log.info( + f"[{name}/{pnic_name}] server_iface=" + f"{None if server_iface is None else (server_iface.nb_id, grab(server_iface, 'data.name'))}" + ) + + self._create_cable_if_possible(server_iface, neighbor, name, pnic_name) def add_virtual_machine(self, obj): """ From 3beb9c934d8a4e6cd8a62a97944afd8053d60d6e Mon Sep 17 00:00:00 2001 From: Sergey Sannikov Date: Thu, 10 Sep 2026 04:05:34 +0400 Subject: [PATCH 2/2] vmware: put CDP/LLDP cable sync behind sync_host_cables and harden it --- docs/source_vmware.md | 33 ++ module/netbox/object_classes.py | 74 ++- module/sources/vmware/config.py | 10 + module/sources/vmware/connection.py | 706 ++++++++++++++++------------ settings-example.ini | 7 + tests/test_vmware_host_cables.py | 472 +++++++++++++++++++ 6 files changed, 976 insertions(+), 326 deletions(-) create mode 100644 tests/test_vmware_host_cables.py diff --git a/docs/source_vmware.md b/docs/source_vmware.md index 9716d9d4..247083b8 100644 --- a/docs/source_vmware.md +++ b/docs/source_vmware.md @@ -149,6 +149,39 @@ Custom Fields: VMware Guest Hostname: appprd01.corp.example.com ``` +### Cables to CDP/LLDP neighbors + +An ESXi host reports the switch and the switch port each of its physical interfaces (pNICs) is +connected to, if CDP or LLDP is enabled on the switch. With the option `sync_host_cables` enabled +netbox-sync uses this information to create cables in NetBox between the host interface and the +switch port. + +```ini +sync_host_cables = True +``` + +Cables are objects which are usually maintained by hand, that's why this option is disabled by +default. With the option disabled no cable is read from or written to NetBox at all. NetBox 3.3 or +newer is needed, on older versions the option is ignored. + +netbox-sync only connects things it can find, it never creates the other end of a cable: + +* the device the neighbor reports as its system name must already exist in NetBox. The name is + matched exactly first, a short name is only matched against a FQDN if that match is unambiguous +* the port the neighbor reports must already exist as an interface of that device. Long and short + interface names are matched against each other, so a reported `FastEthernet0/16` also matches an + interface named `Fa0/16` in NetBox. CDP reports the port ID, LLDP additionally reports a port + description and both are tried +* both interfaces must already exist in NetBox. An interface which was just discovered gets its + cable during the next run +* neither of the two interfaces may be connected already. A cable which was created by hand or which + connects to a different port is never changed or deleted, it is reported at log level `DEBUG` + instead + +Cables created by this source are tagged like every other object and are marked as orphaned and +pruned once the host stops reporting that neighbor (see `prune_enabled`). Disabling the option again +leaves all previously created cables untouched in NetBox. + ### Filtering VM Disk Information VM disks are synchronized between vCenter and NetBox. Since NetBox 3.7.0, virtual disks are tracked as separate objects linked to VMs. In some scenarios, such as when temporary disks are attached to VMs during backup operations diff --git a/module/netbox/object_classes.py b/module/netbox/object_classes.py index 65f2cb53..2e57bc03 100644 --- a/module/netbox/object_classes.py +++ b/module/netbox/object_classes.py @@ -13,7 +13,7 @@ # noinspection PyUnresolvedReferences from packaging import version -from module.common.misc import grab +from module.common.misc import grab, get_string_or_none from module.common.logging import get_logger from module.netbox.manufacturer_mapping import sanitize_manufacturer_name @@ -2433,8 +2433,11 @@ class NBCable(NetBoxObject): name = "cable" api_path = "dcim/cables" object_type = "dcim.cable" + # a cable has no natural name, the label is the only free form text attribute it has primary_key = "label" prune = True + # cable terminations are lists of objects since NetBox 3.3 + min_netbox_version = "3.3" def __init__(self, *args, **kwargs): self.data_model = { @@ -2446,27 +2449,72 @@ def __init__(self, *args, **kwargs): "cat3", "cat5", "cat5e", "cat6", "cat6a", "cat7", "cat7a", "cat8", "dac-active", "dac-passive", "mmf", "mmf-om1", "mmf-om2", "mmf-om3", "mmf-om4", "mmf-om5", - "smf", "smf-os1", "smf-os2", "aoc", "power", "usb", "coaxial", + "smf", "smf-os1", "smf-os2", "aoc", "power", "usb", "coaxial" ], "description": 200, "color": str, "length": float, "length_unit": ["km", "m", "cm", "mi", "ft", "in"], - "tags": NBTagList, + "tags": NBTagList } super().__init__(*args, **kwargs) + def format_termination(self, termination): + """ + format a single cable termination as string + + Parameters + ---------- + termination: dict + a single entry of a cable "a_terminations"/"b_terminations" list + + Returns + ------- + (str, None): the name of the terminated object, None if it can't be determined + """ + + if not isinstance(termination, dict): + return None + + # data read from NetBox contains the terminated object, data compiled by a source only the ID + termination_object = termination.get("object") + if isinstance(termination_object, dict) and termination_object.get("display") is not None: + return f"{termination_object.get('display')}" + + object_id = termination.get("object_id") + if object_id is None: + return None + + # a source only knows the ID of an interface it compiled a cable for + if termination.get("object_type") == NBInterface.object_type and self.inventory is not None: + interface_object = self.inventory.get_by_id(NBInterface, nb_id=object_id) + if interface_object is not None: + return interface_object.get_display_name(including_second_key=True) + + return f"{termination.get('object_type')} {object_id}" + def get_display_name(self, data=None, including_second_key=False): + """ + A cable label is optional and mostly unset. Fall back to the objects this cable + connects to get a name which actually says something. + """ + this_data = data if data is not None else self.data - if not this_data: - return "Cable" - label = this_data.get("label") - if label: - return str(label) - a = (this_data.get("a_terminations") or [{}])[0] - b = (this_data.get("b_terminations") or [{}])[0] - a_id = a.get("object_id") if isinstance(a, dict) else None - b_id = b.get("object_id") if isinstance(b, dict) else None - return f"Cable a={a_id} ↔ b={b_id}" + + label = get_string_or_none(this_data.get(self.primary_key)) + if label is not None: + return label + + terminations = list() + for side in ["a_terminations", "b_terminations"]: + side_names = [self.format_termination(x) for x in this_data.get(side) or list()] + side_names = [x for x in side_names if x is not None] + if len(side_names) > 0: + terminations.append(", ".join(side_names)) + + if len(terminations) == 0: + return None + + return " <> ".join(terminations) # EOF diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index fe68a718..24524f1b 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -560,6 +560,16 @@ def __init__(self): will maintain all physical nics in netbox. This option will skip this part.""" , default_value=False ), + ConfigOption("sync_host_cables", + bool, + description="""Create cables in NetBox between the physical interfaces (pNICs) of an + ESXi host and the switch ports which are reported as CDP/LLDP neighbors by this host. + A cable is only created if the reported switch and the reported switch port both + already exist in NetBox and if neither of the two interfaces is cabled yet. Cables + are visible objects which are usually maintained by hand, that's why this is + disabled by default.""", + default_value=False + ), # removed settings ConfigOption("netbox_host_device_role", diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 9b05b15d..1732a7f4 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright (c) 2020 - 2026 Ricardo Bartels. All rights reserved. # # netbox-sync.py @@ -76,43 +77,23 @@ class VMWareHandler(SourceBase): NBVLANGroup, NBCustomField, NBVirtualDisk, - NBMACAddress, - NBCable + NBMACAddress ] - IFACE_PREFIX_MAP = [ - # 100G - ("HundredGigabitEthernet", "HundredGigE"), - ("HundredGigabitEthernet", "Hu"), - ("HundredGigE", "Hu"), - # 50G - ("FiftyGigabitEthernet", "FiftyGigE"), - ("FiftyGigabitEthernet", "Fi"), - ("FiftyGigE", "Fi"), - # 40G - ("FortyGigabitEthernet", "Fo"), - ("FortyGigabitEthernet", "FortyGigE"), - # 25G - ("TwentyFiveGigabitEthernet", "TwentyFiveGigE"), - ("TwentyFiveGigabitEthernet", "Twe"), - ("TwentyFiveGigabitEthernet", "TF"), - ("TwentyFiveGigabitEthernet", "25GigE"), - ("TwentyFiveGigE", "Twe"), - ("TwentyFiveGigE", "TF"), - ("TwentyFiveGigE", "25GigE"), - # 10G (Te после Twe!) - ("TenGigabitEthernet", "Te"), - ("TenGigabitEthernet", "TenGigE"), - # Huawei-style 10G - ("XGigabitEthernet", "XGi"), - ("XGigabitEthernet", "XGE"), - # 1G - ("GigabitEthernet", "Gi"), - ("GigabitEthernet", "GE"), - # 100M - ("FastEthernet", "Fa"), - # generic - ("Ethernet", "Eth"), + # maps the long interface name a CDP/LLDP neighbor can report to the short forms which are + # commonly used as interface name in NetBox and the other way around: Fa0/16 <> FastEthernet0/16 + # the first entry which matches a reported name wins, longer prefixes need to be listed first + interface_name_prefixes = [ + ("HundredGigabitEthernet", ["HundredGigE", "Hu"]), + ("FiftyGigabitEthernet", ["FiftyGigE", "Fi"]), + ("FortyGigabitEthernet", ["FortyGigE", "Fo"]), + ("TwentyFiveGigabitEthernet", ["TwentyFiveGigE", "25GigE", "Twe", "TF"]), + ("TenGigabitEthernet", ["TenGigE", "Te"]), + # Huawei style 10G + ("XGigabitEthernet", ["XGE", "XGi"]), + ("GigabitEthernet", ["GigE", "Gi", "GE"]), + ("FastEthernet", ["Fa"]), + ("Ethernet", ["Eth", "Et"]) ] source_type = "vmware" @@ -146,6 +127,18 @@ def __init__(self, name=None): self.set_source_tag() self.site_name = f"vCenter: {name}" + # index of NetBox interface id to the cable terminated on it, compiled on demand + self.cable_index = None + + # cables are only read from and written to NetBox if this source is meant to maintain them + if self.settings.sync_host_cables is True: + if version.parse(self.inventory.netbox_api_version) < version.parse(NBCable.min_netbox_version): + log.warning(f"Option 'sync_host_cables' needs NetBox version {NBCable.min_netbox_version} " + f"or newer. Disabling it for source '{name}'.") + self.settings.sync_host_cables = False + else: + self.dependent_netbox_objects = self.dependent_netbox_objects + [NBCable] + if self.settings.enabled is False: log.info(f"Source '{name}' is currently disabled. Skipping") return @@ -177,245 +170,349 @@ def __init__(self, name=None): self.objects_to_reevaluate = list() self.parsing_objects_to_reevaluate = False - def _expand_interface_names(self, name): - """Generates interface name variants (Fa0/16 ↔ FastEthernet0/16)""" - if not name: - return [] + @classmethod + def get_interface_name_variants(cls, name): + """ + return all spellings of an interface name a CDP/LLDP neighbor reported + + A neighbor can report the long name of a port ("FastEthernet0/16") while the very same + interface is named with a short form in NetBox ("Fa0/16") or the other way around. + Comparing names is done case-insensitive, that's why only one spelling per variant + is returned. + + Parameters + ---------- + name: str + interface name as reported by the neighbor + + Returns + ------- + list: of all name variants, empty if no name was reported + """ + + name = get_string_or_none(name) + if name is None: + return list() + variants = [name] - name_l = name.lower() - for long_form, short_form in self.IFACE_PREFIX_MAP: - long_l = long_form.lower() - short_l = short_form.lower() - if name_l.startswith(long_l): - rest = name[len(long_form):] - variants.extend([short_form + rest, short_form.lower() + rest, short_form.upper() + rest]) - if name_l.startswith(short_l) and not name_l.startswith(long_l): - rest = name[len(short_form):] - if rest and (rest[0].isdigit() or rest[0] in "/-"): - variants.append(long_form + rest) - seen = set() - unique = [] - for v in variants: - if v and v not in seen: - seen.add(v) - unique.append(v) - return unique - - def _get_pnic_neighbor_info(self, host_obj, pnic_name, host_name): + name_lower = name.lower() + + for long_prefix, short_prefixes in cls.interface_name_prefixes: + + remainder = None + if name_lower.startswith(long_prefix.lower()): + remainder = name[len(long_prefix):] + else: + for short_prefix in short_prefixes: + if not name_lower.startswith(short_prefix.lower()): + continue + short_remainder = name[len(short_prefix):] + # "Te0/1" uses the short form, "TenGigE0/1" just starts with the same letters + if len(short_remainder) > 0 and (short_remainder[0].isdigit() or short_remainder[0] in "/-"): + remainder = short_remainder + break + + if remainder is None: + continue + + variants.append(f"{long_prefix}{remainder}") + variants.extend([f"{x}{remainder}" for x in short_prefixes]) + break + + return list(dict.fromkeys(variants)) + + @staticmethod + def get_pnic_neighbor(hint): """ - CDP/LLDP neighbor для pNIC. - Returns dict: system_name, port_id, port_description, protocol - or None. + extract the neighbor a physical host interface reported via CDP or LLDP + + CDP is preferred as it reports the name of the connected switch directly. LLDP + reports the same information in a list of key/value parameters. + + Parameters + ---------- + hint: vim.host.PhysicalNic.NetworkHint + network hint of a single physical interface as returned by QueryNetworkHint() + + Returns + ------- + (dict, None): "system_name", "port_id", "port_description" and "protocol" of the + reported neighbor, None if this interface reported no usable neighbor """ - try: - hints = host_obj.configManager.networkSystem.QueryNetworkHint(pnic_name) - if not hints: - return None - hint = hints[0] - - # --- CDP (priority) --- - cdp = grab(hint, "connectedSwitchPort") - if cdp is not None: - sys_name = grab(cdp, "systemName") or grab(cdp, "devId") - port_id = grab(cdp, "portId") - if sys_name: - return { - "system_name": str(sys_name).strip(), - "port_id": str(port_id).strip() if port_id else None, - "port_description": str(port_id).strip() if port_id else None, - "protocol": "CDP", - } - # --- LLDP --- - lldp = grab(hint, "lldpInfo") - if lldp is not None: - params = {} - for param in grab(lldp, "parameter", fallback=list()) or []: - key = grab(param, "key") - value = grab(param, "value") - if key is not None and value is not None: - params[str(key).strip().lower()] = str(value).strip() - - sys_name = ( - params.get("system name") - or params.get("systemname") - or grab(lldp, "chassisId") - ) - # Port ID — real name port (ex: XGigabitEthernet0/0/14) - port_id = ( - params.get("port id") - or params.get("portid") - or grab(lldp, "portId") - ) - # Port Description — description from Network Switch (ex: MAIN-DETAIL12/Eth1) - port_desc = ( - params.get("port description") - or params.get("portdescription") - ) - - if sys_name: - return { - "system_name": str(sys_name).strip(), - "port_id": str(port_id).strip() if port_id else None, - "port_description": str(port_desc).strip() if port_desc else None, - "protocol": "LLDP", - } - except Exception as e: - log.debug2(f"[{host_name}/{pnic_name}] QueryNetworkHint failed: {e}") + if hint is None: + return None + + connected_switch_port = grab(hint, "connectedSwitchPort") + if connected_switch_port is not None: + system_name = get_string_or_none(grab(connected_switch_port, "systemName")) or \ + get_string_or_none(grab(connected_switch_port, "devId")) + + if system_name is not None: + return { + "system_name": system_name, + "port_id": get_string_or_none(grab(connected_switch_port, "portId")), + "port_description": None, + "protocol": "CDP" + } + + lldp_info = grab(hint, "lldpInfo") + if lldp_info is not None: + + parameters = dict() + for parameter in grab(lldp_info, "parameter", fallback=list()): + key = get_string_or_none(grab(parameter, "key")) + value = get_string_or_none(grab(parameter, "value")) + if key is not None and value is not None: + parameters[key.lower()] = value + + system_name = parameters.get("system name") or parameters.get("systemname") + + # the port id is the interface name of the neighbor (i.e.: "XGigabitEthernet0/0/14") + port_id = parameters.get("port id") or parameters.get("portid") + if port_id is None: + port_id = get_string_or_none(grab(lldp_info, "portId")) + + # the port description is maintained by the switch admin (i.e.: "MAIN-DETAIL12/Eth1") + port_description = parameters.get("port description") or parameters.get("portdescription") + + if system_name is not None: + return { + "system_name": system_name, + "port_id": port_id, + "port_description": port_description, + "protocol": "LLDP" + } + return None - def _cable_type_for_port(self, port_name): - """FastEthernet/Fa → cat5, others → dac-active. (for next optional edit)""" - if not port_name: - return "dac-active" - n = port_name.lower() - if n.startswith("fastethernet") or n.startswith("fa") and ( - len(n) == 2 or (len(n) > 2 and n[2] in "0123456789/-") - ): - return "cat5" - return "dac-active" - - def _find_device_by_name(self, name): - if not name: + @staticmethod + def get_cable_interface_ids(cable): + """ + return the NetBox IDs of all interfaces a cable is terminated on + + Parameters + ---------- + cable: NBCable + the cable object to read the terminations from + + Returns + ------- + list: of NetBox interface IDs + """ + + interface_ids = list() + for side in ["a_terminations", "b_terminations"]: + for termination in grab(cable, f"data.{side}", fallback=list()): + + if not isinstance(termination, dict): + continue + if termination.get("object_type") != NBInterface.object_type: + continue + if isinstance(termination.get("object_id"), int): + interface_ids.append(termination.get("object_id")) + + return interface_ids + + def get_cable_for_interface_id(self, interface_id): + """ + return the cable which is terminated on a NetBox interface + + All cables are looked at only once, cables added afterwards are added to the index + by add_cable_to_neighbor(). + + Parameters + ---------- + interface_id: int + NetBox ID of the interface to find the cable for + + Returns + ------- + (NBCable, None): the cable terminated on this interface, None if there is none + """ + + if self.cable_index is None: + self.cable_index = dict() + for cable in self.inventory.get_all_items(NBCable): + for cable_interface_id in self.get_cable_interface_ids(cable): + self.cable_index.setdefault(cable_interface_id, cable) + + return self.cable_index.get(interface_id) + + def get_device_by_neighbor_name(self, name): + """ + find the NetBox device a CDP/LLDP neighbor reported as its system name + + An exact match always wins. A neighbor can report a FQDN while the device is named + with its short name in NetBox (or the other way around), that's why short names are + compared as well. A short name match is only accepted if it is unambiguous and if it + does not compare two different domains with each other. + + Parameters + ---------- + name: str + system name the neighbor reported + + Returns + ------- + (NBDevice, None): the matching device, None if there was no or no unique match + """ + + name = get_string_or_none(name) + if name is None: return None - name_l = name.strip().lower() - name_short = name_l.split(".")[0] - for dev in self.inventory.get_all_items(NBDevice): - dname = (grab(dev, "data.name") or "").strip() - if not dname: + + name = name.lower() + short_name = name.split(".")[0] + + short_name_matches = list() + for device in self.inventory.get_all_items(NBDevice): + + device_name = get_string_or_none(grab(device, "data.name")) + if device_name is None: continue - dname_l = dname.lower() - if dname_l == name_l: - return dev - if dname_l == name_short or dname_l.split(".")[0] == name_short: - return dev - if dname_l.split(".")[0] == name_short: - return dev - return None - def _find_iface_on_device(self, device, port_candidates): - """Find interface on Switch/Device (with alias Fa/Gi/...).""" - if device is None or not port_candidates: - return None - cand_l = {c.lower() for c in port_candidates if c} - for iface in self.inventory.get_all_items(NBInterface): - if grab(iface, "data.device") is not device and grab(iface, "data.device") != device: - dev_ref = grab(iface, "data.device") - if dev_ref is None: - continue - if getattr(dev_ref, "nb_id", None) != getattr(device, "nb_id", None): - if grab(dev_ref, "data.name") != grab(device, "data.name"): - continue - iname = (grab(iface, "data.name") or "") - if iname.lower() in cand_l: - return iface + device_name = device_name.lower() + if device_name == name: + return device + + # "sw01.dc1.example.com" and "sw01.dc2.example.com" are not the same device + if "." in name and "." in device_name: + continue + + if device_name.split(".")[0] == short_name: + short_name_matches.append(device) + + if len(short_name_matches) == 1: + return short_name_matches[0] + + if len(short_name_matches) > 1: + log.debug(f"Neighbor '{name}' matches more than one {NBDevice.name} in NetBox: " + f"{[grab(x, 'data.name') for x in short_name_matches]}") + return None - def _find_switch_interface(self, system_name, port_id, port_description): + def get_interface_by_neighbor_port(self, device, port_names): """ - Find switch and interface in inventory - Returns (NBDevice|None, NBInterface|None) + find the interface of a device which matches one of the port names a neighbor reported + + Parameters + ---------- + device: NBDevice + the device to look for the interface on + port_names: list + port names reported by the neighbor, in the order they should be tried + + Returns + ------- + (NBInterface, None): the matching interface, None if none of the names matched """ - if not system_name: - return None, None - - switch = self._find_device_by_name(system_name) - if switch is None: - return None, None - - candidates = [] - if port_id: - candidates.extend(self._expand_interface_names(port_id)) - if port_description and port_description != port_id: - candidates.extend(self._expand_interface_names(port_description)) - seen = set() - uniq = [] - for c in candidates: - if c and c not in seen: - seen.add(c) - uniq.append(c) - - iface = self._find_iface_on_device(switch, uniq) - return switch, iface - - def _find_existing_cable(self, iface_a, iface_b): - """Valid existing сables.""" - id_a = getattr(iface_a, "nb_id", 0) or 0 - id_b = getattr(iface_b, "nb_id", 0) or 0 - if id_a == 0 or id_b == 0: + + if device is None: + return None + + wanted_names = list() + for port_name in port_names: + wanted_names.extend([x.lower() for x in self.get_interface_name_variants(port_name)]) + + if len(wanted_names) == 0: return None - for cable in self.inventory.get_all_items(NBCable): - terms = [] - for side in ("a_terminations", "b_terminations"): - for t in grab(cable, f"data.{side}", fallback=[]) or []: - if isinstance(t, dict): - terms.append(t.get("object_id")) - if id_a in terms and id_b in terms: - return cable + + device_interfaces = dict() + for interface in self.inventory.get_all_interfaces(device): + interface_name = get_string_or_none(grab(interface, "data.name")) + if interface_name is not None: + device_interfaces.setdefault(interface_name.lower(), interface) + + for wanted_name in dict.fromkeys(wanted_names): + if device_interfaces.get(wanted_name) is not None: + return device_interfaces.get(wanted_name) + return None - def _create_cable_if_possible(self, server_iface, neighbor, host_name, pnic_name): + def add_cable_to_neighbor(self, host_interface, neighbor, host_name, pnic_name): """ - Creates an NBCable server_iface ↔ switch_iface if both ends - resolve and both have an nb_id (otherwise, it’s handled in the next sync). + add a cable between a physical host interface and the switch port its CDP/LLDP neighbor reported + + A cable is only added if the reported switch and switch port were both found in NetBox and + if neither of the two interfaces is connected with a cable already. Cables which were created + by this source before are claimed again so they don't end up being marked as orphaned. + + Parameters + ---------- + host_interface: NBInterface + interface object of the physical host interface + neighbor: dict + neighbor data as returned by get_pnic_neighbor() + host_name: str + name of the host this interface belongs to, used for logging + pnic_name: str + name of the physical interface, used for logging """ - if neighbor is None or server_iface is None: + + if host_interface is None or neighbor is None: return - sys_name = neighbor.get("system_name") - port_id = neighbor.get("port_id") - port_desc = neighbor.get("port_description") + log_name = f"Neighbor of interface '{pnic_name}' on host '{host_name}'" - switch, switch_iface = self._find_switch_interface(sys_name, port_id, port_desc) - if switch is None: - log.debug2(f"[{host_name}/{pnic_name}] Switch '{sys_name}' not in inventory, skip cable") + switch_object = self.get_device_by_neighbor_name(neighbor.get("system_name")) + if switch_object is None: + log.debug2(f"{log_name}: {NBDevice.name} '{neighbor.get('system_name')}' not found in NetBox. " + f"Not adding a cable.") return - if switch_iface is None: - log.debug2( - f"[{host_name}/{pnic_name}] Port '{port_id or port_desc}' " - f"not found on '{sys_name}', skip cable" - ) + + port_names = [neighbor.get("port_id"), neighbor.get("port_description")] + switch_interface = self.get_interface_by_neighbor_port(switch_object, port_names) + if switch_interface is None: + log.debug2(f"{log_name}: no interface matching {[x for x in port_names if x is not None]} found on " + f"{NBDevice.name} '{grab(switch_object, 'data.name')}'. Not adding a cable.") return - srv_id = getattr(server_iface, "nb_id", 0) or 0 - sw_id = getattr(switch_iface, "nb_id", 0) or 0 - if srv_id == 0 or sw_id == 0: - log.debug2( - f"[{host_name}/{pnic_name}] Interface(s) not yet in NetBox " - f"(server_id={srv_id}, switch_id={sw_id}), cable on next sync" - ) + host_interface_id = getattr(host_interface, "nb_id", 0) + switch_interface_id = getattr(switch_interface, "nb_id", 0) + + # a cable can only reference interfaces which exist in NetBox. + # an interface which was just discovered gets its cable during the next run + if host_interface_id == 0 or switch_interface_id == 0: + log.debug2(f"{log_name}: {NBInterface.name} '{host_interface.get_display_name()}' or " + f"'{switch_interface.get_display_name()}' does not exist in NetBox yet. " + f"A cable can be added during the next run.") return - if self._find_existing_cable(server_iface, switch_iface) is not None: - log.debug2(f"[{host_name}/{pnic_name}] Cable already exists, skip") + existing_cable = self.get_cable_for_interface_id(host_interface_id) or \ + self.get_cable_for_interface_id(switch_interface_id) + + if existing_cable is not None: + + existing_interface_ids = self.get_cable_interface_ids(existing_cable) + + if host_interface_id in existing_interface_ids and switch_interface_id in existing_interface_ids: + log.debug2(f"{log_name}: cable '{existing_cable.get_display_name()}' already exists") + + # a cable this source added before is still valid and must not be marked as orphaned. + # a cable which somebody else created stays untouched and unmanaged + if self.source_tag in existing_cable.get_tags(): + existing_cable.set_source(self) + else: + log.debug(f"{log_name}: {NBInterface.name} '{host_interface.get_display_name()}' or " + f"'{switch_interface.get_display_name()}' is already connected with cable " + f"'{existing_cable.get_display_name()}'. Not adding a cable.") + return - cable_type = self._cable_type_for_port(port_id or port_desc or "") - desc = port_desc or "" - - label = f"{srv_id}:{sw_id}" # ex: "20538:1869" - - cable_data = { - "label": label, - "a_terminations": [ - {"object_type": "dcim.interface", "object_id": srv_id} - ], - "b_terminations": [ - {"object_type": "dcim.interface", "object_id": sw_id} - ], - "status": "connected", - "type": cable_type, - "description": (desc[:200] if desc else None), - "tags": [{"name": self.source_tag}] if getattr(self, "source_tag", None) else None, - } - cable_data = {k: v for k, v in cable_data.items() if v is not None} + log.debug2(f"{log_name}: reported via {neighbor.get('protocol')} as " + f"'{neighbor.get('system_name')}' port '{neighbor.get('port_id')}'") - self.inventory.add_object(NBCable, data=cable_data, source=self) - log.info( - f"Cable queued: [{host_name}:{pnic_name}] ↔ " - f"[{grab(switch, 'data.name')}:{grab(switch_iface, 'data.name')}] " - f"type={cable_type} label={label}" - ) + cable_object = self.inventory.add_object(NBCable, source=self, data={ + # a label is not mandatory in NetBox and stays empty, the terminations name this cable + "label": "", + "a_terminations": [{"object_type": NBInterface.object_type, "object_id": host_interface_id}], + "b_terminations": [{"object_type": NBInterface.object_type, "object_id": switch_interface_id}], + "status": "connected" + }) + + for interface_id in [host_interface_id, switch_interface_id]: + self.cable_index[interface_id] = cable_object def create_sdk_session(self): """ @@ -1445,6 +1542,10 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v disk_data: list data of discs which belong to a VM + Returns + ------- + tuple: the added/updated (NBDevice, NBVM) object and a dict of all interface objects + which were added/updated for it, discovered interface name as key """ if object_type not in [NBDevice, NBVM]: @@ -1671,6 +1772,8 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v except ValueError: log.error(f"Primary IPv6 ({p_ipv6}) does not appear to be a valid IP address (needs included suffix).") + interface_objects = dict() + for int_name, int_data in nic_data.items(): if nic_object_dict.get(int_name) is not None: @@ -1684,6 +1787,8 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v int_data, nic_ips.get(int_name, list()), vmware_object=vmware_object) + interface_objects[int_name] = nic_object + # add all interface IPs for ip_object in ip_address_objects: @@ -1732,7 +1837,7 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v f"'{device_vm_object.get_display_name()}'") device_vm_object.update(data={f"primary_ip{ip_version}": ip_object}) - return + return device_vm_object, interface_objects def get_parent_object_by_class(self, obj, object_class_to_find): @@ -2287,6 +2392,14 @@ def add_host(self, obj): # now iterate over all physical interfaces and collect data pnic_data_dict = dict() + pnic_neighbors = dict() + pnic_hints = dict() + # noinspection PyBroadException + try: + for hint in obj.configManager.networkSystem.QueryNetworkHint(""): + pnic_hints[hint.device] = hint + except Exception: + pass pnic_list = grab(obj, "config.network.pnic", fallback=list()) if self.settings.skip_host_nics is True: @@ -2322,25 +2435,30 @@ def add_host(self, obj): pnic_description = f"{pnic_description} pNIC" pnic_mtu = None + pnic_mode = None # check virtual switches for interface data for vs_name, vs_data in self.network_data["vswitch"][name].items(): + if pnic_key in vs_data.get("pnics", list()): pnic_description = f"{pnic_description} ({vs_name})" pnic_mtu = vs_data.get("mtu") # check proxy switches for interface data for ps_uuid, ps_data in self.network_data["pswitch"][name].items(): + if pnic_key in ps_data.get("pnics", list()): ps_name = ps_data.get("name") pnic_description = f"{pnic_description} ({ps_name})" pnic_mtu = ps_data.get("mtu") + pnic_mode = "tagged-all" # check vlans on this pnic pnic_vlans = list() for pg_name, pg_data in self.network_data["host_pgroup"][name].items(): + if pnic_name in pg_data.get("nics", list()): pnic_vlans.append({ "name": pg_name, @@ -2349,22 +2467,32 @@ def add_host(self, obj): pnic_mac_address = normalize_mac_address(grab(pnic, "mac")) - # --- CDP / LLDP: structured neighbor + description --- - neighbor = self._get_pnic_neighbor_info(obj, pnic_name, name) - if neighbor: - sys_name = neighbor.get("system_name") - port_show = neighbor.get("port_id") or neighbor.get("port_description") - if sys_name: - if port_show: - pnic_description += f" (conn: {sys_name} - {port_show})" - else: - pnic_description += f" (conn: {sys_name})" + if pnic_hints.get(pnic_name) is not None: + pnic_switch_port = grab(pnic_hints.get(pnic_name), 'connectedSwitchPort') + if pnic_switch_port is not None: + pnic_sp_sys_name = grab(pnic_switch_port, 'systemName') + if pnic_sp_sys_name is None: + pnic_sp_sys_name = grab(pnic_switch_port, 'devId') + if pnic_sp_sys_name is not None: + pnic_description += f" (conn: {pnic_sp_sys_name} - {grab(pnic_switch_port, 'portId')})" if self.settings.host_nic_exclude_by_mac_list is not None and \ pnic_mac_address in self.settings.host_nic_exclude_by_mac_list: log.debug2(f"Host NIC with MAC '{pnic_mac_address}' excluded from sync. Skipping") continue + # collect the reported neighbor to add a cable for this interface later on + if self.settings.sync_host_cables is True: + pnic_neighbor = self.get_pnic_neighbor(pnic_hints.get(pnic_name)) + + if pnic_neighbor is not None: + pnic_neighbors[pnic_name] = pnic_neighbor + + # a CDP neighbor is already part of the description + if pnic_neighbor.get("protocol") == "LLDP": + neighbor_port = pnic_neighbor.get("port_id") or pnic_neighbor.get("port_description") + pnic_description += f" (conn: {pnic_neighbor.get('system_name')} - {neighbor_port})" + pnic_data = { "name": unquote(pnic_name), "device": None, # will be set once we found the correct device @@ -2418,7 +2546,6 @@ def add_host(self, obj): if len(tagged_vlan_list) > 0: pnic_data["tagged_vlans"] = tagged_vlan_list - pnic_data["_neighbor"] = neighbor pnic_data_dict[pnic_name] = pnic_data host_primary_ip4 = None @@ -2566,64 +2693,17 @@ def add_host(self, obj): host_primary_ip6 = int_v6 # add host to inventory - pending_cables = {} - for pnic_name, pnic_data in list(pnic_data_dict.items()): - neighbor = pnic_data.pop("_neighbor", None) - if neighbor: - pending_cables[pnic_name] = neighbor + device_object, interface_objects = \ + self.add_device_vm_to_inventory(NBDevice, object_data=host_data, pnic_data=pnic_data_dict, + vnic_data=vnic_data_dict, nic_ips=vnic_ips, + p_ipv4=host_primary_ip4, p_ipv6=host_primary_ip6, vmware_object=obj) - log.info(f"[{name}] pending_cables={len(pending_cables)} keys={list(pending_cables.keys())}") - - self.add_device_vm_to_inventory(NBDevice, object_data=host_data, pnic_data=pnic_data_dict, - vnic_data=vnic_data_dict, nic_ips=vnic_ips, - p_ipv4=host_primary_ip4, p_ipv6=host_primary_ip6, vmware_object=obj) - - device_object = self.inventory.get_by_data( - NBDevice, data={"name": name, "site": {"name": site_name}} - ) - - if device_object is None: - device_object = self.inventory.get_by_data(NBDevice, data={"name": name}) - - log.info( - f"[{name}] device_object=" - f"{None if device_object is None else (device_object.nb_id, grab(device_object, 'data.name'))}" - ) - - if device_object is None: - log.warning(f"[{name}] device not found in inventory after add — skip cables") - return + # add cables to the switch ports which were reported via CDP/LLDP + if device_object is not None: + for pnic_name, pnic_neighbor in pnic_neighbors.items(): + self.add_cable_to_neighbor(interface_objects.get(pnic_name), pnic_neighbor, name, pnic_name) - if not pending_cables: - log.info(f"[{name}] no neighbors on pNICs — skip cables") - return - - for pnic_name, neighbor in pending_cables.items(): - log.info( - f"[{name}/{pnic_name}] neighbor=" - f"{neighbor.get('system_name')} / " - f"port_id={neighbor.get('port_id')} / " - f"port_desc={neighbor.get('port_description')} / " - f"proto={neighbor.get('protocol')}" - ) - - server_iface = self.inventory.get_by_data( - NBInterface, - data={"name": unquote(pnic_name), "device": device_object} - ) - if server_iface is None: - for iface in self.inventory.get_all_items(NBInterface): - if grab(iface, "data.device") is device_object and \ - grab(iface, "data.name") == unquote(pnic_name): - server_iface = iface - break - - log.info( - f"[{name}/{pnic_name}] server_iface=" - f"{None if server_iface is None else (server_iface.nb_id, grab(server_iface, 'data.name'))}" - ) - - self._create_cable_if_possible(server_iface, neighbor, name, pnic_name) + return def add_virtual_machine(self, obj): """ diff --git a/settings-example.ini b/settings-example.ini index 3f842acd..dcf84675 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -528,6 +528,13 @@ password = super-secret ; disk synchronization. A VM with this tag will still be synced to NetBox, but its disk information won't be updated. ;vm_exclude_disk_sync_by_tag = backup-vm, veeam-job +; Create cables in NetBox between the physical interfaces (pNICs) of an ESXi host and the +; switch ports which are reported as CDP/LLDP neighbors by this host. A cable is only +; created if the reported switch and the reported switch port both already exist in NetBox +; and if neither of the two interfaces is cabled yet. Cables are visible objects which are +; usually maintained by hand, that's why this is disabled by default. +;sync_host_cables = False + [source/my-redfish-example] ; Defines if this source is enabled or not diff --git a/tests/test_vmware_host_cables.py b/tests/test_vmware_host_cables.py new file mode 100644 index 00000000..89d034ef --- /dev/null +++ b/tests/test_vmware_host_cables.py @@ -0,0 +1,472 @@ +""" +Cables from the CDP/LLDP neighbors an ESXi host reports for its physical interfaces. + +The vcsim captures carry no CDP/LLDP data, so the parts which turn a reported neighbor +into a cable are tested against a hand built inventory. What the vcsim run has to prove +is that the feature stays completely out of the way while `sync_host_cables` is disabled. +""" +from types import SimpleNamespace + +import pytest + +from module.netbox.object_classes import NBCable, NBDevice, NBInterface, NBTag +from module.sources import instantiate_sources +from module.sources.vmware.connection import VMWareHandler + + +def cdp_hint(system_name=None, device_id=None, port_id=None): + """a QueryNetworkHint() result of an interface which sees a CDP neighbor""" + + return SimpleNamespace( + connectedSwitchPort=SimpleNamespace(systemName=system_name, devId=device_id, portId=port_id), + lldpInfo=None + ) + + +def lldp_hint(port_id=None, **parameters): + """a QueryNetworkHint() result of an interface which sees an LLDP neighbor""" + + return SimpleNamespace( + connectedSwitchPort=None, + lldpInfo=SimpleNamespace( + portId=port_id, + parameter=[SimpleNamespace(key=key, value=value) for key, value in parameters.items()] + ) + ) + + +def termination(object_id, object_type="dcim.interface"): + return {"object_type": object_type, "object_id": object_id} + + +@pytest.fixture +def cable_source(inventory): + """ + A VMware source handler with nothing but the state the cable code touches, on the + fresh in-memory inventory. Building it without __init__ keeps vCenter out of the way. + """ + source = object.__new__(VMWareHandler) + source.inventory = inventory + source.name = "test" + source.source_tag = "Source: test" + source.cable_index = None + + inventory.add_update_object(NBTag, data={"name": source.source_tag}) + + return source + + +@pytest.fixture +def netbox_interface(inventory, cable_source): + """Returns a function adding a device interface which already exists in NetBox.""" + + devices = {} + + def _add(device_name, interface_name, nb_id): + device = devices.get(device_name) + if device is None: + device = inventory.add_object(NBDevice, data={"name": device_name}, source=cable_source) + devices[device_name] = device + + interface = inventory.add_object(NBInterface, data={"name": interface_name, "device": device}, + source=cable_source) + interface.nb_id = nb_id + interface.is_new = False + + return interface + + return _add + + +# --- interface name variants ------------------------------------------------------------------- + +@pytest.mark.parametrize("reported, expected", [ + ("FastEthernet0/16", "Fa0/16"), + ("Fa0/16", "FastEthernet0/16"), + ("Te1/0/1", "TenGigabitEthernet1/0/1"), + ("Te1/0/1", "TenGigE1/0/1"), + ("TenGigE0/0/1", "Te0/0/1"), + ("Twe1/0/5", "TwentyFiveGigabitEthernet1/0/5"), + ("TwentyFiveGigabitEthernet1/0/5", "TF1/0/5"), + ("XGigabitEthernet0/0/14", "XGE0/0/14"), + ("GE1/0/1", "GigabitEthernet1/0/1"), + ("GigabitEthernet1/0/1", "Gi1/0/1"), + ("Ethernet1/1", "Eth1/1"), + ("Eth1/1", "Ethernet1/1"), +]) +def test_interface_name_variants_contain_the_other_spelling(reported, expected): + assert expected in VMWareHandler.get_interface_name_variants(reported) + + +def test_interface_name_variants_start_with_the_reported_name_and_are_unique(): + variants = VMWareHandler.get_interface_name_variants("FastEthernet0/16") + + assert variants[0] == "FastEthernet0/16" + assert len(variants) == len(set(variants)) + + +@pytest.mark.parametrize("reported", [None, "", " "]) +def test_interface_name_variants_of_an_unreported_port(reported): + assert VMWareHandler.get_interface_name_variants(reported) == [] + + +def test_interface_name_variants_do_not_confuse_ten_and_twentyfive_gigabit(): + variants = VMWareHandler.get_interface_name_variants("Te1/0/1") + + assert "TwentyFiveGigabitEthernet1/0/1" not in variants + + +def test_interface_name_variants_keep_names_which_only_start_like_a_short_form(): + # "TenGigE" is not "Te" plus a port number and "Bundle-Ether1" is no Ethernet port at all + assert VMWareHandler.get_interface_name_variants("TenGigE") == ["TenGigE"] + assert VMWareHandler.get_interface_name_variants("Bundle-Ether1") == ["Bundle-Ether1"] + + +# --- reading the neighbor of a physical interface ------------------------------------------------ + +def test_no_neighbor_reported(): + assert VMWareHandler.get_pnic_neighbor(None) is None + assert VMWareHandler.get_pnic_neighbor(SimpleNamespace(connectedSwitchPort=None, lldpInfo=None)) is None + + +def test_cdp_neighbor(): + neighbor = VMWareHandler.get_pnic_neighbor(cdp_hint(system_name=" sw01.example.com ", port_id=" Gi1/0/1 ")) + + assert neighbor == { + "system_name": "sw01.example.com", + "port_id": "Gi1/0/1", + "port_description": None, + "protocol": "CDP" + } + + +def test_cdp_neighbor_falls_back_to_the_device_id(): + neighbor = VMWareHandler.get_pnic_neighbor(cdp_hint(system_name="", device_id="sw01", port_id=None)) + + assert neighbor.get("system_name") == "sw01" + assert neighbor.get("port_id") is None + + +def test_cdp_without_a_name_falls_through_to_lldp(): + hint = lldp_hint(**{"System Name": "sw01", "Port ID": "Gi1/0/1"}) + hint.connectedSwitchPort = SimpleNamespace(systemName=None, devId=None, portId="Gi1/0/1") + + assert VMWareHandler.get_pnic_neighbor(hint).get("protocol") == "LLDP" + + +def test_lldp_neighbor(): + neighbor = VMWareHandler.get_pnic_neighbor(lldp_hint(**{ + "System Name": "sw01.example.com", + "Port ID": "XGigabitEthernet0/0/14", + "Port Description": "MAIN-DETAIL12/Eth1" + })) + + assert neighbor == { + "system_name": "sw01.example.com", + "port_id": "XGigabitEthernet0/0/14", + "port_description": "MAIN-DETAIL12/Eth1", + "protocol": "LLDP" + } + + +def test_lldp_neighbor_port_id_attribute_is_used_if_no_parameter_was_reported(): + neighbor = VMWareHandler.get_pnic_neighbor(lldp_hint(port_id=42, **{"System Name": "sw01"})) + + assert neighbor.get("port_id") == "42" + + +def test_lldp_neighbor_without_a_system_name_is_unusable(): + assert VMWareHandler.get_pnic_neighbor(lldp_hint(**{"Port ID": "Gi1/0/1"})) is None + + +def test_lldp_parameters_which_are_not_a_name_are_ignored(): + hint = lldp_hint(**{"System Name": ["sw01", "sw02"], "Port ID": "Gi1/0/1"}) + + assert VMWareHandler.get_pnic_neighbor(hint) is None + + +# --- cable terminations --------------------------------------------------------------------------- + +def test_cable_interface_ids_of_both_sides(inventory, cable_source): + cable = inventory.add_object(NBCable, read_from_netbox=True, data={ + "id": 5, "label": "", "a_terminations": [termination(10)], "b_terminations": [termination(20)] + }) + + assert VMWareHandler.get_cable_interface_ids(cable) == [10, 20] + + +def test_cable_interface_ids_ignore_terminations_which_are_no_interface(inventory, cable_source): + cable = inventory.add_object(NBCable, read_from_netbox=True, data={ + "id": 5, + "label": "", + "a_terminations": [termination(10, object_type="dcim.frontport"), "broken", {"object_id": None}], + "b_terminations": [termination(20)] + }) + + assert VMWareHandler.get_cable_interface_ids(cable) == [20] + + +# --- how a cable is named ----------------------------------------------------------------------- + +def test_a_cable_is_named_after_the_interfaces_it_connects(inventory, cable_source, netbox_interface): + netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "Fa0/16", 200) + cable = inventory.add_object(NBCable, source=cable_source, data={ + "label": "", "a_terminations": [termination(100)], "b_terminations": [termination(200)] + }) + + assert cable.get_display_name() == "vmnic0 (esx01) <> Fa0/16 (sw01)" + + +def test_a_cable_read_from_netbox_is_named_after_its_terminations(inventory, cable_source): + cable = inventory.add_object(NBCable, read_from_netbox=True, data={ + "id": 9, + "label": "", + "a_terminations": [{"object_type": "dcim.interface", "object_id": 1, + "object": {"display": "Gi1/0/2 (sw01)"}}], + "b_terminations": [{"object_type": "dcim.interface", "object_id": 2, + "object": {"display": "vmnic1 (esx02)"}}] + }) + + assert cable.get_display_name() == "Gi1/0/2 (sw01) <> vmnic1 (esx02)" + + +def test_a_label_someone_set_names_the_cable(inventory, cable_source): + cable = inventory.add_object(NBCable, read_from_netbox=True, data={ + "id": 10, "label": "patch-42", "a_terminations": [termination(1)], "b_terminations": [termination(2)] + }) + + assert cable.get_display_name() == "patch-42" + + +# --- adding a cable to the reported neighbor --------------------------------------------------- + +def test_cable_is_added_between_both_reported_ends(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "Fa0/16", 200) + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01", "port_id": "FastEthernet0/16", + "port_description": None, "protocol": "CDP"}, "esx01", "vmnic0") + + cables = list(inventory.get_all_items(NBCable)) + assert len(cables) == 1 + assert VMWareHandler.get_cable_interface_ids(cables[0]) == [100, 200] + assert cables[0].data.get("status") == "connected" + assert cables[0].source is cable_source + + +def test_cable_is_added_for_a_port_matched_by_its_description(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "MAIN-DETAIL12/Eth1", 200) + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01", "port_id": "XGigabitEthernet0/0/14", + "port_description": "MAIN-DETAIL12/Eth1", "protocol": "LLDP"}, "esx01", "vmnic0") + + assert len(list(inventory.get_all_items(NBCable))) == 1 + + +def test_a_neighbor_reporting_a_fqdn_matches_the_short_device_name(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "Gi1/0/1", 200) + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01.example.com", "port_id": "Gi1/0/1", + "port_description": None, "protocol": "CDP"}, "esx01", "vmnic0") + + assert len(list(inventory.get_all_items(NBCable))) == 1 + + +def test_an_ambiguous_short_device_name_is_not_matched(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01.dc1.example.com", "Gi1/0/1", 200) + netbox_interface("sw01.dc2.example.com", "Gi1/0/1", 300) + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01", "port_id": "Gi1/0/1", + "port_description": None, "protocol": "CDP"}, "esx01", "vmnic0") + + assert list(inventory.get_all_items(NBCable)) == [] + + +def test_two_different_domains_are_two_different_devices(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01.dc1.example.com", "Gi1/0/1", 200) + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01.dc2.example.com", "port_id": "Gi1/0/1", + "port_description": None, "protocol": "CDP"}, "esx01", "vmnic0") + + assert list(inventory.get_all_items(NBCable)) == [] + + +def test_no_cable_if_the_neighbor_is_not_in_netbox(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01", "port_id": "Gi1/0/1", + "port_description": None, "protocol": "CDP"}, "esx01", "vmnic0") + + assert list(inventory.get_all_items(NBCable)) == [] + + +def test_no_cable_if_the_reported_port_is_not_in_netbox(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "Gi1/0/2", 200) + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01", "port_id": "Gi1/0/1", + "port_description": None, "protocol": "CDP"}, "esx01", "vmnic0") + + assert list(inventory.get_all_items(NBCable)) == [] + + +def test_no_cable_before_both_interfaces_exist_in_netbox(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + switch_interface = netbox_interface("sw01", "Gi1/0/1", 200) + # a switch port which was discovered during this very run has no NetBox ID yet + switch_interface.nb_id = 0 + + cable_source.add_cable_to_neighbor( + host_interface, {"system_name": "sw01", "port_id": "Gi1/0/1", + "port_description": None, "protocol": "CDP"}, "esx01", "vmnic0") + + assert list(inventory.get_all_items(NBCable)) == [] + + +def test_an_existing_cable_is_not_added_a_second_time(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "Gi1/0/1", 200) + inventory.add_object(NBCable, read_from_netbox=True, data={ + "id": 7, "label": "", "a_terminations": [termination(200)], "b_terminations": [termination(100)], + "tags": [{"name": cable_source.source_tag}] + }) + inventory.resolve_relations() + + neighbor = {"system_name": "sw01", "port_id": "Gi1/0/1", "port_description": None, "protocol": "CDP"} + cable_source.add_cable_to_neighbor(host_interface, neighbor, "esx01", "vmnic0") + + cables = list(inventory.get_all_items(NBCable)) + assert len(cables) == 1 + # the cable is still reported by this source, so it must not be marked as orphaned + assert cables[0].source is cable_source + + +def test_a_cable_created_by_somebody_else_is_not_claimed(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "Gi1/0/1", 200) + inventory.add_object(NBCable, read_from_netbox=True, data={ + "id": 7, "label": "", "a_terminations": [termination(100)], "b_terminations": [termination(200)] + }) + inventory.resolve_relations() + + neighbor = {"system_name": "sw01", "port_id": "Gi1/0/1", "port_description": None, "protocol": "CDP"} + cable_source.add_cable_to_neighbor(host_interface, neighbor, "esx01", "vmnic0") + + cables = list(inventory.get_all_items(NBCable)) + assert len(cables) == 1 + assert cables[0].source is None + + +def test_an_interface_which_is_cabled_somewhere_else_is_left_alone(inventory, cable_source, netbox_interface): + host_interface = netbox_interface("esx01", "vmnic0", 100) + netbox_interface("sw01", "Gi1/0/1", 200) + inventory.add_object(NBCable, read_from_netbox=True, data={ + "id": 7, "label": "", "a_terminations": [termination(100)], "b_terminations": [termination(999)] + }) + inventory.resolve_relations() + + neighbor = {"system_name": "sw01", "port_id": "Gi1/0/1", "port_description": None, "protocol": "CDP"} + cable_source.add_cable_to_neighbor(host_interface, neighbor, "esx01", "vmnic0") + + assert len(list(inventory.get_all_items(NBCable))) == 1 + + +def test_two_hosts_reporting_each_other_get_one_cable(inventory, cable_source, netbox_interface): + """A direct link between two ESXi hosts is reported from both sides during the same run.""" + + first = netbox_interface("esx01", "vmnic0", 100) + second = netbox_interface("esx02", "vmnic0", 200) + + cable_source.add_cable_to_neighbor( + first, {"system_name": "esx02", "port_id": "vmnic0", "port_description": None, "protocol": "LLDP"}, + "esx01", "vmnic0") + cable_source.add_cable_to_neighbor( + second, {"system_name": "esx01", "port_id": "vmnic0", "port_description": None, "protocol": "LLDP"}, + "esx02", "vmnic0") + + assert len(list(inventory.get_all_items(NBCable))) == 1 + + +def test_a_pnic_without_an_interface_object_is_skipped(inventory, cable_source, netbox_interface): + netbox_interface("sw01", "Gi1/0/1", 200) + + cable_source.add_cable_to_neighbor( + None, {"system_name": "sw01", "port_id": "Gi1/0/1", "port_description": None, "protocol": "CDP"}, + "esx01", "vmnic0") + + assert list(inventory.get_all_items(NBCable)) == [] + + +# --- the option gates the whole feature --------------------------------------------------------- + +def test_disabled_by_default(vcsim, inventory, load_config, vmware_settings): + load_config(vmware_settings) + sources = instantiate_sources() + assert sources and sources[0].init_successful + + assert sources[0].settings.sync_host_cables is False + + +def test_nothing_cable_related_happens_while_the_option_is_disabled(vcsim, inventory, load_config, + vmware_settings, monkeypatch): + looked_at = list() + monkeypatch.setattr(VMWareHandler, "get_pnic_neighbor", staticmethod(looked_at.append)) + + load_config(vmware_settings) + sources = instantiate_sources() + assert sources and sources[0].init_successful + source = sources[0] + + # a cable which is not read from NetBox can not be changed, tagged or pruned by this run + assert NBCable not in source.dependent_netbox_objects + + inventory.resolve_relations() + source.apply() + + assert looked_at == [], "the CDP/LLDP neighbor of a pNIC must not be read while the option is disabled" + assert list(inventory.get_all_items(NBCable)) == [], "no cable may be created while the option is disabled" + + +def test_enabling_the_option_reads_cables_from_netbox(vcsim, inventory, load_config, vmware_settings): + load_config(vmware_settings + "\nsync_host_cables = True\n") + sources = instantiate_sources() + assert sources and sources[0].init_successful + + assert sources[0].settings.sync_host_cables is True + assert NBCable in sources[0].dependent_netbox_objects + + +def test_the_option_is_disabled_on_a_netbox_which_is_too_old(vcsim, inventory, load_config, vmware_settings): + inventory.netbox_api_version = "3.2.0" + + load_config(vmware_settings + "\nsync_host_cables = True\n") + sources = instantiate_sources() + assert sources and sources[0].init_successful + + assert sources[0].settings.sync_host_cables is False + assert NBCable not in sources[0].dependent_netbox_objects + + +def test_a_sync_with_the_option_enabled_still_works(vcsim, inventory, load_config, vmware_settings): + load_config(vmware_settings + "\nsync_host_cables = True\n") + sources = instantiate_sources() + assert sources and sources[0].init_successful + + inventory.resolve_relations() + sources[0].apply() + + assert list(inventory.get_all_items(NBDevice)), "hosts must still be synced" + # none of the captured vcsim inventories reports a CDP/LLDP neighbor + assert list(inventory.get_all_items(NBCable)) == []