From 9ddb8e28959ee45ddb5e1e5aed4007ef020417a8 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Fri, 17 Oct 2025 15:50:15 +0100 Subject: [PATCH 01/14] Add update_baremetal_port ironic inspection hook --- .../tests/test_update_baremetal_port.py | 52 +++++ .../tests/test_vlan_group_name_convention.py | 45 +++++ .../update_baremetal_port.py | 181 ++++++++++++++++++ .../vlan_group_name_convention.py | 54 ++++++ python/ironic-understack/pyproject.toml | 2 + python/ironic-understack/uv.lock | 14 ++ 6 files changed, 348 insertions(+) create mode 100644 python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py create mode 100644 python/ironic-understack/ironic_understack/update_baremetal_port.py create mode 100644 python/ironic-understack/ironic_understack/vlan_group_name_convention.py diff --git a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py new file mode 100644 index 000000000..440e43c7b --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py @@ -0,0 +1,52 @@ +from ironic.objects import port as ironic_port +from oslo_utils import uuidutils + +from ironic_understack.update_baremetal_port import UpdateBaremetalPortsHook + +_INTERFACE_1 = { + "name": "example1", + "mac_address": "11:11:11:11:11:11", + "ipv4_address": "1.1.1.1", + "lldp": [ + (0, ""), + (1, "04885a92ec5459"), + (2, "0545746865726e6574312f3138"), + (3, "0078"), + (5, "6632302d332d32662e69616433"), + ], +} + +_PLUGIN_DATA = {"all_interfaces": {"example1": _INTERFACE_1}} + +_INVENTORY = {"interfaces": [_INTERFACE_1]} + + +def test_with_valid_data(mocker): + node_uuid = uuidutils.generate_uuid() + mock_node = mocker.Mock() + mock_task = mocker.Mock(node=mock_node) + mock_port = mocker.Mock( + uuid=uuidutils.generate_uuid(), + node_id=node_uuid, + address="11:11:11:11:11:11", + local_link_connection={}, + physical_network="original_value", + ) + mocker.patch( + "ironic_understack.update_baremetal_port.objects.port.Port.get_by_address", + return_value=mock_port, + ) + + UpdateBaremetalPortsHook().__call__(mock_task, _INVENTORY, _PLUGIN_DATA) + + assert mock_port.local_link_connection == { + "port_id": "Ethernet1/18", + "switch_id": "88:5a:92:ec:54:59", + "switch_info": "f20-3-2f.iad3", + } + assert mock_port.physical_network == "f20-3-storage" + + mock_port.save.assert_called() + mock_node.remove_trait.assert_not_called() + mock_node.add_trait.assert_called_once_with("CUSTOM_STORAGE_SWITCH") + mock_node.save.assert_called_once() diff --git a/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py b/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py new file mode 100644 index 000000000..a8c2d0ee3 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py @@ -0,0 +1,45 @@ +import pytest + +from ironic_understack.vlan_group_name_convention import vlan_group_name + + +def test_vlan_group_name_valid_switches(): + assert vlan_group_name("a1-1-1") == "a1-1-network" + assert vlan_group_name("a1-2-1") == "a1-2-network" + assert vlan_group_name("b12-1") == "b12-network" + assert vlan_group_name("a2-12-1") == "a2-12-network" + assert vlan_group_name("a2-12-2") == "a2-12-network" + assert vlan_group_name("a2-12-1f") == "a2-12-storage" + assert vlan_group_name("a2-12-2f") == "a2-12-storage" + assert vlan_group_name("a2-12-3f") == "a2-12-storage-appliance" + assert vlan_group_name("a2-12-4f") == "a2-12-storage-appliance" + assert vlan_group_name("a2-12-1d") == "a2-12-bmc" + + +def test_vlan_group_name_with_domain(): + assert vlan_group_name("a2-12-1.iad3.rackspace.net") == "a2-12-network" + assert vlan_group_name("a2-12-1f.lon3.rackspace.net") == "a2-12-storage" + + +def test_vlan_group_name_case_insensitive(): + assert vlan_group_name("A2-12-1F") == "a2-12-storage" + assert vlan_group_name("A2-12-1") == "a2-12-network" + + +def test_vlan_group_name_invalid_format(): + with pytest.raises(ValueError, match="Unknown switch name format"): + vlan_group_name("invalid") + + with pytest.raises(ValueError, match="Unknown switch name format"): + vlan_group_name("") + + +def test_vlan_group_name_unknown_suffix(): + with pytest.raises(ValueError, match="Unknown switch suffix"): + vlan_group_name("a2-12-99") + + with pytest.raises(ValueError, match="Unknown switch suffix"): + vlan_group_name("a2-12-5f") + + with pytest.raises(ValueError, match="Unknown switch suffix"): + vlan_group_name("a2-12-xyz") diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py new file mode 100644 index 000000000..942c09c52 --- /dev/null +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -0,0 +1,181 @@ +import binascii +from typing import Any + +import netaddr +import openstack +from construct import core +from ironic import objects +from ironic.common import exception +from ironic.drivers.modules.inspector import lldp_tlvs +from ironic.drivers.modules.inspector.hooks import base +from oslo_log import log as logging + +import ironic_understack.vlan_group_name_convention + +LOG = logging.getLogger(__name__) + +LldpData = list[tuple[int, str]] + +class UpdateBaremetalPortsHook(base.InspectionHook): + """Hook to update ports according to LLDP data.""" + + dependencies = ["validate-interfaces"] + + def __call__(self, task, inventory, plugin_data): + """Update Ports' local_link_info and physnet based on LLDP data. + + Process the LLDP packet fields for each NIC in the inventory. + + Updates attributes of the baremetal port: + + - local_link_info.port_id (e.g. "Ethernet1/1") + - local_link_info.switch_id (e.g. "aa:bb:cc:dd:ee:ff") + - local_link_info.switch_info (e.g. "a1-1-1.ord1") + - physical_network (e.g. "a1-1-network") + + Also adds or removes node "traits" based on the inventory data. We + control the trait "CUSTOM_STORAGE_SWITCH". + """ + lldp_raw: dict[str, LldpData] = plugin_data.get("lldp_raw") or {} + node_uuid: str = task.node.id + interfaces: list[dict] = inventory["interfaces"] + # The all_interfaces field in plugin_data is provided by the + # validate-interfaces hook, so it is a dependency for this hook + all_interfaces: dict[str, dict] = plugin_data["all_interfaces"] + context = task.context + vlan_groups: set[str] = set() + + for iface in interfaces: + if iface["name"] not in all_interfaces: + # This interface was not "validated" so don't bother with it + continue + + mac_address = iface["mac_address"] + port = objects.port.Port.get_by_address(context, mac_address) + if not port: + LOG.debug( + "Skipping LLDP processing for interface %s of node " + "%s: matching port not found in Ironic.", + mac_address, + node_uuid, + ) + continue + + lldp_data = lldp_raw.get(iface["name"]) or iface.get("lldp") + if lldp_data is None: + LOG.warning( + "No LLDP data found for interface %s of node %s", + mac_address, + node_uuid, + ) + continue + + local_link_connection = _parse_lldp(lldp_data, node_uuid) + vlan_group = vlan_group_name(local_link_connection) + + _set_local_link_connection(port, node_uuid, local_link_connection) + _update_port_physical_network(port, vlan_group) + if vlan_group: + vlan_groups.add(vlan_group) + _update_node_traits(task, vlan_groups) + + +def _set_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict): + try: + LOG.debug("Updating port %s for node %s", port.address, node_uuid) + port.local_link_connection = local_link_connection + port.save() + except exception.IronicException as e: + LOG.warning( + "Failed to update port %(uuid)s for node %(node)s. Error: %(error)s", + {"uuid": port.id, "node": node_uuid, "error": e}, + ) + + +def _parse_lldp(lldp_data: LldpData, node_id: str) -> dict[str, str]: + """Convert Ironic's "lldp_raw" format to local_link dict.""" + try: + decoded = {} + for tlv_type, tlv_value in lldp_data: + if tlv_type not in decoded: + decoded[tlv_type] = [] + decoded[tlv_type].append(bytearray(binascii.unhexlify(tlv_value))) + + local_link = { + "port_id": _extract_port_id(decoded), + "switch_id": _extract_switch_id(decoded), + "switch_info": _extract_hostname(decoded), + } + return { k: v for k, v in local_link.items() if v } + except (binascii.Error, core.MappingError, netaddr.AddrFormatError) as e: + LOG.warning("Failed to parse lldp_raw data for Node %s: %s", node_id, e) + return {} + + +def _extract_port_id(data: dict) -> str | None: + for value in data.get(lldp_tlvs.LLDP_TLV_PORT_ID, []): + parsed = lldp_tlvs.PortId.parse(value) + if parsed.value: # pyright: ignore reportAttributeAccessIssue + return parsed.value.value # pyright: ignore reportAttributeAccessIssue + + +def _extract_switch_id(data: dict) -> str | None: + for value in data.get(lldp_tlvs.LLDP_TLV_CHASSIS_ID, []): + parsed = lldp_tlvs.ChassisId.parse(value) + if "mac_address" in parsed.subtype: # pyright: ignore reportAttributeAccessIssue + return str(parsed.value.value) # pyright: ignore reportAttributeAccessIssue + + +def _extract_hostname(data: dict) -> str | None: + for value in data.get(lldp_tlvs.LLDP_TLV_SYS_NAME, []): + parsed = lldp_tlvs.SysName.parse(value) + if parsed.value: # pyright: ignore reportAttributeAccessIssue + return parsed.value # pyright: ignore reportAttributeAccessIssue + + +def vlan_group_name(local_link_connection) -> str | None: + switch_name = local_link_connection.get("switch_info") + if not switch_name: + return + + return ironic_understack.vlan_group_name_convention.vlan_group_name(switch_name) + + +def _update_port_physical_network(port, new_physical_network: str|None): + old_physical_network = port.physical_network + + if new_physical_network == old_physical_network: + return + + LOG.debug( + "Updating port %s physical_network from %s to %s", + port.id, + old_physical_network, + new_physical_network, + ) + port.physical_network = new_physical_network + port.save() + + +def _update_node_traits(task, vlan_groups: set[str]): + """Add or remove traits to the node. + + We manage one trait: "CUSTOM_STORAGE_SWITCH" which is added if the node has + any ports connected to a storage fabric, othwise it is removed from the + node. + """ + TRAIT_STORAGE_SWITCH = "CUSTOM_STORAGE_SWITCH" + + storage_vlan_groups = { + x for x in vlan_groups if x.endswith("-storage") + } + + if storage_vlan_groups: + task.node.add_trait(TRAIT_STORAGE_SWITCH) + else: + try: + task.node.remove_trait(TRAIT_STORAGE_SWITCH) + except openstack.exceptions.NotFoundException: + pass + + task.node.save() diff --git a/python/ironic-understack/ironic_understack/vlan_group_name_convention.py b/python/ironic-understack/ironic_understack/vlan_group_name_convention.py new file mode 100644 index 000000000..33991f9d8 --- /dev/null +++ b/python/ironic-understack/ironic_understack/vlan_group_name_convention.py @@ -0,0 +1,54 @@ +def vlan_group_name(switch_name: str) -> str: + """The VLAN GROUP name is a function of the switch name. + + Switch hostname convention is site-dependent, but in Rackspace all + top-of-rack switch names follow the convention: - + Example switch names include a2-12-1f and a2-12-1. (These are normally + qualified with a site-specific domain name like a2-12-1.iad3.rackspace.net, + but we are only considering the unqualified name, ignoring everything after + the first dot). + + It easy to parse the switch name into cabinet and suffix. Convert the + switch-name-suffix to vlan-group-suffix using the following mapping: + + 1 → network + 2 → network + 1f → storage + 2f → storage + 3f → storage-appliance + 4f → storage-appliance + 1d → bmc + + The VLAN GROUP name results from joining the cabinet name to the new suffix + with a hyphen. The result is in lower case: - + + So for example, switch a2-12-1 is in VLAN GROUP a2-12-network. + """ + # Remove domain suffix if present (everything after first dot) + switch_name = switch_name.split(".")[0].lower() + + # Split into cabinet and suffix (last component after last hyphen) + parts = switch_name.rsplit("-", 1) + if len(parts) != 2: + raise ValueError(f"Unknown switch name format: {switch_name}") + + cabinet_name, suffix = parts + + # Map suffix to VLAN group suffix + suffix_mapping = { + "1": "network", + "2": "network", + "3": "network", + "4": "network", + "1f": "storage", + "2f": "storage", + "3f": "storage-appliance", + "4f": "storage-appliance", + "1d": "bmc", + } + + vlan_suffix = suffix_mapping.get(suffix) + if vlan_suffix is None: + raise ValueError(f"Unknown switch suffix: {suffix}") + + return f"{cabinet_name}-{vlan_suffix}" diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index cb3c5f52d..058acd15f 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -12,12 +12,14 @@ readme = "README.md" license = "MIT" dependencies = [ "ironic>=29.0,<30", + "pytest-mock>=3.15.1", "pyyaml~=6.0", "understack-flavor-matcher", ] [project.entry-points."ironic.inspection.hooks"] resource-class = "ironic_understack.resource_class:ResourceClassHook" +update-baremetal-port = "ironic_understack.update_baremetal_port:UpdateBaremetalPortsHook" [project.entry-points."ironic.hardware.interfaces.inspect"] redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackRedfishInspect" diff --git a/python/ironic-understack/uv.lock b/python/ironic-understack/uv.lock index 6d7b97c42..a439e3e6f 100644 --- a/python/ironic-understack/uv.lock +++ b/python/ironic-understack/uv.lock @@ -426,6 +426,7 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "ironic" }, + { name = "pytest-mock" }, { name = "pyyaml" }, { name = "understack-flavor-matcher" }, ] @@ -440,6 +441,7 @@ test = [ [package.metadata] requires-dist = [ { name = "ironic", specifier = ">=29.0,<30" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pyyaml", specifier = "~=6.0" }, { name = "understack-flavor-matcher", directory = "../understack-flavor-matcher" }, ] @@ -1268,6 +1270,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/73/7b0b15cb8605ee967b34aa1d949737ab664f94e6b0f1534e8339d9e64ab2/pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf", size = 6030, upload-time = "2025-01-17T22:39:31.701Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 115918931f50dad4d5b0919991b5bf6310fed14b Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 20 Oct 2025 16:15:07 +0100 Subject: [PATCH 02/14] DROP! make this use a different container --- components/images-openstack.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/images-openstack.yaml b/components/images-openstack.yaml index 2bcf45592..f0b521392 100644 --- a/components/images-openstack.yaml +++ b/components/images-openstack.yaml @@ -23,7 +23,7 @@ images: # ironic ironic_api: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" - ironic_conductor: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" + ironic_conductor: "ghcr.io/rackerlabs/understack/ironic:pr-1347" ironic_pxe: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" ironic_pxe_init: "ghcr.io/rackerlabs/understack/ironic:2025.1-ubuntu_jammy" ironic_pxe_http: "docker.io/nginx:1.13.3" From 6a6e7bde039884cfde383afbefa62c0f5a9fc45b Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 20 Oct 2025 17:22:09 +0100 Subject: [PATCH 03/14] Improve diagnostic log messages --- .../ironic_understack/update_baremetal_port.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index 942c09c52..c903d7bfc 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -82,7 +82,8 @@ def __call__(self, task, inventory, plugin_data): def _set_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict): try: - LOG.debug("Updating port %s for node %s", port.address, node_uuid) + LOG.debug("Updating port %s for node %s local_link_connection %s", + port.id, node_uuid, local_link_connection) port.local_link_connection = local_link_connection port.save() except exception.IronicException as e: From 7cb0bd683d8f7041bb8d1e676defa9084cc46af1 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 20 Oct 2025 17:36:59 +0100 Subject: [PATCH 04/14] Don't put None values in local_link_info - abort if its incomplete --- .../ironic_understack/update_baremetal_port.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index c903d7bfc..dd677be72 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -102,12 +102,12 @@ def _parse_lldp(lldp_data: LldpData, node_id: str) -> dict[str, str]: decoded[tlv_type] = [] decoded[tlv_type].append(bytearray(binascii.unhexlify(tlv_value))) - local_link = { - "port_id": _extract_port_id(decoded), - "switch_id": _extract_switch_id(decoded), - "switch_info": _extract_hostname(decoded), - } - return { k: v for k, v in local_link.items() if v } + port_id = _extract_port_id(decoded) + switch_id = _extract_switch_id(decoded) + switch_info = _extract_hostname(decoded) + if port_id and switch_id and switch_info: + return {"port_id": port_id, "switch_id": switch_id, "switch_info": switch_info} + LOG.warning("Failed to extract local_link_info from LLDP data for %s", node_id) except (binascii.Error, core.MappingError, netaddr.AddrFormatError) as e: LOG.warning("Failed to parse lldp_raw data for Node %s: %s", node_id, e) return {} From d555da531287d959d63a95621de559dac294c7db Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 20 Oct 2025 17:37:35 +0100 Subject: [PATCH 05/14] DROP! debug logging of all invocational args --- .../ironic_understack/update_baremetal_port.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index dd677be72..ea93ae508 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -36,6 +36,8 @@ def __call__(self, task, inventory, plugin_data): Also adds or removes node "traits" based on the inventory data. We control the trait "CUSTOM_STORAGE_SWITCH". """ + LOG.debug(f"{__class__} called with {task=!r} {inventory=!r} {plugin_data=!r}") + lldp_raw: dict[str, LldpData] = plugin_data.get("lldp_raw") or {} node_uuid: str = task.node.id interfaces: list[dict] = inventory["interfaces"] From dce94356038242c40ec241f44f63671aeaa0b95b Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 21 Oct 2025 12:01:46 +0100 Subject: [PATCH 06/14] Attempt to set traits --- .../update_baremetal_port.py | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index ea93ae508..7fa0dd356 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -16,6 +16,7 @@ LldpData = list[tuple[int, str]] + class UpdateBaremetalPortsHook(base.InspectionHook): """Hook to update ports according to LLDP data.""" @@ -32,14 +33,20 @@ def __call__(self, task, inventory, plugin_data): - local_link_info.switch_id (e.g. "aa:bb:cc:dd:ee:ff") - local_link_info.switch_info (e.g. "a1-1-1.ord1") - physical_network (e.g. "a1-1-network") + - pxe_boot flag? Also adds or removes node "traits" based on the inventory data. We control the trait "CUSTOM_STORAGE_SWITCH". + + The IPA image will normally have exactly one inventory.interfaces with + an ipv4_address address and has_carrier set to True. This is our pxe + boot interface. We should clear the pxe interface flag on all other + baremetal ports. """ LOG.debug(f"{__class__} called with {task=!r} {inventory=!r} {plugin_data=!r}") lldp_raw: dict[str, LldpData] = plugin_data.get("lldp_raw") or {} - node_uuid: str = task.node.id + node_uuid: str = task.node.uuid interfaces: list[dict] = inventory["interfaces"] # The all_interfaces field in plugin_data is provided by the # validate-interfaces hook, so it is a dependency for this hook @@ -64,7 +71,7 @@ def __call__(self, task, inventory, plugin_data): continue lldp_data = lldp_raw.get(iface["name"]) or iface.get("lldp") - if lldp_data is None: + if not lldp_data: LOG.warning( "No LLDP data found for interface %s of node %s", mac_address, @@ -84,8 +91,12 @@ def __call__(self, task, inventory, plugin_data): def _set_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict): try: - LOG.debug("Updating port %s for node %s local_link_connection %s", - port.id, node_uuid, local_link_connection) + LOG.debug( + "Updating port %s for node %s local_link_connection %s", + port.uuid, + node_uuid, + local_link_connection, + ) port.local_link_connection = local_link_connection port.save() except exception.IronicException as e: @@ -108,7 +119,11 @@ def _parse_lldp(lldp_data: LldpData, node_id: str) -> dict[str, str]: switch_id = _extract_switch_id(decoded) switch_info = _extract_hostname(decoded) if port_id and switch_id and switch_info: - return {"port_id": port_id, "switch_id": switch_id, "switch_info": switch_info} + return { + "port_id": port_id, + "switch_id": switch_id, + "switch_info": switch_info, + } LOG.warning("Failed to extract local_link_info from LLDP data for %s", node_id) except (binascii.Error, core.MappingError, netaddr.AddrFormatError) as e: LOG.warning("Failed to parse lldp_raw data for Node %s: %s", node_id, e) @@ -144,10 +159,12 @@ def vlan_group_name(local_link_connection) -> str | None: return ironic_understack.vlan_group_name_convention.vlan_group_name(switch_name) -def _update_port_physical_network(port, new_physical_network: str|None): +def _update_port_physical_network(port, new_physical_network: str | None): old_physical_network = port.physical_network if new_physical_network == old_physical_network: + LOG.debug("Port %s physical_network already set to %s", + port.id, new_physical_network) return LOG.debug( @@ -169,15 +186,15 @@ def _update_node_traits(task, vlan_groups: set[str]): """ TRAIT_STORAGE_SWITCH = "CUSTOM_STORAGE_SWITCH" - storage_vlan_groups = { - x for x in vlan_groups if x.endswith("-storage") - } + storage_vlan_groups = {x for x in vlan_groups if x.endswith("-storage")} if storage_vlan_groups: - task.node.add_trait(TRAIT_STORAGE_SWITCH) + existing_traits = task.node.traits.get_trait_names() + LOG.debug(f"Existing traits of node {task.node.uuid=} {task.node.traits=} {existing_traits=}") + task.node.traits = existing_traits.create(TRAIT_STORAGE_SWITCH) else: try: - task.node.remove_trait(TRAIT_STORAGE_SWITCH) + task.node.traits.destroy(TRAIT_STORAGE_SWITCH) except openstack.exceptions.NotFoundException: pass From 8c3467dbe6b4713f9e61148ac424dc14c4119f47 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 21 Oct 2025 15:40:08 +0100 Subject: [PATCH 07/14] DROP! example arguments to __call__() --- .../ironic_understack/output-inspection | 611 ++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 python/ironic-understack/ironic_understack/output-inspection diff --git a/python/ironic-understack/ironic_understack/output-inspection b/python/ironic-understack/ironic_understack/output-inspection new file mode 100644 index 000000000..3e504d9cf --- /dev/null +++ b/python/ironic-understack/ironic_understack/output-inspection @@ -0,0 +1,611 @@ +2025-10-21 10:06:12.416 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] called with task= + + +inventory = { + "interfaces": [ + { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:7a:dc", + "ipv4_address": "10.4.50.78", + "ipv6_address": "fe80::d604:e6ff:fe4f:7adc%eno3np0", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e4553f"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "44656c6c2d393347535730345f4e49432e496e74656772617465642e312d31"], + [5, "6632302d332d312e69616433"], + [127, "0001420101"], + [127, "00120f042328"], + [127, "0080c2030faa11343031305f70726f766973696f6e696e67"], + [127, "0080c2070100000000"], + [127, "00014208464c4d323732343042384d"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + }, + { + "name": "ens2f0np0", + "mac_address": "14:23:f3:f5:3c:a0", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.0", + "driver": "bnxt_en", + }, + { + "name": "eno4np1", + "mac_address": "d4:04:e6:4f:7a:dd", + "ipv4_address": None, + "ipv6_address": "fe80::d604:e6ff:fe4f:7add%eno4np1", + "has_carrier": True, + "lldp": [ + [1, "04401482813ee3"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d31662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384d4c"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.1", + "driver": "bnxt_en", + }, + { + "name": "ens2f1np1", + "mac_address": "14:23:f3:f5:3c:a1", + "ipv4_address": None, + "ipv6_address": "fe80::1623:f3ff:fef5:3ca1%ens2f1np1", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e7a037"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d32662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384e39"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.1", + "driver": "bnxt_en", + }, + { + "name": "eno8303", + "mac_address": "c4:cb:e1:bf:90:8e", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.0", + "driver": "tg3", + }, + { + "name": "eno8403", + "mac_address": "c4:cb:e1:bf:90:8f", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.1", + "driver": "tg3", + }, + ], + "cpu": { + "model_name": "AMD EPYC 9124 16-Core Processor", + "frequency": "", + "count": 32, + "architecture": "x86_64", + "flags": [ + "fpu", + "vme", + "de", + "pse", + "tsc", + "msr", + "pae", + "mce", + "cx8", + "apic", + "sep", + "mtrr", + "pge", + "mca", + "cmov", + "pat", + "pse36", + "clflush", + "mmx", + "fxsr", + "sse", + "sse2", + "ht", + "syscall", + "nx", + "mmxext", + "fxsr_opt", + "pdpe1gb", + "rdtscp", + "lm", + "constant_tsc", + "rep_good", + "amd_lbr_v2", + "nopl", + "nonstop_tsc", + "cpuid", + "extd_apicid", + "aperfmperf", + "rapl", + "pni", + "pclmulqdq", + "monitor", + "ssse3", + "fma", + "cx16", + "pcid", + "sse4_1", + "sse4_2", + "x2apic", + "movbe", + "popcnt", + "aes", + "xsave", + "avx", + "f16c", + "rdrand", + "lahf_lm", + "cmp_legacy", + "svm", + "extapic", + "cr8_legacy", + "abm", + "sse4a", + "misalignsse", + "3dnowprefetch", + "osvw", + "ibs", + "skinit", + "wdt", + "tce", + "topoext", + "perfctr_core", + "perfctr_nb", + "bpext", + "perfctr_llc", + "mwaitx", + "cpb", + "cat_l3", + "cdp_l3", + "invpcid_single", + "hw_pstate", + "ssbd", + "mba", + "perfmon_v2", + "ibrs", + "ibpb", + "stibp", + "ibrs_enhanced", + "vmmcall", + "fsgsbase", + "bmi1", + "avx2", + "smep", + "bmi2", + "erms", + "invpcid", + "cqm", + "rdt_a", + "avx512f", + "avx512dq", + "rdseed", + "adx", + "smap", + "avx512ifma", + "clflushopt", + "clwb", + "avx512cd", + "sha_ni", + "avx512bw", + "avx512vl", + "xsaveopt", + "xsavec", + "xgetbv1", + "xsaves", + "cqm_llc", + "cqm_occup_llc", + "cqm_mbm_total", + "cqm_mbm_local", + "avx512_bf16", + "clzero", + "irperf", + "xsaveerptr", + "rdpru", + "wbnoinvd", + "amd_ppin", + "cppc", + "arat", + "npt", + "lbrv", + "svm_lock", + "nrip_save", + "tsc_scale", + "vmcb_clean", + "flushbyasid", + "decodeassists", + "pausefilter", + "pfthreshold", + "avic", + "v_vmsave_vmload", + "vgif", + "x2avic", + "v_spec_ctrl", + "avx512vbmi", + "umip", + "pku", + "ospke", + "avx512_vbmi2", + "gfni", + "vaes", + "vpclmulqdq", + "avx512_vnni", + "avx512_bitalg", + "avx512_vpopcntdq", + "la57", + "rdpid", + "overflow_recov", + "succor", + "smca", + "fsrm", + "flush_l1d", + ], + "socket_count": 1, + }, + "disks": [ + { + "name": "/dev/sda", + "model": "PERC H755 Front", + "size": 479559942144, + "rotational": False, + "wwn": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "serial": "001ad44a7ec1a50a30005ca76a80e04e", + "vendor": "DELL", + "wwn_with_extension": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "wwn_vendor_extension": "0x300aa5c17e4ad41a", + "hctl": "0:3:110:0", + "by_path": "/dev/disk/by-path/pci-0000:41:00.0-scsi-0:3:110:0", + "logical_sectors": 512, + "physical_sectors": 512, + } + ], + "memory": {"total": 100793757696, "physical_mb": 98304}, + "bmc_address": "10.46.96.164", + "bmc_v6address": "::/0", + "system_vendor": { + "product_name": "PowerEdge R7615 (SKU=0AF7;ModelName=PowerEdge R7615)", + "serial_number": "93GSW04", + "manufacturer": "Dell Inc.", + "firmware": { + "vendor": "Dell Inc.", + "version": "1.6.10", + "build_date": "12/08/2023", + }, + }, + "boot": {"current_boot_mode": "uefi", "pxe_interface": "d4:04:e6:4f:7a:dc"}, + "hostname": "debian", + "bmc_mac": "a8:3c:a5:35:4a:b2", +} + + +plugin_data = { + "root_disk": { + "name": "/dev/sda", + "model": "PERC H755 Front", + "size": 479559942144, + "rotational": False, + "wwn": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "serial": "001ad44a7ec1a50a30005ca76a80e04e", + "vendor": "DELL", + "wwn_with_extension": "0x6f4ee0806aa75c00300aa5c17e4ad41a", + "wwn_vendor_extension": "0x300aa5c17e4ad41a", + "hctl": "0:3:110:0", + "by_path": "/dev/disk/by-path/pci-0000:41:00.0-scsi-0:3:110:0", + "logical_sectors": 512, + "physical_sectors": 512, + }, + "boot_interface": "d4:04:e6:4f:7a:dc", + "configuration": { + "collectors": ["default", "logs"], + "managers": [{"name": "generic_hardware_manager", "version": "1.2"}], + }, + "all_interfaces": { + "eno3np0": { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:7a:dc", + "ipv4_address": "10.4.50.78", + "ipv6_address": "fe80::d604:e6ff:fe4f:7adc", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e4553f"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "44656c6c2d393347535730345f4e49432e496e74656772617465642e312d31"], + [5, "6632302d332d312e69616433"], + [127, "0001420101"], + [127, "00120f042328"], + [127, "0080c2030faa11343031305f70726f766973696f6e696e67"], + [127, "0080c2070100000000"], + [127, "00014208464c4d323732343042384d"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + "pxe_enabled": True, + }, + "ens2f0np0": { + "name": "ens2f0np0", + "mac_address": "14:23:f3:f5:3c:a0", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.0", + "driver": "bnxt_en", + "pxe_enabled": False, + }, + "eno4np1": { + "name": "eno4np1", + "mac_address": "d4:04:e6:4f:7a:dd", + "ipv4_address": None, + "ipv6_address": "fe80::d604:e6ff:fe4f:7add", + "has_carrier": True, + "lldp": [ + [1, "04401482813ee3"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d31662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384d4c"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.1", + "driver": "bnxt_en", + "pxe_enabled": False, + }, + "ens2f1np1": { + "name": "ens2f1np1", + "mac_address": "14:23:f3:f5:3c:a1", + "ipv4_address": None, + "ipv6_address": "fe80::1623:f3ff:fef5:3ca1", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e7a037"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "45746865726e6574312f31"], + [5, "6632302d332d32662e69616433"], + [127, "0001420101"], + [127, "00120f0405dc"], + [127, "0080c2070100000000"], + [127, "00014208464c4d3237323430384e39"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c4:00.1", + "driver": "bnxt_en", + "pxe_enabled": False, + }, + "eno8303": { + "name": "eno8303", + "mac_address": "c4:cb:e1:bf:90:8e", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.0", + "driver": "tg3", + "pxe_enabled": False, + }, + "eno8403": { + "name": "eno8403", + "mac_address": "c4:cb:e1:bf:90:8f", + "ipv4_address": None, + "ipv6_address": None, + "has_carrier": False, + "lldp": [], + "vendor": "0x14e4", + "product": "0x165f", + "client_id": None, + "biosdevname": None, + "speed_mbps": 1000, + "pci_address": "0000:c3:00.1", + "driver": "tg3", + "pxe_enabled": False, + }, + }, + "valid_interfaces": { + "eno3np0": { + "name": "eno3np0", + "mac_address": "d4:04:e6:4f:7a:dc", + "ipv4_address": "10.4.50.78", + "ipv6_address": "fe80::d604:e6ff:fe4f:7adc", + "has_carrier": True, + "lldp": [ + [1, "04c47ee0e4553f"], + [2, "0545746865726e6574312f31"], + [3, "0078"], + [4, "44656c6c2d393347535730345f4e49432e496e74656772617465642e312d31"], + [5, "6632302d332d312e69616433"], + [127, "0001420101"], + [127, "00120f042328"], + [127, "0080c2030faa11343031305f70726f766973696f6e696e67"], + [127, "0080c2070100000000"], + [127, "00014208464c4d323732343042384d"], + [0, ""], + ], + "vendor": "0x14e4", + "product": "0x16d7", + "client_id": None, + "biosdevname": None, + "speed_mbps": 25000, + "pci_address": "0000:c5:00.0", + "driver": "bnxt_en", + "pxe_enabled": True, + } + }, + "macs": ["d4:04:e6:4f:7a:dc"], +} + + + +__call__ /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:39 + +2025-10-21 10:06:12.429 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1459 for node 160 local_link_connection {'port_id': 'Ethernet1/1', 'switch_id': 'c4:7e:e0:e4:55:3f', 'switch_info': 'f20-3-1.iad3'} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.452 1 WARNING ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Failed to extract local_link_info from LLDP data for 160 +2025-10-21 10:06:12.453 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1463 for node 160 local_link_connection {} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.475 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1461 for node 160 local_link_connection {'port_id': 'Ethernet1/1', 'switch_id': '40:14:82:81:3e:e3', 'switch_info': 'f20-3-1f.iad3'} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.493 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1465 for node 160 local_link_connection {'port_id': 'Ethernet1/1', 'switch_id': 'c4:7e:e0:e7:a0:37', 'switch_info': 'f20-3-2f.iad3'} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.513 1 WARNING ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Failed to extract local_link_info from LLDP data for 160 +2025-10-21 10:06:12.513 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1454 for node 160 local_link_connection {} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.529 1 WARNING ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Failed to extract local_link_info from LLDP data for 160 +2025-10-21 10:06:12.530 1 DEBUG ironic_understack.update_baremetal_port [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Updating port 1457 for node 160 local_link_connection {} _set_local_link_connection /var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py:87 +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Unexpected exception while running inspection hooks for node b6b6dcec-7d48-48c4-89ff-da04b8af40b7: AttributeError: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils Traceback (most recent call last): +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 545, in run_inspection_hooks +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils _run_post_hooks(task, inventory, plugin_data, hooks) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 621, in _run_post_hooks +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils hook.obj.__call__(task, inventory, plugin_data) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 82, in __call__ +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils _update_node_traits(task, vlan_groups) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 177, in _update_node_traits +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils task.node.add_trait(TRAIT_STORAGE_SWITCH) +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils AttributeError: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.540 1 ERROR ironic.drivers.modules.inspect_utils +2025-10-21 10:06:12.542 1 INFO ironic.drivers.utils [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Ramdisk logs were stored in local storage for node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Error when processing inspection data for node b6b6dcec-7d48-48c4-89ff-da04b8af40b7: ironic.common.exception.HardwareInspectionFailure: Failed to inspect hardware. Reason: Unexpected exception AttributeError during processing for node: b6b6dcec-7d48-48c4-89ff-da04b8af40b7. Error: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection Traceback (most recent call last): +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 545, in run_inspection_hooks +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection _run_post_hooks(task, inventory, plugin_data, hooks) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 621, in _run_post_hooks +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection hook.obj.__call__(task, inventory, plugin_data) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 82, in __call__ +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection _update_node_traits(task, vlan_groups) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic_understack/update_baremetal_port.py", line 177, in _update_node_traits +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection task.node.add_trait(TRAIT_STORAGE_SWITCH) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection AttributeError: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection During handling of the above exception, another exception occurred: +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection Traceback (most recent call last): +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/inspection.py", line 125, in continue_inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection result = task.driver.inspect.continue_inspection( +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspector/agent.py", line 94, in continue_inspection +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection inspect_utils.run_inspection_hooks(task, inventory, plugin_data, +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection File "/var/lib/openstack/lib/python3.10/site-packages/ironic/drivers/modules/inspect_utils.py", line 560, in run_inspection_hooks +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection raise exception.HardwareInspectionFailure(error=msg) +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection ironic.common.exception.HardwareInspectionFailure: Failed to inspect hardware. Reason: Unexpected exception AttributeError during processing for node: b6b6dcec-7d48-48c4-89ff-da04b8af40b7. Error: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.543 1 ERROR ironic.conductor.inspection +2025-10-21 10:06:12.550 1 DEBUG ironic.common.states [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Exiting old state 'inspecting' in response to event 'fail' on_exit /var/lib/openstack/lib/python3.10/site-packages/ironic/common/states.py:361 +2025-10-21 10:06:12.550 1 DEBUG ironic.common.states [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Entering new state 'inspect failed' in response to event 'fail' on_enter /var/lib/openstack/lib/python3.10/site-packages/ironic/common/states.py:367 +2025-10-21 10:06:12.571 1 ERROR ironic.conductor.task_manager [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 moved to provision state "inspect failed" from state "inspecting"; target provision state is "manageable": ironic.common.exception.HardwareInspectionFailure: Failed to inspect hardware. Reason: Unexpected exception AttributeError during processing for node: b6b6dcec-7d48-48c4-89ff-da04b8af40b7. Error: 'Node' object has no attribute 'add_trait' +2025-10-21 10:06:12.585 1 DEBUG ironic.conductor.task_manager [req-f2511219-7d71-4852-9b0e-cb5793c9d297 req-284c47c2-5626-42b0-81a9-1a4e880a1afd - - - - - -] Successfully released exclusive lock for continue inspection on node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 (lock was held 0.26 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:01.159 1 DEBUG ironic.common.hash_ring [-] Rebuilding cached hash rings ring /var/lib/openstack/lib/python3.10/site-packages/ironic/common/hash_ring.py:62 +2025-10-21 10:07:01.187 1 DEBUG ironic.common.hash_ring [-] Finished rebuilding hash rings, available drivers are :fake-hardware, :idrac, :ilo, :ilo5, :redfish ring /var/lib/openstack/lib/python3.10/site-packages/ironic/common/hash_ring.py:65 +2025-10-21 10:07:01.313 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 2fb79bdb-c925-4701-b304-b3768deeb85e (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.316 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 86eb7354-cc10-4173-8ff2-d1ac2ea6befd (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.321 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async import configuration task. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.322 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async firmware update tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.325 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.370 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking if async firmware update failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.371 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking if async update of firmware component failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.371 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.372 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.373 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async firmware update tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.378 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking if async firmware update failed. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.378 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async RAID config tasks. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.383 1 DEBUG ironic.conductor.periodics [-] Completed periodic task for purpose checking async update of firmware component. wrapper /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/periodics.py:174 +2025-10-21 10:07:01.384 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node a8a8548c-fc07-4d9c-a5f2-5f2c6fe7992c (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.387 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.390 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 3271d507-9c1b-4440-bd39-0b1a9e779c5b (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.393 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 2f75cab3-63d7-45ad-9045-b80f44e86132 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.396 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 49f69ba1-bcce-4b43-aab5-a610a49f29bf (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:01.400 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 3df4d4ef-a65e-4f41-abe8-66169ea51a21 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.852 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node a8a8548c-fc07-4d9c-a5f2-5f2c6fe7992c (lock was held 1.47 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.857 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node b6b6dcec-7d48-48c4-89ff-da04b8af40b7 (lock was held 1.47 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.873 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 2fb79bdb-c925-4701-b304-b3768deeb85e (lock was held 1.56 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.905 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node f86c82b1-dd24-41de-b32a-aeb3eb0ff020 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.908 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 5180e19d-c3c6-4afb-b626-08d70ec1f456 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.912 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node c5b249c8-e707-4acf-9c36-ad9ce574282f (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.939 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 86eb7354-cc10-4173-8ff2-d1ac2ea6befd (lock was held 1.62 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.940 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 3271d507-9c1b-4440-bd39-0b1a9e779c5b (lock was held 1.55 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:02.954 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 055818eb-7de7-43f5-b747-e8704ad7db45 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:02.961 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 609a8c97-32a2-4308-95ff-e4256706d28f (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:03.021 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 2f75cab3-63d7-45ad-9045-b80f44e86132 (lock was held 1.63 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:03.038 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 5c1bfa75-d081-4fbe-9448-417eb54552b7 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +2025-10-21 10:07:03.218 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Successfully released shared lock for power state sync on node 49f69ba1-bcce-4b43-aab5-a610a49f29bf (lock was held 1.82 sec) release_resources /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:459 +2025-10-21 10:07:03.233 1 DEBUG ironic.conductor.task_manager [None req-f1715a00-bc9f-4443-95d8-e529c376bfad - - - - - -] Attempting to get shared lock on node 461737c4-037c-41bf-9c17-f4f33ff20dd7 (for power state sync) __init__ /var/lib/openstack/lib/python3.10/site-packages/ironic/conductor/task_manager.py:235 +-- +2 From 389d5f4e3b72b4a73c5f7f0d75c00819e3181775 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 3 Nov 2025 10:33:20 +0000 Subject: [PATCH 08/14] Refactor --- .../ironic_understack/update_baremetal_port.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index 7fa0dd356..f56ab7a0a 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -82,14 +82,14 @@ def __call__(self, task, inventory, plugin_data): local_link_connection = _parse_lldp(lldp_data, node_uuid) vlan_group = vlan_group_name(local_link_connection) - _set_local_link_connection(port, node_uuid, local_link_connection) - _update_port_physical_network(port, vlan_group) + _set_port_local_link_connection(port, node_uuid, local_link_connection) + _set_port_physical_network(port, vlan_group) if vlan_group: vlan_groups.add(vlan_group) - _update_node_traits(task, vlan_groups) + _set_node_traits(task, vlan_groups) -def _set_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict): +def _set_port_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict): try: LOG.debug( "Updating port %s for node %s local_link_connection %s", @@ -159,7 +159,7 @@ def vlan_group_name(local_link_connection) -> str | None: return ironic_understack.vlan_group_name_convention.vlan_group_name(switch_name) -def _update_port_physical_network(port, new_physical_network: str | None): +def _set_port_physical_network(port, new_physical_network: str | None): old_physical_network = port.physical_network if new_physical_network == old_physical_network: @@ -177,7 +177,7 @@ def _update_port_physical_network(port, new_physical_network: str | None): port.save() -def _update_node_traits(task, vlan_groups: set[str]): +def _set_node_traits(task, vlan_groups: set[str]): """Add or remove traits to the node. We manage one trait: "CUSTOM_STORAGE_SWITCH" which is added if the node has From f9f5badeecb2bc2f75da64732fc06bd284de4aef Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 3 Nov 2025 13:42:57 +0000 Subject: [PATCH 09/14] Extract vlan group switch name mapping to config option --- .../ironic_understack/conf.py | 15 +++++ .../tests/test_vlan_group_name_convention.py | 55 +++++++++++------- .../update_baremetal_port.py | 6 +- .../vlan_group_name_convention.py | 57 +++++++------------ 4 files changed, 74 insertions(+), 59 deletions(-) diff --git a/python/ironic-understack/ironic_understack/conf.py b/python/ironic-understack/ironic_understack/conf.py index d41bbb234..335ea8661 100644 --- a/python/ironic-understack/ironic_understack/conf.py +++ b/python/ironic-understack/ironic_understack/conf.py @@ -10,6 +10,21 @@ def setup_conf(): "device_types_dir", help="directory storing Device Type description YAML files", default="/var/lib/understack/device-types", + ), + cfg.DictOpt( + "switch_name_vlan_group_mapping", + help="Dictionary of switch hostname suffix to vlan group name", + default={ + "1": "network", + "2": "network", + "3": "network", + "4": "network", + "1f": "storage", + "2f": "storage", + "3f": "storage-appliance", + "4f": "storage-appliance", + "1d": "bmc", + }, ) ] cfg.CONF.register_group(grp) diff --git a/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py b/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py index a8c2d0ee3..73175ee61 100644 --- a/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py +++ b/python/ironic-understack/ironic_understack/tests/test_vlan_group_name_convention.py @@ -2,44 +2,55 @@ from ironic_understack.vlan_group_name_convention import vlan_group_name +mapping = { + "1": "network", + "2": "network", + "3": "network", + "4": "network", + "1f": "storage", + "2f": "storage", + "3f": "storage-appliance", + "4f": "storage-appliance", + "1d": "bmc", +} def test_vlan_group_name_valid_switches(): - assert vlan_group_name("a1-1-1") == "a1-1-network" - assert vlan_group_name("a1-2-1") == "a1-2-network" - assert vlan_group_name("b12-1") == "b12-network" - assert vlan_group_name("a2-12-1") == "a2-12-network" - assert vlan_group_name("a2-12-2") == "a2-12-network" - assert vlan_group_name("a2-12-1f") == "a2-12-storage" - assert vlan_group_name("a2-12-2f") == "a2-12-storage" - assert vlan_group_name("a2-12-3f") == "a2-12-storage-appliance" - assert vlan_group_name("a2-12-4f") == "a2-12-storage-appliance" - assert vlan_group_name("a2-12-1d") == "a2-12-bmc" + assert vlan_group_name("a1-1-1", mapping) == "a1-1-network" + assert vlan_group_name("a1-2-1", mapping) == "a1-2-network" + assert vlan_group_name("b12-1", mapping) == "b12-network" + assert vlan_group_name("a2-12-1", mapping) == "a2-12-network" + assert vlan_group_name("a2-12-2", mapping) == "a2-12-network" + assert vlan_group_name("a2-12-1f", mapping) == "a2-12-storage" + assert vlan_group_name("a2-12-2f", mapping) == "a2-12-storage" + assert vlan_group_name("a2-12-3f", mapping) == "a2-12-storage-appliance" + assert vlan_group_name("a2-12-4f", mapping) == "a2-12-storage-appliance" + assert vlan_group_name("a2-12-1d", mapping) == "a2-12-bmc" def test_vlan_group_name_with_domain(): - assert vlan_group_name("a2-12-1.iad3.rackspace.net") == "a2-12-network" - assert vlan_group_name("a2-12-1f.lon3.rackspace.net") == "a2-12-storage" + assert vlan_group_name("a2-12-1.iad3.rackspace.net", mapping) == "a2-12-network" + assert vlan_group_name("a2-12-1f.lon3.rackspace.net", mapping) == "a2-12-storage" def test_vlan_group_name_case_insensitive(): - assert vlan_group_name("A2-12-1F") == "a2-12-storage" - assert vlan_group_name("A2-12-1") == "a2-12-network" + assert vlan_group_name("A2-12-1F", mapping) == "a2-12-storage" + assert vlan_group_name("A2-12-1", mapping) == "a2-12-network" def test_vlan_group_name_invalid_format(): with pytest.raises(ValueError, match="Unknown switch name format"): - vlan_group_name("invalid") + vlan_group_name("invalid", mapping) with pytest.raises(ValueError, match="Unknown switch name format"): - vlan_group_name("") + vlan_group_name("", mapping) def test_vlan_group_name_unknown_suffix(): - with pytest.raises(ValueError, match="Unknown switch suffix"): - vlan_group_name("a2-12-99") + with pytest.raises(ValueError, match="Switch suffix 99 is not present"): + vlan_group_name("a2-12-99", mapping) - with pytest.raises(ValueError, match="Unknown switch suffix"): - vlan_group_name("a2-12-5f") + with pytest.raises(ValueError, match="Switch suffix 5f is not present"): + vlan_group_name("a2-12-5f", mapping) - with pytest.raises(ValueError, match="Unknown switch suffix"): - vlan_group_name("a2-12-xyz") + with pytest.raises(ValueError, match="Switch suffix xyz is not present"): + vlan_group_name("a2-12-xyz", mapping) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index f56ab7a0a..eda77ada9 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -11,6 +11,7 @@ from oslo_log import log as logging import ironic_understack.vlan_group_name_convention +from ironic_understack.conf import CONF LOG = logging.getLogger(__name__) @@ -156,7 +157,10 @@ def vlan_group_name(local_link_connection) -> str | None: if not switch_name: return - return ironic_understack.vlan_group_name_convention.vlan_group_name(switch_name) + return ironic_understack.vlan_group_name_convention.vlan_group_name( + switch_name, + CONF.ironic_understack.switch_name_vlan_group_mapping + ) def _set_port_physical_network(port, new_physical_network: str | None): diff --git a/python/ironic-understack/ironic_understack/vlan_group_name_convention.py b/python/ironic-understack/ironic_understack/vlan_group_name_convention.py index 33991f9d8..2c65e05ff 100644 --- a/python/ironic-understack/ironic_understack/vlan_group_name_convention.py +++ b/python/ironic-understack/ironic_understack/vlan_group_name_convention.py @@ -1,54 +1,39 @@ -def vlan_group_name(switch_name: str) -> str: +def vlan_group_name(switch_name: str, mapping: dict[str, str]) -> str: """The VLAN GROUP name is a function of the switch name. - Switch hostname convention is site-dependent, but in Rackspace all - top-of-rack switch names follow the convention: - - Example switch names include a2-12-1f and a2-12-1. (These are normally - qualified with a site-specific domain name like a2-12-1.iad3.rackspace.net, - but we are only considering the unqualified name, ignoring everything after - the first dot). + Top-of-rack switch hostname is required to follow the convention: - It easy to parse the switch name into cabinet and suffix. Convert the - switch-name-suffix to vlan-group-suffix using the following mapping: + - - 1 → network - 2 → network - 1f → storage - 2f → storage - 3f → storage-appliance - 4f → storage-appliance - 1d → bmc + We only consider the unqualified name, ignoring everything after the first + dot. + + The switch name suffix must be one of the keys in the supplied mapping. The + corresponding value is used to name the VLAN Group (aka physical network). The VLAN GROUP name results from joining the cabinet name to the new suffix - with a hyphen. The result is in lower case: - + with a hyphen. - So for example, switch a2-12-1 is in VLAN GROUP a2-12-network. + >>> vlan_group_name("a123-20-1", {"1": "network"}) + >>> "a123-20-network" """ - # Remove domain suffix if present (everything after first dot) switch_name = switch_name.split(".")[0].lower() - # Split into cabinet and suffix (last component after last hyphen) parts = switch_name.rsplit("-", 1) if len(parts) != 2: - raise ValueError(f"Unknown switch name format: {switch_name}") + raise ValueError( + f"Unknown switch name format: {switch_name} - this hook requires " + f"that switch names follow the convention -" + ) cabinet_name, suffix = parts - # Map suffix to VLAN group suffix - suffix_mapping = { - "1": "network", - "2": "network", - "3": "network", - "4": "network", - "1f": "storage", - "2f": "storage", - "3f": "storage-appliance", - "4f": "storage-appliance", - "1d": "bmc", - } - - vlan_suffix = suffix_mapping.get(suffix) + vlan_suffix = mapping.get(suffix) if vlan_suffix is None: - raise ValueError(f"Unknown switch suffix: {suffix}") + raise ValueError( + f"Switch suffix {suffix} is not present in the mapping configured " + f"in ironic_understack.switch_name_vlan_group_mapping. Recognised " + f"suffixes are: {mapping.keys()}" + ) return f"{cabinet_name}-{vlan_suffix}" From cd8d5ab564675881d4405830ed1e7bd1c0c9a0dd Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 3 Nov 2025 15:32:48 +0000 Subject: [PATCH 10/14] Manage traits for all VLAN Group types, not just STORAGE_SWITCH --- .../tests/test_update_baremetal_port.py | 12 ++++-- .../update_baremetal_port.py | 43 +++++++++++++------ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py index 440e43c7b..6eca388e0 100644 --- a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py @@ -23,7 +23,8 @@ def test_with_valid_data(mocker): node_uuid = uuidutils.generate_uuid() - mock_node = mocker.Mock() + mock_traits = mocker.Mock() + mock_node = mocker.Mock(traits=mock_traits) mock_task = mocker.Mock(node=mock_node) mock_port = mocker.Mock( uuid=uuidutils.generate_uuid(), @@ -37,6 +38,8 @@ def test_with_valid_data(mocker): return_value=mock_port, ) + mock_traits.get_trait_names.return_value = ["CUSTOM_NETWORK_SWITCH", "bar"] + UpdateBaremetalPortsHook().__call__(mock_task, _INVENTORY, _PLUGIN_DATA) assert mock_port.local_link_connection == { @@ -45,8 +48,9 @@ def test_with_valid_data(mocker): "switch_info": "f20-3-2f.iad3", } assert mock_port.physical_network == "f20-3-storage" - mock_port.save.assert_called() - mock_node.remove_trait.assert_not_called() - mock_node.add_trait.assert_called_once_with("CUSTOM_STORAGE_SWITCH") + + mock_traits.get_trait_names.assert_called_once() + mock_traits.destroy.assert_called_once_with("CUSTOM_NETWORK_SWITCH") + mock_traits.create.assert_called_once_with("CUSTOM_STORAGE_SWITCH") mock_node.save.assert_called_once() diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index eda77ada9..48582c41c 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -39,9 +39,9 @@ def __call__(self, task, inventory, plugin_data): Also adds or removes node "traits" based on the inventory data. We control the trait "CUSTOM_STORAGE_SWITCH". - The IPA image will normally have exactly one inventory.interfaces with - an ipv4_address address and has_carrier set to True. This is our pxe - boot interface. We should clear the pxe interface flag on all other + TODO: The IPA image will normally have exactly one inventory.interfaces + with an ipv4_address address and has_carrier set to True. This is our + pxe boot interface. We should clear the pxe interface flag on all other baremetal ports. """ LOG.debug(f"{__class__} called with {task=!r} {inventory=!r} {plugin_data=!r}") @@ -184,22 +184,39 @@ def _set_port_physical_network(port, new_physical_network: str | None): def _set_node_traits(task, vlan_groups: set[str]): """Add or remove traits to the node. - We manage one trait: "CUSTOM_STORAGE_SWITCH" which is added if the node has - any ports connected to a storage fabric, othwise it is removed from the + We manage a traits for each type of VLAN Group that can be connected to a node. + + For example, a connection to VLAN Group whose name ends in "-storage" will + result in a trait being added to the node called "CUSTOM_STORAGE_SWITCH". + + We remove pre-existing traits if the node does not have the required + connections. """ - TRAIT_STORAGE_SWITCH = "CUSTOM_STORAGE_SWITCH" + all_possible_suffixes = set( + CONF.ironic_understack.switch_name_vlan_group_mapping.values() + ) + all_traits = { _trait_name(x) for x in all_possible_suffixes } + required_traits = { _trait_name(x) for x in vlan_groups } + existing_traits = set(task.node.traits.get_trait_names()).intersection(all_traits) - storage_vlan_groups = {x for x in vlan_groups if x.endswith("-storage")} + LOG.debug(f"Existing traits of node {task.node.uuid=} {task.node.traits=} {existing_traits=}") - if storage_vlan_groups: - existing_traits = task.node.traits.get_trait_names() - LOG.debug(f"Existing traits of node {task.node.uuid=} {task.node.traits=} {existing_traits=}") - task.node.traits = existing_traits.create(TRAIT_STORAGE_SWITCH) - else: + traits_to_remove = existing_traits.difference(required_traits) + traits_to_add = required_traits.difference(existing_traits) + + print(f"{task.node.uuid=} {task.node.traits=} {existing_traits=} {all_traits=} {required_traits=} {traits_to_add=} {traits_to_remove=}") + for trait in traits_to_remove: try: - task.node.traits.destroy(TRAIT_STORAGE_SWITCH) + task.node.traits.destroy(trait) except openstack.exceptions.NotFoundException: pass + for trait in traits_to_add: + task.node.traits = task.node.traits.create(trait) + task.node.save() + +def _trait_name(vlan_group_name: str) -> str: + suffix = vlan_group_name.upper().split("-")[-1] + return f"CUSTOM_{suffix}_SWITCH" From 96ac19800726e1ab60ecb2e584e839d8092068d2 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 3 Nov 2025 19:12:57 +0000 Subject: [PATCH 11/14] Replace the link-local-connection hook with our own customised version --- components/ironic/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index 2f5a61e7a..209bb5013 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -88,7 +88,7 @@ conf: loader_file_paths: "snponly.efi:/usr/lib/ipxe/snponly.efi" inspector: extra_kernel_params: ipa-collect-lldp=1 - hooks: "$default_hooks,pci-devices,parse-lldp,local-link-connection,resource-class" + hooks: "$default_hooks,pci-devices,parse-lldp,resource-class,update-baremetal-port" # enable sensors and metrics for redfish metrics - https://docs.openstack.org/ironic/latest/admin/drivers/redfish/metrics.html sensor_data: send_sensor_data: true From d786564d281334cbbf5b1d4c925a439b65148e30 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 3 Nov 2025 19:13:20 +0000 Subject: [PATCH 12/14] Have inspection hook add baremetal ports for all NICs it finds --- components/ironic/values.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index 209bb5013..2f3bc2d6b 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -89,6 +89,7 @@ conf: inspector: extra_kernel_params: ipa-collect-lldp=1 hooks: "$default_hooks,pci-devices,parse-lldp,resource-class,update-baremetal-port" + add_ports: "all" # enable sensors and metrics for redfish metrics - https://docs.openstack.org/ironic/latest/admin/drivers/redfish/metrics.html sensor_data: send_sensor_data: true From 3b3481f1d3cfb6760487c5d48a0f9dec4a2ce4c9 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 3 Nov 2025 21:07:31 +0000 Subject: [PATCH 13/14] Fix traits.create call --- .../tests/test_update_baremetal_port.py | 4 ++-- .../update_baremetal_port.py | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py index 6eca388e0..fce8ec855 100644 --- a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py @@ -24,7 +24,7 @@ def test_with_valid_data(mocker): node_uuid = uuidutils.generate_uuid() mock_traits = mocker.Mock() - mock_node = mocker.Mock(traits=mock_traits) + mock_node = mocker.Mock(id=1234, traits=mock_traits) mock_task = mocker.Mock(node=mock_node) mock_port = mocker.Mock( uuid=uuidutils.generate_uuid(), @@ -52,5 +52,5 @@ def test_with_valid_data(mocker): mock_traits.get_trait_names.assert_called_once() mock_traits.destroy.assert_called_once_with("CUSTOM_NETWORK_SWITCH") - mock_traits.create.assert_called_once_with("CUSTOM_STORAGE_SWITCH") + mock_traits.create.assert_called_once_with(None, 1234, ["CUSTOM_STORAGE_SWITCH"]) mock_node.save.assert_called_once() diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index 48582c41c..c2436fe37 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -200,22 +200,29 @@ def _set_node_traits(task, vlan_groups: set[str]): required_traits = { _trait_name(x) for x in vlan_groups } existing_traits = set(task.node.traits.get_trait_names()).intersection(all_traits) - LOG.debug(f"Existing traits of node {task.node.uuid=} {task.node.traits=} {existing_traits=}") - traits_to_remove = existing_traits.difference(required_traits) traits_to_add = required_traits.difference(existing_traits) - print(f"{task.node.uuid=} {task.node.traits=} {existing_traits=} {all_traits=} {required_traits=} {traits_to_add=} {traits_to_remove=}") + LOG.debug( + "Checking traits for node %s: existing=%s required=%s", + task.node.uuid, existing_traits, required_traits, + ) + for trait in traits_to_remove: + LOG.debug("Removing trait %s from node %s", trait, task.node.uuid) try: task.node.traits.destroy(trait) except openstack.exceptions.NotFoundException: pass - for trait in traits_to_add: - task.node.traits = task.node.traits.create(trait) + if traits_to_add: + LOG.debug("Adding traits %s to node %s", traits_to_add, task.node.uuid) + task.node.traits = task.node.traits.create( + None, task.node.id, list(traits_to_add) + ) - task.node.save() + if traits_to_add or traits_to_remove: + task.node.save() def _trait_name(vlan_group_name: str) -> str: suffix = vlan_group_name.upper().split("-")[-1] From 9132e72625fb7e0e2e2b01798ee5cb225a7e4dd5 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 4 Nov 2025 13:35:12 +0000 Subject: [PATCH 14/14] fix trait handling: Pass proper context --- .../ironic_understack/tests/test_update_baremetal_port.py | 5 +++-- .../ironic_understack/update_baremetal_port.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py index fce8ec855..220c6f884 100644 --- a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py @@ -24,8 +24,9 @@ def test_with_valid_data(mocker): node_uuid = uuidutils.generate_uuid() mock_traits = mocker.Mock() + mock_context = mocker.Mock() mock_node = mocker.Mock(id=1234, traits=mock_traits) - mock_task = mocker.Mock(node=mock_node) + mock_task = mocker.Mock(node=mock_node, context=mock_context) mock_port = mocker.Mock( uuid=uuidutils.generate_uuid(), node_id=node_uuid, @@ -52,5 +53,5 @@ def test_with_valid_data(mocker): mock_traits.get_trait_names.assert_called_once() mock_traits.destroy.assert_called_once_with("CUSTOM_NETWORK_SWITCH") - mock_traits.create.assert_called_once_with(None, 1234, ["CUSTOM_STORAGE_SWITCH"]) + mock_traits.create.assert_called_once_with(mock_context, 1234, ["CUSTOM_STORAGE_SWITCH"]) mock_node.save.assert_called_once() diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index c2436fe37..83eb8b8c0 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -218,7 +218,7 @@ def _set_node_traits(task, vlan_groups: set[str]): if traits_to_add: LOG.debug("Adding traits %s to node %s", traits_to_add, task.node.uuid) task.node.traits = task.node.traits.create( - None, task.node.id, list(traits_to_add) + task.context, task.node.id, list(traits_to_add) ) if traits_to_add or traits_to_remove: