diff --git a/python/neutron-understack/neutron_understack/config.py b/python/neutron-understack/neutron_understack/config.py index 5ac50b65d..2b33b4107 100644 --- a/python/neutron-understack/neutron_understack/config.py +++ b/python/neutron-understack/neutron_understack/config.py @@ -50,13 +50,6 @@ "is used to trunk all vlans used by a neutron router." ), ), - cfg.StrOpt( - "network_node_trunk_uuid", - help=( - "UUID of the trunk that is used to trunk all vlans used by a Neutron" - " router." - ), - ), cfg.StrOpt( "network_node_switchport_physnet", help=( diff --git a/python/neutron-understack/neutron_understack/ironic.py b/python/neutron-understack/neutron_understack/ironic.py index ebed76a6b..50f196b05 100644 --- a/python/neutron-understack/neutron_understack/ironic.py +++ b/python/neutron-understack/neutron_understack/ironic.py @@ -32,3 +32,17 @@ def _port_by_local_link(self, local_link_info: dict) -> BaremetalPort | None: ) except StopIteration: return None + + def baremetal_node_name(self, node_uuid: str) -> str | None: + try: + node = self.irclient.get_node(node_uuid) + return node.name if node else None + except Exception: + return None + + def baremetal_node_uuid(self, node_name: str) -> str | None: + try: + node = self.irclient.get_node(node_name) + return node.id if node else None + except Exception: + return None diff --git a/python/neutron-understack/neutron_understack/routers.py b/python/neutron-understack/neutron_understack/routers.py index d63a30628..e972ac708 100644 --- a/python/neutron-understack/neutron_understack/routers.py +++ b/python/neutron-understack/neutron_understack/routers.py @@ -98,9 +98,11 @@ def add_subport_to_trunk(shared_port: PortDict, segment: NetworkSegmentDict) -> }, ] } + trunk_id = utils.fetch_network_node_trunk_id() + utils.fetch_trunk_plugin().add_subports( context=n_context.get_admin_context(), - trunk_id=cfg.CONF.ml2_understack.network_node_trunk_uuid, + trunk_id=trunk_id, subports=subports, ) @@ -251,8 +253,7 @@ def handle_router_interface_removal(_resource, _event, trigger, payload) -> None def handle_subport_removal(port: Port) -> None: """Removes router's subport from a network node trunk.""" - # trunk_id will be discovered dynamically at some point - trunk_id = cfg.CONF.ml2_understack.network_node_trunk_uuid + trunk_id = utils.fetch_network_node_trunk_id() LOG.debug("Router, Removing subport: %s(port)s", {"port": port}) port_id = port["id"] try: diff --git a/python/neutron-understack/neutron_understack/tests/test_routers.py b/python/neutron-understack/neutron_understack/tests/test_routers.py index 5d9558c65..4530e11a1 100644 --- a/python/neutron-understack/neutron_understack/tests/test_routers.py +++ b/python/neutron-understack/neutron_understack/tests/test_routers.py @@ -37,8 +37,8 @@ def test_when_successful(self, mocker): port = {"id": "port-123"} segment = {"segmentation_id": 42} mocker.patch( - "oslo_config.cfg.CONF.ml2_understack.network_node_trunk_uuid", - trunk_id, + "neutron_understack.utils.fetch_network_node_trunk_id", + return_value=trunk_id, ) mocker.patch( "neutron_lib.context.get_admin_context", return_value="admin_context" @@ -70,7 +70,8 @@ def test_when_successful(self, mocker): class TestHandleSubportRemoval: def test_when_successful(self, mocker, port_id, trunk_id): mocker.patch( - "oslo_config.cfg.CONF.ml2_understack.network_node_trunk_uuid", str(trunk_id) + "neutron_understack.utils.fetch_network_node_trunk_id", + return_value=str(trunk_id), ) mock_remove = mocker.patch("neutron_understack.utils.remove_subport_from_trunk") port = {"id": str(port_id)} diff --git a/python/neutron-understack/neutron_understack/tests/test_trunk.py b/python/neutron-understack/neutron_understack/tests/test_trunk.py index 9540f6e5d..a93c2e035 100644 --- a/python/neutron-understack/neutron_understack/tests/test_trunk.py +++ b/python/neutron-understack/neutron_understack/tests/test_trunk.py @@ -336,13 +336,13 @@ class TestCheckSubportsSegmentationId: def test_when_trunk_id_is_network_node_trunk_id( self, mocker, - oslo_config, understack_trunk_driver, trunk_id, ): - oslo_config.config( - network_node_trunk_uuid=str(trunk_id), - group="ml2_understack", + # Mock fetch_network_node_trunk_id to return the trunk_id + mocker.patch( + "neutron_understack.utils.fetch_network_node_trunk_id", + return_value=str(trunk_id), ) # Mock to ensure the function returns early and doesn't call this allowed_ranges_mock = mocker.patch( @@ -362,6 +362,11 @@ def test_when_segmentation_id_is_in_allowed_range( trunk_id, subport, ): + # Mock fetch_network_node_trunk_id to return a different trunk ID + mocker.patch( + "neutron_understack.utils.fetch_network_node_trunk_id", + return_value="different-trunk-id", + ) allowed_ranges = mocker.patch( "neutron_understack.utils.allowed_tenant_vlan_id_ranges", return_value=[(1, 1500)], @@ -380,6 +385,11 @@ def test_when_segmentation_id_is_not_in_allowed_range( trunk_id, subport, ): + # Mock fetch_network_node_trunk_id to return a different trunk ID + mocker.patch( + "neutron_understack.utils.fetch_network_node_trunk_id", + return_value="different-trunk-id", + ) mocker.patch( "neutron_understack.utils.allowed_tenant_vlan_id_ranges", return_value=[(1, 1500)], diff --git a/python/neutron-understack/neutron_understack/tests/test_utils.py b/python/neutron-understack/neutron_understack/tests/test_utils.py index 711a2ec52..5aaf1ce9a 100644 --- a/python/neutron-understack/neutron_understack/tests/test_utils.py +++ b/python/neutron-understack/neutron_understack/tests/test_utils.py @@ -1,3 +1,4 @@ +from unittest.mock import MagicMock from unittest.mock import patch import pytest @@ -236,3 +237,406 @@ def test_single_range( expected_result = [(1, 499), (701, 2000)] result = utils.allowed_tenant_vlan_id_ranges() assert result == expected_result + + +class TestIsUuid: + def test_valid_uuid(self): + assert utils._is_uuid("7ca98881-bca5-4c82-9369-66eb36292a95") is True + + def test_invalid_uuid(self): + assert utils._is_uuid("not-a-uuid") is False + + def test_hostname(self): + assert utils._is_uuid("1327172-hp1") is False + + def test_empty_string(self): + assert utils._is_uuid("") is False + + +class TestFetchNetworkNodeTrunkId: + @pytest.fixture(autouse=True) + def reset_cache(self): + """Reset the cache before each test.""" + utils._cached_network_node_trunk_id = None + yield + utils._cached_network_node_trunk_id = None + + def test_successful_discovery_with_hostname(self, mocker): + """Test successful trunk discovery when gateway host is a hostname.""" + # Mock context and plugin + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + # Mock gateway agent with hostname + mock_plugin.get_agents.return_value = [ + {"host": "gateway-host-1", "id": "agent-1"} + ] + + # Mock Ironic client to resolve hostname to UUID + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_ironic = MagicMock() + mock_ironic.baremetal_node_uuid.return_value = gateway_uuid + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + # Mock port binding + mock_binding = MagicMock() + mock_binding.port_id = "port-123" + mock_binding.host = "gateway-host-1" + mocker.patch( + "neutron.objects.ports.PortBinding.get_objects", + return_value=[mock_binding], + ) + + # Mock port + mock_port = MagicMock() + mock_port.id = "port-123" + mocker.patch("neutron.objects.ports.Port.get_object", return_value=mock_port) + + # Mock trunk + mock_trunk = MagicMock() + mock_trunk.id = "trunk-456" + mock_trunk.port_id = "port-123" + + mocker.patch( + "neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk] + ) + + result = utils.fetch_network_node_trunk_id() + + assert result == "trunk-456" + assert utils._cached_network_node_trunk_id == "trunk-456" + mock_ironic.baremetal_node_uuid.assert_called_once_with("gateway-host-1") + + def test_successful_discovery_with_uuid(self, mocker): + """Test successful trunk discovery when gateway host is a UUID.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + # Mock gateway agent with UUID + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_plugin.get_agents.return_value = [{"host": gateway_uuid, "id": "agent-1"}] + + # Mock Ironic client to resolve UUID to hostname + mock_ironic = MagicMock() + mock_ironic.baremetal_node_name.return_value = "gateway-host-1" + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + # Mock port binding bound to UUID + mock_binding = MagicMock() + mock_binding.port_id = "port-123" + mock_binding.host = gateway_uuid + mocker.patch( + "neutron.objects.ports.PortBinding.get_objects", + return_value=[mock_binding], + ) + + # Mock port + mock_port = MagicMock() + mock_port.id = "port-123" + mocker.patch("neutron.objects.ports.Port.get_object", return_value=mock_port) + + # Mock trunk + mock_trunk = MagicMock() + mock_trunk.id = "trunk-456" + mock_trunk.port_id = "port-123" + + mocker.patch( + "neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk] + ) + + result = utils.fetch_network_node_trunk_id() + + assert result == "trunk-456" + mock_ironic.baremetal_node_name.assert_called_once_with(gateway_uuid) + mock_ironic.baremetal_node_uuid.assert_not_called() + + def test_cache_returns_cached_value(self, mocker): + """Test that subsequent calls return cached value without querying.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + mock_plugin.get_agents.return_value = [ + {"host": "gateway-host-1", "id": "agent-1"} + ] + + # Mock Ironic client + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_ironic = MagicMock() + mock_ironic.baremetal_node_uuid.return_value = gateway_uuid + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + # Mock port binding + mock_binding = MagicMock() + mock_binding.port_id = "port-123" + mock_binding.host = "gateway-host-1" + mock_get_bindings = mocker.patch( + "neutron.objects.ports.PortBinding.get_objects", + return_value=[mock_binding], + ) + + # Mock port + mock_port = MagicMock() + mock_port.id = "port-123" + mocker.patch("neutron.objects.ports.Port.get_object", return_value=mock_port) + + mock_trunk = MagicMock() + mock_trunk.id = "trunk-456" + mock_trunk.port_id = "port-123" + + mocker.patch( + "neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk] + ) + + # First call + result1 = utils.fetch_network_node_trunk_id() + assert result1 == "trunk-456" + + # Second call should use cache + result2 = utils.fetch_network_node_trunk_id() + assert result2 == "trunk-456" + + assert mock_get_bindings.call_count == 2 + + def test_no_gateway_agents_found(self, mocker): + """Test exception when no alive gateway agents found.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + mock_plugin.get_agents.return_value = [] + + with pytest.raises(Exception, match="No alive OVN Controller Gateway agents"): + utils.fetch_network_node_trunk_id() + + def test_no_core_plugin(self, mocker): + """Test exception when core plugin is not available.""" + mock_context = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=None) + + with pytest.raises(Exception, match="Unable to obtain core plugin"): + utils.fetch_network_node_trunk_id() + + def test_ironic_resolution_fails_uuid_to_hostname(self, mocker): + """Test exception when Ironic fails to resolve UUID to hostname.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_plugin.get_agents.return_value = [{"host": gateway_uuid, "id": "agent-1"}] + + mock_ironic = MagicMock() + mock_ironic.baremetal_node_name.return_value = None + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + with pytest.raises(Exception, match="Failed to resolve baremetal node UUID"): + utils.fetch_network_node_trunk_id() + + def test_ironic_resolution_fails_hostname_to_uuid(self, mocker): + """Test exception when Ironic fails to resolve hostname to UUID.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + mock_plugin.get_agents.return_value = [ + {"host": "gateway-host-1", "id": "agent-1"} + ] + + mock_ironic = MagicMock() + mock_ironic.baremetal_node_uuid.return_value = None + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + with pytest.raises(Exception, match="Failed to resolve hostname"): + utils.fetch_network_node_trunk_id() + + def test_no_ports_bound_to_gateway(self, mocker): + """Test exception when no ports are bound to gateway host.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + mock_plugin.get_agents.return_value = [ + {"host": "gateway-host-1", "id": "agent-1"} + ] + + # Mock Ironic client + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_ironic = MagicMock() + mock_ironic.baremetal_node_uuid.return_value = gateway_uuid + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + # Mock no port bindings found for gateway hosts + mocker.patch("neutron.objects.ports.PortBinding.get_objects", return_value=[]) + + with pytest.raises(Exception, match="No ports found bound to gateway hosts"): + utils.fetch_network_node_trunk_id() + + def test_no_trunk_found(self, mocker): + """Test exception when no trunk matches gateway ports.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + mock_plugin.get_agents.return_value = [ + {"host": "gateway-host-1", "id": "agent-1"} + ] + + # Mock Ironic client + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_ironic = MagicMock() + mock_ironic.baremetal_node_uuid.return_value = gateway_uuid + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + # Mock port binding + mock_binding = MagicMock() + mock_binding.port_id = "port-123" + mock_binding.host = "gateway-host-1" + mocker.patch( + "neutron.objects.ports.PortBinding.get_objects", + return_value=[mock_binding], + ) + + # Mock port + mock_port = MagicMock() + mock_port.id = "port-123" + mocker.patch("neutron.objects.ports.Port.get_object", return_value=mock_port) + + # Mock trunk with different parent port + mock_trunk = MagicMock() + mock_trunk.id = "trunk-456" + mock_trunk.port_id = "different-port" + + mocker.patch( + "neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk] + ) + + with pytest.raises(Exception, match="Unable to find network node trunk"): + utils.fetch_network_node_trunk_id() + + def test_port_bound_to_resolved_hostname(self, mocker): + """Test when port is bound to resolved hostname instead of UUID.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_plugin.get_agents.return_value = [{"host": gateway_uuid, "id": "agent-1"}] + + mock_ironic = MagicMock() + mock_ironic.baremetal_node_name.return_value = "gateway-host-1" + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + # Port binding bound to hostname, not UUID + mock_binding = MagicMock() + mock_binding.port_id = "port-123" + mock_binding.host = "gateway-host-1" + mocker.patch( + "neutron.objects.ports.PortBinding.get_objects", + return_value=[mock_binding], + ) + + # Mock port + mock_port = MagicMock() + mock_port.id = "port-123" + mocker.patch("neutron.objects.ports.Port.get_object", return_value=mock_port) + + mock_trunk = MagicMock() + mock_trunk.id = "trunk-456" + mock_trunk.port_id = "port-123" + + mocker.patch( + "neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk] + ) + + result = utils.fetch_network_node_trunk_id() + + assert result == "trunk-456" + + def test_port_bound_to_uuid_when_agent_reports_hostname(self, mocker): + """Test when agent reports hostname but port is bound to UUID.""" + mock_context = MagicMock() + mock_plugin = MagicMock() + + mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context) + mocker.patch( + "neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin + ) + + # Agent reports hostname + mock_plugin.get_agents.return_value = [ + {"host": "gateway-host-1", "id": "agent-1"} + ] + + # Ironic resolves hostname to UUID + gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95" + mock_ironic = MagicMock() + mock_ironic.baremetal_node_uuid.return_value = gateway_uuid + mocker.patch("neutron_understack.utils.IronicClient", return_value=mock_ironic) + + # Port binding bound to UUID, not hostname + mock_binding = MagicMock() + mock_binding.port_id = "port-123" + mock_binding.host = gateway_uuid + mocker.patch( + "neutron.objects.ports.PortBinding.get_objects", + return_value=[mock_binding], + ) + + # Mock port + mock_port = MagicMock() + mock_port.id = "port-123" + mocker.patch("neutron.objects.ports.Port.get_object", return_value=mock_port) + + mock_trunk = MagicMock() + mock_trunk.id = "trunk-456" + mock_trunk.port_id = "port-123" + + mocker.patch( + "neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk] + ) + + result = utils.fetch_network_node_trunk_id() + + assert result == "trunk-456" + mock_ironic.baremetal_node_uuid.assert_called_once_with("gateway-host-1") diff --git a/python/neutron-understack/neutron_understack/trunk.py b/python/neutron-understack/neutron_understack/trunk.py index 716f59e15..c56b0880c 100644 --- a/python/neutron-understack/neutron_understack/trunk.py +++ b/python/neutron-understack/neutron_understack/trunk.py @@ -129,7 +129,7 @@ def _check_subports_segmentation_id( segment VLAN tags allocated to the subports. Therefore, there is no possibility of conflict with the native VLAN. """ - if trunk_id == cfg.CONF.ml2_understack.network_node_trunk_uuid: + if trunk_id == utils.fetch_network_node_trunk_id(): return ns_ranges = utils.allowed_tenant_vlan_id_ranges() diff --git a/python/neutron-understack/neutron_understack/utils.py b/python/neutron-understack/neutron_understack/utils.py index 8dba080c0..5fe58dcb0 100644 --- a/python/neutron-understack/neutron_understack/utils.py +++ b/python/neutron-understack/neutron_understack/utils.py @@ -1,7 +1,11 @@ +import logging +import uuid from contextlib import contextmanager +from neutron.common.ovn import constants as ovn_const from neutron.db import models_v2 from neutron.objects import ports as port_obj +from neutron.objects import trunk as trunk_obj from neutron.objects.network import NetworkSegment from neutron.objects.network_segment_range import NetworkSegmentRange from neutron.plugins.ml2.driver_context import portbindings @@ -14,10 +18,13 @@ from neutron_lib.plugins.ml2 import api from oslo_config import cfg +from neutron_understack.ironic import IronicClient from neutron_understack.ml2_type_annotations import NetworkSegmentDict from neutron_understack.ml2_type_annotations import PortContext from neutron_understack.ml2_type_annotations import PortDict +LOG = logging.getLogger(__name__) + def fetch_port_object(port_id: str) -> port_obj.Port: context = n_context.get_admin_context() @@ -101,6 +108,270 @@ def fetch_trunk_plugin() -> TrunkPlugin: return trunk_plugin +def _is_uuid(value: str) -> bool: + """Check if a string is a UUID.""" + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def _get_gateway_agent_host(core_plugin, context): + """Get the host of an alive OVN Controller Gateway agent. + + Args: + core_plugin: Neutron core plugin instance + context: Neutron context + + Returns: + str: Gateway agent host (may be hostname or UUID) + + Raises: + Exception: If no alive gateway agents found + """ + LOG.debug("Looking for OVN Controller Gateway agents") + gateway_agents = core_plugin.get_agents( + context, + filters={"agent_type": [ovn_const.OVN_CONTROLLER_GW_AGENT], "alive": [True]}, + ) + + if not gateway_agents: + raise Exception( + "No alive OVN Controller Gateway agents found. " + "Please ensure the network node is running and the " + "OVN gateway agent is active." + ) + + # Use the first gateway agent's host + # TODO: In the future, support multiple gateway agents for HA + gateway_host = gateway_agents[0]["host"] + LOG.debug( + "Found OVN Gateway agent on host: %s (agent_id: %s)", + gateway_host, + gateway_agents[0]["id"], + ) + return gateway_host + + +def _resolve_gateway_host(gateway_host): + """Resolve gateway host to both hostname and UUID. + + This function ensures we have both the hostname and UUID for the gateway host, + regardless of which format the OVN agent reports. This is necessary because + some ports may be bound using hostname while others use UUID. + + Args: + gateway_host: Gateway host (hostname or UUID) + + Returns: + tuple: (hostname, uuid) - both values will be populated + + Raises: + Exception: If resolution via Ironic fails + """ + ironic_client = IronicClient() + + if _is_uuid(gateway_host): + # Input is UUID, resolve to hostname + LOG.debug( + "Gateway host %s is a baremetal UUID, resolving to hostname via Ironic", + gateway_host, + ) + gateway_node_uuid = gateway_host + resolved_name = ironic_client.baremetal_node_name(gateway_node_uuid) + + if not resolved_name: + raise Exception( + f"Failed to resolve baremetal node UUID {gateway_node_uuid} " + "to hostname via Ironic" + ) + + LOG.debug( + "Resolved gateway baremetal node %s to hostname %s", + gateway_node_uuid, + resolved_name, + ) + return resolved_name, gateway_node_uuid + else: + # Input is hostname, resolve to UUID + LOG.debug( + "Gateway host %s is a hostname, resolving to UUID via Ironic", + gateway_host, + ) + gateway_hostname = gateway_host + resolved_uuid = ironic_client.baremetal_node_uuid(gateway_hostname) + + if not resolved_uuid: + raise Exception( + f"Failed to resolve hostname {gateway_hostname} " + "to baremetal node UUID via Ironic" + ) + + LOG.debug( + "Resolved gateway hostname %s to baremetal node UUID %s", + gateway_hostname, + resolved_uuid, + ) + return gateway_hostname, resolved_uuid + + +def _find_ports_bound_to_hosts(context, host_filters): + """Find ports bound to any of the specified hosts. + + Args: + context: Neutron context + host_filters: List of hostnames/UUIDs to match + + Returns: + list: Port objects bound to the specified hosts + + Raises: + Exception: If no ports found + """ + LOG.debug("Searching for ports bound to hosts: %s", host_filters) + + # Query PortBinding objects for each host (more efficient than fetching all ports) + gateway_port_ids = set() + for host in host_filters: + bindings = port_obj.PortBinding.get_objects(context, host=host) + for binding in bindings: + gateway_port_ids.add(binding.port_id) + LOG.debug("Found port %s bound to gateway host %s", binding.port_id, host) + + if not gateway_port_ids: + raise Exception( + f"No ports found bound to gateway hosts (searched for: {host_filters})" + ) + + # Fetch the actual Port objects for the found port IDs + gateway_ports = [ + port_obj.Port.get_object(context, id=port_id) for port_id in gateway_port_ids + ] + # Filter out any None values (in case a port was deleted between queries) + gateway_ports = [p for p in gateway_ports if p is not None] + + if not gateway_ports: + raise Exception( + f"No ports found bound to gateway hosts (searched for: {host_filters})" + ) + + LOG.debug("Found %d port(s) bound to gateway host", len(gateway_ports)) + return gateway_ports + + +def _find_trunk_by_port_ids(context, port_ids, gateway_host): + """Find trunk whose parent port is in the given port IDs. + + Args: + context: Neutron context + port_ids: List of port IDs to check + gateway_host: Gateway hostname for logging + + Returns: + str: Trunk UUID + + Raises: + Exception: If no matching trunk found + """ + trunks = trunk_obj.Trunk.get_objects(context) + + if not trunks: + raise Exception("No trunks found in the system") + + LOG.debug("Checking %d trunk(s) for parent ports in gateway ports", len(trunks)) + + for trunk in trunks: + if trunk.port_id in port_ids: + LOG.info( + "Found network node trunk: %s (parent_port: %s, host: %s)", + trunk.id, + trunk.port_id, + gateway_host, + ) + return str(trunk.id) + + # No matching trunk found + raise Exception( + f"Unable to find network node trunk on gateway host '{gateway_host}'. " + f"Found {len(port_ids)} port(s) bound to gateway host and " + f"{len(trunks)} trunk(s) in system, but no trunk uses any of the " + f"gateway ports as parent port. " + "Please ensure a trunk exists with a parent port on the network node." + ) + + +_cached_network_node_trunk_id = None + + +def fetch_network_node_trunk_id() -> str: + """Dynamically discover the network node trunk ID via OVN Gateway agent. + + This function discovers the network node trunk by: + 1. Finding alive OVN Controller Gateway agents + 2. Getting the host of the gateway agent + 3. Resolve to both hostname and UUID via Ironic (handles both directions) + 4. Query ports bound to either hostname or UUID + 5. Find trunks that use those ports as parent ports + + The network node trunk is used to connect router networks to the + network node (OVN gateway) by adding subports for each VLAN. + + Note: We need both hostname and UUID because some ports may be bound + using hostname while others use UUID in their binding_host_id. + + Returns: + str: The UUID of the network node trunk + + Raises: + Exception: If no gateway agent or suitable trunk is found + + Example: + >>> fetch_network_node_trunk_id() + '2e558202-0bd0-4971-a9f8-61d1adea0427' + """ + global _cached_network_node_trunk_id + if _cached_network_node_trunk_id: + LOG.info( + "Returning cached network node trunk ID: %s", _cached_network_node_trunk_id + ) + return _cached_network_node_trunk_id + + context = n_context.get_admin_context() + core_plugin = directory.get_plugin() + + if not core_plugin: + raise Exception("Unable to obtain core plugin") + + # Step 1: Get gateway agent host + gateway_host = _get_gateway_agent_host(core_plugin, context) + + # Step 2: Resolve gateway host if it's a UUID (single Ironic call) + gateway_host, gateway_node_uuid = _resolve_gateway_host(gateway_host) + + # Step 3: Build host filters (both hostname and UUID if applicable) + host_filters = [gateway_host] + if gateway_node_uuid: + host_filters.append(gateway_node_uuid) + + # Step 4: Find ports bound to gateway host + gateway_ports = _find_ports_bound_to_hosts(context, host_filters) + + # Step 5: Find trunk using gateway ports + gateway_port_ids = [port.id for port in gateway_ports] + _cached_network_node_trunk_id = _find_trunk_by_port_ids( + context, gateway_port_ids, gateway_host + ) + LOG.info( + "Discovered and cached network node trunk ID: %s " + "(gateway_host: %s, gateway_uuid: %s)", + _cached_network_node_trunk_id, + gateway_host, + gateway_node_uuid, + ) + return _cached_network_node_trunk_id + + def allocate_dynamic_segment( network_id: str, network_type: str = "vlan",