From 2598ae0f34a5196349161f08ef8b1b70699e25fb Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Wed, 28 Jan 2026 10:58:07 +0000 Subject: [PATCH 1/6] Refactor: sort the pyproject [project.scripts] section --- python/understack-workflows/pyproject.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index 90b4b000a..cbbfa592d 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -30,16 +30,16 @@ dependencies = [ ] [project.scripts] -sync-keystone = "understack_workflows.main.sync_keystone:main" -undersync-switch = "understack_workflows.main.undersync_switch:main" +bmc-kube-password = "understack_workflows.main.bmc_display_password:main" +bmc-password = "understack_workflows.main.print_bmc_password:main" enroll-server = "understack_workflows.main.enroll_server:main" get-raid-devices = "understack_workflows.main.get_raid_devices:main" -bmc-password = "understack_workflows.main.print_bmc_password:main" -bmc-kube-password = "understack_workflows.main.bmc_display_password:main" -sync-network-segment-range = "understack_workflows.main.sync_ucvni_group_range:main" -openstack-oslo-event = "understack_workflows.main.openstack_oslo_event:main" -netapp-create-svm = "understack_workflows.main.netapp_create_svm:main" netapp-configure-interfaces = "understack_workflows.main.netapp_configure_net:main" +netapp-create-svm = "understack_workflows.main.netapp_create_svm:main" +openstack-oslo-event = "understack_workflows.main.openstack_oslo_event:main" +sync-keystone = "understack_workflows.main.sync_keystone:main" +sync-network-segment-range = "understack_workflows.main.sync_ucvni_group_range:main" +undersync-switch = "understack_workflows.main.undersync_switch:main" [dependency-groups] test = [ From 0ceaa1baab3350217f7264cc0aeff1cb70762148 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Wed, 28 Jan 2026 12:55:28 +0000 Subject: [PATCH 2/6] Extract bios settings update functionality into separate python script --- python/understack-workflows/pyproject.toml | 1 + .../tests/test_pxe_port_heuristic.py | 75 +++++++++++++++++++ .../main/bios_settings.py | 64 ++++++++++++++++ .../main/enroll_server.py | 50 ------------- .../pxe_port_heuristic.py | 55 ++++++++++++++ 5 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 python/understack-workflows/tests/test_pxe_port_heuristic.py create mode 100644 python/understack-workflows/understack_workflows/main/bios_settings.py create mode 100644 python/understack-workflows/understack_workflows/pxe_port_heuristic.py diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index cbbfa592d..8cfd5295c 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ ] [project.scripts] +bios-settings = "understack_workflows.main.bios_settings:main" bmc-kube-password = "understack_workflows.main.bmc_display_password:main" bmc-password = "understack_workflows.main.print_bmc_password:main" enroll-server = "understack_workflows.main.enroll_server:main" diff --git a/python/understack-workflows/tests/test_pxe_port_heuristic.py b/python/understack-workflows/tests/test_pxe_port_heuristic.py new file mode 100644 index 000000000..fa2f06084 --- /dev/null +++ b/python/understack-workflows/tests/test_pxe_port_heuristic.py @@ -0,0 +1,75 @@ +from understack_workflows.bmc_chassis_info import ChassisInfo +from understack_workflows.bmc_chassis_info import InterfaceInfo +from understack_workflows.pxe_port_heuristic import guess_pxe_interface + + +def test_integrated_is_best(): + x = "test" + device_info = ChassisInfo( + manufacturer=x, + model_number=x, + serial_number=x, + bmc_ip_address=x, + bios_version=x, + power_on=False, + memory_gib=0, + cpu=x, + interfaces=[ + InterfaceInfo("iDRAC", x, x), + InterfaceInfo("NIC.Embedded.1-1", x, x), + InterfaceInfo("NIC.Embedded.1-2", x, x), + InterfaceInfo("NIC.Integrated.1-1", x, x), + InterfaceInfo("NIC.Integrated.1-2", x, x), + InterfaceInfo("NIC.Slot.1-1", x, x), + InterfaceInfo("NIC.Slot.1-2", x, x), + ], + ) + assert guess_pxe_interface(device_info) == "NIC.Integrated.1-1" + + +def test_slot_is_second_best(): + x = "test" + device_info = ChassisInfo( + manufacturer=x, + model_number=x, + serial_number=x, + bmc_ip_address=x, + bios_version=x, + power_on=False, + memory_gib=0, + cpu=x, + interfaces=[ + InterfaceInfo("iDRAC", x, x), + InterfaceInfo("NIC.Embedded.1-1", x, x), + InterfaceInfo("NIC.Embedded.1-2", x, x), + InterfaceInfo("NIC.Slot.1-2", x, x), + InterfaceInfo("NIC.Slot.1-1", x, x), + InterfaceInfo("NIC.Slot.2-2", x, x), + InterfaceInfo("NIC.Slot.2-1", x, x), + ], + ) + assert guess_pxe_interface(device_info) == "NIC.Slot.1-1" + + +def test_connected_is_better(): + x = "test" + device_info = ChassisInfo( + manufacturer=x, + model_number=x, + serial_number=x, + bmc_ip_address=x, + bios_version=x, + power_on=False, + memory_gib=0, + cpu=x, + interfaces=[ + InterfaceInfo("iDRAC", x, x, remote_switch_port_name=x), + InterfaceInfo("NIC.Embedded.1-1", x, x, remote_switch_port_name=x), + InterfaceInfo("NIC.Embedded.1-1", x, x, remote_switch_port_name=x), + InterfaceInfo("NIC.Integrated.1-1", x, x, remote_switch_port_name=None), + InterfaceInfo("NIC.Integrated.1-2", x, x, remote_switch_port_name=None), + InterfaceInfo("NIC.Slot.1-1", x, x, remote_switch_port_name=None), + InterfaceInfo("NIC.Slot.1-2", x, x, remote_switch_port_name=x), + ], + ) + assert guess_pxe_interface(device_info) == "NIC.Slot.1-2" diff --git a/python/understack-workflows/understack_workflows/main/bios_settings.py b/python/understack-workflows/understack_workflows/main/bios_settings.py new file mode 100644 index 000000000..65c571d6c --- /dev/null +++ b/python/understack-workflows/understack_workflows/main/bios_settings.py @@ -0,0 +1,64 @@ +import argparse +import logging +import os + +from understack_workflows.bmc import bmc_for_ip_address +from understack_workflows.bmc_bios import update_dell_bios_settings +from understack_workflows.bmc_chassis_info import chassis_info +from understack_workflows.helpers import setup_logger +from understack_workflows.pxe_port_heuristic import guess_pxe_interface + +logger = setup_logger(__name__) + +# These are extremely verbose by default: +for name in ["ironicclient", "keystoneauth", "stevedore"]: + logging.getLogger(name).setLevel(logging.INFO) + + +def main(): + """Update BIOS settings to undercloud standard for the given server. + + - Using BMC, configure our standard BIOS settings + - set PXE boot device + - set timezone to UTC + - set the hostname + + NOTE: take care with order of execution of these workflow steps: + + When asked to change BIOS settings, iDRAC enqueues a "job" that will execute + on next boot of the server. + + The assumption for this workflow is that this server will shortly be PXE + booting into the IPA image for cleaning or inspection. + + Therefore this workflow does not itself boot the server. + + Any subsequent iDRAC operations such as a code update or ironic validation + can clear our pending BIOS update job from the iDRAC job queue. Before we + perform any such operation, we should first do something that will cause a + reboot of the server. + """ + args = argument_parser().parse_args() + + bmc_ip_address = args.bmc_ip_address + logger.info("%s starting for bmc_ip_address=%s", __file__, bmc_ip_address) + + bmc = bmc_for_ip_address(bmc_ip_address) + device_info = chassis_info(bmc) + pxe_interface = guess_pxe_interface(device_info) + logger.info("Selected %s as PXE interface", pxe_interface) + + update_dell_bios_settings(bmc, pxe_interface=pxe_interface) + + +def argument_parser(): + """Parse runtime arguments.""" + parser = argparse.ArgumentParser( + prog=os.path.basename(__file__), description="Update BIOS Settings" + ) + parser.add_argument("--bmc-ip-address", type=str, required=True, help="BMC IP") + return parser + + +if __name__ == "__main__": + main() diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index 2b5eb6586..9f9573603 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -8,9 +8,6 @@ from understack_workflows import ironic_node from understack_workflows.bmc import Bmc from understack_workflows.bmc import bmc_for_ip_address -from understack_workflows.bmc_bios import update_dell_bios_settings -from understack_workflows.bmc_chassis_info import ChassisInfo -from understack_workflows.bmc_chassis_info import InterfaceInfo from understack_workflows.bmc_chassis_info import chassis_info from understack_workflows.bmc_credentials import set_bmc_password from understack_workflows.bmc_hostname import bmc_set_hostname @@ -86,13 +83,6 @@ def enroll_server(bmc: Bmc, old_password: str | None) -> str: bmc_set_hostname(bmc, device_info.bmc_hostname, device_name) - pxe_interface = guess_pxe_interface(device_info) - logger.info("Selected %s as PXE interface", pxe_interface) - - # Note the above may require a restart of the DRAC, which also may delete - # any pending BIOS jobs, so do BIOS settings after the DRAC settings. - update_dell_bios_settings(bmc, pxe_interface=pxe_interface) - node = ironic_node.create_or_update( bmc=bmc, name=device_name, @@ -103,46 +93,6 @@ def enroll_server(bmc: Bmc, old_password: str | None) -> str: return node.uuid -def guess_pxe_interface(device_info: ChassisInfo) -> str: - """Determine most probable PXE interface for BMC.""" - interface = max(device_info.interfaces, key=_pxe_preference) - return interface.name - - -def _pxe_preference(interface: InterfaceInfo) -> int: - """Restrict BMC interfaces from PXE selection.""" - _name = interface.name.upper() - if "DRAC" in _name or "ILO" in _name or "NIC.EMBEDDED" in _name: - return 0 - enabled_result = 100 if (interface.remote_switch_port_name is not None) else 0 - - NIC_PREFERENCE = { - "NIC.Integrated.1-1-1": 100, - "NIC.Integrated.1-1": 99, - "NIC.Slot.1-1-1": 98, - "NIC.Slot.1-1": 97, - "NIC.Integrated.1-2-1": 96, - "NIC.Integrated.1-2": 95, - "NIC.Slot.1-2-1": 94, - "NIC.Slot.1-2": 93, - "NIC.Slot.1-3-1": 92, - "NIC.Slot.1-3": 91, - "NIC.Slot.2-1-1": 90, - "NIC.Slot.2-1": 89, - "NIC.Integrated.2-1-1": 88, - "NIC.Integrated.2-1": 87, - "NIC.Slot.2-2-1": 86, - "NIC.Slot.2-2": 85, - "NIC.Integrated.2-2-1": 84, - "NIC.Integrated.2-2": 83, - "NIC.Slot.3-1-1": 82, - "NIC.Slot.3-1": 81, - "NIC.Slot.3-2-1": 80, - "NIC.Slot.3-2": 79, - } - return NIC_PREFERENCE.get(interface.name, 50) + enabled_result - - def argument_parser(): """Parse runtime arguments.""" parser = argparse.ArgumentParser( diff --git a/python/understack-workflows/understack_workflows/pxe_port_heuristic.py b/python/understack-workflows/understack_workflows/pxe_port_heuristic.py new file mode 100644 index 000000000..5d0047d69 --- /dev/null +++ b/python/understack-workflows/understack_workflows/pxe_port_heuristic.py @@ -0,0 +1,55 @@ +from understack_workflows.bmc_chassis_info import ChassisInfo +from understack_workflows.bmc_chassis_info import InterfaceInfo + +# We try not choose interface whose name contains any of these: +DISQUALIFIED = ["DRAC", "ILO", "NIC.EMBEDDED"] + +# A higher number is more likely to be PXE interface: +NIC_PREFERENCE = { + "NIC.Integrated.1-1-1": 100, + "NIC.Integrated.1-1": 99, + "NIC.Slot.1-1-1": 98, + "NIC.Slot.1-1": 97, + "NIC.Integrated.1-2-1": 96, + "NIC.Integrated.1-2": 95, + "NIC.Slot.1-2-1": 94, + "NIC.Slot.1-2": 93, + "NIC.Slot.1-3-1": 92, + "NIC.Slot.1-3": 91, + "NIC.Slot.2-1-1": 90, + "NIC.Slot.2-1": 89, + "NIC.Integrated.2-1-1": 88, + "NIC.Integrated.2-1": 87, + "NIC.Slot.2-2-1": 86, + "NIC.Slot.2-2": 85, + "NIC.Integrated.2-2-1": 84, + "NIC.Integrated.2-2": 83, + "NIC.Slot.3-1-1": 82, + "NIC.Slot.3-1": 81, + "NIC.Slot.3-2-1": 80, + "NIC.Slot.3-2": 79, +} + + +def guess_pxe_interface(device_info: ChassisInfo) -> str: + """Determine most probable PXE interface for BMC.""" + interface = max(device_info.interfaces, key=_pxe_preference) + return interface.name + + +def _pxe_preference(interface: InterfaceInfo) -> list: + """Relative likelihood that interface is used for PXE. + + Prefer names that are not disqualified. + + After that, prefer interfaces that have an LLDP neighbor. + + Finally, score the interface name according to the list above. + """ + is_eligible = not any(x in interface.name.upper() for x in DISQUALIFIED) + + link_detected = interface.remote_switch_port_name is not None + + name_preference = NIC_PREFERENCE.get(interface.name, 0) + + return [is_eligible, link_detected, name_preference] From 84443082140208b35350e483cd15468bb8824e15 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Wed, 28 Jan 2026 13:17:28 +0000 Subject: [PATCH 3/6] Improve logging of the BIOS setting changes This log message was somewhat disingenuous. --- .../understack-workflows/understack_workflows/bmc_bios.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/understack-workflows/understack_workflows/bmc_bios.py b/python/understack-workflows/understack_workflows/bmc_bios.py index d1895d525..a8d447209 100644 --- a/python/understack-workflows/understack_workflows/bmc_bios.py +++ b/python/understack-workflows/understack_workflows/bmc_bios.py @@ -35,12 +35,16 @@ def update_dell_bios_settings(bmc: Bmc, pxe_interface: str) -> dict: if (k in current_settings and current_settings[k] != v) } + missing_keys = {k for k in required_settings.keys() if k not in current_settings} + if missing_keys: + logger.info("%s Has no BIOS setting for %s, ignoring.", bmc, missing_keys) + if required_changes: logger.info("%s Updating BIOS settings: %s", bmc, required_changes) patch_bios_settings(bmc, required_changes) logger.info("%s BIOS settings will be updated on next server boot.", bmc) else: - logger.info("%s all required BIOS settings present and correct.", bmc) + logger.info("%s No BIOS settings need to be changed.", bmc) return required_changes From 97443e78f441fe7a9ab2a37d2ceacf229bac115a Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Wed, 28 Jan 2026 15:28:52 +0000 Subject: [PATCH 4/6] Add the separate bios-settings step to the enrol workflow We wanted to do this later in the process, at least after the server is out of "enrol" state so that validation can complete and clean up the BMC for us. However we should do this prior to inspection because: 1) inspection will reboot the box and thereby apply our changes 2) inspection needs to PXE, therefore it won't work until after bios-settings has happened. --- .../workflowtemplates/enroll-server.yaml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/workflows/argo-events/workflowtemplates/enroll-server.yaml b/workflows/argo-events/workflowtemplates/enroll-server.yaml index a530b7a0c..ac8033fac 100644 --- a/workflows/argo-events/workflowtemplates/enroll-server.yaml +++ b/workflows/argo-events/workflowtemplates/enroll-server.yaml @@ -55,6 +55,8 @@ spec: parameters: - name: device_id value: "{{steps.enroll-server.outputs.result}}" + - - name: bios-settings + template: bios-settings - - name: inspect-server templateRef: name: inspect-server @@ -114,6 +116,32 @@ spec: items: - key: clouds.yaml path: clouds.yaml + - name: bios-settings + container: + image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest + command: + - bios-settings + args: + - --bmc-ip-address + - "{{workflow.parameters.ip_address}}" + volumeMounts: + - mountPath: /etc/openstack + name: baremetal-manage + readOnly: true + - mountPath: /etc/bmc_master/ + name: bmc-master + readOnly: true + env: + - name: WF_NS + value: "{{workflow.namespace}}" + - name: WF_NAME + value: "{{workflow.name}}" + - name: WF_UID + value: "{{workflow.uid}}" + volumes: + - name: bmc-master + secret: + secretName: bmc-master - name: openstack-wait-cmd inputs: parameters: From cdefde5bbf9202ea94cec597303e95f7e599ef55 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Fri, 6 Feb 2026 17:25:51 +0000 Subject: [PATCH 5/6] DROP! set the container temporarily for testing --- workflows/argo-events/workflowtemplates/enroll-server.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workflows/argo-events/workflowtemplates/enroll-server.yaml b/workflows/argo-events/workflowtemplates/enroll-server.yaml index ac8033fac..50b981cad 100644 --- a/workflows/argo-events/workflowtemplates/enroll-server.yaml +++ b/workflows/argo-events/workflowtemplates/enroll-server.yaml @@ -86,7 +86,7 @@ spec: when: "{{steps.server-manage-state.outputs.result}} == manageable" - name: enroll-server container: - image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest + image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:pr-1633 command: - enroll-server args: @@ -118,7 +118,7 @@ spec: path: clouds.yaml - name: bios-settings container: - image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest + image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:pr-1633 command: - bios-settings args: From 9add0cf79a630671be23b97226cea4e164bc4561 Mon Sep 17 00:00:00 2001 From: Steve Keay Date: Mon, 16 Feb 2026 19:59:47 +0000 Subject: [PATCH 6/6] Have enroll workflow operate on servers in non-enrol states --- .../workflowtemplates/enroll-server.yaml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/workflows/argo-events/workflowtemplates/enroll-server.yaml b/workflows/argo-events/workflowtemplates/enroll-server.yaml index 50b981cad..c5e49415b 100644 --- a/workflows/argo-events/workflowtemplates/enroll-server.yaml +++ b/workflows/argo-events/workflowtemplates/enroll-server.yaml @@ -22,8 +22,8 @@ spec: steps: - - name: enroll-server template: enroll-server - - - name: server-enroll-state - template: openstack-state-cmd + - - name: baremetal-node-provision-state + template: get-baremetal-node-provision-state-cmd arguments: parameters: - name: device_id @@ -36,7 +36,11 @@ spec: value: "manage" - name: device_id value: "{{steps.enroll-server.outputs.result}}" - when: "{{steps.server-enroll-state.outputs.result}} == enroll" + when: >- + '{{steps.baremetal-node-provision-state.outputs.result}} == enroll' || + '{{steps.baremetal-node-provision-state.outputs.result}} == available' || + '{{steps.baremetal-node-provision-state.outputs.result}} == "clean failed"' || + '{{steps.baremetal-node-provision-state.outputs.result}} == "inspect failed"' - - name: get-raid-config template: get-raid-config when: "{{workflow.parameters.raid_configure}} == true" @@ -50,7 +54,7 @@ spec: value: "{{steps.get-raid-config.outputs.result}}" when: "{{workflow.parameters.raid_configure}} == true" - - name: server-manage-state - template: openstack-state-cmd + template: get-baremetal-node-provision-state-cmd arguments: parameters: - name: device_id @@ -249,7 +253,7 @@ spec: items: - key: clouds.yaml path: clouds.yaml - - name: openstack-state-cmd + - name: get-baremetal-node-provision-state-cmd inputs: parameters: - name: device_id