From 85e1fefb18a1c26ced00544be4a7e8a3607b1e57 Mon Sep 17 00:00:00 2001 From: haseeb Date: Wed, 19 Nov 2025 21:44:17 +0530 Subject: [PATCH] sync Ironic portgroups as LAG interfaces to Nautobot via Oslo event handlers --- components/site-workflows/kustomization.yaml | 1 + .../sensors/sensor-ironic-node-portgroup.yaml | 77 ++++ .../baremetal-portgroup-create-end.json | 1 + .../baremetal-portgroup-delete-end.json | 1 + .../baremetal-portgroup-update-end.json | 1 + .../tests/test_oslo_event_ironic_port.py | 32 +- .../tests/test_oslo_event_ironic_portgroup.py | 379 ++++++++++++++++++ .../main/openstack_oslo_event.py | 4 + .../oslo_event/ironic_port.py | 74 ++-- .../oslo_event/ironic_portgroup.py | 168 ++++++++ 10 files changed, 695 insertions(+), 43 deletions(-) create mode 100644 components/site-workflows/sensors/sensor-ironic-node-portgroup.yaml create mode 100644 python/understack-workflows/tests/json_samples/baremetal-portgroup-create-end.json create mode 100644 python/understack-workflows/tests/json_samples/baremetal-portgroup-delete-end.json create mode 100644 python/understack-workflows/tests/json_samples/baremetal-portgroup-update-end.json create mode 100644 python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py create mode 100644 python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py diff --git a/components/site-workflows/kustomization.yaml b/components/site-workflows/kustomization.yaml index 39a92161c..fd4605e00 100644 --- a/components/site-workflows/kustomization.yaml +++ b/components/site-workflows/kustomization.yaml @@ -22,6 +22,7 @@ resources: - sensors/sensor-neutron-olso-event.yaml - sensors/sensor-ironic-reclean.yaml - sensors/sensor-ironic-node-port.yaml + - sensors/sensor-ironic-node-portgroup.yaml - sensors/sensor-ironic-oslo-event.yaml helmCharts: diff --git a/components/site-workflows/sensors/sensor-ironic-node-portgroup.yaml b/components/site-workflows/sensors/sensor-ironic-node-portgroup.yaml new file mode 100644 index 000000000..41ef0da72 --- /dev/null +++ b/components/site-workflows/sensors/sensor-ironic-node-portgroup.yaml @@ -0,0 +1,77 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: ironic-node-portgroup + annotations: + workflows.argoproj.io/title: Sync Portgroups to Nautobot LAGs + workflows.argoproj.io/description: |+ + Triggers on the following Ironic Events: + + - baremetal.portgroup.create.end which happens when a baremetal portgroup is created + - baremetal.portgroup.update.end which happens when a portgroup is updated + - baremetal.portgroup.delete.end which happens when a portgroup is deleted + + This sensor: + 1. Validates that portgroup names are prefixed with the node name + 2. Creates/updates LAGs (Link Aggregation Groups) in Nautobot + 3. Strips the node name prefix when creating LAG names in Nautobot + + Resulting code should be very similar to: + + ``` + argo -n argo-events submit --from workflowtemplate/openstack-oslo-event \ + -p event-json "JSON-payload" + ``` + + Defined in `workflows/argo-events/sensors/ironic-node-portgroup.yaml` +spec: + dependencies: + - eventName: openstack + eventSourceName: openstack-ironic + name: ironic-dep + transform: + # the event is a string-ified JSON so we need to decode it + # replace the whole event body + jq: | + .body = (.body["oslo.message"] | fromjson) + filters: + # applies each of the items in data with 'and' but there's only one + dataLogicalOperator: "and" + data: + - path: "body.event_type" + type: "string" + value: + - "baremetal.portgroup.create.end" + - "baremetal.portgroup.update.end" + - "baremetal.portgroup.delete.end" + template: + serviceAccountName: sensor-submit-workflow + triggers: + - template: + name: ironic-node-portgroup + # creates workflow object directly via k8s API + k8s: + operation: create + parameters: + # first parameter is the parsed oslo.message + - dest: spec.arguments.parameters.0.value + src: + dataKey: body + dependencyName: ironic-dep + source: + # create a workflow in argo-events prefixed with ironic-node-portgroup- + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: ironic-node-portgroup- + namespace: argo-events + spec: + # defines the parameters being replaced above + arguments: + parameters: + - name: event-json + # references the workflow + workflowTemplateRef: + name: openstack-oslo-event diff --git a/python/understack-workflows/tests/json_samples/baremetal-portgroup-create-end.json b/python/understack-workflows/tests/json_samples/baremetal-portgroup-create-end.json new file mode 100644 index 000000000..3b4007acb --- /dev/null +++ b/python/understack-workflows/tests/json_samples/baremetal-portgroup-create-end.json @@ -0,0 +1 @@ +{"oslo.version": "2.0", "oslo.message": "{\"message_id\": \"e1d67320-b0ee-4931-8898-c0d50b30da5d\", \"publisher_id\": \"ironic-api.ironic-api-df96c5d6f-c5qc9\", \"event_type\": \"baremetal.portgroup.create.end\", \"priority\": \"INFO\", \"payload\": {\"ironic_object.name\": \"PortgroupCRUDPayload\", \"ironic_object.namespace\": \"ironic\", \"ironic_object.version\": \"1.0\", \"ironic_object.data\": {\"address\": \"52:54:00:aa:bb:cc\", \"extra\": {}, \"mode\": \"active-backup\", \"name\": \"bond0\", \"node_uuid\": \"7ca98881-bca5-4c82-9369-66eb36292a95\", \"properties\": {}, \"standalone_ports_supported\": true, \"created_at\": \"2025-05-06T15:24:51Z\", \"updated_at\": null, \"uuid\": \"629b8821-6c0a-4a6f-9312-109fe8a0931f\"}}, \"timestamp\": \"2025-05-06 15:24:51.499233\", \"_unique_id\": \"8b1280e345594bbb9dc4b57b85276431\"}"} diff --git a/python/understack-workflows/tests/json_samples/baremetal-portgroup-delete-end.json b/python/understack-workflows/tests/json_samples/baremetal-portgroup-delete-end.json new file mode 100644 index 000000000..93da9b7d6 --- /dev/null +++ b/python/understack-workflows/tests/json_samples/baremetal-portgroup-delete-end.json @@ -0,0 +1 @@ +{"oslo.version": "2.0", "oslo.message": "{\"message_id\": \"a3b89541-d2aa-6b53-0b00-e2f72d52fc7f\", \"publisher_id\": \"ironic-api.ironic-api-df96c5d6f-c5qc9\", \"event_type\": \"baremetal.portgroup.delete.end\", \"priority\": \"INFO\", \"payload\": {\"ironic_object.name\": \"PortgroupCRUDPayload\", \"ironic_object.namespace\": \"ironic\", \"ironic_object.version\": \"1.0\", \"ironic_object.data\": {\"address\": \"52:54:00:aa:bb:cc\", \"extra\": {}, \"mode\": \"802.3ad\", \"name\": \"server-123_bond0\", \"node_uuid\": \"7ca98881-bca5-4c82-9369-66eb36292a95\", \"properties\": {}, \"standalone_ports_supported\": true, \"created_at\": \"2025-05-06T15:24:51Z\", \"updated_at\": \"2025-05-06T16:30:00Z\", \"uuid\": \"629b8821-6c0a-4a6f-9312-109fe8a0931f\"}}, \"timestamp\": \"2025-05-06 17:00:00.789012\", \"_unique_id\": \"0d3402g567716ddd1fe6d79d07498653\"}"} diff --git a/python/understack-workflows/tests/json_samples/baremetal-portgroup-update-end.json b/python/understack-workflows/tests/json_samples/baremetal-portgroup-update-end.json new file mode 100644 index 000000000..e892f678d --- /dev/null +++ b/python/understack-workflows/tests/json_samples/baremetal-portgroup-update-end.json @@ -0,0 +1 @@ +{"oslo.version": "2.0", "oslo.message": "{\"message_id\": \"f2e78430-c1ff-5a42-9a99-d1e61c41eb6e\", \"publisher_id\": \"ironic-api.ironic-api-df96c5d6f-c5qc9\", \"event_type\": \"baremetal.portgroup.update.end\", \"priority\": \"INFO\", \"payload\": {\"ironic_object.name\": \"PortgroupCRUDPayload\", \"ironic_object.namespace\": \"ironic\", \"ironic_object.version\": \"1.0\", \"ironic_object.data\": {\"address\": \"52:54:00:aa:bb:cc\", \"extra\": {}, \"mode\": \"802.3ad\", \"name\": \"server-123_bond0\", \"node_uuid\": \"7ca98881-bca5-4c82-9369-66eb36292a95\", \"properties\": {}, \"standalone_ports_supported\": true, \"created_at\": \"2025-05-06T15:24:51Z\", \"updated_at\": \"2025-05-06T16:30:00Z\", \"uuid\": \"629b8821-6c0a-4a6f-9312-109fe8a0931f\"}}, \"timestamp\": \"2025-05-06 16:30:00.123456\", \"_unique_id\": \"9c2391f456605ccc0ed5c68c96387542\"}"} diff --git a/python/understack-workflows/tests/test_oslo_event_ironic_port.py b/python/understack-workflows/tests/test_oslo_event_ironic_port.py index d495dc09d..7ed7c43d1 100644 --- a/python/understack-workflows/tests/test_oslo_event_ironic_port.py +++ b/python/understack-workflows/tests/test_oslo_event_ironic_port.py @@ -50,7 +50,8 @@ def test_from_event_dict_create(self, port_create_event_data): assert event.uuid == "63a3c79c-dd84-4569-a398-cc795287300f" assert event.name == "1327172-hp1:NIC2-1" - assert event.interface_name == "NIC2-1" + # interface_name now returns the port name directly (MAC address) + assert event.interface_name == "1327172-hp1:NIC2-1" assert event.address == "00:11:0a:69:a9:99" assert event.node_uuid == "7ca98881-bca5-4c82-9369-66eb36292a95" assert event.physical_network == "f20-1-network" @@ -65,7 +66,8 @@ def test_from_event_dict_update(self, port_update_event_data): assert event.uuid == "438711ba-1bcd-4f19-8b34-53cdc6d61bc4" assert event.name == "1327172-hp1:NIC1-1" - assert event.interface_name == "NIC1-1" + # interface_name now returns the port name directly (MAC address) + assert event.interface_name == "1327172-hp1:NIC1-1" assert event.address == "00:11:0a:6a:c7:05" assert event.node_uuid == "7ca98881-bca5-4c82-9369-66eb36292a95" assert event.remote_port_id == "Ethernet1/1" @@ -78,19 +80,23 @@ def test_from_event_dict_delete(self, port_delete_event_data): assert event.uuid == "f8888f0b-1451-432e-9ae7-4b77303dd9ef" assert event.name == "f8888f0b-1451-432e-9ae7-4b77303dd9ef:NIC.Integrated.1-2" - assert event.interface_name == "NIC.Integrated.1-2" + # interface_name now returns the port name directly (MAC address) + assert ( + event.interface_name + == "f8888f0b-1451-432e-9ae7-4b77303dd9ef:NIC.Integrated.1-2" + ) assert event.address == "d4:04:e6:4f:64:5d" assert event.node_uuid == "74feccaf-3aae-401c-bc1f-eeeb26b9f542" assert event.remote_port_id == "Ethernet1/14" assert event.remote_switch_info == "f20-5-1f.iad3.rackspace.net" assert event.remote_switch_id == "f4:ee:31:c0:8c:b3" - def test_interface_name_parsing(self): - """Test interface name parsing from event name.""" + def test_interface_name_with_mac_address(self): + """Test interface name returns MAC address when set.""" event = IronicPortEvent( uuid="test-uuid", - name="1327172-hp1:NIC2-1", - address="00:11:22:33:44:55", + name="00110a69a999", # MAC address (normalized) + address="00:11:0a:69:a9:99", node_uuid="node-uuid", physical_network="test-network", pxe_enabled=True, @@ -98,13 +104,13 @@ def test_interface_name_parsing(self): remote_switch_info="switch1.example.com", remote_switch_id="aa:bb:cc:dd:ee:ff", ) - assert event.interface_name == "NIC2-1" + assert event.interface_name == "00110a69a999" - def test_interface_name_fallback(self): - """Test interface name fallback to UUID when parsing fails.""" + def test_interface_name_fallback_to_uuid(self): + """Test interface name falls back to UUID when name is empty.""" event = IronicPortEvent( - uuid="test-uuid", - name="no-colon-name", + uuid="test-uuid-123", + name="", # Empty name address="00:11:22:33:44:55", node_uuid="node-uuid", physical_network="test-network", @@ -113,7 +119,7 @@ def test_interface_name_fallback(self): remote_switch_info="switch1.example.com", remote_switch_id="aa:bb:cc:dd:ee:ff", ) - assert event.interface_name == "test-uuid" + assert event.interface_name == "test-uuid-123" class TestCableManagement: diff --git a/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py b/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py new file mode 100644 index 000000000..fb072bdda --- /dev/null +++ b/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py @@ -0,0 +1,379 @@ +"""Tests for ironic_portgroup_event functionality.""" + +import json +from unittest.mock import Mock + +import pytest + +from understack_workflows.oslo_event.ironic_portgroup import IronicPortgroupEvent +from understack_workflows.oslo_event.ironic_portgroup import ( + handle_portgroup_create_update, +) +from understack_workflows.oslo_event.ironic_portgroup import handle_portgroup_delete + + +@pytest.fixture +def portgroup_create_event_data(): + """Load portgroup create event data from JSON sample.""" + with open("tests/json_samples/baremetal-portgroup-create-end.json") as f: + raw_data = f.read() + + oslo_message = json.loads(raw_data) + return json.loads(oslo_message["oslo.message"]) + + +@pytest.fixture +def portgroup_update_event_data(): + """Load portgroup update event data from JSON sample.""" + with open("tests/json_samples/baremetal-portgroup-update-end.json") as f: + raw_data = f.read() + + oslo_message = json.loads(raw_data) + return json.loads(oslo_message["oslo.message"]) + + +@pytest.fixture +def portgroup_delete_event_data(): + """Load portgroup delete event data from JSON sample.""" + with open("tests/json_samples/baremetal-portgroup-delete-end.json") as f: + raw_data = f.read() + + oslo_message = json.loads(raw_data) + return json.loads(oslo_message["oslo.message"]) + + +class TestIronicPortgroupEvent: + """Test IronicPortgroupEvent class.""" + + def test_from_event_dict_create(self, portgroup_create_event_data): + """Test parsing of portgroup create event data.""" + event = IronicPortgroupEvent.from_event_dict(portgroup_create_event_data) + + assert event.uuid == "629b8821-6c0a-4a6f-9312-109fe8a0931f" + assert event.name == "bond0" + assert event.node_uuid == "7ca98881-bca5-4c82-9369-66eb36292a95" + assert event.address == "52:54:00:aa:bb:cc" + assert event.mode == "active-backup" + assert event.standalone_ports_supported is True + assert event.properties == {} + + def test_from_event_dict_update(self, portgroup_update_event_data): + """Test parsing of portgroup update event data.""" + event = IronicPortgroupEvent.from_event_dict(portgroup_update_event_data) + + assert event.uuid == "629b8821-6c0a-4a6f-9312-109fe8a0931f" + assert event.name == "server-123_bond0" + assert event.node_uuid == "7ca98881-bca5-4c82-9369-66eb36292a95" + assert event.address == "52:54:00:aa:bb:cc" + assert event.mode == "802.3ad" + + def test_from_event_dict_delete(self, portgroup_delete_event_data): + """Test parsing of portgroup delete event data.""" + event = IronicPortgroupEvent.from_event_dict(portgroup_delete_event_data) + + assert event.uuid == "629b8821-6c0a-4a6f-9312-109fe8a0931f" + assert event.name == "server-123_bond0" + assert event.node_uuid == "7ca98881-bca5-4c82-9369-66eb36292a95" + + def test_lag_name_with_underscore(self): + """Test LAG name extraction with underscore separator.""" + event = IronicPortgroupEvent( + uuid="test-uuid", + name="server-123_bond0", + node_uuid="node-uuid", + address="aa:bb:cc:dd:ee:ff", + mode="802.3ad", + properties={}, + standalone_ports_supported=True, + ) + assert event.lag_name == "bond0" + + def test_lag_name_without_underscore(self): + """Test LAG name when no underscore separator (returns as-is with warning).""" + event = IronicPortgroupEvent( + uuid="test-uuid", + name="bond0", + node_uuid="node-uuid", + address="aa:bb:cc:dd:ee:ff", + mode="802.3ad", + properties={}, + standalone_ports_supported=True, + ) + # Should return as-is when no underscore + assert event.lag_name == "bond0" + + def test_lag_name_complex_interface_name(self): + """Test LAG name extraction with complex interface name.""" + event = IronicPortgroupEvent( + uuid="test-uuid", + name="node-456_port-channel101", + node_uuid="node-uuid", + address="aa:bb:cc:dd:ee:ff", + mode="802.3ad", + properties={}, + standalone_ports_supported=True, + ) + assert event.lag_name == "port-channel101" + + def test_lag_name_fallback_to_uuid(self): + """Test LAG name fallback to UUID when name is None.""" + event = IronicPortgroupEvent( + uuid="test-uuid-123", + name=None, + node_uuid="node-uuid", + address="aa:bb:cc:dd:ee:ff", + mode="802.3ad", + properties={}, + standalone_ports_supported=True, + ) + assert event.lag_name == "test-uuid-123" + + +class TestHandlePortgroupCreateUpdate: + """Test handle_portgroup_create_update function.""" + + @pytest.fixture + def mock_conn(self): + """Create mock connection.""" + return Mock() + + @pytest.fixture + def mock_nautobot(self): + """Create mock nautobot instance.""" + nautobot = Mock() + return nautobot + + def test_create_portgroup( + self, mock_conn, mock_nautobot, portgroup_create_event_data + ): + """Test creating portgroup syncs to Nautobot only.""" + # Mock no existing LAG interface + mock_nautobot.dcim.interfaces.get.return_value = None + + # Mock LAG interface creation + created_lag = Mock() + created_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" + mock_nautobot.dcim.interfaces.create.return_value = created_lag + + # Test the function + result = handle_portgroup_create_update( + mock_conn, mock_nautobot, portgroup_create_event_data + ) + + # Verify result + assert result == 0 + + # Verify NO Ironic updates were made (inspection hook handles that) + assert ( + not hasattr(mock_conn.baremetal, "update_port_group") + or not mock_conn.baremetal.update_port_group.called + ) + + # Verify LAG interface was created in Nautobot + mock_nautobot.dcim.interfaces.create.assert_called_once() + call_args = mock_nautobot.dcim.interfaces.create.call_args[1] + assert call_args["id"] == "629b8821-6c0a-4a6f-9312-109fe8a0931f" + assert call_args["name"] == "bond0" # Stripped name + assert call_args["device"] == "7ca98881-bca5-4c82-9369-66eb36292a95" + assert call_args["type"] == "lag" + assert call_args["status"] == "Active" + assert call_args["mac_address"] == "52:54:00:aa:bb:cc" + assert call_args["description"] == "Bond mode: active-backup" + + def test_update_portgroup( + self, mock_conn, mock_nautobot, portgroup_update_event_data + ): + """Test updating portgroup syncs to Nautobot only.""" + # Mock existing LAG interface + existing_lag = Mock() + existing_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" + mock_nautobot.dcim.interfaces.get.return_value = existing_lag + + # Test the function + result = handle_portgroup_create_update( + mock_conn, mock_nautobot, portgroup_update_event_data + ) + + # Verify result + assert result == 0 + + # Verify NO Ironic updates were made (inspection hook handles that) + assert ( + not hasattr(mock_conn.baremetal, "update_port_group") + or not mock_conn.baremetal.update_port_group.called + ) + + # Verify LAG interface was updated in Nautobot + existing_lag.save.assert_called_once() + assert existing_lag.name == "bond0" # Stripped name + assert existing_lag.status == "Active" + assert existing_lag.type == "lag" + assert existing_lag.description == "Bond mode: 802.3ad" + + def test_create_portgroup_nautobot_error( + self, mock_conn, mock_nautobot, portgroup_create_event_data + ): + """Test creating portgroup when Nautobot creation fails.""" + # Mock no existing LAG interface + mock_nautobot.dcim.interfaces.get.return_value = None + + # Mock LAG interface creation failure + mock_nautobot.dcim.interfaces.create.side_effect = Exception( + "Nautobot API error" + ) + + # Test the function + result = handle_portgroup_create_update( + mock_conn, mock_nautobot, portgroup_create_event_data + ) + + # Verify error result + assert result == 1 + + def test_create_portgroup_without_mac_address(self, mock_conn, mock_nautobot): + """Test creating portgroup without MAC address.""" + # Create event data without MAC address + event_data = { + "payload": { + "ironic_object.data": { + "uuid": "test-uuid", + "name": "bond0", + "node_uuid": "node-uuid", + "address": None, # No MAC address + "mode": "802.3ad", + "properties": {}, + "standalone_ports_supported": True, + } + } + } + + # Mock no existing LAG interface + mock_nautobot.dcim.interfaces.get.return_value = None + + # Mock LAG interface creation + created_lag = Mock() + mock_nautobot.dcim.interfaces.create.return_value = created_lag + + # Test the function + result = handle_portgroup_create_update(mock_conn, mock_nautobot, event_data) + + # Verify result + assert result == 0 + + # Verify LAG interface was created without MAC address + call_args = mock_nautobot.dcim.interfaces.create.call_args[1] + assert "mac_address" not in call_args + + def test_create_portgroup_race_condition( + self, mock_conn, mock_nautobot, portgroup_create_event_data + ): + """Test handling race condition when interface is created by another process.""" + import pynautobot.core.query + + # Mock no existing LAG interface initially (both lookups return None) + existing_lag = Mock() + existing_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" + + # First get() call returns None (by UUID) + # Second get() (after race condition) returns the interface + mock_nautobot.dcim.interfaces.get.side_effect = [None, existing_lag] + + # Mock create() raising duplicate error (race condition) + mock_request = Mock() + mock_request.status_code = 400 + mock_request.text = "The fields device, name must make a unique set." + mock_request.json.return_value = { + "non_field_errors": ["The fields device, name must make a unique set."] + } + + # Create RequestError with proper structure + error = pynautobot.core.query.RequestError(mock_request) + error.req = mock_request # RequestError expects req attribute + # Make str(error) contain "unique set" + error.error = "The fields device, name must make a unique set." + + mock_nautobot.dcim.interfaces.create.side_effect = error + + # Test the function + result = handle_portgroup_create_update( + mock_conn, mock_nautobot, portgroup_create_event_data + ) + + # Verify result is success (race condition handled) + assert result == 0 + + # Verify create was attempted + assert mock_nautobot.dcim.interfaces.create.call_count == 1 + + # Verify get was called twice (initial lookup + after race) + assert mock_nautobot.dcim.interfaces.get.call_count == 2 + + # Verify the existing interface was updated + existing_lag.save.assert_called_once() + + +class TestHandlePortgroupDelete: + """Test handle_portgroup_delete function.""" + + @pytest.fixture + def mock_conn(self): + """Create mock connection.""" + return Mock() + + @pytest.fixture + def mock_nautobot(self): + """Create mock nautobot instance.""" + nautobot = Mock() + return nautobot + + def test_delete_portgroup_success( + self, mock_conn, mock_nautobot, portgroup_delete_event_data + ): + """Test successful portgroup deletion.""" + # Mock existing LAG interface + existing_lag = Mock() + existing_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" + mock_nautobot.dcim.interfaces.get.return_value = existing_lag + + # Test the function + result = handle_portgroup_delete( + mock_conn, mock_nautobot, portgroup_delete_event_data + ) + + # Verify result + assert result == 0 + + # Verify LAG interface was deleted + existing_lag.delete.assert_called_once() + + def test_delete_portgroup_not_found( + self, mock_conn, mock_nautobot, portgroup_delete_event_data + ): + """Test deleting portgroup when LAG interface not found.""" + # Mock LAG interface not found + mock_nautobot.dcim.interfaces.get.return_value = None + + # Test the function + result = handle_portgroup_delete( + mock_conn, mock_nautobot, portgroup_delete_event_data + ) + + # Verify result (success - nothing to delete) + assert result == 0 + + def test_delete_portgroup_nautobot_error( + self, mock_conn, mock_nautobot, portgroup_delete_event_data + ): + """Test deleting portgroup when Nautobot deletion fails.""" + # Mock existing LAG interface + existing_lag = Mock() + existing_lag.delete.side_effect = Exception("Nautobot API error") + mock_nautobot.dcim.interfaces.get.return_value = existing_lag + + # Test the function + result = handle_portgroup_delete( + mock_conn, mock_nautobot, portgroup_delete_event_data + ) + + # Verify error result + assert result == 1 diff --git a/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py b/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py index d182c766e..16e0e5893 100644 --- a/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py +++ b/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py @@ -15,6 +15,7 @@ from understack_workflows.openstack.client import get_openstack_client from understack_workflows.oslo_event import ironic_node from understack_workflows.oslo_event import ironic_port +from understack_workflows.oslo_event import ironic_portgroup from understack_workflows.oslo_event import keystone_project from understack_workflows.oslo_event import neutron_network from understack_workflows.oslo_event import neutron_subnet @@ -67,6 +68,9 @@ class NoEventHandlerError(Exception): "baremetal.port.create.end": ironic_port.handle_port_create_update, "baremetal.port.update.end": ironic_port.handle_port_create_update, "baremetal.port.delete.end": ironic_port.handle_port_delete, + "baremetal.portgroup.create.end": ironic_portgroup.handle_portgroup_create_update, + "baremetal.portgroup.update.end": ironic_portgroup.handle_portgroup_create_update, + "baremetal.portgroup.delete.end": ironic_portgroup.handle_portgroup_delete, "baremetal.node.provision_set.end": ironic_node.handle_provision_end, "identity.project.created": keystone_project.handle_project_created, "identity.project.updated": keystone_project.handle_project_updated, diff --git a/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py b/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py index e1b717cc4..67ba72785 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py @@ -29,7 +29,9 @@ class IronicPortEvent: @property def interface_name(self) -> str: try: - return self.name.split(":")[1] + if self.name: + return self.name + return self.uuid except Exception: return self.uuid @@ -62,41 +64,53 @@ def from_event_dict(cls, data: dict) -> IronicPortEvent: def handle_port_create_update( _conn: Connection, nautobot: Nautobot, event_data: dict ) -> int: - """Operates on an Ironic Port create and update event.""" + """Sync Ironic Port to Nautobot interface.""" event = IronicPortEvent.from_event_dict(event_data) - logger.debug("looking up interface in nautobot by UUID: %s", event.uuid) + logger.debug("Looking up interface %s in Nautobot", event.uuid) intf = nautobot.dcim.interfaces.get(id=event.uuid) - if not intf: - logger.debug( - "looking up interface in nautobot by device %s and name %s", - event.node_uuid, - event.interface_name, - ) - intf = nautobot.dcim.interfaces.get( - device=event.node_uuid, name=event.interface_name - ) + + # Prepare interface attributes + attrs = { + "name": event.interface_name, + "type": INTERFACE_TYPE, + "status": "Active", + "mac_address": event.address, + "device": event.node_uuid, + } if not intf: - logger.info("No interface found in nautobot, creating") - attrs = { - "id": event.uuid, - "name": event.interface_name, - "type": INTERFACE_TYPE, - "status": "Active", - "mac_address": event.address, - "device": event.node_uuid, - } - intf = nautobot.dcim.interfaces.create(**attrs) - else: - logger.info("Existing interface found in nautobot, updating") - intf.name = event.interface_name # type: ignore - intf.type = INTERFACE_TYPE # type: ignore - intf.status = "Active" # type: ignore - intf.mac_address = event.address # type: ignore - cast(Record, intf).save() + # Create new interface + logger.info("Creating interface %s in Nautobot", event.uuid) + attrs["id"] = event.uuid + + try: + intf = nautobot.dcim.interfaces.create(**attrs) + logger.info("Created interface %s", event.uuid) + except Exception as e: + # Handle race condition - another workflow created the interface + if "unique set" in str(e).lower(): + logger.info("Interface %s already exists, fetching", event.uuid) + intf = nautobot.dcim.interfaces.get(id=event.uuid) + if not intf: + logger.error("Interface %s not found", event.uuid) + return 1 + else: + logger.exception("Failed to create interface %s", event.uuid) + return 1 + + # Update interface attributes + logger.debug("Updating interface %s", event.uuid) + for key, value in attrs.items(): + if key != "id": # Don't update ID + setattr(intf, key, value) - logger.info("Interface %s in sync with nautobot", event.uuid) + try: + cast(Record, intf).save() + logger.info("Interface %s synced to Nautobot", event.uuid) + except Exception: + logger.exception("Failed to update interface %s", event.uuid) + return 1 # Handle cable management if we have remote switch connection information if event.remote_port_id and event.remote_switch_info: diff --git a/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py b/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py new file mode 100644 index 000000000..fd5fe741f --- /dev/null +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import cast + +import pynautobot.core.query +from openstack.connection import Connection +from pynautobot.core.api import Api as Nautobot +from pynautobot.core.response import Record + +logger = logging.getLogger(__name__) + + +@dataclass +class IronicPortgroupEvent: + uuid: str + name: str | None + node_uuid: str + address: str | None + mode: str | None + properties: dict + standalone_ports_supported: bool + + @property + def lag_name(self) -> str: + """Extract LAG interface name by stripping node name prefix. + + Expected format: $NODENAME_$INTERFACE (using underscore separator) + Example: "server-123_bond0" -> "bond0" + """ + if not self.name: + return self.uuid + + # Strip the node name prefix: "nodename_interface" -> "interface" + try: + if "_" in self.name: + parts = self.name.split("_", 1) + if len(parts) == 2: + return parts[1] + # If split didn't produce 2 parts, return as-is + logger.warning( + "Portgroup name '%s' has underscore but unexpected format", + self.name, + ) + return self.name + else: + # If no underscore, return as-is (shouldn't happen after validation) + logger.warning( + "Portgroup name '%s' does not contain underscore separator", + self.name, + ) + return self.name + except Exception: + logger.warning( + "Could not parse LAG interface name from '%s', using as-is", self.name + ) + return self.name + + @classmethod + def from_event_dict(cls, data: dict) -> IronicPortgroupEvent: + payload = data.get("payload") + if payload is None: + raise Exception("Invalid event. No 'payload'") + + # Extract the actual data from the nested ironic object structure + payload_data = payload.get("ironic_object.data") + if payload_data is None: + raise Exception("Invalid event. No 'ironic_object.data' in payload") + + return IronicPortgroupEvent( + payload_data["uuid"], + payload_data.get("name"), + payload_data["node_uuid"], + payload_data.get("address"), + payload_data.get("mode"), + payload_data.get("properties") or {}, + payload_data.get("standalone_ports_supported", True), + ) + + +def handle_portgroup_create_update( + _conn: Connection, nautobot: Nautobot, event_data: dict +) -> int: + """Sync Ironic Portgroup to Nautobot LAG interface.""" + event = IronicPortgroupEvent.from_event_dict(event_data) + + logger.debug("Looking up LAG interface %s in Nautobot", event.uuid) + lag_intf = nautobot.dcim.interfaces.get(id=event.uuid) + + # Prepare LAG interface attributes + attrs = { + "name": event.lag_name, + "device": event.node_uuid, + "type": "lag", + "status": "Active", + } + + if event.address: + attrs["mac_address"] = event.address + + if event.mode: + attrs["description"] = f"Bond mode: {event.mode}" + + if not lag_intf: + # Create new LAG interface + logger.info("Creating LAG interface %s in Nautobot", event.uuid) + attrs["id"] = event.uuid + + try: + lag_intf = nautobot.dcim.interfaces.create(**attrs) + logger.info("Created LAG interface %s", event.uuid) + except pynautobot.core.query.RequestError as e: + # Handle race condition - another workflow created the interface + if e.req.status_code == 400 and "unique set" in str(e).lower(): + logger.info("LAG interface %s already exists, fetching", event.uuid) + lag_intf = nautobot.dcim.interfaces.get(id=event.uuid) + if not lag_intf: + logger.error("LAG interface %s not found", event.uuid) + return 1 + else: + logger.exception("Failed to create LAG interface %s", event.uuid) + return 1 + except Exception: + logger.exception("Failed to create LAG interface %s", event.uuid) + return 1 + + # Update LAG interface attributes + logger.debug("Updating LAG interface %s", event.uuid) + for key, value in attrs.items(): + if key != "id": # Don't update ID + setattr(lag_intf, key, value) + + try: + cast(Record, lag_intf).save() + logger.info("LAG interface %s synced to Nautobot", event.uuid) + except Exception: + logger.exception("Failed to update LAG interface %s", event.uuid) + return 1 + + return 0 + + +def handle_portgroup_delete( + _conn: Connection, nautobot: Nautobot, event_data: dict +) -> int: + """Handle Ironic Portgroup delete event.""" + event = IronicPortgroupEvent.from_event_dict(event_data) + + logger.debug("Handling portgroup delete for LAG interface %s", event.uuid) + + # Find the LAG interface in Nautobot + lag_intf = nautobot.dcim.interfaces.get(id=event.uuid) + if not lag_intf: + logger.debug( + "LAG interface %s not found in Nautobot, nothing to delete", event.uuid + ) + return 0 + + # Delete the LAG interface + logger.info("Deleting LAG interface %s from Nautobot", event.uuid) + try: + cast(Record, lag_intf).delete() + logger.info("Successfully deleted LAG interface %s from Nautobot", event.uuid) + return 0 + except Exception: + logger.exception("Failed to delete LAG interface %s from Nautobot", event.uuid) + return 1