From 2db2f04288226d4b0be163fb2eadb7e0d8aff4f4 Mon Sep 17 00:00:00 2001 From: haseeb Date: Thu, 22 Oct 2026 00:30:27 +0530 Subject: [PATCH 1/3] feat(argo-workflows): Allow enrollment to prepare nodes for monitoring after inspection --- python/understack-workflows/README.md | 19 ++ python/understack-workflows/pyproject.toml | 2 +- .../tests/test_enroll_server.py | 173 ++++++++++++++++++ .../understack_workflows/ironic_node.py | 29 +++ .../main/enroll_server.py | 34 ++++ python/understack-workflows/uv.lock | 28 +-- .../workflowtemplates/enroll-server.yaml | 4 + 7 files changed, 269 insertions(+), 20 deletions(-) diff --git a/python/understack-workflows/README.md b/python/understack-workflows/README.md index b6deac02f..29c1efa05 100644 --- a/python/understack-workflows/README.md +++ b/python/understack-workflows/README.md @@ -8,3 +8,22 @@ use inside of Argo Workflows. This script takes an OpenStack Project ID and ensures the proper operation happens against the Nautobot Tenants. Operations include create, update and delete. + + +## enroll-server post-inspection runbooks + +Optionally pass an ordered JSON array of runbook names or UUIDs: + +```sh +enroll-server --ip-address 192.0.2.10 \ + --post-inspection-runbooks '["first-runbook", "second-runbook"]' +``` + +For Argo, use the `enroll-server` WorkflowTemplate parameter +`post_inspection_runbooks` with the same JSON array. It defaults to `[]`. + +Runbooks execute sequentially after agent inspection and final BIOS +configuration, before RAID configuration, firmware updates, and `provide`. +Each requires at least one matching node/runbook trait (Ironic API 1.112 or +newer), uses the runbook's ramdisk setting, and waits for `manageable`. +A failed lookup, trait check, or cleaning operation stops enrollment. diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index 4ec60a951..3e122c907 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ dependencies = [ "pydantic>=2,<3", "pynautobot>=3,<4", - "python-ironicclient>=6,<7", + "python-ironicclient>=6.3.0,<7", "sushy>=5.3.0,<6", "kubernetes==33.1.0", "netapp-ontap==9.17.1.0", diff --git a/python/understack-workflows/tests/test_enroll_server.py b/python/understack-workflows/tests/test_enroll_server.py index be2213830..755fd46ac 100644 --- a/python/understack-workflows/tests/test_enroll_server.py +++ b/python/understack-workflows/tests/test_enroll_server.py @@ -1,9 +1,11 @@ +import argparse import secrets from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock from unittest.mock import call +import pytest from ironicclient.common.apiclient import exceptions as ironic_exceptions from ironicclient.v1.node import Node @@ -596,3 +598,174 @@ def test_get_lldp_connected_interfaces_absent_from_lldp(): result = get_lldp_connected_interfaces(interfaces, parsed_lldp={}) assert result == [] + + +@pytest.mark.parametrize("runbooks", [None, [], ["first", "second"]]) +def test_post_inspection_runbook_order(mocker, runbooks): + node = SimpleNamespace(uuid="node-123") + bmc = make_bmc(mocker) + mocker.patch.object(enroll_server, "bmc_for_ip_address", return_value=bmc) + mocker.patch.object( + enroll_server, "initialize_bmc", return_value=make_device_info() + ) + mocker.patch.object( + enroll_server.ironic_node, "create_or_update", return_value=node + ) + mocker.patch.object(enroll_server.ironic_node, "inspect_out_of_band") + mocker.patch.object( + enroll_server.ironic_node, "get_node_inventory", return_value={} + ) + mocker.patch.object( + enroll_server.ironic_node, "pxe_enabled_bios_name", return_value="NIC.1" + ) + events = MagicMock() + for name in ("agent_inspection", "update_dell_bios_settings", "configure_raid"): + events.attach_mock(mocker.patch.object(enroll_server, name), name) + for name in ("run_post_inspection_runbook", "apply_firmware_updates", "transition"): + events.attach_mock(mocker.patch.object(enroll_server.ironic_node, name), name) + + enroll_server.enroll( + ip_address=bmc.ip_address, + firmware_update=True, + raid_configure=True, + old_password=None, + post_inspection_runbooks=runbooks, + ) + + expected = [ + call.update_dell_bios_settings(bmc, pxe_interface="NIC.1"), + call.agent_inspection(node, virtual_media=False), + call.update_dell_bios_settings(bmc, pxe_interface="NIC.1"), + ] + expected.extend( + call.run_post_inspection_runbook(node, runbook) for runbook in runbooks or [] + ) + expected.extend( + [ + call.configure_raid(node, {}), + call.apply_firmware_updates(node), + call.transition(node, target_state="provide", expected_state="available"), + ] + ) + assert events.mock_calls == expected + + +def test_post_inspection_runbook_uses_uuid_and_waits(mocker): + client = mocker.patch.object(enroll_server.ironic_node, "IronicClient").return_value + client.get_runbook.return_value = SimpleNamespace( + uuid="runbook-123", traits=["CUSTOM_DELL", "CUSTOM_OTHER"], disable_ramdisk=True + ) + client.get_node_traits.return_value = ["CUSTOM_DELL"] + node = cast(Node, SimpleNamespace(uuid="node-123")) + + enroll_server.ironic_node.run_post_inspection_runbook(node, "post-inspection") + + client.get_runbook.assert_called_once_with("post-inspection") + client.get_node_traits.assert_called_once_with(node.uuid) + client.set_node_provision_state.assert_called_once_with( + node.uuid, + "clean", + clean_steps=None, + runbook="runbook-123", + disable_ramdisk=None, + ) + client.wait_for_node_provision_state.assert_called_once_with( + node.uuid, + "manageable", + timeout=enroll_server.ironic_node.NODE_STATE_TIMEOUT_SECS, + ) + + +@pytest.mark.parametrize("traits", [[], ["CUSTOM_OTHER"], None]) +def test_post_inspection_runbook_rejects_ineligible_node(mocker, traits): + client = mocker.patch.object(enroll_server.ironic_node, "IronicClient").return_value + client.get_runbook.return_value = SimpleNamespace(uuid="runbook-123", traits=traits) + client.get_node_traits.return_value = ["CUSTOM_DELL"] + node = cast(Node, SimpleNamespace(uuid="node-123")) + + with pytest.raises(ValueError, match=r"traits metadata|matching node trait"): + enroll_server.ironic_node.run_post_inspection_runbook(node, "post-inspection") + + client.set_node_provision_state.assert_not_called() + + +def test_post_inspection_runbook_failure_propagates(mocker): + client = mocker.patch.object(enroll_server.ironic_node, "IronicClient").return_value + client.get_runbook.return_value = SimpleNamespace( + uuid="runbook-123", traits=["CUSTOM_DELL"] + ) + client.get_node_traits.return_value = ["CUSTOM_DELL"] + client.wait_for_node_provision_state.side_effect = RuntimeError("clean failed") + node = cast(Node, SimpleNamespace(uuid="node-123")) + + with pytest.raises(RuntimeError, match="clean failed"): + enroll_server.ironic_node.run_post_inspection_runbook(node, "post-inspection") + + +def test_post_inspection_runbook_cli(mocker): + mocker.patch( + "sys.argv", + [ + "enroll-server", + "--ip-address", + "10.0.0.10", + "--post-inspection-runbooks", + '["first", "second"]', + ], + ) + mocker.patch.object(enroll_server.helpers, "setup_logger") + enroll = mocker.patch.object(enroll_server, "enroll") + + enroll_server.main() + + assert enroll.call_args.kwargs["post_inspection_runbooks"] == ["first", "second"] + + +@pytest.mark.parametrize( + "value", ["invalid", '"first"', "{}", "null", "[null]", "[1]", '[""]', '[" "]'] +) +def test_post_inspection_runbooks_reject_invalid_cli(value): + with pytest.raises(argparse.ArgumentTypeError, match="JSON array"): + enroll_server.parse_runbooks(value) + + +def test_post_inspection_runbooks_stop_on_failure(mocker): + node = SimpleNamespace(uuid="node-123") + mocker.patch.object(enroll_server, "bmc_for_ip_address") + mocker.patch.object( + enroll_server, "initialize_bmc", return_value=make_device_info() + ) + mocker.patch.object( + enroll_server.ironic_node, "create_or_update", return_value=node + ) + mocker.patch.object(enroll_server.ironic_node, "inspect_out_of_band") + mocker.patch.object( + enroll_server.ironic_node, "get_node_inventory", return_value={} + ) + mocker.patch.object( + enroll_server.ironic_node, "pxe_enabled_bios_name", return_value="NIC.1" + ) + mocker.patch.object(enroll_server, "agent_inspection") + mocker.patch.object(enroll_server, "update_dell_bios_settings") + runbook = mocker.patch.object( + enroll_server.ironic_node, + "run_post_inspection_runbook", + side_effect=RuntimeError("clean failed"), + ) + raid = mocker.patch.object(enroll_server, "configure_raid") + firmware = mocker.patch.object(enroll_server.ironic_node, "apply_firmware_updates") + transition = mocker.patch.object(enroll_server.ironic_node, "transition") + + with pytest.raises(RuntimeError, match="clean failed"): + enroll_server.enroll( + ip_address="10.0.0.10", + firmware_update=True, + raid_configure=True, + old_password=None, + post_inspection_runbooks=["first", "second"], + ) + + runbook.assert_called_once_with(node, "first") + raid.assert_not_called() + firmware.assert_not_called() + transition.assert_not_called() diff --git a/python/understack-workflows/understack_workflows/ironic_node.py b/python/understack-workflows/understack_workflows/ironic_node.py index 92632e298..54baa97e3 100644 --- a/python/understack-workflows/understack_workflows/ironic_node.py +++ b/python/understack-workflows/understack_workflows/ironic_node.py @@ -339,6 +339,35 @@ def inspect(node: Node, inspect_interface: str = "agent"): transition(node, target_state="inspect", expected_state="manageable") +def run_post_inspection_runbook(node: Node, name_or_id: str) -> None: + """Run a selected runbook while the inspected node is manageable. + + Require at least one matching runbook trait before starting cleaning. + Let the runbook determine whether cleaning needs an agent ramdisk. + """ + client = IronicClient() + runbook = client.get_runbook(name_or_id) + traits = client.get_node_traits(node.uuid) + runbook_traits = getattr(runbook, "traits", None) + if runbook_traits is None: + raise ValueError( + f"[node:{node.uuid}] Runbook {name_or_id!r} has no traits metadata; " + "Ironic API 1.112 or newer is required" + ) + if not set(traits).intersection(runbook_traits): + raise ValueError( + f"[node:{node.uuid}] Runbook {name_or_id!r} requires at least one " + f"matching node trait from {sorted(runbook_traits)}" + ) + logger.info("[node:%s] Running post-inspection runbook %s", node.uuid, name_or_id) + transition( + node, + target_state="clean", + expected_state="manageable", + runbook=runbook.uuid, + ) + + def apply_firmware_updates(node: Node) -> None: client = IronicClient() traits = client.get_node_traits(node.uuid) diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index 63565b519..39c4dc50f 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -1,6 +1,7 @@ # pylint: disable=E1131,C0103 import argparse +import json import logging import os @@ -51,6 +52,8 @@ def main() -> None: mode for cleaning and provisioning (we prefer PXE-based booting over virtual media, for performance reasons). + - Optionally execute post-inspection runbooks in the supplied order. + - Optionally configure RAID. - Optionally apply firmware updates. @@ -66,6 +69,7 @@ def main() -> None: firmware_update=args.firmware_update, raid_configure=args.raid_configure, external_cmdb_id=args.external_cmdb_id, + post_inspection_runbooks=args.post_inspection_runbooks, ) @@ -75,6 +79,7 @@ def enroll( raid_configure: bool, old_password: str | None, external_cmdb_id: int | str | None = None, + post_inspection_runbooks: list[str] | None = None, ) -> None: logger.info("Starting enroll workflow for bmc_ip_address=%s", ip_address) @@ -126,6 +131,9 @@ def enroll( update_dell_bios_settings(bmc, pxe_interface=pxe_interface) + for runbook in post_inspection_runbooks or []: + ironic_node.run_post_inspection_runbook(node, runbook) + if raid_configure: configure_raid(node, inventory) # RAID reconfiguration changes the disk layout; refresh inventory. @@ -196,6 +204,23 @@ def parse_bool(value: str) -> bool: return value.lower() == "true" +def parse_runbooks(value: str) -> list[str]: + """Parse an ordered JSON array of runbook names or UUIDs.""" + try: + runbooks = json.loads(value) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "Expected a JSON array of runbook names or UUIDs" + ) from exc + if not isinstance(runbooks, list) or any( + not isinstance(runbook, str) or not runbook.strip() for runbook in runbooks + ): + raise argparse.ArgumentTypeError( + "Expected a JSON array of nonempty runbook names or UUIDs" + ) + return runbooks + + def argument_parser(): parser = argparse.ArgumentParser( prog=os.path.basename(__file__), @@ -207,6 +232,15 @@ def argument_parser(): required=False, help="Old (current) BMC password", ) + parser.add_argument( + "--post-inspection-runbooks", + type=parse_runbooks, + default=[], + help=( + "JSON array of runbook names or UUIDs to execute in order " + "after inspection and BIOS configuration" + ), + ) parser.add_argument( "--firmware-update", type=parse_bool, diff --git a/python/understack-workflows/uv.lock b/python/understack-workflows/uv.lock index 83dfdd47a..09a3cd2ea 100644 --- a/python/understack-workflows/uv.lock +++ b/python/understack-workflows/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = "==3.12.*" [[package]] @@ -481,7 +481,7 @@ wheels = [ [[package]] name = "openstacksdk" -version = "4.8.0" +version = "4.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -496,12 +496,10 @@ dependencies = [ { name = "platformdirs" }, { name = "psutil" }, { name = "pyyaml" }, - { name = "requestsexceptions" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/24/1167097740136e302c74043c1c6feecf8d757b052d7b457960e0dc60fa03/openstacksdk-4.8.0.tar.gz", hash = "sha256:4dc038e1c17d893005f3a0a8951456afd9d148f3f65d448f94adcceb278d7f31", size = 1309981, upload-time = "2025-11-13T13:59:59.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/93/b3fc4d0f0d2fdc1371eaf15014b73caacffdf2d53536e275dafec5ac965d/openstacksdk-4.19.1.tar.gz", hash = "sha256:706f732a04c6c1cb92a9f41cdd2de5d3ae0e1dc5cf110b59ac3855a989e88dbf", size = 1437828, upload-time = "2026-08-27T10:21:49.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/45/87fa873f35abdf191d66d4821fe965f781e2d3e37e58883c6e23e95fa794/openstacksdk-4.8.0-py3-none-any.whl", hash = "sha256:7f7c438d418a4ee0c8737b1ac0859b3c1e8e21401677782415d21bce3324b9dd", size = 1849694, upload-time = "2025-11-13T13:59:57.634Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6d/444b267b6f180b1ca0eb75d02dc9d498982c1e8313b8b044cf4fafe7b502/openstacksdk-4.19.1-py3-none-any.whl", hash = "sha256:61b9568e7b72db9c59a030da25bd75452bc077964732e46d9a6cfc9d359ecb3d", size = 2037680, upload-time = "2026-08-27T10:21:47.81Z" }, ] [[package]] @@ -838,7 +836,7 @@ wheels = [ [[package]] name = "python-ironicclient" -version = "6.0.0" +version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cliff" }, @@ -854,9 +852,9 @@ dependencies = [ { name = "requests" }, { name = "stevedore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/42/5c41c9b0705e3f3ae500dc14d8466d9d68a979c47ccc733882221ee9be16/python_ironicclient-6.0.0.tar.gz", hash = "sha256:6b96e715ec8766ec54fc54e02d23fc55aabd54b0ea10ae987559ad832952458f", size = 230466, upload-time = "2026-02-25T10:57:20.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/2b/b23d717dda75942f98490588b878825f43260c95193039d58be12965eafa/python_ironicclient-6.3.0.tar.gz", hash = "sha256:ea3f9d4d0f6aeda0db78cc5d73a1a48b230fe6200abf5e5c5f05c437a596e4d7", size = 236336, upload-time = "2026-08-26T12:19:11.872Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/92/cdc5558f3f910866844d0a89014cdab075c3ef7ca0cd1f6416fd591c2ffd/python_ironicclient-6.0.0-py3-none-any.whl", hash = "sha256:4ae61cab95453db196123574601a8f84b520c6459d39a1bfbbaf502841d2a6d0", size = 248148, upload-time = "2026-02-25T10:57:19.227Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/02f663532b7065eed0830b499ad0940b443f3617c1b4f9d28b4fb7ff57ac/python_ironicclient-6.3.0-py3-none-any.whl", hash = "sha256:3d21bdebfc8a579dba643fc46691e6b01283ea5940b909ca1f32bee358d24576", size = 256948, upload-time = "2026-08-26T12:19:10.423Z" }, ] [[package]] @@ -943,15 +941,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] -[[package]] -name = "requestsexceptions" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/61b9652d3256503c99b0b8f145d9c8aa24c514caff6efc229989505937c1/requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065", size = 6880, upload-time = "2018-02-01T17:04:45.294Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/8c/49ca60ea8c907260da4662582c434bec98716177674e88df3fd340acf06d/requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3", size = 3802, upload-time = "2018-02-01T17:04:39.07Z" }, -] - [[package]] name = "rich" version = "14.2.0" @@ -1097,6 +1086,7 @@ wheels = [ name = "understack-workflows" version = "0.0.0" source = { editable = "." } +default-groups = ["test"] dependencies = [ { name = "kubernetes" }, { name = "netapp-ontap" }, @@ -1126,7 +1116,7 @@ requires-dist = [ { name = "netapp-ontap", specifier = "==9.17.1.0" }, { name = "pydantic", specifier = ">=2,<3" }, { name = "pynautobot", specifier = ">=3,<4" }, - { name = "python-ironicclient", specifier = ">=6,<7" }, + { name = "python-ironicclient", specifier = ">=6.3.0,<7" }, { name = "sushy", specifier = ">=5.3.0,<6" }, { name = "tenacity", specifier = ">=8.0.0" }, ] diff --git a/workflows/argo-events/workflowtemplates/enroll-server.yaml b/workflows/argo-events/workflowtemplates/enroll-server.yaml index 0001ee46a..3737de883 100644 --- a/workflows/argo-events/workflowtemplates/enroll-server.yaml +++ b/workflows/argo-events/workflowtemplates/enroll-server.yaml @@ -23,6 +23,8 @@ spec: arguments: parameters: - name: ip_address + - name: post_inspection_runbooks + value: "[]" - name: firmware_update value: "false" - name: raid_configure @@ -44,6 +46,8 @@ spec: args: - --ip-address - "{{workflow.parameters.ip_address}}" + - --post-inspection-runbooks + - "{{workflow.parameters.post_inspection_runbooks}}" - --firmware-update - "{{workflow.parameters.firmware_update}}" - --raid-configure From fa7de61504e9ad537457711aaab23e68a33caa0c Mon Sep 17 00:00:00 2001 From: haseeb Date: Sat, 24 Oct 2026 06:07:53 +0530 Subject: [PATCH 2/3] node_uuid as a named output out of the enroll-server Argo Workflow, so downstream steps can consume it directly instead of parsing logs or resolving BMC addresses. --- .../tests/test_enroll_server.py | 26 ++++++++++++++++++- .../main/enroll_server.py | 13 ++++++++-- .../workflowtemplates/enroll-server.yaml | 16 ++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/python/understack-workflows/tests/test_enroll_server.py b/python/understack-workflows/tests/test_enroll_server.py index 755fd46ac..f2cec7911 100644 --- a/python/understack-workflows/tests/test_enroll_server.py +++ b/python/understack-workflows/tests/test_enroll_server.py @@ -624,7 +624,7 @@ def test_post_inspection_runbook_order(mocker, runbooks): for name in ("run_post_inspection_runbook", "apply_firmware_updates", "transition"): events.attach_mock(mocker.patch.object(enroll_server.ironic_node, name), name) - enroll_server.enroll( + result = enroll_server.enroll( ip_address=bmc.ip_address, firmware_update=True, raid_configure=True, @@ -648,6 +648,7 @@ def test_post_inspection_runbook_order(mocker, runbooks): ] ) assert events.mock_calls == expected + assert result == node.uuid def test_post_inspection_runbook_uses_uuid_and_waits(mocker): @@ -769,3 +770,26 @@ def test_post_inspection_runbooks_stop_on_failure(mocker): raid.assert_not_called() firmware.assert_not_called() transition.assert_not_called() + + +def test_main_emits_node_uuid_only_after_success(mocker, tmp_path): + output = tmp_path / "node-uuid" + mocker.patch( + "sys.argv", + [ + "enroll-server", + "--ip-address", + "192.0.2.1", + "--node-uuid-output", + str(output), + ], + ) + mocker.patch.object(enroll_server.helpers, "setup_logger") + enroll = mocker.patch.object(enroll_server, "enroll", return_value="node-123") + enroll_server.main() + assert output.read_text() == "node-123\n" + output.unlink() + enroll.side_effect = RuntimeError("enrollment failed") + with pytest.raises(RuntimeError, match="enrollment failed"): + enroll_server.main() + assert not output.exists() diff --git a/python/understack-workflows/understack_workflows/main/enroll_server.py b/python/understack-workflows/understack_workflows/main/enroll_server.py index 39c4dc50f..b58ad4209 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -4,6 +4,7 @@ import json import logging import os +from pathlib import Path from ironicclient.v1.node import Node @@ -63,7 +64,7 @@ def main() -> None: helpers.setup_logger() args = argument_parser().parse_args() - enroll( + node_uuid = enroll( ip_address=args.ip_address, old_password=args.old_password, firmware_update=args.firmware_update, @@ -72,6 +73,9 @@ def main() -> None: post_inspection_runbooks=args.post_inspection_runbooks, ) + if args.node_uuid_output: + Path(args.node_uuid_output).write_text(node_uuid + "\n") + def enroll( ip_address: str, @@ -80,7 +84,7 @@ def enroll( old_password: str | None, external_cmdb_id: int | str | None = None, post_inspection_runbooks: list[str] | None = None, -) -> None: +) -> str: logger.info("Starting enroll workflow for bmc_ip_address=%s", ip_address) if external_cmdb_id: @@ -146,6 +150,7 @@ def enroll( ironic_node.transition(node, target_state="provide", expected_state="available") logger.info("Completed enroll workflow for bmc_ip_address=%s", ip_address) + return str(node.uuid) def agent_inspection(node: Node, virtual_media=False) -> None: @@ -226,6 +231,10 @@ def argument_parser(): prog=os.path.basename(__file__), description="Run the server enroll workflow", ) + parser.add_argument( + "--node-uuid-output", + help="Write node UUID to this file after successful enrollment", + ) parser.add_argument("--ip-address", required=True, help="IP Address of BMC") parser.add_argument( "--old-password", diff --git a/workflows/argo-events/workflowtemplates/enroll-server.yaml b/workflows/argo-events/workflowtemplates/enroll-server.yaml index 3737de883..5b34c6fb6 100644 --- a/workflows/argo-events/workflowtemplates/enroll-server.yaml +++ b/workflows/argo-events/workflowtemplates/enroll-server.yaml @@ -11,6 +11,8 @@ spec: serviceAccountName: workflow entrypoint: main volumes: + - name: enrollment-result + emptyDir: {} - name: bmc-master secret: secretName: bmc-master @@ -35,15 +37,27 @@ spec: value: "" templates: - name: main + outputs: + parameters: + - name: node_uuid + valueFrom: + parameter: "{{steps.enroll-server.outputs.parameters.node_uuid}}" steps: - - name: enroll-server template: enroll-server - name: enroll-server + outputs: + parameters: + - name: node_uuid + valueFrom: + path: /run/enrollment/node-uuid container: image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest command: - enroll-server args: + - --node-uuid-output + - /run/enrollment/node-uuid - --ip-address - "{{workflow.parameters.ip_address}}" - --post-inspection-runbooks @@ -57,6 +71,8 @@ spec: - --external-cmdb-id - "{{workflow.parameters.external_cmdb_id}}" volumeMounts: + - mountPath: /run/enrollment + name: enrollment-result - mountPath: /etc/openstack name: baremetal-manage readOnly: true From 3a61a58d4415d3abea516db7227ca4a044077b5f Mon Sep 17 00:00:00 2001 From: haseeb Date: Sat, 24 Oct 2026 06:10:12 +0530 Subject: [PATCH 3/3] adding hook so a deploy site can inject Kustomize patches into the public workflows/argo-events source. --- charts/argocd-understack/README.md | 13 +++++++++++++ .../application-argo-events-workflows.yaml | 5 +++++ charts/argocd-understack/values.yaml | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/charts/argocd-understack/README.md b/charts/argocd-understack/README.md index 9a176f77f..4501dd87c 100644 --- a/charts/argocd-understack/README.md +++ b/charts/argocd-understack/README.md @@ -266,3 +266,16 @@ spec: ## License Apache 2.0 + +### Site workflow patches + +`argo_events_workflows_patches` accepts Argo CD Kustomize `patches` entries for +resources in the public `workflows/argo-events` source. Private site resources +are rendered as a separate source and cannot patch that source directly. +Keep site-specific patch values in private deployment inventory. The default +empty list preserves the unmodified public workflows. + +The `enroll-server` template exposes `node_uuid` after successful enrollment. +Downstream workflow steps can consume this output without parsing logs or +resolving mutable BMC addresses. The output does not imply downstream work has +completed or gate the node's transition to `available`. diff --git a/charts/argocd-understack/templates/application-argo-events-workflows.yaml b/charts/argocd-understack/templates/application-argo-events-workflows.yaml index e2949f2f1..14402751c 100644 --- a/charts/argocd-understack/templates/application-argo-events-workflows.yaml +++ b/charts/argocd-understack/templates/application-argo-events-workflows.yaml @@ -18,6 +18,11 @@ spec: - path: workflows/argo-events repoURL: {{ include "understack.understack_url" $ }} targetRevision: {{ include "understack.understack_ref" $ }} + {{- with $.Values.argo_events_workflows_patches }} + kustomize: + patches: + {{- toYaml . | nindent 8 }} + {{- end }} - path: {{ include "understack.deploy_path" $ }}/argo-events-workflows repoURL: {{ include "understack.deploy_url" $ }} targetRevision: {{ include "understack.deploy_ref" $ }} diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index e839335f2..3c21cbc4d 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -609,3 +609,7 @@ site: # -- Enable/disable deploying undersync # @default -- false enabled: false + +# Optional Kustomize patches for the public workflow source. Kept separate from +# private site resources because Argo CD renders each source independently. +argo_events_workflows_patches: []