diff --git a/components/site-workflows/sensors/sensor-ironic-node-update.yaml b/components/site-workflows/sensors/sensor-ironic-node-update.yaml index 776b0a818..5dd9a5b0a 100644 --- a/components/site-workflows/sensors/sensor-ironic-node-update.yaml +++ b/components/site-workflows/sensors/sensor-ironic-node-update.yaml @@ -8,18 +8,15 @@ metadata: workflows.argoproj.io/description: |+ Triggers on the following Ironic Events: - - baremetal.node.provision_set.end which happens after a state change on the node - - baremetal.node.update.end which happens when node fields are updated. - - Currently parses out the following fields: - - - provision_state + - baremetal.node.create.end which happens when a baremetal node is created + - baremetal.node.update.end which happens when node fields are updated + - baremetal.node.delete.end which happens when a node is deleted Resulting code should be very similar to: ``` - argo -n argo-events submit --from workflowtemplate/sync-provision-state-to-nautobot \ - -p device_uuid=00000000-0000-0000-0000-000000000000 -p provision_state=available + argo -n argo-events submit --from workflowtemplate/openstack-oslo-event \ + -p event-json "JSON-payload" ``` Defined in `workflows/argo-events/sensors/ironic-node-update.yaml` @@ -30,16 +27,19 @@ spec: name: ironic-dep transform: # the event is a string-ified JSON so we need to decode it - jq: ".body[\"oslo.message\"] | fromjson" + # 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: "event_type" + - path: "body.event_type" type: "string" value: + - "baremetal.node.create.end" - "baremetal.node.update.end" - - "baremetal.node.provision_set.end" + - "baremetal.node.delete.end" template: serviceAccountName: sensor-submit-workflow triggers: @@ -49,25 +49,10 @@ spec: k8s: operation: create parameters: - # first parameter's value is replaced with the uuid + # first parameter is the parsed oslo.message - dest: spec.arguments.parameters.0.value src: - dataKey: payload.ironic_object\.data.uuid - dependencyName: ironic-dep - # second parameter's value is replaced with the provision_state - - dest: spec.arguments.parameters.1.value - src: - dataKey: payload.ironic_object\.data.provision_state - dependencyName: ironic-dep - # third parameter's value is replaced with the lessee - - dest: spec.arguments.parameters.2.value - src: - dataKey: payload.ironic_object\.data.lessee - dependencyName: ironic-dep - # fourth parameter's value is replaced with the resource_class - - dest: spec.arguments.parameters.3.value - src: - dataKey: payload.ironic_object\.data.resource_class + dataKey: body dependencyName: ironic-dep source: # create a workflow in argo-events prefixed with ironic-node-update- @@ -81,10 +66,7 @@ spec: # defines the parameters being replaced above arguments: parameters: - - name: device_uuid - - name: provision_state - - name: lessee - - name: resource_class + - name: event-json # references the workflow workflowTemplateRef: - name: sync-provision-state-to-nautobot + name: openstack-oslo-event diff --git a/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml b/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml index 02d87af6d..43cb66728 100644 --- a/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml +++ b/components/site-workflows/sensors/sensor-ironic-oslo-event.yaml @@ -10,14 +10,19 @@ metadata: - baremetal.node.provision_set.end which happens after a state change on the node + This sensor handles ALL provision state changes. The event handlers internally + filter for specific states: + - deploying: Sets up storage (volume connectors) for instances with storage enabled + - inspecting: Updates Nautobot device with inspection data (inventory, ports) + Resulting code should be very similar to: ``` - argo -n argo-events submit --from workflowtemplate/ironic-oslo-event \ - -p event-json "JSON-payload" -p device_id= -p project_id= + argo -n argo-events submit --from workflowtemplate/openstack-oslo-event \ + -p event-json "JSON-payload" ``` - Defined in `workflows/argo-events/sensors/sensor-ironic-oslo-event.yaml` + Defined in `components/site-workflows/sensors/sensor-ironic-oslo-event.yaml` spec: dependencies: - eventName: openstack @@ -39,10 +44,6 @@ spec: type: "string" value: - "baremetal.node.provision_set.end" - - path: "body.ironic_object.previous_provision_state" - type: "string" - value: - - "deploying" template: serviceAccountName: sensor-submit-workflow triggers: @@ -56,9 +57,9 @@ spec: src: dataKey: body dependencyName: ironic-dep - - dest: spec.arguments.parameters.1.value # device_id + - dest: spec.arguments.parameters.1.value # previous_provision_state src: - dataKey: body.ironic_object.uuid + dataKey: body.ironic_object.previous_provision_state dependencyName: ironic-dep - dest: spec.arguments.parameters.2.value # project_id src: @@ -79,7 +80,7 @@ spec: arguments: parameters: - name: event-json - - name: device_id + - name: previous_provision_state - name: project_id templates: - name: main @@ -93,6 +94,7 @@ spec: - name: event-json value: "{{workflow.parameters.event-json}}" - name: convert-project-id + when: "\"{{workflow.parameters.previous_provision_state}}\" == deploying" inline: script: image: python:alpine @@ -102,7 +104,7 @@ spec: project_id_without_dashes = "{{workflow.parameters.project_id}}" print(str(uuid.UUID(project_id_without_dashes))) - - name: ansible-storage-update - when: "\"{{steps.oslo-events.outputs.parameters.storage}}\" == wanted" + when: "\"{{steps.oslo-events.outputs.parameters.storage}}\" == wanted && \"{{workflow.parameters.previous_provision_state}}\" == deploying" templateRef: name: ansible-workflow-template template: ansible-run diff --git a/python/understack-workflows/tests/test_oslo_event_ironic_node.py b/python/understack-workflows/tests/test_oslo_event_ironic_node.py index 8948f2299..e2ad4b8b1 100644 --- a/python/understack-workflows/tests/test_oslo_event_ironic_node.py +++ b/python/understack-workflows/tests/test_oslo_event_ironic_node.py @@ -5,12 +5,70 @@ import pytest +from understack_workflows.oslo_event.ironic_node import IronicNodeEvent from understack_workflows.oslo_event.ironic_node import IronicProvisionSetEvent from understack_workflows.oslo_event.ironic_node import create_volume_connector from understack_workflows.oslo_event.ironic_node import handle_provision_end from understack_workflows.oslo_event.ironic_node import instance_nqn +class TestIronicNodeEvent: + """Test cases for IronicNodeEvent class.""" + + def test_from_event_dict_success(self): + """Test successful node event parsing.""" + event_data = { + "payload": { + "ironic_object.data": { + "uuid": "test-uuid-123", + "name": "test-node", + "provision_state": "available", + } + } + } + + event = IronicNodeEvent.from_event_dict(event_data) + + assert event.uuid == "test-uuid-123" + + def test_from_event_dict_minimal_data(self): + """Test parsing with only required UUID field.""" + event_data = {"payload": {"ironic_object.data": {"uuid": "minimal-uuid"}}} + + event = IronicNodeEvent.from_event_dict(event_data) + + assert event.uuid == "minimal-uuid" + + def test_from_event_dict_no_payload(self): + """Test event parsing with missing payload.""" + event_data = {} + + with pytest.raises(ValueError, match="Invalid event. No 'payload'"): + IronicNodeEvent.from_event_dict(event_data) + + def test_from_event_dict_no_ironic_object_data(self): + """Test event parsing with missing ironic_object.data.""" + event_data = {"payload": {"other_field": "value"}} + + with pytest.raises( + ValueError, match="Invalid event. No 'ironic_object.data' in payload" + ): + IronicNodeEvent.from_event_dict(event_data) + + def test_from_event_dict_missing_uuid(self): + """Test event parsing with missing UUID field.""" + event_data = {"payload": {"ironic_object.data": {"name": "test-node"}}} + + with pytest.raises(KeyError): # KeyError when uuid is missing + IronicNodeEvent.from_event_dict(event_data) + + def test_direct_initialization(self): + """Test direct initialization of IronicNodeEvent.""" + event = IronicNodeEvent(uuid="direct-uuid") + + assert event.uuid == "direct-uuid" + + class TestIronicProvisionSetEvent: """Test cases for IronicProvisionSetEvent class.""" @@ -124,6 +182,7 @@ def valid_event_data(self): "lessee": uuid.uuid4(), "event": "provision_end", "uuid": uuid.uuid4(), + "previous_provision_state": "deploying", } }, } @@ -264,9 +323,26 @@ def test_handle_provision_end_storage_metadata_missing( mock_server.metadata = {"other_key": "value"} mock_conn.get_server_by_id.return_value = mock_server - # This should raise a KeyError when accessing metadata["storage"] - with pytest.raises(KeyError): - handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + # When storage key is missing, it should treat it as "not-set" + result = handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + + assert result == 0 + ironic_data = valid_event_data["payload"]["ironic_object.data"] + instance_uuid = ironic_data["instance_uuid"] + node_uuid = ironic_data["uuid"] + + mock_conn.get_server_by_id.assert_called_once_with(instance_uuid) + + # Check save_output calls - storage should be "not-set" when key is missing + expected_calls = [ + ("storage", "not-set"), + ("node_uuid", str(node_uuid)), + ("instance_uuid", str(instance_uuid)), + ] + actual_calls = [call.args for call in mock_save_output.call_args_list] + assert actual_calls == expected_calls + + mock_create_connector.assert_called_once() @patch("understack_workflows.oslo_event.ironic_node.is_project_svm_enabled") def test_handle_provision_end_invalid_event_data( 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 764ed476c..b2f86bd1a 100644 --- a/python/understack-workflows/tests/test_oslo_event_ironic_port.py +++ b/python/understack-workflows/tests/test_oslo_event_ironic_port.py @@ -344,6 +344,161 @@ def mock_interface_get(*args, **kwargs): # Verify cable creation was called mock_nautobot.dcim.cables.create.assert_called_once() + def test_handle_port_create_update_without_lldp_data( + self, mock_conn, mock_nautobot + ): + """Test handling port create/update without LLDP data.""" + # Create event data without local_link_connection + event_data = { + "event_type": "baremetal.port.create.end", + "payload": { + "ironic_object.data": { + "uuid": "test-port-uuid", + "name": "test-port", + "address": "aa:bb:cc:dd:ee:ff", + "node_uuid": "test-node-uuid", + "physical_network": "test-network", + "pxe_enabled": True, + "extra": {}, + "local_link_connection": {}, # Empty LLDP data + } + }, + } + + # Mock interface creation + created_interface = Mock() + created_interface.id = "test-port-uuid" + created_interface.cable = None + mock_nautobot.dcim.interfaces.create.return_value = created_interface + mock_nautobot.dcim.interfaces.get.return_value = None + + # Test the function + result = handle_port_create_update(mock_conn, mock_nautobot, event_data) + + # Verify result + assert result == 0 + + # Verify interface creation was called + mock_nautobot.dcim.interfaces.create.assert_called_once() + + # Verify cable creation was NOT called (no LLDP data) + mock_nautobot.dcim.cables.create.assert_not_called() + + def test_handle_port_create_update_with_partial_lldp_data( + self, mock_conn, mock_nautobot + ): + """Test port create/update with partial LLDP data (missing switch_info).""" + # Create event data with partial local_link_connection (only port_id) + event_data = { + "event_type": "baremetal.port.create.end", + "payload": { + "ironic_object.data": { + "uuid": "test-port-uuid", + "name": "test-port", + "address": "aa:bb:cc:dd:ee:ff", + "node_uuid": "test-node-uuid", + "physical_network": "test-network", + "pxe_enabled": True, + "extra": {}, + "local_link_connection": { + "port_id": "Ethernet1/1", + # Missing switch_info + }, + } + }, + } + + # Mock interface creation + created_interface = Mock() + created_interface.id = "test-port-uuid" + created_interface.cable = None + mock_nautobot.dcim.interfaces.create.return_value = created_interface + mock_nautobot.dcim.interfaces.get.return_value = None + + # Test the function + result = handle_port_create_update(mock_conn, mock_nautobot, event_data) + + # Verify result + assert result == 0 + + # Verify interface creation was called + mock_nautobot.dcim.interfaces.create.assert_called_once() + + # Verify cable creation was NOT called (incomplete LLDP data) + mock_nautobot.dcim.cables.create.assert_not_called() + + def test_handle_port_update_merges_data_from_multiple_inspections( + self, mock_conn, mock_nautobot + ): + """Test that port updates merge data from Redfish and Agent inspections.""" + # Scenario: Redfish inspection creates port with bios_name but no LLDP + # Then Agent inspection adds LLDP data + + # Mock existing interface (already created by Redfish inspection) + existing_interface = Mock() + existing_interface.id = "test-port-uuid" + existing_interface.name = "NIC.Integrated.1-1" # From Redfish + existing_interface.type = "25gbase-x-sfp28" + existing_interface.status = "Active" + existing_interface.mac_address = "aa:bb:cc:dd:ee:ff" + existing_interface.device = "test-node-uuid" + existing_interface.cable = None + mock_nautobot.dcim.interfaces.get.return_value = existing_interface + + # Agent inspection event (adds LLDP data) + agent_event = { + "event_type": "baremetal.port.update.end", + "payload": { + "ironic_object.data": { + "uuid": "test-port-uuid", + "name": "port-name", # Linux name, not bios_name + "address": "aa:bb:cc:dd:ee:ff", + "node_uuid": "test-node-uuid", + "physical_network": "test-network", + "pxe_enabled": True, + "extra": {"bios_name": "NIC.Integrated.1-1"}, + "local_link_connection": { + "port_id": "Ethernet1/1", + "switch_info": "switch1.example.com", + "switch_id": "aa:bb:cc:dd:ee:00", + }, + } + }, + } + + # Mock switch interface for cable creation + switch_interface = Mock() + switch_interface.id = "switch-interface-123" + + def mock_interface_get(*args, **kwargs): + if "id" in kwargs: + # Return existing interface + return existing_interface + elif "device" in kwargs and "name" in kwargs: + # Switch interface lookup + return switch_interface + return None + + mock_nautobot.dcim.interfaces.get.side_effect = mock_interface_get + + # Mock cable creation + created_cable = Mock() + created_cable.id = "cable-123" + mock_nautobot.dcim.cables.create.return_value = created_cable + + # Test the function with agent event (which has LLDP data) + result = handle_port_create_update(mock_conn, mock_nautobot, agent_event) + + # Verify result + assert result == 0 + + # Verify interface name was NOT overwritten (should keep bios_name) + # The interface already has the correct name, so it shouldn't be changed + assert existing_interface.name == "NIC.Integrated.1-1" + + # Verify cable was created with LLDP data + mock_nautobot.dcim.cables.create.assert_called_once() + class TestHandlePortDelete: """Test handle_port_delete function.""" @@ -401,3 +556,35 @@ def test_handle_port_delete_interface_not_found( # Verify no cable operations were attempted mock_nautobot.dcim.cables.get.assert_not_called() + + def test_handle_port_delete_cable_on_termination_b( + self, mock_conn, mock_nautobot, port_delete_event_data + ): + """Test handling port delete when cable is on termination_b side.""" + # Mock interface + interface = Mock() + interface.id = "interface-123" + mock_nautobot.dcim.interfaces.get.return_value = interface + + # Mock cable on termination_b side + cable_on_b_side = Mock() + cable_on_b_side.id = "cable-456" + + # First call (termination_a) returns None, + # second call (termination_b) returns cable + mock_nautobot.dcim.cables.get.side_effect = [None, cable_on_b_side] + + # Test the function + result = handle_port_delete(mock_conn, mock_nautobot, port_delete_event_data) + + # Verify result + assert result == 0 + + # Verify cable deletion was called + cable_on_b_side.delete.assert_called_once() + + # Verify interface deletion was called + interface.delete.assert_called_once() + + # Verify both cable queries were made + assert mock_nautobot.dcim.cables.get.call_count == 2 diff --git a/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py b/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py index fb072bdda..83b69398c 100644 --- a/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py +++ b/python/understack-workflows/tests/test_oslo_event_ironic_portgroup.py @@ -155,6 +155,9 @@ def test_create_portgroup( created_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" mock_nautobot.dcim.interfaces.create.return_value = created_lag + # Mock no member ports + mock_conn.baremetal.ports.return_value = [] + # Test the function result = handle_portgroup_create_update( mock_conn, mock_nautobot, portgroup_create_event_data @@ -180,6 +183,11 @@ def test_create_portgroup( assert call_args["mac_address"] == "52:54:00:aa:bb:cc" assert call_args["description"] == "Bond mode: active-backup" + # Verify member ports were queried + mock_conn.baremetal.ports.assert_called_once_with( + details=True, portgroup_uuid="629b8821-6c0a-4a6f-9312-109fe8a0931f" + ) + def test_update_portgroup( self, mock_conn, mock_nautobot, portgroup_update_event_data ): @@ -189,6 +197,9 @@ def test_update_portgroup( existing_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" mock_nautobot.dcim.interfaces.get.return_value = existing_lag + # Mock no member ports + mock_conn.baremetal.ports.return_value = [] + # Test the function result = handle_portgroup_create_update( mock_conn, mock_nautobot, portgroup_update_event_data @@ -294,6 +305,9 @@ def test_create_portgroup_race_condition( mock_nautobot.dcim.interfaces.create.side_effect = error + # Mock no member ports + mock_conn.baremetal.ports.return_value = [] + # Test the function result = handle_portgroup_create_update( mock_conn, mock_nautobot, portgroup_create_event_data @@ -311,6 +325,100 @@ def test_create_portgroup_race_condition( # Verify the existing interface was updated existing_lag.save.assert_called_once() + def test_create_portgroup_with_member_ports( + self, mock_conn, mock_nautobot, portgroup_create_event_data + ): + """Test creating portgroup with member ports associates them correctly.""" + # 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 + + # Mock member ports from Ironic + member_port1 = Mock() + member_port1.id = "port-uuid-1" + member_port1.uuid = "port-uuid-1" + member_port2 = Mock() + member_port2.id = "port-uuid-2" + member_port2.uuid = "port-uuid-2" + mock_conn.baremetal.ports.return_value = [member_port1, member_port2] + + # Mock member interfaces in Nautobot + member_intf1 = Mock() + member_intf1.id = "port-uuid-1" + member_intf2 = Mock() + member_intf2.id = "port-uuid-2" + + # Setup get() to return different interfaces based on call + def get_interface_side_effect(id): + if id == "629b8821-6c0a-4a6f-9312-109fe8a0931f": + return None # LAG doesn't exist yet + elif id == "port-uuid-1": + return member_intf1 + elif id == "port-uuid-2": + return member_intf2 + return None + + mock_nautobot.dcim.interfaces.get.side_effect = get_interface_side_effect + + # Test the function + result = handle_portgroup_create_update( + mock_conn, mock_nautobot, portgroup_create_event_data + ) + + # Verify result + assert result == 0 + + # Verify member ports were queried from Ironic + mock_conn.baremetal.ports.assert_called_once_with( + details=True, portgroup_uuid="629b8821-6c0a-4a6f-9312-109fe8a0931f" + ) + + # Verify member interfaces were associated with LAG + assert member_intf1.lag == "629b8821-6c0a-4a6f-9312-109fe8a0931f" + assert member_intf2.lag == "629b8821-6c0a-4a6f-9312-109fe8a0931f" + member_intf1.save.assert_called_once() + member_intf2.save.assert_called_once() + + def test_create_portgroup_member_port_not_in_nautobot( + self, mock_conn, mock_nautobot, portgroup_create_event_data + ): + """Test creating portgroup when member port doesn't exist in Nautobot.""" + # 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 + + # Mock member port from Ironic + member_port = Mock() + member_port.id = "port-uuid-1" + member_port.uuid = "port-uuid-1" + mock_conn.baremetal.ports.return_value = [member_port] + + # Mock member interface not found in Nautobot + def get_interface_side_effect(id): + if id == "629b8821-6c0a-4a6f-9312-109fe8a0931f": + return None # LAG doesn't exist yet + elif id == "port-uuid-1": + return None # Member interface not found + return None + + mock_nautobot.dcim.interfaces.get.side_effect = get_interface_side_effect + + # Test the function - should succeed even if member not found + result = handle_portgroup_create_update( + mock_conn, mock_nautobot, portgroup_create_event_data + ) + + # Verify result is still success (LAG created, member skipped) + assert result == 0 + class TestHandlePortgroupDelete: """Test handle_portgroup_delete function.""" @@ -335,6 +443,9 @@ def test_delete_portgroup_success( existing_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" mock_nautobot.dcim.interfaces.get.return_value = existing_lag + # Mock no member ports + mock_conn.baremetal.ports.return_value = [] + # Test the function result = handle_portgroup_delete( mock_conn, mock_nautobot, portgroup_delete_event_data @@ -343,6 +454,11 @@ def test_delete_portgroup_success( # Verify result assert result == 0 + # Verify member ports were queried + mock_conn.baremetal.ports.assert_called_once_with( + details=True, portgroup_uuid="629b8821-6c0a-4a6f-9312-109fe8a0931f" + ) + # Verify LAG interface was deleted existing_lag.delete.assert_called_once() @@ -361,6 +477,9 @@ def test_delete_portgroup_not_found( # Verify result (success - nothing to delete) assert result == 0 + # Verify member ports were NOT queried (LAG doesn't exist) + mock_conn.baremetal.ports.assert_not_called() + def test_delete_portgroup_nautobot_error( self, mock_conn, mock_nautobot, portgroup_delete_event_data ): @@ -370,6 +489,9 @@ def test_delete_portgroup_nautobot_error( existing_lag.delete.side_effect = Exception("Nautobot API error") mock_nautobot.dcim.interfaces.get.return_value = existing_lag + # Mock no member ports + mock_conn.baremetal.ports.return_value = [] + # Test the function result = handle_portgroup_delete( mock_conn, mock_nautobot, portgroup_delete_event_data @@ -377,3 +499,63 @@ def test_delete_portgroup_nautobot_error( # Verify error result assert result == 1 + + def test_delete_portgroup_with_member_ports( + self, mock_conn, mock_nautobot, portgroup_delete_event_data + ): + """Test deleting portgroup clears member port associations.""" + # Mock existing LAG interface + existing_lag = Mock() + existing_lag.id = "629b8821-6c0a-4a6f-9312-109fe8a0931f" + mock_nautobot.dcim.interfaces.get.return_value = existing_lag + + # Mock member ports from Ironic + member_port1 = Mock() + member_port1.id = "port-uuid-1" + member_port1.uuid = "port-uuid-1" + member_port2 = Mock() + member_port2.id = "port-uuid-2" + member_port2.uuid = "port-uuid-2" + mock_conn.baremetal.ports.return_value = [member_port1, member_port2] + + # Mock member interfaces in Nautobot + member_intf1 = Mock() + member_intf1.id = "port-uuid-1" + member_intf1.lag = "629b8821-6c0a-4a6f-9312-109fe8a0931f" + member_intf2 = Mock() + member_intf2.id = "port-uuid-2" + member_intf2.lag = "629b8821-6c0a-4a6f-9312-109fe8a0931f" + + # Setup get() to return different interfaces based on call + def get_interface_side_effect(id): + if id == "629b8821-6c0a-4a6f-9312-109fe8a0931f": + return existing_lag + elif id == "port-uuid-1": + return member_intf1 + elif id == "port-uuid-2": + return member_intf2 + return None + + mock_nautobot.dcim.interfaces.get.side_effect = get_interface_side_effect + + # Test the function + result = handle_portgroup_delete( + mock_conn, mock_nautobot, portgroup_delete_event_data + ) + + # Verify result + assert result == 0 + + # Verify member ports were queried from Ironic + mock_conn.baremetal.ports.assert_called_once_with( + details=True, portgroup_uuid="629b8821-6c0a-4a6f-9312-109fe8a0931f" + ) + + # Verify member interfaces had LAG association cleared + assert member_intf1.lag is None + assert member_intf2.lag is None + member_intf1.save.assert_called_once() + member_intf2.save.assert_called_once() + + # Verify LAG interface was deleted + existing_lag.delete.assert_called_once() diff --git a/python/understack-workflows/tests/test_oslo_event_provision_state_sync.py b/python/understack-workflows/tests/test_oslo_event_provision_state_sync.py new file mode 100644 index 000000000..80c4542cf --- /dev/null +++ b/python/understack-workflows/tests/test_oslo_event_provision_state_sync.py @@ -0,0 +1,451 @@ +"""Unit tests for provision_state_sync oslo event handler.""" + +import uuid +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from understack_workflows.oslo_event.provision_state_sync import handle_provision_end + + +class TestHandleProvisionEnd: + """Test cases for handle_provision_end function.""" + + @pytest.fixture + def mock_conn(self): + """Create a mock OpenStack connection.""" + return MagicMock() + + @pytest.fixture + def mock_nautobot(self): + """Create a mock Nautobot API instance.""" + mock = MagicMock() + mock.base_url = "http://nautobot.example.com" + mock.token = "test-token" + return mock + + @pytest.fixture + def valid_event_data(self): + """Create valid event data for testing.""" + node_uuid = uuid.uuid4() + lessee_uuid = uuid.uuid4() + return { + "payload": { + "ironic_object.data": { + "uuid": str(node_uuid), + "provision_state": "active", + "lessee": str(lessee_uuid), + "resource_class": "baremetal", + } + } + } + + @pytest.fixture + def minimal_event_data(self): + """Create minimal valid event data (only required fields).""" + node_uuid = uuid.uuid4() + return { + "payload": { + "ironic_object.data": { + "uuid": str(node_uuid), + "provision_state": "available", + } + } + } + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_success_with_all_fields( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + valid_event_data, + ): + """Test successful handling with all fields present.""" + # Setup mocks + mock_mapper.translate_to_nautobot.return_value = "Active" + mock_nb_helper = MagicMock() + mock_nb_helper_class.return_value = mock_nb_helper + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + + # Verify + assert result == 0 + + # Check mapper was called + mock_mapper.translate_to_nautobot.assert_called_once_with("active") + + # Check NautobotHelper was initialized correctly + mock_nb_helper_class.assert_called_once_with( + url="http://nautobot.example.com", + token="test-token", + logger=mock_nb_helper_class.call_args[1]["logger"], + session=mock_nautobot, + ) + + # Check update_cf was called with correct parameters + ironic_data = valid_event_data["payload"]["ironic_object.data"] + expected_device_uuid = uuid.UUID(ironic_data["uuid"]) + expected_tenant_uuid = uuid.UUID(ironic_data["lessee"]) + + mock_nb_helper.update_cf.assert_called_once_with( + device_id=expected_device_uuid, + tenant_id=expected_tenant_uuid, + fields={ + "ironic_provision_state": "active", + "resource_class": "baremetal", + }, + ) + + # Check update_device_status was called + mock_nb_helper.update_device_status.assert_called_once_with( + expected_device_uuid, "Active" + ) + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_success_minimal_fields( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + minimal_event_data, + ): + """Test successful handling with only required fields.""" + # Setup mocks + mock_mapper.translate_to_nautobot.return_value = "Planned" + mock_nb_helper = MagicMock() + mock_nb_helper_class.return_value = mock_nb_helper + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, minimal_event_data) + + # Verify + assert result == 0 + + # Check update_cf was called with minimal fields + ironic_data = minimal_event_data["payload"]["ironic_object.data"] + expected_device_uuid = uuid.UUID(ironic_data["uuid"]) + + mock_nb_helper.update_cf.assert_called_once_with( + device_id=expected_device_uuid, + tenant_id=None, + fields={ + "ironic_provision_state": "available", + }, + ) + + # Check update_device_status was called + mock_nb_helper.update_device_status.assert_called_once_with( + expected_device_uuid, "Planned" + ) + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_no_status_mapping( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + valid_event_data, + ): + """Test handling when provision state has no Nautobot status mapping.""" + # Setup mocks - return None for intermediate states + mock_mapper.translate_to_nautobot.return_value = None + mock_nb_helper = MagicMock() + mock_nb_helper_class.return_value = mock_nb_helper + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + + # Verify + assert result == 0 + + # Check update_cf was still called + mock_nb_helper.update_cf.assert_called_once() + + # Check update_device_status was NOT called + mock_nb_helper.update_device_status.assert_not_called() + + def test_handle_provision_end_missing_payload(self, mock_conn, mock_nautobot): + """Test handling with missing payload.""" + event_data = {} + + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + assert result == 1 + + def test_handle_provision_end_missing_ironic_object_data( + self, mock_conn, mock_nautobot + ): + """Test handling with missing ironic_object.data.""" + event_data = {"payload": {"other_field": "value"}} + + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + assert result == 1 + + def test_handle_provision_end_missing_uuid(self, mock_conn, mock_nautobot): + """Test handling with missing node UUID.""" + event_data = { + "payload": { + "ironic_object.data": { + "provision_state": "active", + } + } + } + + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + assert result == 1 + + def test_handle_provision_end_missing_provision_state( + self, mock_conn, mock_nautobot + ): + """Test handling with missing provision state.""" + event_data = { + "payload": { + "ironic_object.data": { + "uuid": str(uuid.uuid4()), + } + } + } + + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + assert result == 1 + + def test_handle_provision_end_invalid_node_uuid(self, mock_conn, mock_nautobot): + """Test handling with invalid node UUID format.""" + event_data = { + "payload": { + "ironic_object.data": { + "uuid": "not-a-valid-uuid", + "provision_state": "active", + } + } + } + + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + assert result == 1 + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_invalid_lessee_uuid( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + ): + """Test handling with invalid lessee UUID (should log warning but continue).""" + # Setup mocks + mock_mapper.translate_to_nautobot.return_value = "Active" + mock_nb_helper = MagicMock() + mock_nb_helper_class.return_value = mock_nb_helper + + event_data = { + "payload": { + "ironic_object.data": { + "uuid": str(uuid.uuid4()), + "provision_state": "active", + "lessee": "not-a-valid-uuid", + } + } + } + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + # Should succeed but with tenant_id=None + assert result == 0 + + # Check update_cf was called with tenant_id=None + call_args = mock_nb_helper.update_cf.call_args + assert call_args[1]["tenant_id"] is None + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_nautobot_update_cf_fails( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + valid_event_data, + ): + """Test handling when Nautobot update_cf fails.""" + # Setup mocks + mock_mapper.translate_to_nautobot.return_value = "Active" + mock_nb_helper = MagicMock() + mock_nb_helper.update_cf.side_effect = Exception("Nautobot API error") + mock_nb_helper_class.return_value = mock_nb_helper + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + + # Should return error + assert result == 1 + + # update_device_status should not be called + mock_nb_helper.update_device_status.assert_not_called() + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_nautobot_update_status_fails( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + valid_event_data, + ): + """Test handling when Nautobot update_device_status fails.""" + # Setup mocks + mock_mapper.translate_to_nautobot.return_value = "Active" + mock_nb_helper = MagicMock() + mock_nb_helper.update_cf.return_value = True + mock_nb_helper.update_device_status.side_effect = Exception( + "Status update failed" + ) + mock_nb_helper_class.return_value = mock_nb_helper + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, valid_event_data) + + # Should return error + assert result == 1 + + # update_cf should have been called + mock_nb_helper.update_cf.assert_called_once() + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_different_provision_states( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + ): + """Test handling different provision states.""" + mock_nb_helper = MagicMock() + mock_nb_helper_class.return_value = mock_nb_helper + + test_cases = [ + ("available", "Planned"), + ("active", "Active"), + ("deploying", "Staged"), + ("deploy failed", "Quarantine"), + ("error", "Quarantine"), + ("inspecting", "Inventory"), + ] + + for provision_state, expected_status in test_cases: + # Reset mocks + mock_mapper.reset_mock() + mock_nb_helper.reset_mock() + + # Setup + mock_mapper.translate_to_nautobot.return_value = expected_status + + event_data = { + "payload": { + "ironic_object.data": { + "uuid": str(uuid.uuid4()), + "provision_state": provision_state, + } + } + } + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + # Verify + assert result == 0 + mock_mapper.translate_to_nautobot.assert_called_once_with(provision_state) + mock_nb_helper.update_device_status.assert_called_once() + status_call_args = mock_nb_helper.update_device_status.call_args + assert status_call_args[0][1] == expected_status + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_with_resource_class( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + ): + """Test handling with different resource classes.""" + mock_mapper.translate_to_nautobot.return_value = "Active" + mock_nb_helper = MagicMock() + mock_nb_helper_class.return_value = mock_nb_helper + + resource_classes = ["baremetal", "compute", "storage", "network"] + + for resource_class in resource_classes: + # Reset mocks + mock_nb_helper.reset_mock() + + event_data = { + "payload": { + "ironic_object.data": { + "uuid": str(uuid.uuid4()), + "provision_state": "active", + "resource_class": resource_class, + } + } + } + + # Execute + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + # Verify + assert result == 0 + call_args = mock_nb_helper.update_cf.call_args + assert call_args[1]["fields"]["resource_class"] == resource_class + + @patch("understack_workflows.oslo_event.provision_state_sync.NautobotHelper") + @patch("understack_workflows.oslo_event.provision_state_sync.ProvisionStateMapper") + def test_handle_provision_end_uuid_formats( + self, + mock_mapper, + mock_nb_helper_class, + mock_conn, + mock_nautobot, + ): + """Test handling with different UUID formats (with/without dashes).""" + mock_mapper.translate_to_nautobot.return_value = "Active" + mock_nb_helper = MagicMock() + mock_nb_helper_class.return_value = mock_nb_helper + + # Test with UUID object + node_uuid_obj = uuid.uuid4() + lessee_uuid_obj = uuid.uuid4() + + event_data = { + "payload": { + "ironic_object.data": { + "uuid": str(node_uuid_obj), + "provision_state": "active", + "lessee": str(lessee_uuid_obj), + } + } + } + + result = handle_provision_end(mock_conn, mock_nautobot, event_data) + + assert result == 0 + + # Verify UUIDs were converted correctly + call_args = mock_nb_helper.update_cf.call_args + assert call_args[1]["device_id"] == node_uuid_obj + assert call_args[1]["tenant_id"] == lessee_uuid_obj diff --git a/python/understack-workflows/understack_workflows/ironic/client.py b/python/understack-workflows/understack_workflows/ironic/client.py index 227587378..4e902ba3f 100644 --- a/python/understack-workflows/understack_workflows/ironic/client.py +++ b/python/understack-workflows/understack_workflows/ironic/client.py @@ -1,68 +1,110 @@ +from typing import cast + +from ironicclient.common.apiclient import exceptions as ironic_exceptions +from ironicclient.v1.client import Client as IronicV1Client +from ironicclient.v1.node import Node +from ironicclient.v1.port import Port + +from understack_workflows.helpers import setup_logger from understack_workflows.openstack.client import get_ironic_client +logger = setup_logger(__name__) + class IronicClient: - def __init__( - self, - ) -> None: + def __init__(self, cloud: str | None = None) -> None: """Initialize our ironicclient wrapper.""" - self.logged_in = False - - def login(self): - self.client = get_ironic_client() + self.client: IronicV1Client = get_ironic_client(cloud=cloud) self.logged_in = True - def create_node(self, node_data: dict): - self._ensure_logged_in() + def login(self): + """Deprecated: client is initialized in __init__.""" + pass - return self.client.node.create(**node_data) + def create_node(self, node_data: dict) -> Node: + return cast(Node, self.client.node.create(**node_data)) def list_nodes(self): - self._ensure_logged_in() - return self.client.node.list() - def get_node(self, node_ident: str, fields: list[str] | None = None): - self._ensure_logged_in() - - return self.client.node.get( - node_ident, - fields, - ) + def get_node(self, node_ident: str, fields: list[str] | None = None) -> Node: + return cast(Node, self.client.node.get(node_ident, fields)) def update_node(self, node_id, patch): - self._ensure_logged_in() - - return self.client.node.update( - node_id, - patch, - ) - - def create_port(self, port_data: dict): - self._ensure_logged_in() - - return self.client.port.create(**port_data) + return self.client.node.update(node_id, patch) + + def get_node_inventory(self, node_ident: str) -> dict: + """Fetch node inventory data from Ironic API. + + Args: + node_ident: Node UUID, name, or other identifier + + Returns: + Dict containing node inventory data + + Raises: + ironic_exceptions.NotFound: If node doesn't exist + ironic_exceptions.ClientException: For other API errors + """ + try: + logger.info("Fetching inventory for node: %s", node_ident) + + # Call the inventory API endpoint + inventory = self.client.node.get_inventory(node_ident) + + logger.info("Successfully retrieved inventory for node %s", node_ident) + return inventory + + except ironic_exceptions.NotFound: + logger.error("Node not found: %s", node_ident) + raise + except ironic_exceptions.ClientException as e: + logger.error("Ironic API error for node %s: %s", node_ident, e) + raise + except Exception as e: + logger.error( + "Unexpected error fetching inventory for %s: %s", node_ident, e + ) + raise + + def create_port(self, port_data: dict) -> Port: + return cast(Port, self.client.port.create(**port_data)) + + def get_port(self, port_ident: str, fields: list[str] | None = None) -> Port: + """Get a specific port by UUID or address. + + Args: + port_ident: Port UUID or MAC address + fields: Optional list of fields to return + + Returns: + Port object + + Raises: + ironic_exceptions.NotFound: If port doesn't exist + ironic_exceptions.ClientException: For other API errors + """ + try: + logger.debug("Fetching port: %s", port_ident) + port = self.client.port.get(port_ident, fields) + logger.debug("Successfully retrieved port %s", port_ident) + return cast(Port, port) + except ironic_exceptions.NotFound: + logger.error("Port not found: %s", port_ident) + raise + except ironic_exceptions.ClientException as e: + logger.error("Ironic API error for port %s: %s", port_ident, e) + raise def update_port(self, port_id: str, patch: list): - self._ensure_logged_in() - - return self.client.port.update( - port_id, - patch, - ) + return self.client.port.update(port_id, patch) def delete_port(self, port_id: str): - self._ensure_logged_in() - - return self.client.port.delete( - port_id, - ) + return self.client.port.delete(port_id) def list_ports(self, node_id: str): - self._ensure_logged_in() - return self.client.port.list(node=node_id, detail=True) def _ensure_logged_in(self): - if not self.logged_in: - self.login() + """Deprecated: client is initialized in __init__.""" + pass diff --git a/python/understack-workflows/understack_workflows/ironic/inventory.py b/python/understack-workflows/understack_workflows/ironic/inventory.py new file mode 100644 index 000000000..fb2922f41 --- /dev/null +++ b/python/understack-workflows/understack_workflows/ironic/inventory.py @@ -0,0 +1,215 @@ +from ipaddress import IPv4Interface + +from understack_workflows.bmc_chassis_info import ChassisInfo +from understack_workflows.bmc_chassis_info import InterfaceInfo +from understack_workflows.helpers import setup_logger + +logger = setup_logger(__name__) + + +def parse_port_to_interface(port_data: dict, hostname: str) -> InterfaceInfo: + """Parse Ironic port data to InterfaceInfo. + + This is the preferred method as ports have enriched data from inspection hooks: + - bios_name in extra field (from Redfish inspection) + - local_link_connection with switch info (from Agent inspection) + + Args: + port_data: Port dict from Ironic API + hostname: Node hostname + + Returns: + InterfaceInfo object with parsed data + """ + extra = port_data.get("extra", {}) + llc = port_data.get("local_link_connection", {}) + + # Prefer bios_name from extra, fall back to port name, then MAC + interface_name = ( + extra.get("bios_name") + or port_data.get("name") + or port_data.get("address", "unknown") + ) + + # Extract switch connection info from local_link_connection + remote_switch_mac = llc.get("switch_id") + remote_switch_port = llc.get("port_id") + + return InterfaceInfo( + name=interface_name, + description=f"Port {port_data.get('uuid', 'unknown')}", + mac_address=port_data["address"].upper(), + hostname=hostname, + ipv4_address=None, # Server interfaces don't have IPs + ipv4_gateway=None, + dhcp=False, + remote_switch_mac_address=remote_switch_mac, + remote_switch_port_name=remote_switch_port, + remote_switch_data_stale=False, + ) + + +def parse_interface_data( + interface_data: dict, hostname: str, manufacturer: str +) -> InterfaceInfo: + """Parse interface data from Ironic inspection inventory. + + Args: + interface_data: Interface dict from Ironic inventory + hostname: Node hostname + manufacturer: System manufacturer + + Returns: + InterfaceInfo object with parsed data + """ + # Get interface name - prefer from interface data directly + # The name field in inventory is typically the Linux interface name + interface_name = interface_data.get("name", "unknown") + + # Extract LLDP data if available + # LLDP data structure varies - it could be a list of TLVs or already parsed + lldp_data = interface_data.get("lldp", []) + remote_switch_mac = None + remote_switch_port = None + + # If lldp_data is a list, try to extract switch info + if isinstance(lldp_data, list) and lldp_data: + # LLDP TLVs: type 1 = Chassis ID (switch MAC), type 2 = Port ID + for item in lldp_data: + if isinstance(item, list | tuple) and len(item) >= 2: + tlv_type, tlv_value = item[0], item[1] + if tlv_type == 1: # Chassis ID + remote_switch_mac = tlv_value + elif tlv_type == 2: # Port ID + remote_switch_port = tlv_value + + # For server interfaces, ignore IP addresses (only iDRAC should have IP info) + return InterfaceInfo( + name=interface_name, + description=f"{interface_data.get('driver', 'Unknown')} interface", + mac_address=interface_data["mac_address"].upper(), + hostname=hostname, + ipv4_address=None, # Ignore IP addresses for server interfaces + ipv4_gateway=None, + dhcp=False, + remote_switch_mac_address=remote_switch_mac, + remote_switch_port_name=remote_switch_port, + remote_switch_data_stale=False, # Ironic data is typically fresh + ) + + +def chassis_info_from_ironic_data( + inspection_data: dict, ports_data: list[dict] | None = None +) -> ChassisInfo: + """Build ChassisInfo from Ironic inspection and port data. + + Args: + inspection_data: Node inventory from Ironic inspection + ports_data: Optional list of port dicts from Ironic API. + If provided, will be used instead of inventory interfaces + as ports have enriched data from inspection hooks. + + Returns: + ChassisInfo object + """ + inventory = inspection_data["inventory"] + + system_vendor = inventory["system_vendor"] + memory_info = inventory["memory"] + hostname = inventory.get("hostname") + + # Validate that bmc_address is present + if "bmc_address" not in inventory or not inventory["bmc_address"]: + raise ValueError( + f"bmc_address is required but not present in inventory for {hostname}" + ) + + try: + # NOTE: Ironic inspection doesn't provide BMC subnet mask, only IP address. + # We assume /26 as a fallback. For accurate BMC network config, use + # bmc_chassis_info.bmc_interface() which queries the BMC's Redfish API + # directly and gets the actual SubnetMask from IPv4Addresses. + # This is acceptable here since we're building device info from inspection + # data, and the BMC interface details aren't critical for device creation. + bmc_ipv4 = IPv4Interface(f"{inventory['bmc_address']}/26") + except ValueError: + bmc_ipv4 = None + + bmc_interface = InterfaceInfo( + name="iDRAC", + description="Dedicated iDRAC interface", + mac_address=inventory["bmc_mac"].upper(), + hostname=hostname, + ipv4_address=bmc_ipv4, + ipv4_gateway=None, + dhcp=False, + remote_switch_mac_address=None, + remote_switch_port_name=None, + remote_switch_data_stale=False, + ) + + manufacturer = system_vendor["manufacturer"] + + interfaces = [bmc_interface] + + # Prefer port data if available (has enriched data from inspection hooks) + if ports_data: + logger.debug("Using %d ports from Ironic API", len(ports_data)) + for port_data in ports_data: + try: + interfaces.append(parse_port_to_interface(port_data, hostname)) + except Exception as e: + logger.warning( + "Failed to parse port %s: %s", port_data.get("uuid", "unknown"), e + ) + else: + # Fall back to inventory interfaces + logger.debug("Using %d interfaces from inventory", len(inventory["interfaces"])) + for interface_data in inventory["interfaces"]: + interfaces.append( + parse_interface_data(interface_data, hostname, manufacturer) + ) + + return ChassisInfo( + manufacturer=manufacturer, + model_number=system_vendor["product_name"].split("(")[0].strip(), + serial_number=system_vendor["serial_number"], + bmc_ip_address=inventory["bmc_address"], + bios_version=system_vendor["firmware"]["version"], + power_on=True, # Assume powered on since inspection is running + interfaces=interfaces, + memory_gib=int(memory_info["physical_mb"] / 1024), + cpu=inventory["cpu"]["model_name"], + ) + + +def get_device_info( + inspection_data: dict, ports_data: list[dict] | None = None +) -> ChassisInfo: + """Get device info from Ironic inspection and port data. + + Args: + inspection_data: Node inventory from Ironic inspection + ports_data: Optional list of port dicts from Ironic API + + Returns: + ChassisInfo object + """ + try: + chassis_info = chassis_info_from_ironic_data(inspection_data, ports_data) + + logger.info( + "Successfully processed Ironic inspection data " + "for %s (%s): %d interfaces, %d neighbors", + chassis_info.bmc_hostname, + chassis_info.serial_number, + len(chassis_info.interfaces), + len(chassis_info.neighbors), + ) + + return chassis_info + + except Exception: + hostname = inspection_data.get("inventory", {}).get("hostname", "unknown") + logger.exception("Failed to process Ironic inspection data for %s", hostname) + raise diff --git a/python/understack-workflows/understack_workflows/ironic_node.py b/python/understack-workflows/understack_workflows/ironic_node.py index 577e0cf2c..69af069d2 100644 --- a/python/understack-workflows/understack_workflows/ironic_node.py +++ b/python/understack-workflows/understack_workflows/ironic_node.py @@ -2,11 +2,11 @@ import ironicclient.common.apiclient.exceptions from ironicclient.common.utils import args_array_to_patch +from ironicclient.v1.node import Node from understack_workflows.bmc import Bmc from understack_workflows.helpers import setup_logger from understack_workflows.ironic.client import IronicClient -from understack_workflows.node_configuration import IronicNodeConfiguration STATES_ALLOWING_UPDATES = ["enroll", "manageable"] @@ -73,7 +73,7 @@ def create_ironic_node( client: IronicClient, node_meta: NodeMetadata, bmc: Bmc, -) -> IronicNodeConfiguration: +) -> Node: return client.create_node( { "uuid": node_meta.uuid, 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 16e0e5893..ceb6b9534 100644 --- a/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py +++ b/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py @@ -17,8 +17,10 @@ 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 nautobot_sync from understack_workflows.oslo_event import neutron_network from understack_workflows.oslo_event import neutron_subnet +from understack_workflows.oslo_event import provision_state_sync logger = setup_logger(__name__) @@ -64,14 +66,21 @@ class NoEventHandlerError(Exception): EventHandler = Callable[[Connection, NautobotApi, dict[str, Any]], int] # add the event_type here and the function that should be called -_event_handlers: dict[str, EventHandler] = { +_event_handlers: dict[str, EventHandler | list[EventHandler]] = { "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, + "baremetal.node.create.end": ironic_node.handle_node_create_update, + "baremetal.node.update.end": ironic_node.handle_node_create_update, + "baremetal.node.delete.end": ironic_node.handle_node_delete, + "baremetal.node.provision_set.end": [ + ironic_node.handle_provision_end, + nautobot_sync.handle_provision_end, + provision_state_sync.handle_provision_end, + ], "identity.project.created": keystone_project.handle_project_created, "identity.project.updated": keystone_project.handle_project_updated, "identity.project.deleted": keystone_project.handle_project_deleted, @@ -183,14 +192,20 @@ def main() -> int: logger.info("Processing event type: %s", event_type) - # look up the event handler - event_handler = _event_handlers.get(event_type) - if event_handler is None: + # look up the event handler(s) + event_handlers = _event_handlers.get(event_type) + if event_handlers is None: logger.error("No event handler for event type: %s", event_type) logger.debug("Available event handlers: %s", list(_event_handlers.keys())) sys.exit(_EXIT_NO_EVENT_HANDLER) - logger.debug("Found event handler for event type: %s", event_type) + # normalize to list for consistent processing + if not isinstance(event_handlers, list): + event_handlers = [event_handlers] + + logger.debug( + "Found %d handler(s) for event type: %s", len(event_handlers), event_type + ) # get a connection to OpenStack and to Nautobot try: @@ -199,17 +214,21 @@ def main() -> int: logger.exception("Client initialization failed") sys.exit(_EXIT_CLIENT_ERROR) - # execute the event handler - logger.info("Executing event handler for event type: %s", event_type) - try: - ret = event_handler(conn, nautobot, event) - except Exception: - logger.exception("Event handler failed") - sys.exit(_EXIT_HANDLER_ERROR) - - logger.info("Event handler completed successfully with return code: %s", ret) - - # exit if the event handler provided a return code or just with success - if isinstance(ret, int): - return ret - return _EXIT_SUCCESS + # execute all event handlers + last_ret = _EXIT_SUCCESS + for idx, event_handler in enumerate(event_handlers, 1): + handler_name = getattr(event_handler, "__name__", f"handler_{idx}") + logger.info( + "Executing handler %d/%d: %s", idx, len(event_handlers), handler_name + ) + try: + ret = event_handler(conn, nautobot, event) + if isinstance(ret, int): + last_ret = ret + logger.info("Handler %s completed with return code: %s", handler_name, ret) + except Exception: + logger.exception("Handler %s failed", handler_name) + sys.exit(_EXIT_HANDLER_ERROR) + + logger.info("All handlers completed successfully") + return last_ret diff --git a/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py b/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py index 525dd7290..1208e0cdc 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_node.py @@ -1,19 +1,47 @@ +from __future__ import annotations + from typing import Self +from typing import cast from uuid import UUID +from ironicclient.common.apiclient import exceptions as ironic_exceptions +from ironicclient.v1.node import Node from openstack.connection import Connection from openstack.exceptions import ConflictException from pydantic import BaseModel from pydantic import computed_field from pynautobot.core.api import Api as Nautobot +from pynautobot.core.response import Record from understack_workflows.helpers import save_output from understack_workflows.helpers import setup_logger +from understack_workflows.ironic.client import IronicClient +from understack_workflows.ironic.provision_state_mapper import ProvisionStateMapper from understack_workflows.oslo_event.keystone_project import is_project_svm_enabled logger = setup_logger(__name__) +class IronicNodeEvent(BaseModel): + """Represents an Ironic node create/update/delete event - minimal event data.""" + + uuid: str + + @classmethod + def from_event_dict(cls, data: dict) -> Self: + """Parse Ironic node event from Oslo notification payload to extract UUID.""" + payload = data.get("payload") + if payload is None: + raise ValueError("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 ValueError("Invalid event. No 'ironic_object.data' in payload") + + return cls(uuid=payload_data["uuid"]) + + class IronicProvisionSetEvent(BaseModel): owner: UUID lessee: UUID @@ -46,8 +74,218 @@ def lessee_undashed(self) -> str: return self.lessee.hex +def extract_serial_from_node(node: Node, inventory: dict | None = None) -> str | None: + """Extract serial number from node data. + + Args: + node: Ironic Node object + inventory: Optional inventory data from Ironic + + Returns: + Serial number if found, None otherwise + """ + # Try multiple sources for serial number + # 1. Check properties.serial_number + if hasattr(node, "properties") and node.properties: + if serial := node.properties.get("serial_number"): + return serial + + # 2. Check extra.system.serial_number (from hardware inspection) + if hasattr(node, "extra") and node.extra: + if system := node.extra.get("system"): + if serial := system.get("serial_number"): + return serial + + # 3. Check inventory data if provided + if inventory: + if ( + serial := inventory.get("inventory", {}) + .get("system_vendor", {}) + .get("serial_number") + ): + return serial + + # 4. No serial found + return None + + +def map_provision_state_to_status(provision_state: str | None) -> str: + """Map Ironic provision state to Nautobot device status.""" + if not provision_state: + return "Planned" + + # Use the existing ProvisionStateMapper + status = ProvisionStateMapper.translate_to_nautobot(provision_state) + # If the mapper returns None (intermediate state), default to Planned + return status if status is not None else "Planned" + + +def handle_node_create_update( + conn: Connection, nautobot: Nautobot, event_data: dict +) -> int: + """Sync Ironic Node to Nautobot device. + + Handles both node.create.end and node.update.end events. + Fetches full node data from Ironic API instead of relying on event payload. + """ + try: + event = IronicNodeEvent.from_event_dict(event_data) + except (ValueError, KeyError) as e: + logger.error("Failed to parse node event: %s", e) + return 1 + + # Initialize Ironic client and fetch full node data + try: + ironic = IronicClient() + node = ironic.get_node(event.uuid) + logger.debug("Fetched node %s from Ironic API", event.uuid) + except ironic_exceptions.NotFound: + logger.error("Node %s not found in Ironic", event.uuid) + return 1 + except Exception: + logger.exception("Failed to fetch node %s from Ironic", event.uuid) + return 1 + + # Check if node has required fields + if not node.uuid or not node.name: + logger.warning( + "Node %s lacks required fields (uuid, name) for Nautobot sync, skipping", + event.uuid, + ) + return 0 + + # Try to fetch inventory for serial number + inventory = None + try: + inventory = ironic.get_node_inventory(event.uuid) + logger.debug("Fetched inventory for node %s", event.uuid) + except ironic_exceptions.NotFound: + logger.debug("No inventory data available for node %s", event.uuid) + except Exception: + logger.warning("Failed to fetch inventory for node %s, continuing", event.uuid) + + logger.debug("Looking up device %s in Nautobot", event.uuid) + device = nautobot.dcim.devices.get(id=event.uuid) + + # Prepare device attributes + attrs = { + "name": node.name, + "status": map_provision_state_to_status(node.provision_state), + } + + # Add optional fields if available + if serial := extract_serial_from_node(node, inventory): + attrs["serial"] = serial + + # Add tenant (lessee) if available + if hasattr(node, "lessee") and node.lessee: + try: + # Convert lessee UUID to tenant in Nautobot + tenant_uuid = ( + UUID(node.lessee) if isinstance(node.lessee, str) else node.lessee + ) + # Note: This assumes tenant exists in Nautobot with matching UUID + attrs["tenant"] = str(tenant_uuid) + logger.debug("Setting tenant to %s", tenant_uuid) + except (ValueError, AttributeError) as e: + logger.warning("Invalid lessee UUID %s: %s", node.lessee, e) + + # Add custom fields for Ironic-specific data + custom_fields = {} + if node.provision_state: + custom_fields["ironic_provision_state"] = node.provision_state + if node.resource_class: + custom_fields["resource_class"] = node.resource_class + + # Add hardware specs to custom fields from node properties + if hasattr(node, "properties") and node.properties: + if memory_mb := node.properties.get("memory_mb"): + custom_fields["memory_mb"] = memory_mb + if cpus := node.properties.get("cpus"): + custom_fields["cpus"] = cpus + if cpu_arch := node.properties.get("cpu_arch"): + custom_fields["cpu_arch"] = cpu_arch + if local_gb := node.properties.get("local_gb"): + custom_fields["local_gb"] = local_gb + + if custom_fields: + attrs["custom_fields"] = custom_fields + + if not device: + # Create new device + logger.info("Creating device %s in Nautobot", event.uuid) + attrs["id"] = event.uuid + + # Note: Device creation in Nautobot requires device_type and location + # These should be configured via environment variables or cluster metadata + # For now, we log a warning and skip creation if device doesn't exist + # The device should be pre-created in Nautobot or this handler should be + # enhanced to map resource_class to device_type and get location from config + logger.warning( + "Device %s not found in Nautobot. Device creation requires " + "device_type and location configuration. Skipping creation.", + event.uuid, + ) + return 0 + + # Update existing device + logger.debug("Updating device %s", event.uuid) + for key, value in attrs.items(): + if key != "id": # Don't update ID + setattr(device, key, value) + + try: + cast(Record, device).save() + logger.info("Device %s synced to Nautobot", event.uuid) + return 0 + except Exception: + logger.exception("Failed to update device %s", event.uuid) + return 1 + + +def handle_node_delete(conn: Connection, nautobot: Nautobot, event_data: dict) -> int: + """Remove Ironic Node from Nautobot. + + Handles node.delete.end events. + """ + try: + event = IronicNodeEvent.from_event_dict(event_data) + except (ValueError, KeyError) as e: + logger.error("Failed to parse node delete event: %s", e) + return 1 + + logger.debug("Handling node delete for device %s", event.uuid) + + # Find the device in Nautobot + device = nautobot.dcim.devices.get(id=event.uuid) + if not device: + logger.debug("Device %s not found in Nautobot, nothing to delete", event.uuid) + return 0 + + # Delete the device (this will cascade to interfaces and cables) + logger.info("Deleting device %s from Nautobot", event.uuid) + try: + cast(Record, device).delete() + logger.info("Successfully deleted device %s from Nautobot", event.uuid) + return 0 + except Exception: + logger.exception("Failed to delete device %s from Nautobot", event.uuid) + return 1 + + def handle_provision_end(conn: Connection, _: Nautobot, event_data: dict) -> int: """Operates on an Ironic Node provisioning END event.""" + payload = event_data.get("payload", {}) + payload_data = payload.get("ironic_object.data") + + if payload_data: + previous_provision_state = payload_data.get("previous_provision_state") + if previous_provision_state != "deploying": + logger.info( + "Skipping storage setup for previous_provision_state: %s", + previous_provision_state, + ) + return 0 # Check if the project is configured with tags. event = IronicProvisionSetEvent.from_event_dict(event_data) logger.info("Checking if project %s is tagged with UNDERSTACK_SVM", event.lessee) @@ -63,7 +301,7 @@ def handle_provision_end(conn: Connection, _: Nautobot, event_data: dict) -> int save_output("storage", "not-found") return 1 - if server.metadata["storage"] == "wanted": + if server.metadata.get("storage") == "wanted": save_output("storage", "wanted") else: logger.info("Server %s did not want storage enabled.", server.id) 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 5a877812c..a0382b2d2 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_port.py @@ -105,18 +105,38 @@ def handle_port_create_update( logger.exception("Failed to create interface %s", event.uuid) return 1 - # Update interface attributes + # Update interface attributes (only update if value is present and different) logger.debug("Updating interface %s", event.uuid) + updated = False for key, value in attrs.items(): - if key != "id": # Don't update ID + if key == "id": # Don't update ID + continue + + current_value = getattr(intf, key, None) + + # Only update if: + # 1. Current value is None/empty and we have a value, OR + # 2. Values are different + if value and (not current_value or current_value != value): + logger.debug( + "Updating interface %s field '%s': '%s' -> '%s'", + event.uuid, + key, + current_value, + value, + ) setattr(intf, key, value) + updated = True - 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 + if updated: + 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 + else: + logger.debug("Interface %s already up to date", event.uuid) # Handle cable management if we have remote switch connection information if event.remote_port_id and event.remote_switch_info: @@ -230,11 +250,19 @@ def handle_port_delete(_conn: Connection, nautobot: Nautobot, event_data: dict) return 0 # Find and delete any existing cable connected to this interface + # Check both termination_a and termination_b sides existing_cable = nautobot.dcim.cables.get( termination_a_type="dcim.interface", termination_a_id=intf.id, # type: ignore ) + if not existing_cable: + # Try termination_b side if not found on termination_a + existing_cable = nautobot.dcim.cables.get( + termination_b_type="dcim.interface", + termination_b_id=intf.id, # type: ignore + ) + if existing_cable: logger.info("Deleting cable %s for interface %s", existing_cable.id, event.uuid) # type: ignore cast(Record, existing_cable).delete() diff --git a/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py b/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py index fd5fe741f..4d66983c7 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py +++ b/python/understack-workflows/understack_workflows/oslo_event/ironic_portgroup.py @@ -80,7 +80,7 @@ def from_event_dict(cls, data: dict) -> IronicPortgroupEvent: def handle_portgroup_create_update( - _conn: Connection, nautobot: Nautobot, event_data: dict + conn: Connection, nautobot: Nautobot, event_data: dict ) -> int: """Sync Ironic Portgroup to Nautobot LAG interface.""" event = IronicPortgroupEvent.from_event_dict(event_data) @@ -138,11 +138,162 @@ def handle_portgroup_create_update( logger.exception("Failed to update LAG interface %s", event.uuid) return 1 + # Associate member ports with the LAG interface + if _associate_member_ports(conn, nautobot, event, lag_intf) != 0: + logger.warning( + "Failed to associate member ports with LAG %s, but LAG was created/updated", + event.uuid, + ) + # Don't fail the entire operation if member association fails + # The LAG interface itself was successfully created/updated + return 0 +def _associate_member_ports( + conn: Connection, nautobot: Nautobot, event: IronicPortgroupEvent, lag_intf +) -> int: + """Associate member ports with the LAG interface in Nautobot. + + Queries Ironic for all ports that belong to this portgroup and updates + their corresponding Nautobot interfaces to set the lag field. + + Args: + conn: OpenStack connection for querying Ironic + nautobot: Nautobot API client + event: Portgroup event data + lag_intf: The LAG interface in Nautobot + + Returns: + 0 on success, 1 on failure + """ + logger.debug("Associating member ports with LAG %s", event.uuid) + + try: + # Query Ironic for ports belonging to this portgroup + # Using the baremetal API from openstacksdk + member_ports = list( + conn.baremetal.ports(details=True, portgroup_uuid=event.uuid) # pyright: ignore + ) + + if not member_ports: + logger.debug("No member ports found for portgroup %s", event.uuid) + return 0 + + logger.info( + "Found %d member port(s) for portgroup %s", len(member_ports), event.uuid + ) + + # Update each member interface in Nautobot + for port in member_ports: + port_uuid = port.id if hasattr(port, "id") else port.uuid + logger.debug("Associating port %s with LAG %s", port_uuid, event.uuid) + + # Find the interface in Nautobot + member_intf = nautobot.dcim.interfaces.get(id=port_uuid) + if not member_intf: + logger.warning( + "Member interface %s not found in Nautobot, skipping", port_uuid + ) + continue + + # Set the lag field to associate with the LAG interface + member_intf.lag = lag_intf.id # type: ignore + try: + cast(Record, member_intf).save() + logger.debug( + "Associated interface %s with LAG %s", port_uuid, event.uuid + ) + except Exception: + logger.exception( + "Failed to associate interface %s with LAG %s", + port_uuid, + event.uuid, + ) + # Continue with other ports even if one fails + + logger.info("Successfully associated member ports with LAG %s", event.uuid) + return 0 + + except Exception: + logger.exception( + "Failed to query or associate member ports for LAG %s", event.uuid + ) + return 1 + + +def _clear_member_port_associations( + conn: Connection, nautobot: Nautobot, event: IronicPortgroupEvent +) -> int: + """Clear LAG associations from member ports in Nautobot. + + Queries Ironic for all ports that belong to this portgroup and clears + their lag field in Nautobot, preserving the interfaces themselves. + + Args: + conn: OpenStack connection for querying Ironic + nautobot: Nautobot API client + event: Portgroup event data + + Returns: + 0 on success, 1 on failure + """ + logger.debug("Clearing member port associations for LAG %s", event.uuid) + + try: + # Query Ironic for ports belonging to this portgroup + member_ports = list( + conn.baremetal.ports(details=True, portgroup_uuid=event.uuid) # pyright: ignore + ) + + if not member_ports: + logger.debug("No member ports found for portgroup %s", event.uuid) + return 0 + + logger.info( + "Found %d member port(s) to disassociate from portgroup %s", + len(member_ports), + event.uuid, + ) + + # Clear lag field from each member interface in Nautobot + for port in member_ports: + port_uuid = port.id if hasattr(port, "id") else port.uuid + logger.debug("Clearing LAG association for port %s", port_uuid) + + # Find the interface in Nautobot + member_intf = nautobot.dcim.interfaces.get(id=port_uuid) + if not member_intf: + logger.debug( + "Member interface %s not found in Nautobot, skipping", port_uuid + ) + continue + + # Clear the lag field + member_intf.lag = None # type: ignore + try: + cast(Record, member_intf).save() + logger.debug("Cleared LAG association for interface %s", port_uuid) + except Exception: + logger.exception( + "Failed to clear LAG association for interface %s", port_uuid + ) + # Continue with other ports even if one fails + + logger.info( + "Successfully cleared member port associations for LAG %s", event.uuid + ) + return 0 + + except Exception: + logger.exception( + "Failed to query or clear member port associations for LAG %s", event.uuid + ) + return 1 + + def handle_portgroup_delete( - _conn: Connection, nautobot: Nautobot, event_data: dict + conn: Connection, nautobot: Nautobot, event_data: dict ) -> int: """Handle Ironic Portgroup delete event.""" event = IronicPortgroupEvent.from_event_dict(event_data) @@ -157,6 +308,15 @@ def handle_portgroup_delete( ) return 0 + # Clear member port associations before deleting the LAG + if _clear_member_port_associations(conn, nautobot, event) != 0: + logger.warning( + "Failed to clear member port associations for LAG %s, " + "continuing with deletion", + event.uuid, + ) + # Don't fail the entire operation if clearing associations fails + # Delete the LAG interface logger.info("Deleting LAG interface %s from Nautobot", event.uuid) try: diff --git a/python/understack-workflows/understack_workflows/oslo_event/nautobot_sync.py b/python/understack-workflows/understack_workflows/oslo_event/nautobot_sync.py new file mode 100644 index 000000000..6726971a7 --- /dev/null +++ b/python/understack-workflows/understack_workflows/oslo_event/nautobot_sync.py @@ -0,0 +1,66 @@ +from openstack.connection import Connection +from pynautobot.core.api import Api as Nautobot + +from understack_workflows import nautobot_device +from understack_workflows.helpers import setup_logger +from understack_workflows.ironic.client import IronicClient +from understack_workflows.ironic.inventory import get_device_info + +logger = setup_logger(__name__) + + +def handle_provision_end(_: Connection, nautobot: Nautobot, event_data: dict) -> int: + """Handle Ironic node provisioning END event. + + This handler is triggered after inspection completes to create/update + the Nautobot device with inspection data. It fetches both node inventory + and port data to get the most complete information. + """ + payload = event_data.get("payload", {}) + payload_data = payload.get("ironic_object.data") + + if not payload_data: + raise ValueError("Missing 'ironic_object.data' in event payload") + + previous_provision_state = payload_data.get("previous_provision_state") + if previous_provision_state != "inspecting": + logger.info( + "Skipping Nautobot update for previous_provision_state: %s", + previous_provision_state, + ) + return 0 + + node_uuid = str(payload_data["uuid"]) + ironic_client = IronicClient() + + # Get node inventory + logger.info("Fetching inventory for node %s", node_uuid) + node_inventory = ironic_client.get_node_inventory(node_ident=node_uuid) + + # Get ports for this node (has enriched data from inspection hooks) + logger.info("Fetching ports for node %s", node_uuid) + try: + ports = ironic_client.list_ports(node_id=node_uuid) + # Convert port objects to dicts + ports_data = [ + { + "uuid": p.uuid, + "address": p.address, + "name": getattr(p, "name", None), + "extra": p.extra or {}, + "local_link_connection": p.local_link_connection or {}, + "pxe_enabled": p.pxe_enabled, + } + for p in ports + ] + logger.info("Found %d ports for node %s", len(ports_data), node_uuid) + except Exception as e: + logger.warning("Failed to fetch ports for node %s: %s", node_uuid, e) + ports_data = None + + # Build device info with both inventory and port data + device_info = get_device_info(node_inventory, ports_data) + nb_device = nautobot_device.find_or_create(device_info, nautobot) + + logger.info("Updated Nautobot device: %s", nb_device) + return 0 diff --git a/python/understack-workflows/understack_workflows/oslo_event/provision_state_sync.py b/python/understack-workflows/understack_workflows/oslo_event/provision_state_sync.py new file mode 100644 index 000000000..0243434b7 --- /dev/null +++ b/python/understack-workflows/understack_workflows/oslo_event/provision_state_sync.py @@ -0,0 +1,111 @@ +"""Handler for syncing Ironic provision state changes to Nautobot.""" + +from uuid import UUID + +from openstack.connection import Connection +from pynautobot.core.api import Api as Nautobot + +from understack_workflows.helpers import setup_logger +from understack_workflows.ironic.provision_state_mapper import ProvisionStateMapper +from understack_workflows.nautobot import Nautobot as NautobotHelper + +logger = setup_logger(__name__) + + +def handle_provision_end(_: Connection, nautobot: Nautobot, event_data: dict) -> int: + """Handle Ironic node provision state changes and sync to Nautobot. + + This handler updates the Nautobot device status and custom fields + whenever a provision state change occurs. It runs for ALL provision + state changes (not just specific states like deploying/inspecting). + """ + payload = event_data.get("payload", {}) + payload_data = payload.get("ironic_object.data") + + if not payload_data: + logger.error("Missing 'ironic_object.data' in event payload") + return 1 + + # Extract required fields + node_uuid = payload_data.get("uuid") + provision_state = payload_data.get("provision_state") + + if not node_uuid: + logger.error("Missing 'uuid' in event payload") + return 1 + + if not provision_state: + logger.error("Missing 'provision_state' in event payload") + return 1 + + logger.info("Syncing provision state for node %s: %s", node_uuid, provision_state) + + # Extract optional fields + lessee = payload_data.get("lessee") + resource_class = payload_data.get("resource_class") + + # Convert lessee to UUID if present + tenant_id = None + if lessee: + try: + tenant_id = UUID(lessee) if isinstance(lessee, str) else lessee + except (ValueError, TypeError) as e: + logger.warning("Invalid lessee UUID %s: %s", lessee, e) + + # Convert node UUID + try: + device_uuid = UUID(node_uuid) if isinstance(node_uuid, str) else node_uuid + except (ValueError, TypeError) as e: + logger.error("Invalid node UUID %s: %s", node_uuid, e) + return 1 + + # Translate Ironic provision state to Nautobot status + new_status = ProvisionStateMapper.translate_to_nautobot(provision_state) + + if not new_status: + logger.info( + "Provision state %s has no Nautobot status mapping, skipping status update", + provision_state, + ) + # Still update custom fields even if status doesn't map + new_status = None + + # Prepare custom fields to update + custom_fields_to_update = { + "ironic_provision_state": provision_state, + } + + if resource_class: + custom_fields_to_update["resource_class"] = resource_class + + # Initialize Nautobot helper + nb_helper = NautobotHelper( + url=nautobot.base_url, + token=nautobot.token, + logger=logger, + session=nautobot, + ) + + try: + # Update custom fields and tenant + nb_helper.update_cf( + device_id=device_uuid, + tenant_id=tenant_id, + fields=custom_fields_to_update, + ) + logger.info( + "Updated custom fields for device %s: %s", + device_uuid, + custom_fields_to_update, + ) + + # Update device status if we have a mapping + if new_status: + nb_helper.update_device_status(device_uuid, new_status) + logger.info("Updated device %s status to %s", device_uuid, new_status) + + return 0 + + except Exception: + logger.exception("Failed to sync provision state for device %s", device_uuid) + return 1