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/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/ironic_understack/update_baremetal_port.py b/python/ironic-understack/ironic_understack/inspect_hook_update_baremetal_ports.py similarity index 87% 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 ecdad36ce..fcb87a0e5 100644 --- a/python/ironic-understack/ironic_understack/update_baremetal_port.py +++ b/python/ironic-understack/ironic_understack/inspect_hook_update_baremetal_ports.py @@ -1,3 +1,4 @@ +import re from typing import Any from ironic import objects @@ -13,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. @@ -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,24 @@ 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 +199,7 @@ 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)) 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..74bda2a10 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -18,8 +18,10 @@ 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" +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"