diff --git a/.github/workflows/lint-workflow-templates.yaml b/.github/workflows/lint-workflow-templates.yaml index d4bfeebfe..aa6b48c90 100644 --- a/.github/workflows/lint-workflow-templates.yaml +++ b/.github/workflows/lint-workflow-templates.yaml @@ -40,6 +40,28 @@ jobs: kubectl create namespace argo-events kubectl -n argo-events apply -f workflows/argo-events/workflowtemplates/ + - name: Deploy lint stubs for externally-provided templateRefs + run: | + # Some templates reference WorkflowTemplates that live outside this + # repo (site-specific extensions installed from undercloud-deploy, + # opted into per site). `argo lint` resolves templateRef names against + # the cluster, so those references fail to resolve here unless a + # placeholder is present. Any stub placed in the lint-stubs directory + # is deployed so the reference resolves; the stub bodies are + # deliberately minimal and are never shipped. Add a new external + # reference by dropping a file in that directory -- no change here. + stub_dir="workflows/argo-events/_lint-stubs" + shopt -s nullglob + stubs=("$stub_dir"/*.yaml) + if [ ${#stubs[@]} -gt 0 ]; then + for stub in "${stubs[@]}"; do + echo "Deploying lint stub: $stub" + kubectl -n argo-events apply -f "$stub" + done + else + echo "No lint stubs to deploy." + fi + - name: Argo Lint Workflow Template run: | set +e diff --git a/charts/argocd-understack/templates/application-obm-monitoring.yaml b/charts/argocd-understack/templates/application-obm-monitoring.yaml new file mode 100644 index 000000000..fa64e2a27 --- /dev/null +++ b/charts/argocd-understack/templates/application-obm-monitoring.yaml @@ -0,0 +1,31 @@ +{{- if eq (include "understack.isEnabled" (list $.Values.site "obm_monitoring")) "true" }} +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ printf "%s-%s" $.Release.Name "obm-monitoring" }} + finalizers: + - resources-finalizer.argocd.argoproj.io + annotations: + argocd.argoproj.io/compare-options: ServerSideDiff=true,IncludeMutationWebhook=true +{{- include "understack.appLabelsBlock" $ | nindent 2 }} +spec: + destination: + # argo-events is owned by the argo_events Application, so no CreateNamespace. + namespace: argo-events + server: {{ $.Values.cluster_server }} + project: understack + sources: + - path: {{ include "understack.deploy_path" $ }}/obm-monitoring + repoURL: {{ include "understack.deploy_url" $ }} + targetRevision: {{ include "understack.deploy_ref" $ }} + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - ServerSideApply=true + - RespectIgnoreDifferences=true + - ApplyOutOfSyncOnly=true +{{- end }} diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index e839335f2..96a06b97b 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -583,6 +583,17 @@ site: # @default -- false enabled: false + # -- Out-of-band monitoring WorkflowTemplates. Deploys the site's + # `/obm-monitoring` Kustomize overlay from the deployment + # repository into the existing `argo-events` namespace. The overlay and its + # application code/images live in the deployment repository; this chart only + # renders the ArgoCD Application pointer. A site opts in by setting this to + # true in its deploy.yaml. + obm_monitoring: + # -- Enable/disable deploying OBM monitoring WorkflowTemplates + # @default -- false + enabled: false + # -- Nautobot Celery workers (site-level, connects to global Nautobot) nautobot_worker: # -- Enable/disable deploying Nautobot workers at the site level diff --git a/docs/deploy-guide/components/index.md b/docs/deploy-guide/components/index.md index 1d92e9a83..c39e69bf0 100644 --- a/docs/deploy-guide/components/index.md +++ b/docs/deploy-guide/components/index.md @@ -92,6 +92,7 @@ enablement defaults, validation, and troubleshooting notes. | [nautobot-api-tokens](./nautobot-api-tokens.md) | global | | [nautobot-job-queues](./nautobot-job-queues.md) | global | | [nautobotop](./nautobotop.md) | global | +| [obm-monitoring](./obm-monitoring.md) | site | | [openebs](./openebs.md) | global, site | | [openstack](./openstack.md) | site | | [openstack-exporter](./openstack-exporter.md) | site | diff --git a/docs/deploy-guide/components/obm-monitoring.md b/docs/deploy-guide/components/obm-monitoring.md new file mode 100644 index 000000000..7f3aa073c --- /dev/null +++ b/docs/deploy-guide/components/obm-monitoring.md @@ -0,0 +1,59 @@ +--- +source_text: ArgoCD renders only the sources declared directly in the Application + template. +deploy_overrides: + helm: + mode: none + kustomize: + mode: only_source +--- + +# obm-monitoring + +Out-of-band monitoring WorkflowTemplates, deployed from a deploy-repo Kustomize +overlay into the existing `argo-events` namespace. + +## Deployment Scope + +- Cluster scope: site +- Values key: `site.obm_monitoring` +- ArgoCD Application template: `charts/argocd-understack/templates/application-obm-monitoring.yaml` + +## How ArgoCD Builds It + +{{ component_argocd_builds() }} + +## How to Enable + +Enable this component under the scope that matches your deployment model: + +```yaml title="$CLUSTER_NAME/deploy.yaml" +site: + obm_monitoring: + enabled: true +``` + +## Deployment Repo Content + +{{ secrets_disclaimer }} + +The Application deploys the site overlay at +`$CLUSTER_NAME/obm-monitoring/` from the deployment repository into the +`argo-events` namespace. That namespace, its `workflow` service account, and the +shared executor permissions are owned by the `argo-events` component, so keep +`site.argo_events` and `site.argo_workflows` enabled alongside this one. + +Required or commonly required items: + +- `kustomization.yaml`: Set `namespace: argo-events` and include the shared + WorkflowTemplates base plus any site-specific resources. +- API ExternalSecret: Provide the monitoring platform API credentials the + WorkflowTemplates read at submission time. + +Optional additions: + +- `Additional overlays or patches`: Pin container images or add site-specific + parameters, RBAC, or Secrets as the automation grows. + +Installing the WorkflowTemplates does not submit a workflow; submission remains +an explicit, operator-driven step. diff --git a/properdocs.yml b/properdocs.yml index 7bff4e7a2..c04692b98 100644 --- a/properdocs.yml +++ b/properdocs.yml @@ -285,6 +285,7 @@ nav: - deploy-guide/components/nautobot.md - deploy-guide/components/nautobotop.md - deploy-guide/components/nautobot-worker.md + - deploy-guide/components/obm-monitoring.md - deploy-guide/components/neutron.md - deploy-guide/components/nova.md - deploy-guide/components/octavia.md 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..f2cec7911 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,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() 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..b58ad4209 100644 --- a/python/understack-workflows/understack_workflows/main/enroll_server.py +++ b/python/understack-workflows/understack_workflows/main/enroll_server.py @@ -1,8 +1,10 @@ # pylint: disable=E1131,C0103 import argparse +import json import logging import os +from pathlib import Path from ironicclient.v1.node import Node @@ -51,6 +53,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. @@ -60,14 +64,18 @@ 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, raid_configure=args.raid_configure, external_cmdb_id=args.external_cmdb_id, + 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, @@ -75,7 +83,8 @@ def enroll( raid_configure: bool, old_password: str | None, external_cmdb_id: int | str | None = None, -) -> None: + post_inspection_runbooks: list[str] | None = None, +) -> str: logger.info("Starting enroll workflow for bmc_ip_address=%s", ip_address) if external_cmdb_id: @@ -126,6 +135,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. @@ -138,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: @@ -196,17 +209,47 @@ 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__), 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", 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/_lint-stubs/README.md b/workflows/argo-events/_lint-stubs/README.md new file mode 100644 index 000000000..99f973b91 --- /dev/null +++ b/workflows/argo-events/_lint-stubs/README.md @@ -0,0 +1,34 @@ +# Lint stubs + +Placeholder `WorkflowTemplate` manifests used **only** by the +`Lint Workflow Templates` GitHub Actions workflow +(`.github/workflows/lint-workflow-templates.yaml`). + +## Why this exists + +Some templates in `../workflowtemplates/` reference WorkflowTemplates that are +**not** part of this repository. They are site-specific extensions installed +from `undercloud-deploy`, and only where a site opts in. For example, +`enroll-server.yaml` references `enroll-post-hook` via `templateRef`. + +`argo lint` resolves `templateRef` names against the cluster. In CI the lint +runs against a throwaway kind cluster that only has this repo's templates +deployed, so any external reference fails to resolve and the lint reports a +false failure. + +To keep the lint meaningful without weakening it, the CI job deploys every stub +in this directory into the lint namespace before running `argo lint`. That lets +the external references resolve against a minimal placeholder. + +## Rules + +- These files are **lint-only**. They are never deployed to a real environment. +- Keep each stub minimal: the correct `kind`, `metadata.name`, and the + referenced template name plus its declared inputs are all that matter. +- This directory is prefixed with `_` so it is not picked up by the lint loop, + which globs `../workflowtemplates/*.yaml`. + +## Adding a new external reference + +When a template here starts referencing an out-of-repo WorkflowTemplate, add a +matching stub file in this directory. No change to the CI workflow is needed. diff --git a/workflows/argo-events/_lint-stubs/enroll-post-hook.yaml b/workflows/argo-events/_lint-stubs/enroll-post-hook.yaml new file mode 100644 index 000000000..27f12dae8 --- /dev/null +++ b/workflows/argo-events/_lint-stubs/enroll-post-hook.yaml @@ -0,0 +1,23 @@ +# Lint-only placeholder. NOT deployed to any real environment. +# +# enroll-server.yaml references the `enroll-post-hook` WorkflowTemplate via +# templateRef. The real implementation is site-specific and installed from +# undercloud-deploy only where a site opts in (e.g. SL1); it is intentionally +# absent from this repo. `argo lint` resolves templateRef names against the +# cluster, so this minimal stub is deployed into the lint cluster purely so the +# reference resolves. Its body is irrelevant to linting -- only the kind, name, +# and the `run` template with a `node_uuid` input matter. +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: enroll-post-hook +spec: + entrypoint: run + templates: + - name: run + inputs: + parameters: + - name: node_uuid + container: + image: busybox + command: ["true"] diff --git a/workflows/argo-events/workflowtemplates/enroll-server.yaml b/workflows/argo-events/workflowtemplates/enroll-server.yaml index 0001ee46a..396acd325 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 @@ -23,6 +25,8 @@ spec: arguments: parameters: - name: ip_address + - name: post_inspection_runbooks + value: "[]" - name: firmware_update value: "false" - name: raid_configure @@ -31,19 +35,52 @@ spec: value: calvin - name: external_cmdb_id value: "" + # Optional post-enrollment hook. "false" (default) = no hook, behaviour + # unchanged. A site opts in by setting "true" AND installing an + # `enroll-post-hook` WorkflowTemplate (entrypoint `run`, input param + # `node_uuid`) in the workflow namespace. The ref name is intentionally + # static: Argo resolves templateRef.name at parse time, so it cannot be a + # parameter. Each site decides what enroll-post-hook does. + - name: post_enroll_hook + value: "false" templates: - name: main + outputs: + parameters: + - name: node_uuid + valueFrom: + parameter: "{{steps.enroll-server.outputs.parameters.node_uuid}}" steps: - - name: enroll-server template: enroll-server + # Runs only when the site enabled the hook. This step is a generic + # delegator and does not know about any specific integration. + - - name: post-enroll + when: "{{workflow.parameters.post_enroll_hook}} == true" + templateRef: + name: enroll-post-hook + template: run + arguments: + parameters: + - name: node_uuid + value: "{{steps.enroll-server.outputs.parameters.node_uuid}}" - 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 + - "{{workflow.parameters.post_inspection_runbooks}}" - --firmware-update - "{{workflow.parameters.firmware_update}}" - --raid-configure @@ -53,6 +90,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