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..e74f3a979 --- /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..84b08937c --- /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..92ee4994a 100644 --- a/python/understack-workflows/tests/test_oslo_event_ironic_port.py +++ b/python/understack-workflows/tests/test_oslo_event_ironic_port.py @@ -248,6 +248,82 @@ def test_cable_management_switch_not_found( assert result == 1 +class TestPortNameValidation: + """Test port name validation functionality.""" + + def test_should_fix_port_name_missing_prefix(self): + """Test name validation when prefix is missing.""" + from understack_workflows.oslo_event.ironic_port import _should_fix_port_name + + assert _should_fix_port_name("eth0", "server-123") is True + + def test_should_fix_port_name_correct_prefix(self): + """Test name validation when prefix is correct.""" + from understack_workflows.oslo_event.ironic_port import _should_fix_port_name + + assert _should_fix_port_name("server-123:eth0", "server-123") is False + + def test_should_fix_port_name_empty(self): + """Test name validation when name is empty.""" + from understack_workflows.oslo_event.ironic_port import _should_fix_port_name + + assert _should_fix_port_name("", "server-123") is False + + def test_get_node_name_success(self): + """Test successful node name retrieval.""" + from understack_workflows.oslo_event.ironic_port import _get_node_name + + mock_conn = Mock() + mock_node = Mock() + mock_node.name = "server-123" + mock_conn.baremetal.get_node.return_value = mock_node + + result = _get_node_name(mock_conn, "node-uuid") + + assert result == "server-123" + mock_conn.baremetal.get_node.assert_called_once_with("node-uuid") + + def test_get_node_name_not_found(self): + """Test node name retrieval when node not found.""" + from understack_workflows.oslo_event.ironic_port import _get_node_name + + mock_conn = Mock() + mock_conn.baremetal.get_node.return_value = None + + result = _get_node_name(mock_conn, "node-uuid") + + assert result is None + + def test_fix_port_name_if_needed(self): + """Test port name fixing.""" + from understack_workflows.oslo_event.ironic_port import IronicPortEvent + from understack_workflows.oslo_event.ironic_port import _fix_port_name_if_needed + + mock_conn = Mock() + mock_node = Mock() + mock_node.name = "server-123" + mock_conn.baremetal.get_node.return_value = mock_node + + event = IronicPortEvent( + uuid="test-uuid", + name="eth0", # Missing prefix + address="aa:bb:cc:dd:ee:ff", + node_uuid="node-uuid", + physical_network="test-network", + pxe_enabled=True, + remote_port_id=None, + remote_switch_info=None, + remote_switch_id=None, + ) + + _fix_port_name_if_needed(mock_conn, event) + + # Verify name was fixed + mock_conn.baremetal.update_port.assert_called_once_with( + "test-uuid", name="server-123:eth0" + ) + + class TestHandlePortCreateUpdate: """Test handle_port_create_update function.""" @@ -266,7 +342,12 @@ def mock_nautobot(self): @pytest.fixture def mock_conn(self): """Create mock connection.""" - return Mock() + conn = Mock() + # Mock node lookup for name validation + mock_node = Mock() + mock_node.name = "1327172-hp1" + conn.baremetal.get_node.return_value = mock_node + return conn def test_handle_port_create_update_with_remote_info( self, mock_conn, mock_nautobot, port_create_event_data 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..6d14607c4 --- /dev/null +++ b/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py @@ -0,0 +1,404 @@ +"""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 _get_node_name +from understack_workflows.oslo_event.ironic_portgroup import _should_fix_portgroup_name +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_colon(self): + """Test LAG name extraction with colon 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_colon(self): + """Test LAG name when no colon 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 colon + 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 TestHelperFunctions: + """Test helper functions.""" + + def test_get_node_name_success(self): + """Test successful node name retrieval.""" + mock_conn = Mock() + mock_node = Mock() + mock_node.name = "server-123" + mock_conn.baremetal.get_node.return_value = mock_node + + result = _get_node_name(mock_conn, "node-uuid") + + assert result == "server-123" + mock_conn.baremetal.get_node.assert_called_once_with("node-uuid") + + def test_get_node_name_not_found(self): + """Test node name retrieval when node not found.""" + mock_conn = Mock() + mock_conn.baremetal.get_node.return_value = None + + result = _get_node_name(mock_conn, "node-uuid") + + assert result is None + + def test_get_node_name_exception(self): + """Test node name retrieval when exception occurs.""" + mock_conn = Mock() + mock_conn.baremetal.get_node.side_effect = Exception("Connection error") + + result = _get_node_name(mock_conn, "node-uuid") + + assert result is None + + def test_should_fix_portgroup_name_missing_prefix(self): + """Test name validation when prefix is missing.""" + assert _should_fix_portgroup_name("bond0", "server-123") is True + + def test_should_fix_portgroup_name_correct_prefix(self): + """Test name validation when prefix is correct.""" + assert _should_fix_portgroup_name("server-123:bond0", "server-123") is False + + def test_should_fix_portgroup_name_none(self): + """Test name validation when name is None.""" + assert _should_fix_portgroup_name(None, "server-123") is False + + def test_should_fix_portgroup_name_empty(self): + """Test name validation when name is empty.""" + assert _should_fix_portgroup_name("", "server-123") is False + + +class TestHandlePortgroupCreateUpdate: + """Test handle_portgroup_create_update function.""" + + @pytest.fixture + def mock_conn(self): + """Create mock connection.""" + conn = Mock() + mock_node = Mock() + mock_node.name = "server-123" + conn.baremetal.get_node.return_value = mock_node + return conn + + @pytest.fixture + def mock_nautobot(self): + """Create mock nautobot instance.""" + nautobot = Mock() + return nautobot + + def test_create_portgroup_without_prefix( + self, mock_conn, mock_nautobot, portgroup_create_event_data + ): + """Test creating portgroup that needs name fixing.""" + # 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 name was fixed in Ironic + mock_conn.baremetal.update_port_group.assert_called_once_with( + "629b8821-6c0a-4a6f-9312-109fe8a0931f", name="server-123:bond0" + ) + + # 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_with_correct_prefix( + self, mock_conn, mock_nautobot, portgroup_update_event_data + ): + """Test updating portgroup that already has correct prefix.""" + # 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 name was NOT fixed in Ironic (already correct) + mock_conn.baremetal.update_port_group.assert_not_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_node_not_found( + self, mock_nautobot, portgroup_create_event_data + ): + """Test creating portgroup when node is not found.""" + # Mock connection with node not found + mock_conn = Mock() + mock_conn.baremetal.get_node.return_value = None + + # Test the function + result = handle_portgroup_create_update( + mock_conn, mock_nautobot, portgroup_create_event_data + ) + + # Verify error result + assert result == 1 + + # Verify no Nautobot operations were attempted + mock_nautobot.dcim.interfaces.get.assert_not_called() + mock_nautobot.dcim.interfaces.create.assert_not_called() + + 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 + + +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..4e7058b07 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py @@ -59,12 +59,67 @@ def from_event_dict(cls, data: dict) -> IronicPortEvent: ) +def _get_node_name(conn: Connection, node_uuid: str) -> str | None: + """Get the node name from Ironic by UUID.""" + try: + node = conn.baremetal.get_node(node_uuid) # pyright: ignore + return node.name if node else None + except Exception: + logger.exception("Failed to get node %s from Ironic", node_uuid) + return None + + +def _should_fix_port_name(name: str, node_name: str) -> bool: + """Check if port name needs to be prefixed with node name. + + Expected format: $NODENAME:$INTERFACE + """ + if not name: + return False + return not name.startswith(f"{node_name}:") + + +def _fix_port_name_if_needed(conn: Connection, event: IronicPortEvent) -> None: + """Fix port name by prefixing with node name if needed.""" + if not event.name: + logger.debug("Port %s has no name, skipping name validation", event.uuid) + return + + # Get the node name + node_name = _get_node_name(conn, event.node_uuid) + if not node_name: + logger.error("Could not get node name for node %s", event.node_uuid) + return + + # Check if name needs fixing + if not _should_fix_port_name(event.name, node_name): + logger.debug( + "Port %s name '%s' already has correct prefix", event.uuid, event.name + ) + return + + # Fix the name by prefixing with node name (format: $NODENAME:$INTERFACE) + new_name = f"{node_name}:{event.name}" + logger.info( + "Updating port %s name from '%s' to '%s'", event.uuid, event.name, new_name + ) + + try: + conn.baremetal.update_port(event.uuid, name=new_name) # pyright: ignore + logger.info("Successfully updated port %s name", event.uuid) + except Exception: + logger.exception("Failed to update port %s name", event.uuid) + + def handle_port_create_update( - _conn: Connection, nautobot: Nautobot, event_data: dict + conn: Connection, nautobot: Nautobot, event_data: dict ) -> int: """Operates on an Ironic Port create and update event.""" event = IronicPortEvent.from_event_dict(event_data) + # Check and fix port name if needed + _fix_port_name_if_needed(conn, event) + logger.debug("looking up interface in nautobot by UUID: %s", event.uuid) intf = nautobot.dcim.interfaces.get(id=event.uuid) if not intf: 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..21c11fdcc --- /dev/null +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import cast + +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 + 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: + return self.name.split(":", 1)[1] + else: + # If no colon, return as-is (shouldn't happen after validation) + logger.warning( + "Portgroup name '%s' does not contain colon 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 _get_node_name(conn: Connection, node_uuid: str) -> str | None: + """Get the node name from Ironic by UUID.""" + try: + node = conn.baremetal.get_node(node_uuid) # pyright: ignore + return node.name if node else None + except Exception: + logger.exception("Failed to get node %s from Ironic", node_uuid) + return None + + +def _should_fix_portgroup_name(name: str | None, node_name: str) -> bool: + """Check if portgroup name needs to be prefixed with node name. + + Expected format: $NODENAME:$INTERFACE + """ + if not name: + return False + return not name.startswith(f"{node_name}:") + + +def handle_portgroup_create_update( + conn: Connection, nautobot: Nautobot, event_data: dict +) -> int: + """Handle Ironic Portgroup create and update events.""" + event = IronicPortgroupEvent.from_event_dict(event_data) + + logger.info("Processing portgroup create/update event for %s", event.uuid) + + # Get the node name + node_name = _get_node_name(conn, event.node_uuid) + if not node_name: + logger.error("Could not get node name for node %s", event.node_uuid) + return 1 + + # Check if name needs fixing in Ironic (should be $NODENAME:$INTERFACE) + if event.name and _should_fix_portgroup_name(event.name, node_name): + new_name = f"{node_name}:{event.name}" + logger.info( + "Updating portgroup %s name from '%s' to '%s'", + event.uuid, + event.name, + new_name, + ) + try: + conn.baremetal.update_port_group(event.uuid, name=new_name) # pyright: ignore + logger.info("Successfully updated portgroup %s name in Ironic", event.uuid) + # Update the event object with the new name + event.name = new_name + except Exception: + logger.exception("Failed to update portgroup %s name in Ironic", event.uuid) + # Continue to create/update in Nautobot anyway + + # Create or update LAG interface in Nautobot + logger.debug("Looking up LAG interface in Nautobot by UUID: %s", event.uuid) + lag_intf = nautobot.dcim.interfaces.get(id=event.uuid) + + if not lag_intf: + logger.debug( + "Looking up LAG interface in Nautobot by device %s and name %s", + event.node_uuid, + event.lag_name, + ) + lag_intf = nautobot.dcim.interfaces.get( + device=event.node_uuid, name=event.lag_name + ) + + if not lag_intf: + logger.info("No LAG interface found in Nautobot, creating") + attrs = { + "id": event.uuid, + "name": event.lag_name, + "device": event.node_uuid, + "type": "lag", # This is the key - LAGs are interfaces with type="lag" + "status": "Active", + } + + # Add MAC address if available + if event.address: + attrs["mac_address"] = event.address + + # Add description with bonding mode info + if event.mode: + attrs["description"] = f"Bond mode: {event.mode}" + + try: + lag_intf = nautobot.dcim.interfaces.create(**attrs) + logger.info("Created LAG interface %s in Nautobot", event.uuid) + except Exception: + logger.exception( + "Failed to create LAG interface %s in Nautobot", event.uuid + ) + return 1 + else: + logger.info("Existing LAG interface found in Nautobot, updating") + lag_intf.name = event.lag_name # type: ignore + lag_intf.status = "Active" # type: ignore + lag_intf.type = "lag" # type: ignore + + if event.address: + lag_intf.mac_address = event.address # type: ignore + + if event.mode: + lag_intf.description = f"Bond mode: {event.mode}" # type: ignore + + try: + cast(Record, lag_intf).save() + logger.info("Updated LAG interface %s in Nautobot", event.uuid) + except Exception: + logger.exception( + "Failed to update LAG interface %s in Nautobot", event.uuid + ) + return 1 + + logger.info("LAG interface %s in sync with Nautobot", event.uuid) + 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