From 9bd0b216dad985024773249b4bdf4a98c214f2d6 Mon Sep 17 00:00:00 2001 From: haseeb Date: Thu, 6 Nov 2025 18:47:45 +0530 Subject: [PATCH] automatic discovery of the network node trunk ID via OVN Gateway agent. Previously, the network node trunk ID had to be manually configured in ml2 config. This had operational overhead and made the system less flexible when underling nodes used to deploy neutron is changed. --- .../neutron_understack/config.py | 7 - .../neutron_understack/ironic.py | 14 + .../neutron_understack/routers.py | 7 +- .../neutron_understack/tests/test_routers.py | 7 +- .../neutron_understack/tests/test_trunk.py | 18 +- .../neutron_understack/tests/test_utils.py | 404 ++++++++++++++++++ .../neutron_understack/trunk.py | 2 +- .../neutron_understack/utils.py | 271 ++++++++++++ 8 files changed, 712 insertions(+), 18 deletions(-) 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",