Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions charts/argocd-understack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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" $ }}
Expand Down
4 changes: 4 additions & 0 deletions charts/argocd-understack/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
19 changes: 19 additions & 0 deletions python/understack-workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion python/understack-workflows/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
197 changes: 197 additions & 0 deletions python/understack-workflows/tests/test_enroll_server.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -596,3 +598,198 @@ 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)

result = 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
assert result == node.uuid


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()


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()
29 changes: 29 additions & 0 deletions python/understack-workflows/understack_workflows/ironic_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading