Skip to content

Commit fe821bb

Browse files
feat(ironic): allow automation to configure SNMPv3 user independently of the node's provisioning lifecycle using vendor passthru
1 parent f201abb commit fe821bb

6 files changed

Lines changed: 179 additions & 1 deletion

File tree

‎containers/ironic/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ RUN apt-get update && \
1212
&& apt-get clean && rm -rf /var/lib/apt/lists/*
1313

1414
# renovate: name=openstack/ironic repo=https://github.com/rackerlabs/ironic.git branch=understack/2026.1
15-
ARG IRONIC_GIT_REF=7800737e179295a3bf73590238af3d58875a5040
15+
ARG IRONIC_GIT_REF=a5181744455932776ea9f38171e7f2e699918e44
1616
ADD --keep-git-dir=true https://github.com/rackerlabs/ironic.git#${IRONIC_GIT_REF} /src/ironic
1717
RUN git -C /src/ironic fetch --unshallow --tags
1818

‎python/understack-workflows/pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies = [
3232
[project.scripts]
3333
bmc-kube-password = "understack_workflows.main.bmc_display_password:main"
3434
bmc-password = "understack_workflows.main.print_bmc_password:main"
35+
configure-snmpv3-user = "understack_workflows.main.configure_snmpv3_user:main"
3536
enroll-fw = "understack_workflows.main.enroll_fw:main"
3637
enroll-netdev = "understack_workflows.main.enroll_netdev:main"
3738
enroll-server = "understack_workflows.main.enroll_server:main"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from unittest.mock import Mock
2+
from unittest.mock import call
3+
4+
import pytest
5+
from ironicclient.common.apiclient import exceptions
6+
from tenacity import stop_after_attempt
7+
from tenacity import wait_none
8+
9+
from understack_workflows.main import configure_snmpv3_user as cli
10+
11+
12+
def test_conflict_retries_the_same_request_without_lifecycle_calls():
13+
client = Mock()
14+
client.client.node.vendor_passthru.side_effect = [
15+
exceptions.Conflict("Node is busy"),
16+
{"configured": True},
17+
]
18+
# Tenacity adds retry_with at runtime but does not expose it in its types.
19+
configure = cli.configure_snmpv3_user.retry_with( # pyright: ignore[reportFunctionMemberAccess]
20+
wait=wait_none(), stop=stop_after_attempt(2)
21+
)
22+
23+
configure(client, "node-uuid", 5)
24+
25+
request = call.client.node.vendor_passthru(
26+
"node-uuid", "configure_snmpv3_user", args={"slot": 5}, http_method="POST"
27+
)
28+
assert client.mock_calls == [request, request]
29+
30+
31+
@pytest.mark.parametrize("error", [exceptions.BadRequest, exceptions.Forbidden])
32+
def test_permanent_errors_are_not_retried(error):
33+
client = Mock()
34+
client.client.node.vendor_passthru.side_effect = error("Cannot configure")
35+
36+
with pytest.raises(error):
37+
cli.configure_snmpv3_user(client, "node-uuid", 5)
38+
39+
client.client.node.vendor_passthru.assert_called_once()
40+
41+
42+
def test_busy_node_eventually_fails():
43+
client = Mock()
44+
client.client.node.vendor_passthru.side_effect = exceptions.Conflict("Busy")
45+
# Tenacity adds retry_with at runtime but does not expose it in its types.
46+
configure = cli.configure_snmpv3_user.retry_with( # pyright: ignore[reportFunctionMemberAccess]
47+
wait=wait_none(), stop=stop_after_attempt(2)
48+
)
49+
50+
with pytest.raises(exceptions.Conflict):
51+
configure(client, "node-uuid", 5)
52+
53+
assert client.client.node.vendor_passthru.call_count == 2
54+
55+
56+
def test_main_resolves_node_and_uses_monitoring_slot(mocker):
57+
mocker.patch("sys.argv", ["configure-snmpv3-user", "--node", "Dell-ABC1234"])
58+
client = mocker.patch.object(cli, "IronicClient").return_value
59+
client.get_node.return_value.uuid = "node-uuid"
60+
configure = mocker.patch.object(cli, "configure_snmpv3_user")
61+
62+
cli.main()
63+
64+
configure.assert_called_once_with(client, "node-uuid", 5)
65+
66+
67+
@pytest.mark.parametrize("slot", ["2", "17", "invalid"])
68+
def test_invalid_slot_is_rejected(slot):
69+
with pytest.raises(SystemExit):
70+
cli.argument_parser().parse_args(["--node", "node-uuid", "--slot", slot])
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import argparse
2+
import logging
3+
4+
from ironicclient.common.apiclient import exceptions
5+
from tenacity import before_sleep_log
6+
from tenacity import retry
7+
from tenacity import retry_if_exception_type
8+
from tenacity import stop_after_delay
9+
from tenacity import wait_fixed
10+
11+
from understack_workflows import helpers
12+
from understack_workflows.ironic.client import IronicClient
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@retry(
18+
retry=retry_if_exception_type(exceptions.Conflict),
19+
stop=stop_after_delay(600),
20+
wait=wait_fixed(10),
21+
before_sleep=before_sleep_log(logger, logging.INFO),
22+
reraise=True,
23+
)
24+
def configure_snmpv3_user(client: IronicClient, node_uuid: str, slot: int) -> None:
25+
"""Wait for contention to clear, then configure SNMP without transitions."""
26+
client.client.node.vendor_passthru(
27+
node_uuid,
28+
"configure_snmpv3_user",
29+
args={"slot": slot},
30+
http_method="POST",
31+
)
32+
33+
34+
def argument_parser() -> argparse.ArgumentParser:
35+
parser = argparse.ArgumentParser(
36+
description="Configure BMC SNMPv3 without changing Ironic node state."
37+
)
38+
parser.add_argument("--node", required=True, help="Node name or UUID")
39+
parser.add_argument("--slot", type=int, choices=range(3, 17), default=5)
40+
return parser
41+
42+
43+
def main() -> None:
44+
helpers.setup_logger()
45+
args = argument_parser().parse_args()
46+
client = IronicClient()
47+
node = client.get_node(args.node)
48+
configure_snmpv3_user(client, node.uuid, args.slot)
49+
logger.info("SNMPv3 configured on node %s", node.uuid)
50+
51+
52+
if __name__ == "__main__":
53+
main()

‎workflows/argo-events/kustomization.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ resources:
2323
- workflowtemplates/inspect-server.yaml
2424
- workflowtemplates/bmc-password.yaml
2525
- workflowtemplates/snmpv3-password.yaml
26+
- workflowtemplates/workflowtemplate-bmc-configure-snmpv3-user.yaml
2627
- workflowtemplates/server-firmware-update.yaml
2728
- workflowtemplates/resync-nautobot.yaml
2829
# CronWorkflows
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
apiVersion: argoproj.io/v1alpha1
2+
kind: WorkflowTemplate
3+
metadata:
4+
name: bmc-configure-snmpv3-user
5+
annotations:
6+
workflows.argoproj.io/title: Configure the SNMPv3 user on a node BMC
7+
workflows.argoproj.io/description: |
8+
Defined in `workflows/argo-events/workflowtemplates/workflowtemplate-bmc-configure-snmpv3-user.yaml`
9+
10+
Configures the SNMPv3 user through Ironic vendor passthrough without
11+
changing node provisioning state. Retries busy nodes for up to ten minutes.
12+
spec:
13+
entrypoint: run
14+
serviceAccountName: workflow
15+
arguments:
16+
parameters:
17+
- name: node_uuid
18+
templates:
19+
- name: run
20+
inputs:
21+
parameters:
22+
- name: node_uuid
23+
- name: slot
24+
default: "5"
25+
synchronization:
26+
mutexes:
27+
- name: ironic-runbooks-{{inputs.parameters.node_uuid}}
28+
volumes:
29+
- name: openstack
30+
secret:
31+
secretName: baremetal-manage
32+
items:
33+
- key: clouds.yaml
34+
path: clouds.yaml
35+
container:
36+
image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest
37+
command:
38+
- configure-snmpv3-user
39+
args:
40+
- --node
41+
- "{{inputs.parameters.node_uuid}}"
42+
- --slot
43+
- "{{inputs.parameters.slot}}"
44+
envFrom:
45+
- configMapRef:
46+
name: cluster-metadata
47+
env:
48+
- name: OS_CLOUD
49+
value: understack
50+
volumeMounts:
51+
- name: openstack
52+
mountPath: /etc/openstack
53+
readOnly: true

0 commit comments

Comments
 (0)