Skip to content

Commit fff3c1e

Browse files
fix: unmap Nautobot devices from tenant during keystone project deletion
1 parent a3896d4 commit fff3c1e

File tree

2 files changed

+105
-24
lines changed

2 files changed

+105
-24
lines changed

python/understack-workflows/tests/test_sync_keystone.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,37 @@
11
import uuid
22
from contextlib import nullcontext
3+
from unittest.mock import MagicMock
34

45
import pytest
6+
from openstack.connection import Connection
57
from pytest_lazy_fixtures import lf
68

79
from understack_workflows.main.sync_keystone import Event
810
from understack_workflows.main.sync_keystone import argument_parser
911
from understack_workflows.main.sync_keystone import do_action
12+
from understack_workflows.main.sync_keystone import handle_project_delete
13+
14+
15+
@pytest.fixture
16+
def mock_pynautobot_api(mocker):
17+
mock_client = MagicMock(name="MockPynautobotApi")
18+
19+
mock_devices = MagicMock()
20+
mock_devices.filter.return_value = []
21+
mock_devices.update.return_value = True
22+
mock_client.dcim.devices = mock_devices
23+
24+
mock_tenants = MagicMock()
25+
mock_tenants.get.return_value = None
26+
mock_tenants.delete.return_value = True
27+
mock_client.tenancy.tenants = mock_tenants
28+
29+
mocker.patch(
30+
"understack_workflows.main.sync_keystone.pynautobot.api",
31+
return_value=mock_client,
32+
)
33+
34+
return mock_client
1035

1136

1237
@pytest.mark.parametrize(
@@ -44,32 +69,73 @@ def test_parse_object_id(arg_list, context, expected_id):
4469

4570
def test_create_project(
4671
os_conn,
47-
nautobot,
72+
mock_pynautobot_api,
4873
project_id: uuid.UUID,
4974
domain_id: uuid.UUID,
5075
):
51-
ret = do_action(os_conn, nautobot, Event.ProjectCreate, project_id)
76+
ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectCreate, project_id)
5277
os_conn.identity.get_project.assert_any_call(domain_id.hex)
5378
os_conn.identity.get_project.assert_any_call(project_id.hex)
5479
assert ret == 0
5580

5681

5782
def test_update_project(
5883
os_conn,
59-
nautobot,
84+
mock_pynautobot_api,
6085
project_id: uuid.UUID,
6186
domain_id: uuid.UUID,
6287
):
63-
ret = do_action(os_conn, nautobot, Event.ProjectUpdate, project_id)
88+
ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectUpdate, project_id)
6489
os_conn.identity.get_project.assert_any_call(domain_id.hex)
6590
os_conn.identity.get_project.assert_any_call(project_id.hex)
6691
assert ret == 0
6792

6893

6994
def test_delete_project(
7095
os_conn,
71-
nautobot,
96+
mock_pynautobot_api,
7297
project_id: uuid.UUID,
7398
):
74-
ret = do_action(os_conn, nautobot, Event.ProjectDelete, project_id)
99+
ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectDelete, project_id)
100+
assert ret == 0
101+
102+
103+
@pytest.mark.parametrize(
104+
"tenant_exists, expect_delete_call, expect_unmap_call",
105+
[
106+
(False, False, False), # Tenant does NOT exist
107+
(True, True, True), # Tenant exists
108+
],
109+
)
110+
def test_handle_project_delete(
111+
mocker, mock_pynautobot_api, tenant_exists, expect_delete_call, expect_unmap_call
112+
):
113+
project_id = uuid.uuid4()
114+
115+
tenant_obj = MagicMock()
116+
mock_pynautobot_api.tenancy.tenants.get.return_value = (
117+
tenant_obj if tenant_exists else None
118+
)
119+
120+
mock_delete_network = mocker.patch(
121+
"understack_workflows.main.sync_keystone._delete_outside_network"
122+
)
123+
mock_unmap_devices = mocker.patch(
124+
"understack_workflows.main.sync_keystone._unmap_tenant_from_devices"
125+
)
126+
conn_mock: Connection = MagicMock(spec=Connection)
127+
ret = handle_project_delete(conn_mock, mock_pynautobot_api, project_id)
128+
75129
assert ret == 0
130+
mock_pynautobot_api.tenancy.tenants.get.assert_called_once_with(id=project_id)
131+
132+
if tenant_exists:
133+
mock_delete_network.assert_called_once_with(conn_mock, project_id)
134+
mock_unmap_devices.assert_called_once_with(
135+
tenant_id=project_id, nautobot=mock_pynautobot_api
136+
)
137+
tenant_obj.delete.assert_called_once()
138+
else:
139+
mock_delete_network.assert_not_called()
140+
mock_unmap_devices.assert_not_called()
141+
tenant_obj.delete.assert_not_called()

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

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import argparse
22
import logging
33
import uuid
4+
from collections.abc import Sequence
45
from enum import StrEnum
6+
from typing import cast
7+
8+
import pynautobot
9+
from pynautobot.core.response import Record
510

611
from understack_workflows.helpers import credential
712
from understack_workflows.helpers import parser_nautobot_args
813
from understack_workflows.helpers import setup_logger
9-
from understack_workflows.nautobot import Nautobot
1014
from understack_workflows.openstack.client import Connection
1115
from understack_workflows.openstack.client import get_openstack_client
1216

@@ -109,15 +113,24 @@ def _tenant_attrs(conn: Connection, project_id: uuid.UUID) -> tuple[str, str, bo
109113
return tenant_name, str(project.description), is_default_domain
110114

111115

116+
def _unmap_tenant_from_devices(
117+
tenant_id: uuid.UUID,
118+
nautobot: pynautobot.api,
119+
):
120+
devices: Sequence[Record] = list(nautobot.dcim.devices.filter(tenant=tenant_id))
121+
for d in devices:
122+
d.tenant = None # type: ignore[attr-defined]
123+
nautobot.dcim.devices.update(devices)
124+
125+
112126
def handle_project_create(
113-
conn: Connection, nautobot: Nautobot, project_id: uuid.UUID
127+
conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID
114128
) -> int:
115129
logger.info("got request to create tenant %s", project_id.hex)
116130
tenant_name, tenant_description, is_default_domain = _tenant_attrs(conn, project_id)
117131

118-
nautobot_tenant_api = nautobot.session.tenancy.tenants
119132
try:
120-
tenant = nautobot_tenant_api.create(
133+
tenant = nautobot.tenancy.tenants.create(
121134
id=str(project_id), name=tenant_name, description=tenant_description
122135
)
123136
if is_default_domain:
@@ -133,24 +146,24 @@ def handle_project_create(
133146

134147

135148
def handle_project_update(
136-
conn: Connection, nautobot: Nautobot, project_id: uuid.UUID
149+
conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID
137150
) -> int:
138151
logger.info("got request to update tenant %s", project_id.hex)
139152
tenant_name, tenant_description, is_default_domain = _tenant_attrs(conn, project_id)
140153

141-
tenant_api = nautobot.session.tenancy.tenants
142-
existing_tenant = tenant_api.get(project_id)
154+
existing_tenant = nautobot.tenancy.tenants.get(id=project_id)
143155
logger.info("existing_tenant: %s", existing_tenant)
144156
try:
145157
if existing_tenant is None:
146-
new_tenant = tenant_api.create(
158+
new_tenant = nautobot.tenancy.tenants.create(
147159
id=str(project_id), name=tenant_name, description=tenant_description
148160
)
149161
logger.info("tenant %s created %s", project_id, new_tenant.created) # type: ignore
150162
else:
151-
existing_tenant.name = tenant_name # type: ignore
152-
existing_tenant.description = tenant_description # type: ignore
153-
existing_tenant.save() # type: ignore
163+
existing_tenant = cast(Record, existing_tenant)
164+
existing_tenant.update(
165+
{"name": tenant_name, "description": tenant_description}
166+
) # type: ignore
154167
logger.info(
155168
"tenant %s last updated %s",
156169
project_id,
@@ -168,23 +181,26 @@ def handle_project_update(
168181

169182

170183
def handle_project_delete(
171-
conn: Connection, nautobot: Nautobot, project_id: uuid.UUID
184+
conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID
172185
) -> int:
173186
logger.info("got request to delete tenant %s", project_id)
174-
ten = nautobot.session.tenancy.tenants.get(project_id)
175-
if not ten:
187+
tenant = nautobot.tenancy.tenants.get(id=project_id)
188+
if not tenant:
176189
logger.warning("tenant %s does not exist, nothing to delete", project_id)
177190
return _EXIT_SUCCESS
178191

179192
_delete_outside_network(conn, project_id)
180-
ten.delete() # type: ignore
193+
_unmap_tenant_from_devices(tenant_id=project_id, nautobot=nautobot)
194+
195+
tenant = cast(Record, tenant)
196+
tenant.delete()
181197
logger.info("deleted tenant %s", project_id)
182198
return _EXIT_SUCCESS
183199

184200

185201
def do_action(
186202
conn: Connection,
187-
nautobot: Nautobot,
203+
nautobot: pynautobot.api,
188204
event: Event,
189205
project_id: uuid.UUID,
190206
) -> int:
@@ -206,6 +222,5 @@ def main() -> int:
206222

207223
conn = get_openstack_client(cloud=args.os_cloud)
208224
nb_token = args.nautobot_token or credential("nb-token", "token")
209-
nautobot = Nautobot(args.nautobot_url, nb_token, logger=logger)
210-
225+
nautobot = pynautobot.api(args.nautobot_url, token=nb_token)
211226
return do_action(conn, nautobot, args.event, args.object)

0 commit comments

Comments
 (0)