Skip to content
Merged
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
22 changes: 0 additions & 22 deletions .github/workflows/lint-workflow-templates.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,28 +40,6 @@ 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
Expand Down
66 changes: 66 additions & 0 deletions docs/deploy-guide/components/argo-events-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,69 @@ Required or commonly required items:
Optional additions:

- `Additional integration Secrets`: Add more Secrets beside the API token if individual workflows need their own credentials or endpoints.

## Enrollment Workflow Configuration

Each enrollment workflow can run site-specific templates after it enrolls an
Ironic node. Configure a workflow by creating its ConfigMap in the `argo-events`
namespace:

| Workflow | ConfigMap |
| --- | --- |
| `enroll-server` | `enroll-server-config` |
| `enroll-fw` | `enroll-fw-config` |
| `enroll-netdev` | `enroll-netdev-config` |

The `post_enroll_workflows` value is a JSON array of `WorkflowTemplate` names.
For example, the server configuration is:

```yaml title="enroll-server-config.yaml"
apiVersion: v1
kind: ConfigMap
metadata:
name: enroll-server-config
namespace: argo-events
labels:
workflows.argoproj.io/configmap-type: Parameter
data:
post_enroll_workflows: |
["notify-enrollment", "configure-monitoring"]
```

The `workflows.argoproj.io/configmap-type: Parameter` label is required so the
Argo Workflows controller discovers the ConfigMap as a parameter source.

Each named `WorkflowTemplate` must be in the same namespace and expose a `run`
template that accepts the enrolled server's `node_uuid`:

{% raw %}

```yaml title="workflowtemplate-notify-enrollment.yaml"
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: notify-enrollment
namespace: argo-events
spec:
templates:
- name: run
inputs:
parameters:
- name: node_uuid
container:
image: example.com/notify-enrollment:1.0.0
args:
- "{{inputs.parameters.node_uuid}}"
```

{% endraw %}

The hooks run in parallel as steps of the existing enrollment workflow; they do
not create child `Workflow` resources. A failed hook fails the post-enrollment
step. If the ConfigMap or its key is absent, the hook list defaults to empty and
enrollment completes without running hooks.

Only the referenced `run` template is imported by `templateRef`. Put required
pod configuration on that template, or arrange for it on the parent enrollment
workflow; settings elsewhere in the referenced `WorkflowTemplate` spec are not
inherited. Hook pods use the service account of the parent workflow.
45 changes: 42 additions & 3 deletions python/understack-workflows/tests/test_enroll_fw.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,13 @@ def _patch_paths(update_node_call) -> dict[str, Any]:

def test_enroll_fw_hands_metadata_to_the_engine(mocker):
_mock_client(mocker, node=None) # not found -> new node -> engine enrolls
engine = mocker.patch.object(enroll_fw.netdev_reconciler, "enroll")
engine = mocker.patch.object(
enroll_fw.netdev_reconciler, "enroll", return_value="node-123"
)

enroll_fw.enroll_fw(**BASE_ARGS, **FW_FIELDS)
node_uuid = enroll_fw.enroll_fw(**BASE_ARGS, **FW_FIELDS)

assert node_uuid == "node-123"
engine.assert_called_once_with(
name="PA1410-026701008071",
physical_network="f20-1-network",
Expand Down Expand Up @@ -219,8 +222,9 @@ def test_active_node_updates_metadata_when_ports_match(mocker):
client = _mock_client(mocker, node=_node(), node_ports=[_actual_port()])
engine = mocker.patch.object(enroll_fw.netdev_reconciler, "enroll")

enroll_fw.enroll_fw(**BASE_ARGS, **FW_FIELDS)
node_uuid = enroll_fw.enroll_fw(**BASE_ARGS, **FW_FIELDS)

assert node_uuid == "node-uuid"
engine.assert_not_called()
client.update_node.assert_called_once()
paths = _patch_paths(client.update_node.call_args)
Expand Down Expand Up @@ -365,6 +369,41 @@ def test_main_passes_default_or_explicit_driver(mocker, driver_args, expected):
assert engine.call_args.kwargs["driver"] == expected


def test_main_emits_node_uuid_only_after_success(mocker, tmp_path):
output = tmp_path / "node-uuid"
mocker.patch.object(enroll_fw.helpers, "setup_logger")
mocker.patch(
"sys.argv",
[
"enroll-fw",
"--name",
"fw1",
"--physical-network",
"net",
"--ports",
"[]",
"--resource-class",
"pa1410",
"--management-switch",
"switch1",
"--management-switch-port",
"eth1",
"--node-uuid-output",
str(output),
],
)
engine = mocker.patch.object(enroll_fw, "enroll_fw", return_value="node-123")

enroll_fw.main()

assert output.read_text() == "node-123\n"
output.unlink()
engine.side_effect = RuntimeError("enrollment failed")
with pytest.raises(RuntimeError, match="enrollment failed"):
enroll_fw.main()
assert not output.exists()


def test_argument_parser_requires_resource_class():
parser = enroll_fw.argument_parser()
with pytest.raises(SystemExit):
Expand Down
31 changes: 31 additions & 0 deletions python/understack-workflows/tests/test_enroll_netdev.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from understack_workflows.main import enroll_netdev


Expand Down Expand Up @@ -26,3 +28,32 @@ def test_argument_parser_defaults_resource_class_to_generic():
"intf": "e1",
}
]


def test_main_emits_node_uuid_only_after_success(mocker, tmp_path):
output = tmp_path / "node-uuid"
mocker.patch(
"sys.argv",
[
"enroll-netdev",
"--name",
"leaf01",
"--physical-network",
"net",
"--ports",
"[]",
"--node-uuid-output",
str(output),
],
)
mocker.patch.object(enroll_netdev.helpers, "setup_logger")
enroll = mocker.patch.object(enroll_netdev, "enroll", return_value="node-123")

enroll_netdev.main()

assert output.read_text() == "node-123\n"
output.unlink()
enroll.side_effect = RuntimeError("enrollment failed")
with pytest.raises(RuntimeError, match="enrollment failed"):
enroll_netdev.main()
assert not output.exists()
9 changes: 6 additions & 3 deletions python/understack-workflows/tests/test_netdev_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ def test_enroll_creates_node_ports_logs_and_makes_available(mocker, caplog):
return_value=fake_ironic,
)

netdev_reconciler.enroll(**ENROLL_ARGS, resource_class=None)
node_uuid = netdev_reconciler.enroll(**ENROLL_ARGS, resource_class=None)

assert node_uuid == node.uuid
fake_ironic.node.create.assert_called_once_with(
automated_clean=False,
driver="netdev",
Expand Down Expand Up @@ -260,8 +261,9 @@ def test_enroll_patches_physical_network_drift_on_existing_port(mocker):
return_value=fake_ironic,
)

netdev_reconciler.enroll(**ENROLL_ARGS)
node_uuid = netdev_reconciler.enroll(**ENROLL_ARGS)

assert node_uuid == "node-123"
fake_ironic.port.update.assert_called_once_with(
"uuid-port1",
[{"op": "add", "path": "/physical_network", "value": "f20-1-network"}],
Expand Down Expand Up @@ -472,8 +474,9 @@ def test_enroll_is_true_noop_when_available_and_everything_matches(mocker):
return_value=fake_ironic,
)

netdev_reconciler.enroll(**ENROLL_ARGS)
node_uuid = netdev_reconciler.enroll(**ENROLL_ARGS)

assert node_uuid == "node-123"
# Nothing drifted: no metadata patch, no port work, no state churn.
fake_ironic.node.update.assert_not_called()
fake_ironic.port.update.assert_not_called()
Expand Down
16 changes: 12 additions & 4 deletions python/understack-workflows/understack_workflows/main/enroll_fw.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import logging
import os
from pathlib import Path

from ironicclient.common.apiclient import exceptions as ironic_exceptions

Expand All @@ -24,7 +25,7 @@ def main() -> None:
helpers.setup_logger()
args = argument_parser().parse_args()

enroll_fw(
node_uuid = enroll_fw(
name=args.name,
physical_network=args.physical_network,
ports=netdev_reconciler.parse_ports_arg(args.ports),
Expand All @@ -40,6 +41,9 @@ def main() -> None:
model=args.model,
)

if args.node_uuid_output:
Path(args.node_uuid_output).write_text(node_uuid + "\n")


def _require_specific_resource_class(resource_class: str | None) -> str:
"""Return a normalized, purpose-made resource class.
Expand Down Expand Up @@ -90,7 +94,7 @@ def enroll_fw(
serial: str = "",
vendor: str = "",
model: str = "",
) -> None:
) -> str:
driver = (driver or "").strip()
if not driver:
raise ValueError("enroll-fw requires a non-empty --driver")
Expand Down Expand Up @@ -129,12 +133,12 @@ def enroll_fw(
extra=extra,
properties=properties,
)
return
return node.uuid

# New or re-enrollable node: the engine writes the metadata inside the
# enrollment lifecycle (at create, or patched before the node becomes
# available), so the node is never allocatable in an under-described state.
netdev_reconciler.enroll(
return netdev_reconciler.enroll(
name=name,
physical_network=physical_network,
ports=ports,
Expand Down Expand Up @@ -255,6 +259,10 @@ def argument_parser():
prog=os.path.basename(__file__),
description="Enroll a firewall (defaults to the panos hardware type)",
)
parser.add_argument(
"--node-uuid-output",
help="Write node UUID to this file after successful enrollment",
)
parser.add_argument("--name", required=True, help="Ironic node name")
parser.add_argument(
"--physical-network",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import os
from pathlib import Path

from understack_workflows import helpers
from understack_workflows.netdev_reconciler import DEFAULT_RESOURCE_CLASS
Expand All @@ -24,20 +25,27 @@ def main() -> None:
helpers.setup_logger()
args = argument_parser().parse_args()

enroll(
node_uuid = enroll(
name=args.name,
physical_network=args.physical_network,
ports=parse_ports_arg(args.ports),
external_cmdb_id=args.external_cmdb_id,
resource_class=args.resource_class,
)

if args.node_uuid_output:
Path(args.node_uuid_output).write_text(node_uuid + "\n")


def argument_parser():
parser = argparse.ArgumentParser(
prog=os.path.basename(__file__),
description="Run the netdev enroll workflow",
)
parser.add_argument(
"--node-uuid-output",
help="Write node UUID to this file after successful enrollment",
)
parser.add_argument("--name", required=True, help="Ironic node name")
parser.add_argument(
"--physical-network",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def enroll(
driver_info: dict | None = None,
extra: dict | None = None,
properties: dict | None = None,
) -> None:
) -> str:
effective_resource_class = resource_class or DEFAULT_RESOURCE_CLASS
effective_driver = driver or DEFAULT_DRIVER
netdev_ports = build_netdev_ports(ports)
Expand Down Expand Up @@ -257,7 +257,7 @@ def enroll(
"[node:%s] Node already available and up to date, nothing to do",
node.uuid,
)
return
return node.uuid

for plan in pending:
apply_port_plan(
Expand All @@ -274,6 +274,7 @@ def enroll(
name,
node.uuid,
)
return node.uuid


def warn_orphan_ports(
Expand Down
34 changes: 0 additions & 34 deletions workflows/argo-events/_lint-stubs/README.md

This file was deleted.

Loading
Loading