Skip to content

Commit 0f9a697

Browse files
authored
Merge pull request #1699 from rackerlabs/resync-ironic-nautobot
feat: scheduled resync-ironic-nautobot
2 parents 6d15e8a + e9a00f6 commit 0f9a697

13 files changed

Lines changed: 488 additions & 10 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
apiVersion: argoproj.io/v1alpha1
3+
kind: CronWorkflow
4+
metadata:
5+
name: resync-ironic-nautobot
6+
annotations:
7+
workflows.argoproj.io/title: Scheduled resync of Ironic nodes to Nautobot
8+
workflows.argoproj.io/description: |
9+
Periodically resyncs all Ironic nodes to Nautobot to catch any drift.
10+
Runs daily at 2:00 AM UTC by default.
11+
12+
To manually trigger:
13+
```
14+
argo -n argo-events submit --from cronworkflow/resync-ironic-nautobot
15+
```
16+
17+
Defined in `components/site-workflows/cronworkflows/resync-ironic-nautobot.yaml`
18+
spec:
19+
schedule: "0 2 * * *"
20+
timezone: "UTC"
21+
concurrencyPolicy: "Forbid"
22+
successfulJobsHistoryLimit: 3
23+
failedJobsHistoryLimit: 3
24+
workflowSpec:
25+
workflowTemplateRef:
26+
name: resync-ironic-nautobot

components/site-workflows/kustomization.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ resources:
2424
- sensors/sensor-ironic-node-port.yaml
2525
- sensors/sensor-ironic-node-portgroup.yaml
2626
- sensors/sensor-ironic-oslo-event.yaml
27+
# CronWorkflows
28+
- cronworkflows/resync-ironic-nautobot.yaml
2729

2830
helmCharts:
2931
- name: nautobot-token

docs/component-argo-workflows.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ set of WorkflowTemplates below.
66

77
## WorkflowTemplates
88

9-
| WorkflowTemplate | Description | Input | Output | |
10-
|---------------------- |-----------------------------------------------------------|-------------------------|------------|---|
11-
| get-device-nautobot | Return Device Information from Nautobot | device_id | device | |
12-
| get-bmc-creds | Get the credentials for the target Device | device_id | secret | * |
13-
| get-bmc-ip | Get BMC IP address for target Device | device_id | ip | |
14-
| nautobot-api | HTTP Template Workflow to query the Nautobot API | method,nautobot_url,uri | result | |
15-
| bmc-sync-creds | Sync's a devices BMC password with what we have on record | device_id | | |
9+
| WorkflowTemplate | Description | Input | Output | |
10+
|-------------------------|-----------------------------------------------------------|-------------------------|------------|---|
11+
| get-device-nautobot | Return Device Information from Nautobot | device_id | device | |
12+
| get-bmc-creds | Get the credentials for the target Device | device_id | secret | * |
13+
| get-bmc-ip | Get BMC IP address for target Device | device_id | ip | |
14+
| nautobot-api | HTTP Template Workflow to query the Nautobot API | method,nautobot_url,uri | result | |
15+
| bmc-sync-creds | Sync's a devices BMC password with what we have on record | device_id | | |
16+
| resync-ironic-nautobot | Resync Ironic nodes to Nautobot | node (optional) | | |
1617

1718
\* WorkflowTemplate which requires a manual / custom implementation.
1819

docs/operator-guide/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ be the regular project area.
3737

3838
- [Gateway API Migration Guide](gateway-api.md) - Migration from ingress-nginx to Kubernetes Gateway API with Envoy Gateway
3939
- [Argo Workflows](workflows.md) - Workflow orchestration and troubleshooting
40+
- [Ironic to Nautobot Sync](ironic-nautobot-sync.md) - Event-driven sync and bulk resync operations
4041
- [Monitoring Stack](monitoring.md) - Prometheus and Grafana monitoring
4142

4243
[cli]: <../user-guide/openstack-cli.md>
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Ironic to Nautobot Synchronization
2+
3+
This guide explains how OpenStack Ironic data is synchronized to Nautobot
4+
and how to handle situations when they get out of sync.
5+
6+
## Event-Driven Sync
7+
8+
Under normal operation, Ironic data is automatically synchronized to Nautobot
9+
via Oslo notifications. When changes occur in Ironic, events are published to
10+
RabbitMQ and processed by Argo Events workflows.
11+
12+
### How It Works
13+
14+
1. Ironic publishes Oslo notifications to RabbitMQ when nodes change
15+
2. An Argo Events EventSource consumes messages from the `ironic` queue
16+
3. A Sensor filters for relevant events and triggers workflows
17+
4. The `openstack-oslo-event` workflow processes the event and updates Nautobot
18+
19+
### Supported Events
20+
21+
The following Ironic events trigger Nautobot updates:
22+
23+
| Event Type | Action |
24+
|------------|--------|
25+
| `baremetal.node.create.end` | Creates device in Nautobot |
26+
| `baremetal.node.update.end` | Updates device in Nautobot |
27+
| `baremetal.node.delete.end` | Deletes device from Nautobot |
28+
| `baremetal.node.provision_set.end` | Updates device status and syncs inspection data |
29+
| `baremetal.port.create.end` | Creates interface in Nautobot |
30+
| `baremetal.port.update.end` | Updates interface in Nautobot |
31+
| `baremetal.port.delete.end` | Deletes interface from Nautobot |
32+
33+
### Data Synchronized
34+
35+
For each Ironic node, the following data is synced to Nautobot:
36+
37+
- Device name (generated from manufacturer and service tag)
38+
- Serial number
39+
- Manufacturer and model
40+
- Hardware specs (memory, CPUs, local storage)
41+
- Provision state (mapped to Nautobot status)
42+
- Location and rack (derived from connected switches)
43+
- Tenant (from Ironic lessee field)
44+
- Network interfaces and their connections
45+
46+
## Bulk Resync
47+
48+
When Nautobot gets out of sync with Ironic, you can perform a bulk resync.
49+
50+
### Scheduled Resync (CronWorkflow)
51+
52+
A CronWorkflow runs daily at 2:00 AM UTC to catch any drift between Ironic
53+
and Nautobot. This provides a safety net for missed events.
54+
55+
Check the schedule:
56+
57+
```bash
58+
argo -n argo-events cron list
59+
```
60+
61+
Manually trigger the scheduled workflow:
62+
63+
```bash
64+
argo -n argo-events submit --from cronworkflow/resync-ironic-nautobot
65+
```
66+
67+
Suspend/resume the schedule:
68+
69+
```bash
70+
argo -n argo-events cron suspend resync-ironic-nautobot
71+
argo -n argo-events cron resume resync-ironic-nautobot
72+
```
73+
74+
### On-Demand Resync (WorkflowTemplate)
75+
76+
Resync all nodes:
77+
78+
```bash
79+
argo -n argo-events submit --from workflowtemplate/resync-ironic-nautobot
80+
```
81+
82+
Resync a specific node:
83+
84+
```bash
85+
argo -n argo-events submit --from workflowtemplate/resync-ironic-nautobot \
86+
-p node="<node-uuid>"
87+
```
88+
89+
### Using CLI Directly
90+
91+
For debugging or running outside the cluster:
92+
93+
```bash
94+
# Resync all nodes
95+
resync-ironic-nautobot \
96+
--nautobot_url https://nautobot.example.com \
97+
--nautobot_token <token>
98+
99+
# Resync a specific node
100+
resync-ironic-nautobot \
101+
--node <uuid> \
102+
--nautobot_url https://nautobot.example.com \
103+
--nautobot_token <token>
104+
105+
# Dry run to see what would be synced
106+
resync-ironic-nautobot \
107+
--dry-run \
108+
--nautobot_url https://nautobot.example.com \
109+
--nautobot_token <token>
110+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ nav:
174174
- 'Infrastructure':
175175
- operator-guide/argocd-helm-chart.md
176176
- operator-guide/workflows.md
177+
- operator-guide/ironic-nautobot-sync.md
177178
- operator-guide/monitoring.md
178179
- operator-guide/gateway-api.md
179180
- operator-guide/bmc-password.md

python/understack-workflows/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ get-raid-devices = "understack_workflows.main.get_raid_devices:main"
3838
netapp-configure-interfaces = "understack_workflows.main.netapp_configure_net:main"
3939
netapp-create-svm = "understack_workflows.main.netapp_create_svm:main"
4040
openstack-oslo-event = "understack_workflows.main.openstack_oslo_event:main"
41+
resync-ironic-nautobot = "understack_workflows.main.resync_ironic_to_nautobot:main"
4142
sync-keystone = "understack_workflows.main.sync_keystone:main"
4243
sync-network-segment-range = "understack_workflows.main.sync_ucvni_group_range:main"
4344
undersync-switch = "understack_workflows.main.undersync_switch:main"

python/understack-workflows/tests/test_nautobot_device_interface_sync.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def test_integrated_interface(self):
7373
assert _get_interface_type("NIC.Integrated.1-1") == "25gbase-x-sfp28"
7474

7575
def test_unknown_interface(self):
76-
assert _get_interface_type("eth0") == "unknown"
76+
assert _get_interface_type("eth0") == "other"
7777

7878

7979
class TestGetInterfaceDescription:
@@ -228,7 +228,7 @@ def test_interface_info_defaults(self):
228228
assert iface.enabled is True
229229
assert iface.mgmt_only is False
230230
assert iface.pxe_enabled is False
231-
assert iface.interface_type == "unknown"
231+
assert iface.interface_type == "other"
232232

233233

234234
class TestCreateNautobotInterface:
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Tests for resync_ironic_to_nautobot module."""
2+
3+
from unittest.mock import MagicMock
4+
from unittest.mock import patch
5+
6+
from understack_workflows.main.resync_ironic_to_nautobot import SyncResult
7+
from understack_workflows.main.resync_ironic_to_nautobot import argument_parser
8+
from understack_workflows.main.resync_ironic_to_nautobot import main
9+
from understack_workflows.main.resync_ironic_to_nautobot import sync_nodes
10+
11+
12+
class TestSyncResult:
13+
"""Test cases for SyncResult dataclass."""
14+
15+
def test_defaults(self):
16+
result = SyncResult()
17+
assert result.total == 0
18+
assert result.failed == 0
19+
assert result.succeeded == 0
20+
21+
def test_succeeded_calculation(self):
22+
result = SyncResult(total=10, failed=3)
23+
assert result.succeeded == 7
24+
25+
def test_all_failed(self):
26+
result = SyncResult(total=5, failed=5)
27+
assert result.succeeded == 0
28+
29+
def test_none_failed(self):
30+
result = SyncResult(total=5, failed=0)
31+
assert result.succeeded == 5
32+
33+
34+
class TestArgumentParser:
35+
"""Test cases for argument_parser function."""
36+
37+
def test_default_args(self):
38+
parser = argument_parser()
39+
args = parser.parse_args([])
40+
assert args.node is None
41+
assert args.dry_run is False
42+
43+
def test_node_arg(self):
44+
parser = argument_parser()
45+
args = parser.parse_args(["--node", "test-uuid"])
46+
assert args.node == "test-uuid"
47+
48+
def test_dry_run_arg(self):
49+
parser = argument_parser()
50+
args = parser.parse_args(["--dry-run"])
51+
assert args.dry_run is True
52+
53+
54+
class TestSyncNodes:
55+
"""Test cases for sync_nodes function."""
56+
57+
@patch("understack_workflows.main.resync_ironic_to_nautobot.IronicClient")
58+
@patch(
59+
"understack_workflows.main.resync_ironic_to_nautobot.sync_device_to_nautobot"
60+
)
61+
def test_sync_all_nodes_success(self, mock_sync, mock_ironic_class):
62+
mock_ironic = MagicMock()
63+
mock_ironic_class.return_value = mock_ironic
64+
mock_node1 = MagicMock(uuid="uuid-1", name="node-1")
65+
mock_node2 = MagicMock(uuid="uuid-2", name="node-2")
66+
mock_ironic.list_nodes.return_value = [mock_node1, mock_node2]
67+
mock_sync.return_value = 0
68+
69+
nautobot = MagicMock()
70+
result = sync_nodes(nautobot)
71+
72+
assert result.total == 2
73+
assert result.failed == 0
74+
assert mock_sync.call_count == 2
75+
76+
@patch("understack_workflows.main.resync_ironic_to_nautobot.IronicClient")
77+
@patch(
78+
"understack_workflows.main.resync_ironic_to_nautobot.sync_device_to_nautobot"
79+
)
80+
def test_sync_single_node(self, mock_sync, mock_ironic_class):
81+
mock_ironic = MagicMock()
82+
mock_ironic_class.return_value = mock_ironic
83+
mock_node = MagicMock(uuid="uuid-1", name="node-1")
84+
mock_ironic.get_node.return_value = mock_node
85+
mock_sync.return_value = 0
86+
87+
nautobot = MagicMock()
88+
result = sync_nodes(nautobot, node_uuid="uuid-1")
89+
90+
assert result.total == 1
91+
assert result.failed == 0
92+
mock_ironic.get_node.assert_called_once_with("uuid-1")
93+
94+
@patch("understack_workflows.main.resync_ironic_to_nautobot.IronicClient")
95+
@patch(
96+
"understack_workflows.main.resync_ironic_to_nautobot.sync_device_to_nautobot"
97+
)
98+
def test_sync_with_failures(self, mock_sync, mock_ironic_class):
99+
mock_ironic = MagicMock()
100+
mock_ironic_class.return_value = mock_ironic
101+
mock_node1 = MagicMock(uuid="uuid-1", name="node-1")
102+
mock_node2 = MagicMock(uuid="uuid-2", name="node-2")
103+
mock_ironic.list_nodes.return_value = [mock_node1, mock_node2]
104+
mock_sync.side_effect = [0, 1] # First succeeds, second fails
105+
106+
nautobot = MagicMock()
107+
result = sync_nodes(nautobot)
108+
109+
assert result.total == 2
110+
assert result.failed == 1
111+
assert result.succeeded == 1
112+
113+
@patch("understack_workflows.main.resync_ironic_to_nautobot.IronicClient")
114+
def test_dry_run_skips_sync(self, mock_ironic_class):
115+
mock_ironic = MagicMock()
116+
mock_ironic_class.return_value = mock_ironic
117+
mock_node = MagicMock(uuid="uuid-1", name="node-1")
118+
mock_ironic.list_nodes.return_value = [mock_node]
119+
120+
nautobot = MagicMock()
121+
result = sync_nodes(nautobot, dry_run=True)
122+
123+
assert result.total == 1
124+
assert result.failed == 0
125+
126+
127+
class TestMain:
128+
"""Test cases for main function."""
129+
130+
@patch("understack_workflows.main.resync_ironic_to_nautobot.sync_nodes")
131+
@patch("understack_workflows.main.resync_ironic_to_nautobot.pynautobot")
132+
@patch("understack_workflows.main.resync_ironic_to_nautobot.credential")
133+
@patch("understack_workflows.main.resync_ironic_to_nautobot.setup_logger")
134+
@patch("understack_workflows.main.resync_ironic_to_nautobot.argument_parser")
135+
def test_main_success(
136+
self, mock_parser, mock_logger, mock_cred, mock_pynb, mock_sync
137+
):
138+
mock_args = MagicMock()
139+
mock_args.nautobot_token = "token"
140+
mock_args.nautobot_url = "http://nautobot"
141+
mock_args.node = None
142+
mock_args.dry_run = False
143+
mock_parser.return_value.parse_args.return_value = mock_args
144+
mock_sync.return_value = SyncResult(total=5, failed=0)
145+
146+
result = main()
147+
148+
assert result == 0
149+
150+
@patch("understack_workflows.main.resync_ironic_to_nautobot.sync_nodes")
151+
@patch("understack_workflows.main.resync_ironic_to_nautobot.pynautobot")
152+
@patch("understack_workflows.main.resync_ironic_to_nautobot.credential")
153+
@patch("understack_workflows.main.resync_ironic_to_nautobot.setup_logger")
154+
@patch("understack_workflows.main.resync_ironic_to_nautobot.argument_parser")
155+
def test_main_with_failures(
156+
self, mock_parser, mock_logger, mock_cred, mock_pynb, mock_sync
157+
):
158+
mock_args = MagicMock()
159+
mock_args.nautobot_token = "token"
160+
mock_args.nautobot_url = "http://nautobot"
161+
mock_args.node = None
162+
mock_args.dry_run = False
163+
mock_parser.return_value.parse_args.return_value = mock_args
164+
mock_sync.return_value = SyncResult(total=5, failed=2)
165+
166+
result = main()
167+
168+
assert result == 1

0 commit comments

Comments
 (0)