From 8c243147f5480290c37dd54d601497bc787e2dbe Mon Sep 17 00:00:00 2001 From: Thomas Foks Date: Mon, 12 Oct 2026 09:50:35 +0200 Subject: [PATCH 1/2] fix: match interface names cut to the NetBox name length NetBox stores interface names cut to 64 characters. A Lenovo XCC port is named after the manager model and its Redfish id, which is longer than that, so the name match in map_object_interfaces_to_current_interfaces() never found the stored interface. Without a MAC match the port was created again on every run and NetBox rejected it with "Interface with this Device and Name already exists." Cut the discovered name to the data model length before comparing. Co-Authored-By: Claude Opus 5.5 --- module/sources/common/source_base.py | 14 +++++- .../test_interface_matching_truncated_name.py | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/test_interface_matching_truncated_name.py diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index 70f459f..fc64b1b 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -188,6 +188,9 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac current_interfaces_by_wwn = dict() current_fc_interface_names = set() + # NetBox stores names cut to the data model length, compare discovered names the same way + max_name_length = None + return_data = dict() # grab current data @@ -202,6 +205,9 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac int_wwn = grab(interface, "data.wwn") int_type_string = grab(interface, "data.type", fallback="virtual") + if max_name_length is None and isinstance(interface.data_model.get("name"), int): + max_name_length = interface.data_model.get("name") + if interface_exclude_filter is not None and int_name is not None and \ interface_exclude_filter.match(int_name): log.debug2(f"Current interface '{int_name}' matches interface_exclude_filter. " @@ -244,11 +250,15 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac if "virtual" not in str(int_type_string): int_type = "physical" + stored_int_name = int_name + if max_name_length is not None and isinstance(int_name, str): + stored_int_name = int_name[0:max_name_length] + # match simply by name matching_int = None - if int_name in current_object_interface_names: + if stored_int_name in current_object_interface_names: log.debug2(f"Found 1:1 name match for NIC '{int_name}'") - matching_int = current_object_interfaces.get(int_name) + matching_int = current_object_interfaces.get(stored_int_name) # match by WWN when no interface of that name exists yet if matching_int is None and int_wwn is not None: diff --git a/tests/test_interface_matching_truncated_name.py b/tests/test_interface_matching_truncated_name.py new file mode 100644 index 0000000..320edf4 --- /dev/null +++ b/tests/test_interface_matching_truncated_name.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 - 2026 netbox-sync team. All rights reserved. +# +# netbox-sync.py +# +# This work is licensed under the terms of the MIT license. +# For a copy, see file LICENSE.txt included in this +# repository or visit: . + +"""Interface names longer than the NetBox limit have to match the name NetBox stored. + +NetBox keeps interface names cut to 64 characters. A Lenovo XCC port is named after the +manager model and its Redfish id, which together are longer than that. Comparing the full +discovered name never matched the stored one, so the port was created again on every run +and NetBox rejected it with 'Interface with this Device and Name already exists.' +""" + +from module.netbox.object_classes import NBInterface + +XCC_PORT_NAME = "Lenovo XClarity Controller 2 (1:NIC:672cab626d845d56d397509738c8b03900c7d706)" + + +def xcc_port(): + return {XCC_PORT_NAME: {"name": XCC_PORT_NAME, "type": "other", "mgmt_only": True}} + + +def test_long_name_matches_the_stored_interface(check_redfish_source): + + context = check_redfish_source() + stored = context.inventory.add_object( + NBInterface, data={"name": XCC_PORT_NAME, "device": context.device, "type": "other"}, + source=context.source) + assert stored.data.get("name") != XCC_PORT_NAME, "name was not cut to the NetBox limit" + + data = context.source.map_object_interfaces_to_current_interfaces(context.device, xcc_port(), True) + + assert data[XCC_PORT_NAME] is stored + + +def test_long_name_without_stored_interface_stays_unmatched(check_redfish_source): + + context = check_redfish_source() + + data = context.source.map_object_interfaces_to_current_interfaces(context.device, xcc_port(), True) + + assert data[XCC_PORT_NAME] is None From 4cff7b8db821c400c742cadcdc031826feebb617 Mon Sep 17 00:00:00 2001 From: Thomas Foks Date: Mon, 12 Oct 2026 12:49:36 +0200 Subject: [PATCH 2/2] fix: report interface names colliding after the cut to the name length Review follow-up: - take the name length from the interface class (NBInterface or NBVMInterface) instead of an existing interface, so it is also known on the first run. - log a warning if two discovered names share the same first 64 characters, only one of them can exist in NetBox. The cut itself is logged at debug level. - fix the comment: netbox-sync cuts the name when it creates the object. - add tests for a VM interface and for colliding names. Co-Authored-By: Claude Opus 5.5 --- module/sources/common/source_base.py | 21 ++++++++--- .../test_interface_matching_truncated_name.py | 37 ++++++++++++++++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index fc64b1b..7538e5d 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -188,8 +188,11 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac current_interfaces_by_wwn = dict() current_fc_interface_names = set() - # NetBox stores names cut to the data model length, compare discovered names the same way - max_name_length = None + # netbox-sync cuts names to the data model length when it creates an interface, + # compare discovered names the same way + interface_class = NBVMInterface if isinstance(device_vm_object, NBVM) else NBInterface + max_name_length = interface_class().data_model.get("name") + discovered_names_by_cut_name = dict() return_data = dict() @@ -205,9 +208,6 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac int_wwn = grab(interface, "data.wwn") int_type_string = grab(interface, "data.type", fallback="virtual") - if max_name_length is None and isinstance(interface.data_model.get("name"), int): - max_name_length = interface.data_model.get("name") - if interface_exclude_filter is not None and int_name is not None and \ interface_exclude_filter.match(int_name): log.debug2(f"Current interface '{int_name}' matches interface_exclude_filter. " @@ -251,8 +251,17 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac int_type = "physical" stored_int_name = int_name - if max_name_length is not None and isinstance(int_name, str): + if isinstance(max_name_length, int) and isinstance(int_name, str) and len(int_name) > max_name_length: stored_int_name = int_name[0:max_name_length] + log.debug(f"Name of interface '{int_name}' exceeds {max_name_length} characters, " + f"comparing it as '{stored_int_name}'") + + colliding_name = discovered_names_by_cut_name.get(stored_int_name) + if colliding_name is not None: + log.warning(f"Interfaces '{colliding_name}' and '{int_name}' of " + f"'{device_vm_object.get_display_name()}' share the same first {max_name_length} " + f"characters. Only one of them can exist in NetBox with that name") + discovered_names_by_cut_name[stored_int_name] = int_name # match simply by name matching_int = None diff --git a/tests/test_interface_matching_truncated_name.py b/tests/test_interface_matching_truncated_name.py index 320edf4..fe30130 100644 --- a/tests/test_interface_matching_truncated_name.py +++ b/tests/test_interface_matching_truncated_name.py @@ -9,13 +9,15 @@ """Interface names longer than the NetBox limit have to match the name NetBox stored. -NetBox keeps interface names cut to 64 characters. A Lenovo XCC port is named after the +netbox-sync cuts interface names to 64 characters when it creates them. A Lenovo XCC port is named after the manager model and its Redfish id, which together are longer than that. Comparing the full discovered name never matched the stored one, so the port was created again on every run and NetBox rejected it with 'Interface with this Device and Name already exists.' """ -from module.netbox.object_classes import NBInterface +import logging + +from module.netbox.object_classes import NBInterface, NBVM, NBVMInterface XCC_PORT_NAME = "Lenovo XClarity Controller 2 (1:NIC:672cab626d845d56d397509738c8b03900c7d706)" @@ -44,3 +46,34 @@ def test_long_name_without_stored_interface_stays_unmatched(check_redfish_source data = context.source.map_object_interfaces_to_current_interfaces(context.device, xcc_port(), True) assert data[XCC_PORT_NAME] is None + + +def test_long_vm_interface_name_matches_the_stored_interface(check_redfish_source): + + context = check_redfish_source() + # only the VMware source syncs VMs, the matcher reads its VM exclude filter + context.source.settings.vm_interface_exclude_filter = None + vm = context.inventory.add_object(NBVM, data={"name": "vm01"}, source=context.source) + name = "Network adapter 1 (" + "a" * 60 + ")" + stored = context.inventory.add_object( + NBVMInterface, data={"name": name, "virtual_machine": vm}, source=context.source) + assert stored.data.get("name") != name, "name was not cut to the NetBox limit" + + data = context.source.map_object_interfaces_to_current_interfaces( + vm, {name: {"name": name, "type": "virtual"}}, True) + + assert data[name] is stored + + +def test_names_colliding_after_the_cut_are_reported(check_redfish_source, caplog): + + context = check_redfish_source() + first = XCC_PORT_NAME + second = XCC_PORT_NAME.replace("b03900c7d706", "000000000000") + ports = {name: {"name": name, "type": "other", "mgmt_only": True} for name in [first, second]} + + caplog.set_level(logging.WARNING, logger="NetBox-Sync") + context.source.map_object_interfaces_to_current_interfaces(context.device, ports, True) + + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert any(first in message and second in message for message in warnings), warnings