From 2ba214a0147f711116ae870e8fd6e0ac55f9d2d5 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 16 Dec 2025 12:26:10 +0000 Subject: [PATCH 1/4] Fix node trait handling - don't remove traits outside our wheelhouse --- .../update_baremetal_port.py | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/update_baremetal_port.py index ecdad36ce..2b4e30577 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/update_baremetal_port.py @@ -1,3 +1,4 @@ +import re from typing import Any from ironic import objects @@ -93,7 +94,7 @@ def _update_port_attrs(task, ports_by_mac, vlan_groups, node_uuid): if inspected_port: vlan_group = vlan_groups.get(inspected_port.switch_system_name) LOG.info( - "Port=%(uuid)s Node=%(node)s is connected " "%(lldp)s, %(vlan_group)s", + "Port=%(uuid)s Node=%(node)s is connected %(lldp)s, %(vlan_group)s", { "uuid": baremetal_port.uuid, "node": node_uuid, @@ -173,20 +174,21 @@ def _set_node_traits(task, vlan_groups: set[str]): connections. """ node = task.node - all_possible_suffixes = set( - CONF.ironic_understack.switch_name_vlan_group_mapping.values() - ) - our_traits = {_trait_name(x) for x in all_possible_suffixes if x} - required_traits = {_trait_name(x) for x in vlan_groups if x} - existing_traits = set(node.traits.get_trait_names()).intersection(our_traits) - - LOG.debug( - "Checking traits for node %s: existing=%s required=%s", - node.uuid, - existing_traits, - required_traits, - ) - if existing_traits != required_traits: + existing_traits = set(node.traits.get_trait_names()) + vlan_group_traits = {_trait_name(x) for x in vlan_groups if x} + irrelevant_existing_traits = {x for x in existing_traits if not _is_our_trait(x)} + required_traits = irrelevant_existing_traits.intersection(vlan_group_traits) + + if existing_traits == required_traits: + LOG.debug( + "Node %s traits %s are all present and correct", + node.uuid, vlan_group_traits + ) + else: + LOG.info( + "Updating traits for node %s from %s to %s", + node.uuid, existing_traits, required_traits, + ) objects.TraitList.create(task.context, task.node.id, required_traits) node.save() @@ -194,3 +196,6 @@ def _set_node_traits(task, vlan_groups: set[str]): def _trait_name(vlan_group_name: str) -> str: suffix = vlan_group_name.upper().split("-")[-1] return f"CUSTOM_{suffix}_SWITCH" + +def _is_our_trait(name: str) -> bool: + return bool(re.match(r"^CUSTOM_[A-Z0-9]+_SWITCH$", name)) From e0bda63a97aa3d3047077fe829c87278b39fbd84 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 16 Dec 2025 12:30:42 +0000 Subject: [PATCH 2/4] Rename inspection hook for consistency with others Includes formatting changes that were mandated by ruff --- ...t.py => inspect_hook_update_baremetal_ports.py} | 10 +++++++--- ...=> test_inspect_hook_update_baremetal_ports.py} | 14 +++++++++----- python/ironic-understack/pyproject.toml | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) rename python/ironic-understack/ironic_understack/{update_baremetal_port.py => inspect_hook_update_baremetal_ports.py} (97%) rename python/ironic-understack/ironic_understack/tests/{test_update_baremetal_port.py => test_inspect_hook_update_baremetal_ports.py} (83%) diff --git a/python/ironic-understack/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/inspect_hook_update_baremetal_ports.py similarity index 97% rename from python/ironic-understack/ironic_understack/update_baremetal_port.py rename to python/ironic-understack/ironic_understack/inspect_hook_update_baremetal_ports.py index 2b4e30577..fcb87a0e5 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/inspect_hook_update_baremetal_ports.py @@ -14,7 +14,7 @@ LOG = logging.getLogger(__name__) -class UpdateBaremetalPortsHook(base.InspectionHook): +class InspectHookUpdateBaremetalPorts(base.InspectionHook): """Hook to update ports according to LLDP data.""" # "validate-interfaces" provides the all_interfaces field in plugin_data. @@ -182,12 +182,15 @@ def _set_node_traits(task, vlan_groups: set[str]): if existing_traits == required_traits: LOG.debug( "Node %s traits %s are all present and correct", - node.uuid, vlan_group_traits + node.uuid, + vlan_group_traits, ) else: LOG.info( "Updating traits for node %s from %s to %s", - node.uuid, existing_traits, required_traits, + node.uuid, + existing_traits, + required_traits, ) objects.TraitList.create(task.context, task.node.id, required_traits) node.save() @@ -197,5 +200,6 @@ def _trait_name(vlan_group_name: str) -> str: suffix = vlan_group_name.upper().split("-")[-1] return f"CUSTOM_{suffix}_SWITCH" + def _is_our_trait(name: str) -> bool: return bool(re.match(r"^CUSTOM_[A-Z0-9]+_SWITCH$", name)) diff --git a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py b/python/ironic-understack/ironic_understack/tests/test_inspect_hook_update_baremetal_ports.py similarity index 83% rename from python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py rename to python/ironic-understack/ironic_understack/tests/test_inspect_hook_update_baremetal_ports.py index a5ae366c7..1ba9955ee 100644 --- a/python/ironic-understack/ironic_understack/tests/test_update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/tests/test_inspect_hook_update_baremetal_ports.py @@ -3,7 +3,9 @@ import ironic.objects from oslo_utils import uuidutils -from ironic_understack.update_baremetal_port import UpdateBaremetalPortsHook +from ironic_understack.inspect_hook_update_baremetal_ports import ( + InspectHookUpdateBaremetalPorts, +) # load some metaprgramming normally taken care of during Ironic initialization: ironic.objects.register_all() @@ -78,18 +80,20 @@ def test_with_valid_data(mocker, caplog): ) mocker.patch( - "ironic_understack.update_baremetal_port.ironic_ports_for_node", + "ironic_understack.inspect_hook_update_baremetal_ports.ironic_ports_for_node", return_value=[mock_port], ) mocker.patch( - "ironic_understack.update_baremetal_port.CONF.ironic_understack.switch_name_vlan_group_mapping", + "ironic_understack.inspect_hook_update_baremetal_ports.CONF.ironic_understack.switch_name_vlan_group_mapping", MAPPING, ) - mocker.patch("ironic_understack.update_baremetal_port.objects.TraitList.create") + mocker.patch( + "ironic_understack.inspect_hook_update_baremetal_ports.objects.TraitList.create" + ) mock_traits.get_trait_names.return_value = ["CUSTOM_BMC_SWITCH", "bar"] - UpdateBaremetalPortsHook().__call__(mock_task, _INVENTORY, _PLUGIN_DATA) + InspectHookUpdateBaremetalPorts().__call__(mock_task, _INVENTORY, _PLUGIN_DATA) assert mock_port.local_link_connection == { "port_id": "Ethernet1/18", diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index 411f159a4..aafc8f7d7 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ [project.entry-points."ironic.inspection.hooks"] resource-class = "ironic_understack.resource_class:ResourceClassHook" -update-baremetal-port = "ironic_understack.update_baremetal_port:UpdateBaremetalPortsHook" +update-baremetal-port = "ironic_understack.inspect_hook_update_baremetal_ports:InspectHookUpdateBaremetalPorts" port-bios-name = "ironic_understack.port_bios_name_hook:PortBiosNameHook" [project.entry-points."ironic.hardware.interfaces.inspect"] From 08bfe7330fdddbda68aed9b01b3ff7d93df227cc Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 16 Dec 2025 12:35:19 +0000 Subject: [PATCH 3/4] Add inspection hook that verifies the node name against serial number --- .../inspect_hook_node_name_check.py | 59 +++++++++++++++++++ python/ironic-understack/pyproject.toml | 1 + 2 files changed, 60 insertions(+) create mode 100644 python/ironic-understack/ironic_understack/inspect_hook_node_name_check.py diff --git a/python/ironic-understack/ironic_understack/inspect_hook_node_name_check.py b/python/ironic-understack/ironic_understack/inspect_hook_node_name_check.py new file mode 100644 index 000000000..0b8a349d3 --- /dev/null +++ b/python/ironic-understack/ironic_understack/inspect_hook_node_name_check.py @@ -0,0 +1,59 @@ +from ironic.common import exception +from ironic.drivers.modules.inspector.hooks import base +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + + +class InspectHookNodeNameCheck(base.InspectionHook): + """Check baremetal node name against system identity from inventory data. + + Expect the node name to be a string like "Dell_AN3Z23A" consistent with the + manufacturer and serial number in the inventory data. + + If the node name does not match, abort the inspection process to force + operator intervention. + """ + + def __call__(self, task, inventory, _plugin_data): + node = task.node + sys_data = inventory.get("system_vendor", {}) + + serial_number = sys_data.get("sku", sys_data.get("serial_number")) + if serial_number is None: + raise exception.InvalidNodeInventory( + node=node.uuid, reason="No serial number found in inventory data." + ) + + manufacturer = sys_data.get("manufacturer") + if manufacturer is None: + raise exception.InvalidNodeInventory( + node=node.uuid, reason="No manufacturer found in inventory data." + ) + + manufacturer_slug = _manufacturer_slug(manufacturer) + + if node.name == f"{manufacturer}_{serial_number}": + LOG.debug("Node Name Check passed for node %s", node.uuid) + else: + raise RuntimeError( + "Hardware Identity Crisis with baremetal node %s! The current " + "node name %s is inconsistent with its hardware manufacturer " + "%s and serial number/service tag %s. If this is a " + "replacement hardware, the baremetal node should be deleted " + "and re-enrolled.", + node.uuid, + node.name, + manufacturer_slug, + serial_number, + ) + + +def _manufacturer_slug(manufacturer_name: str) -> str: + name = str(manufacturer_name).upper() + if "DELL" in name: + return "Dell" + elif "HP" in name: + return "HP" + else: + return manufacturer_name.replace(" ", "_") diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index aafc8f7d7..e66468922 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ resource-class = "ironic_understack.resource_class:ResourceClassHook" update-baremetal-port = "ironic_understack.inspect_hook_update_baremetal_ports:InspectHookUpdateBaremetalPorts" port-bios-name = "ironic_understack.port_bios_name_hook:PortBiosNameHook" +node-name-check = "ironic_understack.inspect_hook_node_name_check:InspectHookNodeNameCheck" [project.entry-points."ironic.hardware.interfaces.inspect"] redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackRedfishInspect" From eef1cfd9f81f36956e1c6911bd36a534de96d72c Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 16 Dec 2025 12:37:49 +0000 Subject: [PATCH 4/4] Add inspection hook that sets node property and trait for chassis model --- .../inspect_hook_chassis_model.py | 78 +++++++++++++++++++ python/ironic-understack/pyproject.toml | 1 + 2 files changed, 79 insertions(+) create mode 100644 python/ironic-understack/ironic_understack/inspect_hook_chassis_model.py diff --git a/python/ironic-understack/ironic_understack/inspect_hook_chassis_model.py b/python/ironic-understack/ironic_understack/inspect_hook_chassis_model.py new file mode 100644 index 000000000..e942e12ec --- /dev/null +++ b/python/ironic-understack/ironic_understack/inspect_hook_chassis_model.py @@ -0,0 +1,78 @@ +import re + +from ironic import objects +from ironic.common import exception +from ironic.drivers.modules.inspector.hooks import base +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + + +class InspectHookChassisModel(base.InspectionHook): + """Update baremetal node properties with chassis model number from inventory. + + We set both a baremetal node property and a trait. + """ + + def __call__(self, task, inventory, _plugin_data): + node = task.node + chassis_model = _extract_chassis_model(node, inventory) + manufacturer = _extract_manufacturer(node, inventory) + trait_name = _trait_name(manufacturer, chassis_model) + _set_node_traits(task, "CUSTOM_CHASSIS_MODEL_", trait_name) + + +def _set_node_traits(task, prefix: str, required_trait: str): + """Manage the subset of node traits whose names begin with `prefix`.""" + node = task.node + existing_traits = node.traits.get_trait_names() + + required_traits = {x for x in existing_traits if not x.startswith(prefix)} + required_traits.add(required_trait) + + LOG.debug( + "Checking traits for node %s: existing=%s required=%s", + node.uuid, + existing_traits, + required_trait, + ) + if existing_traits != required_traits: + objects.TraitList.create(task.context, task.node.id, required_traits) + node.save() + + +def _extract_chassis_model(node, inventory: dict) -> str: + """Extract up the system_vendor product name. + + Return a cleaned-up string like "POWEREDGE_R7615". + """ + chassis_model = inventory.get("system_vendor", {}).get("product_name") + if chassis_model is None: + raise exception.InvalidNodeInventory( + node=node.uuid, reason="Missing product_name in inventory data." + ) + return re.sub(r" \(.*\)", "", str(chassis_model)) + + +def _extract_manufacturer(node, inventory: dict) -> str: + """Extract up the system ventor manufacturer name. + + Return a cleaned-up string like "Dell" or "HP". + """ + name = inventory.get("system_vendor", {}).get("manufacturer") + if name is None: + raise exception.InvalidNodeInventory( + node=node.uuid, reason="No manufacturer found in inventory data." + ) + + if "DELL" in name.upper(): + return "Dell" + elif "HP" in name.upper(): + return "HP" + else: + return name.replace(" ", "_") + + +def _trait_name(manufacturer: str, chassis_model: str) -> str: + """The node trait that should be present on this node.""" + return f"{manufacturer}_#{chassis_model}".upper().replace(" ", "_") diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index e66468922..74bda2a10 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -21,6 +21,7 @@ resource-class = "ironic_understack.resource_class:ResourceClassHook" update-baremetal-port = "ironic_understack.inspect_hook_update_baremetal_ports:InspectHookUpdateBaremetalPorts" port-bios-name = "ironic_understack.port_bios_name_hook:PortBiosNameHook" node-name-check = "ironic_understack.inspect_hook_node_name_check:InspectHookNodeNameCheck" +chassis_model = "ironic_understack.inspect_hook_chassis_model:InspectHookChassisModel" [project.entry-points."ironic.hardware.interfaces.inspect"] redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackRedfishInspect"