Skip to content

Commit 04d1dc1

Browse files
authored
Merge pull request #1615 from rackerlabs/handle_baremetal_node_create_event
fix(nautobot_device_sync): PUC-1441: refactoring
2 parents d64a0fe + 4192fda commit 04d1dc1

5 files changed

Lines changed: 246 additions & 95 deletions

File tree

components/site-workflows/sensors/sensor-ironic-node-update.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ metadata:
88
workflows.argoproj.io/description: |+
99
Triggers on the following Ironic Events:
1010
11-
- baremetal.node.create.end which happens when a baremetal node is created
1211
- baremetal.node.update.end which happens when node fields are updated
1312
- baremetal.node.delete.end which happens when a node is deleted
1413
@@ -37,7 +36,6 @@ spec:
3736
- path: "body.event_type"
3837
type: "string"
3938
value:
40-
- "baremetal.node.create.end"
4139
- "baremetal.node.update.end"
4240
- "baremetal.node.delete.end"
4341
template:

python/understack-workflows/tests/test_nautobot_device_sync.py

Lines changed: 113 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,25 @@ def test_set_location_no_switch_info(self, device_info, mock_nautobot):
267267
assert device_info.location_id is None
268268
assert device_info.rack_id is None
269269

270+
def test_set_location_switch_info_is_string_none(self, device_info, mock_nautobot):
271+
"""Test that literal string 'None' in switch_info is skipped."""
272+
ports = [
273+
MagicMock(
274+
local_link_connection={
275+
"switch_info": "None",
276+
"switch_id": "00:00:00:00:00:00",
277+
"port_id": "None",
278+
}
279+
)
280+
]
281+
282+
_set_location_from_switches(device_info, ports, mock_nautobot)
283+
284+
# Should not make any API calls
285+
mock_nautobot.dcim.devices.get.assert_not_called()
286+
assert device_info.location_id is None
287+
assert device_info.rack_id is None
288+
270289
def test_set_location_switch_not_found(self, device_info, mock_nautobot):
271290
ports = [
272291
MagicMock(
@@ -405,6 +424,54 @@ def test_no_changes(self, mock_nautobot_device):
405424
assert result is False
406425
mock_nautobot_device.save.assert_not_called()
407426

427+
def test_update_position_and_face(self, mock_nautobot_device):
428+
"""Test that position and face are updated when preserved from old device."""
429+
mock_nautobot_device.position = None
430+
mock_nautobot_device.face = None
431+
device_info = DeviceInfo(
432+
uuid="test-uuid",
433+
position=42,
434+
face="front",
435+
)
436+
437+
result = _update_nautobot_device(device_info, mock_nautobot_device)
438+
439+
assert result is True
440+
assert mock_nautobot_device.position == 42
441+
assert mock_nautobot_device.face == "front"
442+
mock_nautobot_device.save.assert_called_once()
443+
444+
def test_update_position_defaults_face_to_front(self, mock_nautobot_device):
445+
"""Test that face defaults to 'front' when position is set but face is not."""
446+
mock_nautobot_device.position = None
447+
mock_nautobot_device.face = None
448+
device_info = DeviceInfo(
449+
uuid="test-uuid",
450+
position=10,
451+
# face is None
452+
)
453+
454+
result = _update_nautobot_device(device_info, mock_nautobot_device)
455+
456+
assert result is True
457+
assert mock_nautobot_device.position == 10
458+
assert mock_nautobot_device.face == "front"
459+
460+
def test_update_position_no_change_when_same(self, mock_nautobot_device):
461+
"""Test that no update occurs when position/face are already set correctly."""
462+
mock_nautobot_device.position = 42
463+
mock_nautobot_device.face = MagicMock(value="front")
464+
device_info = DeviceInfo(
465+
uuid="test-uuid",
466+
position=42,
467+
face="front",
468+
)
469+
470+
result = _update_nautobot_device(device_info, mock_nautobot_device)
471+
472+
assert result is False
473+
mock_nautobot_device.save.assert_not_called()
474+
408475

409476
class TestExtractNodeUuidFromEvent:
410477
"""Test cases for _extract_node_uuid_from_event function."""
@@ -443,7 +510,7 @@ def mock_nautobot(self):
443510
return MagicMock()
444511

445512
@patch("understack_workflows.oslo_event.nautobot_device_sync.IronicClient")
446-
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info")
513+
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details")
447514
@patch(
448515
"understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
449516
)
@@ -470,7 +537,7 @@ def test_sync_creates_new_device(
470537
mock_nautobot.dcim.devices.create.assert_called_once()
471538

472539
@patch("understack_workflows.oslo_event.nautobot_device_sync.IronicClient")
473-
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info")
540+
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details")
474541
@patch(
475542
"understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
476543
)
@@ -507,29 +574,34 @@ def test_sync_with_empty_uuid_returns_error(self, mock_nautobot):
507574
assert result == EXIT_STATUS_FAILURE
508575

509576
@patch("understack_workflows.oslo_event.nautobot_device_sync.IronicClient")
510-
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info")
511-
def test_sync_without_location_returns_error(
577+
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details")
578+
def test_sync_without_location_skips_for_uninspected_node(
512579
self, mock_fetch, mock_ironic_class, mock_nautobot
513580
):
581+
"""Test that sync skips gracefully for uninspected nodes without location."""
514582
node_uuid = str(uuid.uuid4())
515583
device_info = DeviceInfo(uuid=node_uuid) # No location
516584
mock_fetch.return_value = (device_info, {}, [])
517585
mock_nautobot.dcim.devices.get.return_value = None
518586

519587
result = sync_device_to_nautobot(node_uuid, mock_nautobot)
520588

589+
# Should fail since no location available
521590
assert result == EXIT_STATUS_FAILURE
591+
# Should not attempt to create device
592+
mock_nautobot.dcim.devices.create.assert_not_called()
522593

523594
@patch("understack_workflows.oslo_event.nautobot_device_sync.IronicClient")
524-
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info")
595+
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details")
525596
@patch(
526597
"understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
527598
)
528-
def test_sync_finds_device_by_name_with_matching_uuid(
599+
def test_sync_recreates_device_with_mismatched_uuid(
529600
self, mock_sync_interfaces, mock_fetch, mock_ironic_class, mock_nautobot
530601
):
531-
"""Test that device found by name with matching UUID is updated."""
602+
"""Test device with mismatched UUID is deleted and recreated."""
532603
node_uuid = str(uuid.uuid4())
604+
old_uuid = str(uuid.uuid4()) # Different UUID
533605
device_info = DeviceInfo(
534606
uuid=node_uuid,
535607
name="Dell-ABC123",
@@ -541,39 +613,34 @@ def test_sync_finds_device_by_name_with_matching_uuid(
541613
mock_fetch.return_value = (device_info, {}, [])
542614

543615
# First get by ID returns None
544-
# Second get by name returns device with same UUID
616+
# Second get by name returns device with different UUID
545617
existing_device = MagicMock()
546-
existing_device.id = node_uuid # Same UUID
618+
existing_device.id = old_uuid # Different UUID
547619
existing_device.status = MagicMock(name="Planned")
548620
existing_device.name = "Dell-ABC123"
549-
existing_device.serial = None
550-
existing_device.location = None
551-
existing_device.rack = None
552-
existing_device.tenant = None
553-
existing_device.custom_fields = {}
554621

555622
mock_nautobot.dcim.devices.get.side_effect = [None, existing_device]
623+
mock_nautobot.dcim.devices.create.return_value = MagicMock()
556624
mock_sync_interfaces.return_value = EXIT_STATUS_SUCCESS
557625

558626
result = sync_device_to_nautobot(node_uuid, mock_nautobot)
559627

560628
assert result == EXIT_STATUS_SUCCESS
561-
# Should NOT delete since UUIDs match
562-
existing_device.delete.assert_not_called()
563-
# Should NOT create new device
564-
mock_nautobot.dcim.devices.create.assert_not_called()
629+
# Should delete old device
630+
existing_device.delete.assert_called_once()
631+
# Should create new device with correct UUID
632+
mock_nautobot.dcim.devices.create.assert_called_once()
565633

566634
@patch("understack_workflows.oslo_event.nautobot_device_sync.IronicClient")
567-
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info")
635+
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details")
568636
@patch(
569637
"understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
570638
)
571-
def test_sync_recreates_device_with_mismatched_uuid(
639+
def test_sync_device_not_found_by_name_creates_new(
572640
self, mock_sync_interfaces, mock_fetch, mock_ironic_class, mock_nautobot
573641
):
574-
"""Test device with mismatched UUID is deleted and recreated."""
642+
"""Test that device not found by UUID or name is created."""
575643
node_uuid = str(uuid.uuid4())
576-
old_uuid = str(uuid.uuid4()) # Different UUID
577644
device_info = DeviceInfo(
578645
uuid=node_uuid,
579646
name="Dell-ABC123",
@@ -584,53 +651,60 @@ def test_sync_recreates_device_with_mismatched_uuid(
584651
)
585652
mock_fetch.return_value = (device_info, {}, [])
586653

587-
# First get by ID returns None
588-
# Second get by name returns device with different UUID
589-
existing_device = MagicMock()
590-
existing_device.id = old_uuid # Different UUID
591-
existing_device.status = MagicMock(name="Planned")
592-
existing_device.name = "Dell-ABC123"
593-
594-
mock_nautobot.dcim.devices.get.side_effect = [None, existing_device]
654+
# Both lookups return None
655+
mock_nautobot.dcim.devices.get.side_effect = [None, None]
595656
mock_nautobot.dcim.devices.create.return_value = MagicMock()
596657
mock_sync_interfaces.return_value = EXIT_STATUS_SUCCESS
597658

598659
result = sync_device_to_nautobot(node_uuid, mock_nautobot)
599660

600661
assert result == EXIT_STATUS_SUCCESS
601-
# Should delete old device
602-
existing_device.delete.assert_called_once()
603-
# Should create new device with correct UUID
604662
mock_nautobot.dcim.devices.create.assert_called_once()
605663

606664
@patch("understack_workflows.oslo_event.nautobot_device_sync.IronicClient")
607-
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info")
665+
@patch("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details")
608666
@patch(
609667
"understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
610668
)
611-
def test_sync_device_not_found_by_name_creates_new(
669+
def test_sync_uuid_mismatch_uses_old_device_location(
612670
self, mock_sync_interfaces, mock_fetch, mock_ironic_class, mock_nautobot
613671
):
614-
"""Test that device not found by UUID or name is created."""
672+
"""Test that location is preserved from old device when new node has none.
673+
674+
When re-enrolling a node that hasn't been inspected yet, we should use
675+
the location from the old Nautobot device to create the new one.
676+
"""
615677
node_uuid = str(uuid.uuid4())
678+
old_uuid = str(uuid.uuid4())
616679
device_info = DeviceInfo(
617680
uuid=node_uuid,
618681
name="Dell-ABC123",
619682
manufacturer="Dell",
620683
model="PowerEdge R640",
621-
location_id="location-uuid",
684+
# No location_id from switch lookup
622685
status="Active",
623686
)
624687
mock_fetch.return_value = (device_info, {}, [])
625688

626-
# Both lookups return None
627-
mock_nautobot.dcim.devices.get.side_effect = [None, None]
689+
# Old device has location
690+
existing_device = MagicMock()
691+
existing_device.id = old_uuid
692+
existing_device.name = "Dell-ABC123"
693+
existing_device.location = MagicMock(id="old-location-uuid")
694+
existing_device.rack = MagicMock(id="old-rack-uuid")
695+
existing_device.position = 42
696+
existing_device.face = MagicMock(value="front")
697+
698+
mock_nautobot.dcim.devices.get.side_effect = [None, existing_device]
628699
mock_nautobot.dcim.devices.create.return_value = MagicMock()
629700
mock_sync_interfaces.return_value = EXIT_STATUS_SUCCESS
630701

631702
result = sync_device_to_nautobot(node_uuid, mock_nautobot)
632703

633704
assert result == EXIT_STATUS_SUCCESS
705+
# Should delete old device after preserving location
706+
existing_device.delete.assert_called_once()
707+
# Should create new device
634708
mock_nautobot.dcim.devices.create.assert_called_once()
635709

636710

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ class NoEventHandlerError(Exception):
7272
"baremetal.portgroup.create.end": ironic_portgroup.handle_portgroup_create_update,
7373
"baremetal.portgroup.update.end": ironic_portgroup.handle_portgroup_create_update,
7474
"baremetal.portgroup.delete.end": ironic_portgroup.handle_portgroup_delete,
75-
"baremetal.node.create.end": nautobot_device_sync.handle_node_event,
7675
"baremetal.node.update.end": nautobot_device_sync.handle_node_event,
7776
"baremetal.node.delete.end": nautobot_device_sync.handle_node_delete_event,
7877
"baremetal.node.provision_set.end": [

python/understack-workflows/understack_workflows/oslo_event/nautobot_device_interface_sync.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,13 @@ def _handle_cable_management(
483483
nautobot_client: Nautobot,
484484
) -> None:
485485
"""Handle cable creation/update for interface with switch connection info."""
486-
if not interface.switch_info or not interface.switch_port_id:
486+
# Skip if switch info is missing or placeholder "None" string
487+
if (
488+
not interface.switch_info
489+
or not interface.switch_port_id
490+
or interface.switch_info == "None"
491+
or interface.switch_port_id == "None"
492+
):
487493
return
488494

489495
logger.debug(

0 commit comments

Comments
 (0)