Skip to content

Commit 812b870

Browse files
Add resync-keystone-nautobot to sync tenants before devices
Added resync-keystone-nautobot workflow to sync Keystone projects as Nautobot tenants, ensuring tenants exist before devices reference them.
1 parent eeba6d6 commit 812b870

12 files changed

Lines changed: 543 additions & 170 deletions

File tree

python/understack-workflows/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ netapp-configure-interfaces = "understack_workflows.main.netapp_configure_net:ma
3939
netapp-create-svm = "understack_workflows.main.netapp_create_svm:main"
4040
openstack-oslo-event = "understack_workflows.main.openstack_oslo_event:main"
4141
resync-ironic-nautobot = "understack_workflows.main.resync_ironic_to_nautobot:main"
42+
resync-keystone-nautobot = "understack_workflows.main.resync_keystone_to_nautobot:main"
4243
sync-keystone = "understack_workflows.main.sync_keystone:main"
4344
sync-network-segment-range = "understack_workflows.main.sync_ucvni_group_range:main"
4445
undersync-switch = "understack_workflows.main.undersync_switch:main"

python/understack-workflows/tests/test_resync_ironic_to_nautobot.py

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,10 @@
33
from unittest.mock import MagicMock
44
from unittest.mock import patch
55

6-
from understack_workflows.main.resync_ironic_to_nautobot import SyncResult
76
from understack_workflows.main.resync_ironic_to_nautobot import argument_parser
87
from understack_workflows.main.resync_ironic_to_nautobot import main
98
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
9+
from understack_workflows.resync import SyncResult
3210

3311

3412
class TestArgumentParser:
@@ -128,16 +106,11 @@ class TestMain:
128106
"""Test cases for main function."""
129107

130108
@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")
109+
@patch("understack_workflows.main.resync_ironic_to_nautobot.get_nautobot_client")
133110
@patch("understack_workflows.main.resync_ironic_to_nautobot.setup_logger")
134111
@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-
):
112+
def test_main_success(self, mock_parser, mock_logger, mock_get_nb, mock_sync):
138113
mock_args = MagicMock()
139-
mock_args.nautobot_token = "token"
140-
mock_args.nautobot_url = "http://nautobot"
141114
mock_args.node = None
142115
mock_args.dry_run = False
143116
mock_parser.return_value.parse_args.return_value = mock_args
@@ -148,16 +121,11 @@ def test_main_success(
148121
assert result == 0
149122

150123
@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")
124+
@patch("understack_workflows.main.resync_ironic_to_nautobot.get_nautobot_client")
153125
@patch("understack_workflows.main.resync_ironic_to_nautobot.setup_logger")
154126
@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-
):
127+
def test_main_with_failures(self, mock_parser, mock_logger, mock_get_nb, mock_sync):
158128
mock_args = MagicMock()
159-
mock_args.nautobot_token = "token"
160-
mock_args.nautobot_url = "http://nautobot"
161129
mock_args.node = None
162130
mock_args.dry_run = False
163131
mock_parser.return_value.parse_args.return_value = mock_args
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Tests for resync_keystone_to_nautobot module."""
2+
3+
from unittest.mock import MagicMock
4+
from unittest.mock import patch
5+
6+
from understack_workflows.main.resync_keystone_to_nautobot import argument_parser
7+
from understack_workflows.main.resync_keystone_to_nautobot import main
8+
from understack_workflows.main.resync_keystone_to_nautobot import sync_projects
9+
from understack_workflows.main.sync_keystone import is_domain
10+
from understack_workflows.resync import SyncResult
11+
12+
13+
class TestIsDomain:
14+
"""Test cases for is_domain helper function."""
15+
16+
def test_is_domain_true(self):
17+
project = MagicMock()
18+
project.is_domain = True
19+
assert is_domain(project) is True
20+
21+
def test_is_domain_false(self):
22+
project = MagicMock()
23+
project.is_domain = False
24+
assert is_domain(project) is False
25+
26+
def test_is_domain_missing_attr(self):
27+
project = MagicMock(spec=[])
28+
assert is_domain(project) is False
29+
30+
31+
class TestArgumentParser:
32+
"""Test cases for argument_parser function."""
33+
34+
def test_default_args(self):
35+
parser = argument_parser()
36+
args = parser.parse_args([])
37+
assert args.project is None
38+
assert args.dry_run is False
39+
40+
def test_project_arg(self):
41+
parser = argument_parser()
42+
args = parser.parse_args(["--project", "test-uuid"])
43+
assert args.project == "test-uuid"
44+
45+
def test_dry_run_arg(self):
46+
parser = argument_parser()
47+
args = parser.parse_args(["--dry-run"])
48+
assert args.dry_run is True
49+
50+
51+
class TestSyncProjects:
52+
"""Test cases for sync_projects function."""
53+
54+
def test_sync_all_projects_success(self):
55+
conn = MagicMock()
56+
project1 = MagicMock(
57+
id="12345678-1234-5678-1234-567812345678",
58+
name="project-1",
59+
is_domain=False,
60+
)
61+
project2 = MagicMock(
62+
id="87654321-4321-8765-4321-876543218765",
63+
name="project-2",
64+
is_domain=False,
65+
)
66+
conn.identity.projects.return_value = [project1, project2]
67+
68+
nautobot = MagicMock()
69+
70+
with patch(
71+
"understack_workflows.main.resync_keystone_to_nautobot.handle_project_update"
72+
) as mock_update:
73+
mock_update.return_value = 0
74+
result = sync_projects(conn, nautobot)
75+
76+
assert result.total == 2
77+
assert result.failed == 0
78+
assert result.skipped == 0
79+
assert mock_update.call_count == 2
80+
81+
def test_sync_single_project(self):
82+
conn = MagicMock()
83+
project = MagicMock(
84+
id="12345678-1234-5678-1234-567812345678",
85+
name="project-1",
86+
is_domain=False,
87+
)
88+
conn.identity.get_project.return_value = project
89+
90+
nautobot = MagicMock()
91+
92+
with patch(
93+
"understack_workflows.main.resync_keystone_to_nautobot.handle_project_update"
94+
) as mock_update:
95+
mock_update.return_value = 0
96+
result = sync_projects(
97+
conn, nautobot, project_uuid="12345678-1234-5678-1234-567812345678"
98+
)
99+
100+
assert result.total == 1
101+
assert result.failed == 0
102+
conn.identity.get_project.assert_called_once_with(
103+
"12345678-1234-5678-1234-567812345678"
104+
)
105+
106+
def test_sync_with_failures(self):
107+
conn = MagicMock()
108+
project1 = MagicMock(
109+
id="12345678-1234-5678-1234-567812345678",
110+
name="project-1",
111+
is_domain=False,
112+
)
113+
project2 = MagicMock(
114+
id="87654321-4321-8765-4321-876543218765",
115+
name="project-2",
116+
is_domain=False,
117+
)
118+
conn.identity.projects.return_value = [project1, project2]
119+
120+
nautobot = MagicMock()
121+
122+
with patch(
123+
"understack_workflows.main.resync_keystone_to_nautobot.handle_project_update"
124+
) as mock_update:
125+
mock_update.side_effect = [0, 1] # First succeeds, second fails
126+
result = sync_projects(conn, nautobot)
127+
128+
assert result.total == 2
129+
assert result.failed == 1
130+
assert result.succeeded == 1
131+
132+
def test_sync_skips_domains(self):
133+
conn = MagicMock()
134+
project = MagicMock(
135+
id="12345678-1234-5678-1234-567812345678",
136+
name="project-1",
137+
is_domain=False,
138+
)
139+
domain = MagicMock(
140+
id="87654321-4321-8765-4321-876543218765",
141+
name="domain-1",
142+
is_domain=True,
143+
)
144+
conn.identity.projects.return_value = [project, domain]
145+
146+
nautobot = MagicMock()
147+
148+
with patch(
149+
"understack_workflows.main.resync_keystone_to_nautobot.handle_project_update"
150+
) as mock_update:
151+
mock_update.return_value = 0
152+
result = sync_projects(conn, nautobot)
153+
154+
assert result.total == 2
155+
assert result.skipped == 1
156+
assert result.succeeded == 1
157+
mock_update.assert_called_once()
158+
159+
def test_dry_run_skips_sync(self):
160+
conn = MagicMock()
161+
project = MagicMock(id="uuid-1", name="project-1", is_domain=False)
162+
conn.identity.projects.return_value = [project]
163+
164+
nautobot = MagicMock()
165+
166+
with patch(
167+
"understack_workflows.main.resync_keystone_to_nautobot.handle_project_update"
168+
) as mock_update:
169+
result = sync_projects(conn, nautobot, dry_run=True)
170+
171+
assert result.total == 1
172+
assert result.failed == 0
173+
mock_update.assert_not_called()
174+
175+
176+
class TestMain:
177+
"""Test cases for main function."""
178+
179+
@patch("understack_workflows.main.resync_keystone_to_nautobot.sync_projects")
180+
@patch("understack_workflows.main.resync_keystone_to_nautobot.get_nautobot_client")
181+
@patch("understack_workflows.main.resync_keystone_to_nautobot.get_openstack_client")
182+
@patch("understack_workflows.main.resync_keystone_to_nautobot.setup_logger")
183+
@patch("understack_workflows.main.resync_keystone_to_nautobot.argument_parser")
184+
def test_main_success(
185+
self, mock_parser, mock_logger, mock_get_os, mock_get_nb, mock_sync
186+
):
187+
mock_args = MagicMock()
188+
mock_args.project = None
189+
mock_args.dry_run = False
190+
mock_parser.return_value.parse_args.return_value = mock_args
191+
mock_sync.return_value = SyncResult(total=5, failed=0)
192+
193+
result = main()
194+
195+
assert result == 0
196+
mock_get_os.assert_called_once_with()
197+
198+
@patch("understack_workflows.main.resync_keystone_to_nautobot.sync_projects")
199+
@patch("understack_workflows.main.resync_keystone_to_nautobot.get_nautobot_client")
200+
@patch("understack_workflows.main.resync_keystone_to_nautobot.get_openstack_client")
201+
@patch("understack_workflows.main.resync_keystone_to_nautobot.setup_logger")
202+
@patch("understack_workflows.main.resync_keystone_to_nautobot.argument_parser")
203+
def test_main_with_failures(
204+
self, mock_parser, mock_logger, mock_get_os, mock_get_nb, mock_sync
205+
):
206+
mock_args = MagicMock()
207+
mock_args.project = None
208+
mock_args.dry_run = False
209+
mock_parser.return_value.parse_args.return_value = mock_args
210+
mock_sync.return_value = SyncResult(total=5, failed=2)
211+
212+
result = main()
213+
214+
assert result == 1

python/understack-workflows/understack_workflows/main/resync_ironic_to_nautobot.py

Lines changed: 7 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,23 @@
88

99
import argparse
1010
import logging
11-
from dataclasses import dataclass
1211

1312
import pynautobot
1413

15-
from understack_workflows.helpers import credential
16-
from understack_workflows.helpers import parser_nautobot_args
1714
from understack_workflows.helpers import setup_logger
1815
from understack_workflows.ironic.client import IronicClient
1916
from understack_workflows.oslo_event.nautobot_device_sync import sync_device_to_nautobot
17+
from understack_workflows.resync import SyncResult
18+
from understack_workflows.resync import get_nautobot_client
19+
from understack_workflows.resync import log_sync_result
20+
from understack_workflows.resync import parser_resync_args
2021

2122
logger = logging.getLogger(__name__)
2223

23-
_EXIT_SUCCESS = 0
24-
_EXIT_SYNC_FAILURES = 1
25-
26-
27-
@dataclass
28-
class SyncResult:
29-
"""Result of a sync operation."""
30-
31-
total: int = 0
32-
failed: int = 0
33-
34-
@property
35-
def succeeded(self) -> int:
36-
return self.total - self.failed
37-
3824

3925
def argument_parser() -> argparse.ArgumentParser:
4026
parser = argparse.ArgumentParser(description="Resync Ironic nodes to Nautobot")
41-
parser.add_argument(
42-
"--node", type=str, help="Sync specific node UUID (default: all nodes)"
43-
)
44-
parser.add_argument(
45-
"--dry-run", action="store_true", help="List nodes without syncing"
46-
)
47-
parser = parser_nautobot_args(parser)
48-
return parser
27+
return parser_resync_args(parser, item_name="node", item_flag="--node")
4928

5029

5130
def sync_nodes(
@@ -86,22 +65,7 @@ def main() -> int:
8665
setup_logger(level=logging.INFO)
8766
args = argument_parser().parse_args()
8867

89-
nb_token = args.nautobot_token or credential("nb-token", "token")
90-
nautobot = pynautobot.api(args.nautobot_url, token=nb_token)
91-
68+
nautobot = get_nautobot_client(args)
9269
result = sync_nodes(nautobot, args.node or None, args.dry_run)
9370

94-
if args.dry_run:
95-
logger.info("Dry run complete. %d nodes would be synced.", result.total)
96-
else:
97-
logger.info(
98-
"Sync complete. %d/%d nodes synced successfully.",
99-
result.succeeded,
100-
result.total,
101-
)
102-
103-
if result.failed:
104-
logger.error("Failed to sync %d nodes", result.failed)
105-
return _EXIT_SYNC_FAILURES
106-
107-
return _EXIT_SUCCESS
71+
return log_sync_result(result, "node", args.dry_run)

0 commit comments

Comments
 (0)