From 48dc39b9fd24411d16ea54c4e3f536a82a0f27a4 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Tue, 20 Oct 2026 10:59:11 +0100 Subject: [PATCH] enroll: iDRAC10 compatibility - handle new redfish "attributes" API Dell iDRAC10 has different paths to iDRAC9, and they also changed the names of some of the attributes. --- python/understack-workflows/tests/test_bmc.py | 46 ++++++++ .../tests/test_bmc_network_config.py | 61 ++++++++++ .../tests/test_bmc_settings.py | 109 ++++++++++++++++++ .../understack_workflows/bmc.py | 32 +++++ .../bmc_network_config.py | 34 ++++-- .../understack_workflows/bmc_settings.py | 26 ++++- 6 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 python/understack-workflows/tests/test_bmc_network_config.py create mode 100644 python/understack-workflows/tests/test_bmc_settings.py diff --git a/python/understack-workflows/tests/test_bmc.py b/python/understack-workflows/tests/test_bmc.py index 6f237808f..2c3c815c7 100644 --- a/python/understack-workflows/tests/test_bmc.py +++ b/python/understack-workflows/tests/test_bmc.py @@ -2,6 +2,7 @@ import pytest +from understack_workflows.bmc import Bmc from understack_workflows.bmc import bmc_for_ip_address @@ -25,3 +26,48 @@ def test_bmc_for_ip_address(mock_creds, monkeypatch): assert bmc.url() == "https://1.2.3.4" assert bmc.username == "root" assert bmc.password == "1MlzcjJ7bnICKp98wrdx" + + +def test_manager_attributes_path_uses_advertised_idrac10_resource(mocker): + bmc = Bmc("10.46.104.6") + bmc._manager_path = "/redfish/v1/Managers/iDRAC.Embedded.1" + mocker.patch.object( + bmc, + "redfish_request", + return_value={ + "Links": { + "Oem": { + "Dell": { + "DellAttributes": [ + { + "@odata.id": ( + "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/" + "DellAttributes/System.Embedded.1" + ) + }, + { + "@odata.id": ( + "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/" + "DellAttributes/iDRAC.Embedded.1" + ) + }, + ] + } + } + } + }, + ) + + assert bmc.manager_attributes_path == ( + "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/DellAttributes/iDRAC.Embedded.1" + ) + + +def test_manager_attributes_path_falls_back_to_idrac9_resource(mocker): + bmc = Bmc("10.46.104.6") + bmc._manager_path = "/redfish/v1/Managers/iDRAC.Embedded.1" + mocker.patch.object(bmc, "redfish_request", return_value={}) + + assert bmc.manager_attributes_path == ( + "/redfish/v1/Managers/iDRAC.Embedded.1/Attributes" + ) diff --git a/python/understack-workflows/tests/test_bmc_network_config.py b/python/understack-workflows/tests/test_bmc_network_config.py new file mode 100644 index 000000000..28001e554 --- /dev/null +++ b/python/understack-workflows/tests/test_bmc_network_config.py @@ -0,0 +1,61 @@ +from ipaddress import IPv4Address +from ipaddress import IPv4Interface + +from understack_workflows.bmc import Bmc +from understack_workflows.bmc_chassis_info import InterfaceInfo +from understack_workflows.bmc_network_config import bmc_set_permanent_ip_addr + + +def _dhcp_interface(): + return InterfaceInfo( + name="iDRAC", + description="Dedicated iDRAC interface", + mac_address="6C:3C:8C:A9:6C:6C", + ipv4_address=IPv4Interface("10.46.104.7/23"), + ipv4_gateway=IPv4Address("10.46.104.1"), + dhcp=True, + ) + + +def test_set_permanent_ip_uses_static_attributes_for_idrac10(mocker): + attribute_path = ( + "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/DellAttributes/iDRAC.Embedded.1" + ) + bmc = mocker.Mock(spec=Bmc) + bmc.manager_attributes_path = attribute_path + + bmc_set_permanent_ip_addr(bmc, _dhcp_interface()) + + bmc.redfish_request.assert_called_once_with( + attribute_path, + method="PATCH", + payload={ + "Attributes": { + "IPv4.1.DHCPEnable": "Disabled", + "IPv4.1.StaticAddress": "10.46.104.7", + "IPv4.1.StaticGateway": "10.46.104.1", + "IPv4.1.StaticNetmask": "255.255.254.0", + } + }, + ) + + +def test_set_permanent_ip_keeps_legacy_idrac9_patch(mocker): + attribute_path = "/redfish/v1/Managers/iDRAC.Embedded.1/Attributes" + bmc = mocker.Mock(spec=Bmc) + bmc.manager_attributes_path = attribute_path + + bmc_set_permanent_ip_addr(bmc, _dhcp_interface()) + + bmc.redfish_request.assert_called_once_with( + attribute_path, + method="PATCH", + payload={ + "Attributes": { + "IPv4.1.DHCPEnable": "Disabled", + "IPv4.1.Address": "10.46.104.7", + "IPv4.1.Gateway": "10.46.104.1", + "IPv4.1.Netmask": "255.255.254.0", + } + }, + ) diff --git a/python/understack-workflows/tests/test_bmc_settings.py b/python/understack-workflows/tests/test_bmc_settings.py new file mode 100644 index 000000000..9f0b38597 --- /dev/null +++ b/python/understack-workflows/tests/test_bmc_settings.py @@ -0,0 +1,109 @@ +from understack_workflows import bmc_settings +from understack_workflows.bmc import Bmc + +MANAGER_ATTRIBUTES_PATH = ( + "/redfish/v1/Managers/iDRAC.Embedded.1/Oem/Dell/DellAttributes/iDRAC.Embedded.1" +) +UNCONFIGURED_IPV4 = "0.0.0.0" # noqa: S104 -- value returned by iDRAC + + +def _current_attribute_values(): + return { + key: value + for key, value in bmc_settings.REQUIRED_VALUES.items() + if value is not None + } + + +def test_update_dell_drac_settings_uses_static_attributes_for_idrac10_dns(mocker): + mocker.patch.dict( + bmc_settings.REQUIRED_VALUES, + { + "IPv4.1.DNS1": "10.0.0.53", + "IPv4.1.DNS2": "10.0.0.54", + }, + ) + mocker.patch.dict( + bmc_settings.VALUES_TO_POST, + { + "IPv4.1.DNS1": "10.0.0.53", + "IPv4.1.DNS2": "10.0.0.54", + }, + ) + bmc = mocker.Mock(spec=Bmc) + bmc.manager_attributes_path = MANAGER_ATTRIBUTES_PATH + bmc.redfish_request.side_effect = [ + { + "Attributes": _current_attribute_values() + | { + "IPv4.1.DNS1": UNCONFIGURED_IPV4, + "IPv4.1.DNS2": UNCONFIGURED_IPV4, + "IPv4.1.StaticDNS1": UNCONFIGURED_IPV4, + "IPv4.1.StaticDNS2": UNCONFIGURED_IPV4, + } + }, + {}, + ] + + changes = bmc_settings.update_dell_drac_settings(bmc) + + assert changes == { + "IPv4.1.StaticDNS1": "10.0.0.53", + "IPv4.1.StaticDNS2": "10.0.0.54", + } + assert bmc.redfish_request.call_args_list == [ + mocker.call(MANAGER_ATTRIBUTES_PATH), + mocker.call( + MANAGER_ATTRIBUTES_PATH, + payload={ + "Attributes": { + "IPv4.1.StaticDNS1": "10.0.0.53", + "IPv4.1.StaticDNS2": "10.0.0.54", + } + }, + method="PATCH", + ), + ] + + +def test_update_dell_drac_settings_keeps_legacy_idrac9_dns_patch(mocker): + attribute_path = "/redfish/v1/Managers/iDRAC.Embedded.1/Attributes" + mocker.patch.dict( + bmc_settings.REQUIRED_VALUES, + { + "IPv4.1.DNS1": "10.0.0.53", + "IPv4.1.DNS2": "10.0.0.54", + }, + ) + mocker.patch.dict( + bmc_settings.VALUES_TO_POST, + { + "IPv4.1.DNS1": "10.0.0.53", + "IPv4.1.DNS2": "10.0.0.54", + }, + ) + current_values = _current_attribute_values() | { + "IPv4.1.DNS1": UNCONFIGURED_IPV4, + "IPv4.1.DNS2": UNCONFIGURED_IPV4, + } + bmc = mocker.Mock(spec=Bmc) + bmc.manager_attributes_path = attribute_path + bmc.redfish_request.side_effect = [{"Attributes": current_values}, {}] + + changes = bmc_settings.update_dell_drac_settings(bmc) + + expected_changes = { + "IPv4.1.DNS1": "10.0.0.53", + "IPv4.1.DNS2": "10.0.0.54", + } + assert changes == expected_changes + bmc.redfish_request.assert_has_calls( + [ + mocker.call(attribute_path), + mocker.call( + attribute_path, + payload={"Attributes": expected_changes}, + method="PATCH", + ), + ] + ) diff --git a/python/understack-workflows/understack_workflows/bmc.py b/python/understack-workflows/understack_workflows/bmc.py index 09e0955df..4491c9ec2 100644 --- a/python/understack-workflows/understack_workflows/bmc.py +++ b/python/understack-workflows/understack_workflows/bmc.py @@ -46,6 +46,7 @@ def __init__( self._base_path: str | None = None self._system_path: str | None = None self._manager_path: str | None = None + self._manager_attributes_path: str | None = None @property def base_path(self) -> str: @@ -65,6 +66,14 @@ def manager_path(self) -> str: self._manager_path = self._manager_path or self.get_manager_path() return self._manager_path + @property + def manager_attributes_path(self) -> str: + """Read the vendor-supported Manager attributes path from the BMC.""" + self._manager_attributes_path = ( + self._manager_attributes_path or self.get_manager_attributes_path() + ) + return self._manager_attributes_path + def __str__(self): """Stringify without password being printed.""" return f"BMC {self.url()}" @@ -88,6 +97,29 @@ def get_manager_path(self): _result = self.redfish_request("/redfish/v1/Managers/") return _result["Members"][0]["@odata.id"].rstrip("/") + def get_manager_attributes_path(self) -> str: + """Get the Manager attributes path advertised by the BMC. + + iDRAC10 exposes Manager attributes through the DellAttributes OEM + resource. Older iDRAC9 versions use the Manager's Attributes child. + """ + manager_path = self.manager_path + manager = self.redfish_request(manager_path) + dell_attributes = ( + manager.get("Links", {}) + .get("Oem", {}) + .get("Dell", {}) + .get("DellAttributes", []) + ) + manager_id = manager_path.rsplit("/", maxsplit=1)[-1] + + for resource in dell_attributes: + path = resource.get("@odata.id") + if path and path.rstrip("/").rsplit("/", maxsplit=1)[-1] == manager_id: + return path.rstrip("/") + + return f"{manager_path}/Attributes" + def get_manufacturer(self) -> str: """Read and return Manufacturer.""" return self.redfish_request(self.system_path)["Manufacturer"].lower() diff --git a/python/understack-workflows/understack_workflows/bmc_network_config.py b/python/understack-workflows/understack_workflows/bmc_network_config.py index 8d7d70a8b..bcca5591f 100644 --- a/python/understack-workflows/understack_workflows/bmc_network_config.py +++ b/python/understack-workflows/understack_workflows/bmc_network_config.py @@ -18,16 +18,30 @@ def bmc_set_permanent_ip_addr(bmc: Bmc, interface_info: InterfaceInfo): if not (interface_info.ipv4_address and interface_info.ipv4_gateway): raise ValueError("BMC InterfaceInfo has missing IP information") - payload = { - "Attributes": { - "IPv4.1.DHCPEnable": "Disabled", - "IPv4.1.Address": str(interface_info.ipv4_address.ip), - "IPv4.1.Gateway": str(interface_info.ipv4_gateway), - "IPv4.1.Netmask": str(interface_info.ipv4_address.netmask), - } - } logger.info( "BMC was DHCP IP %s, making this permanent", interface_info.ipv4_address ) - attribute_path = bmc.manager_path + "/Attributes" - bmc.redfish_request(attribute_path, method="PATCH", payload=payload) + attribute_path = bmc.manager_attributes_path + uses_idrac10_attributes = not attribute_path.rstrip("/").endswith("/Attributes") + address_key = ( + "IPv4.1.StaticAddress" if uses_idrac10_attributes else "IPv4.1.Address" + ) + gateway_key = ( + "IPv4.1.StaticGateway" if uses_idrac10_attributes else "IPv4.1.Gateway" + ) + netmask_key = ( + "IPv4.1.StaticNetmask" if uses_idrac10_attributes else "IPv4.1.Netmask" + ) + + bmc.redfish_request( + attribute_path, + method="PATCH", + payload={ + "Attributes": { + "IPv4.1.DHCPEnable": "Disabled", + address_key: str(interface_info.ipv4_address.ip), + gateway_key: str(interface_info.ipv4_gateway), + netmask_key: str(interface_info.ipv4_address.netmask), + } + }, + ) diff --git a/python/understack-workflows/understack_workflows/bmc_settings.py b/python/understack-workflows/understack_workflows/bmc_settings.py index cbe44aba6..eae0c4c54 100644 --- a/python/understack-workflows/understack_workflows/bmc_settings.py +++ b/python/understack-workflows/understack_workflows/bmc_settings.py @@ -26,6 +26,11 @@ "SNMP.1.SNMPProtocol": "0", } +IDRAC10_ATTRIBUTE_ALIASES = { + "IPv4.1.DNS1": "IPv4.1.StaticDNS1", + "IPv4.1.DNS2": "IPv4.1.StaticDNS2", +} + class BiosSettingException(Exception): """Exception when a required key are not present.""" @@ -36,11 +41,18 @@ def update_dell_drac_settings(bmc: Bmc) -> dict: Returns the changes that were made """ - attribute_path = bmc.manager_path + "/Attributes" + attribute_path = bmc.manager_attributes_path current_values = bmc.redfish_request(attribute_path)["Attributes"] + uses_idrac10_attributes = not attribute_path.rstrip("/").endswith("/Attributes") required_changes = {} - for key, required_value in REQUIRED_VALUES.items(): + required_display_values = {} + for configured_key, required_value in REQUIRED_VALUES.items(): + key = ( + IDRAC10_ATTRIBUTE_ALIASES.get(configured_key, configured_key) + if uses_idrac10_attributes + else configured_key + ) if not required_value: logger.warning( "We have no required value configured for BMC attribute '%s'", key @@ -50,18 +62,22 @@ def update_dell_drac_settings(bmc: Bmc) -> dict: elif current_values[key] == required_value: logger.info("%s: '%s' already set to '%s'", bmc, key, required_value) else: - required_changes[key] = VALUES_TO_POST[key] + required_changes[key] = VALUES_TO_POST[configured_key] + required_display_values[key] = required_value if required_changes: logger.info("%s Updating DRAC settings:", bmc) for k in required_changes: - logger.info(" %s: %s->%s", k, current_values[k], REQUIRED_VALUES[k]) + logger.info( + " %s: %s->%s", k, current_values[k], required_display_values[k] + ) payload = {"Attributes": required_changes} bmc.redfish_request(attribute_path, payload=payload, method="PATCH") logger.info("%s DRAC settings have been updated", bmc) - else: + + if not required_changes: logger.info("%s all required DRAC settings present and correct", bmc) return required_changes