diff --git a/python/neutron-understack/neutron_understack/network_node_trunk.py b/python/neutron-understack/neutron_understack/network_node_trunk.py new file mode 100644 index 000000000..6761e2bad --- /dev/null +++ b/python/neutron-understack/neutron_understack/network_node_trunk.py @@ -0,0 +1,316 @@ +"""Network node trunk discovery and management. + +This module provides functionality to dynamically discover and manage +the network node trunk used for connecting router networks to the +OVN gateway node via VLAN subports. +""" + +import logging +import uuid + +from neutron.common.ovn import constants as ovn_const +from neutron.objects import ports as port_obj +from neutron.objects import trunk as trunk_obj +from neutron_lib import context as n_context +from neutron_lib.plugins import directory + +from neutron_understack.ironic import IronicClient + +LOG = logging.getLogger(__name__) + +# Global cache for the discovered network node trunk ID +_cached_network_node_trunk_id: str | None = None + + +def _is_uuid(value: str) -> bool: + """Check if a string is a valid UUID. + + Args: + value: String to validate + + Returns: + True if the string is a valid UUID, False otherwise + + Example: + >>> _is_uuid("550e8400-e29b-41d4-a716-446655440000") + True + >>> _is_uuid("not-a-uuid") + False + """ + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def _get_gateway_agent_host(core_plugin, context) -> str: + """Get the host of an alive OVN Controller Gateway agent. + + Args: + core_plugin: Neutron core plugin instance + context: Neutron context + + Returns: + Gateway agent host (may be hostname or UUID) + + Raises: + Exception: If no alive gateway agents found + + Example: + >>> _get_gateway_agent_host(plugin, ctx) + 'network-node-01' + """ + 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: str = 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: str) -> tuple[str, str]: + """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 of (hostname, uuid) - both values will be populated + + Raises: + Exception: If resolution via Ironic fails + + Example: + >>> _resolve_gateway_host("550e8400-e29b-41d4-a716-446655440000") + ('network-node-01', '550e8400-e29b-41d4-a716-446655440000') + >>> _resolve_gateway_host("network-node-01") + ('network-node-01', '550e8400-e29b-41d4-a716-446655440000') + """ + 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: str = gateway_host + resolved_name: str | None = 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: str = gateway_host + resolved_uuid: str | None = 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: list[str]) -> list[port_obj.Port]: + """Find ports bound to any of the specified hosts. + + Args: + context: Neutron context + host_filters: List of hostnames/UUIDs to match + + Returns: + List of Port objects bound to the specified hosts + + Raises: + Exception: If no ports found + + Example: + >>> _find_ports_bound_to_hosts(ctx, ['network-node-01', 'uuid-123']) + [, ] + """ + 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[str] = 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: list[port_obj.Port | None] = [ + 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) + filtered_ports: list[port_obj.Port] = [p for p in gateway_ports if p is not None] + + if not filtered_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(filtered_ports)) + return filtered_ports + + +def _find_trunk_by_port_ids(context, port_ids: list[str], gateway_host: str) -> str: + """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: + Trunk UUID + + Raises: + Exception: If no matching trunk found + + Example: + >>> _find_trunk_by_port_ids(ctx, ['port-123', 'port-456'], 'network-node-01') + '2e558202-0bd0-4971-a9f8-61d1adea0427' + """ + trunks: list[trunk_obj.Trunk] = 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." + ) + + +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. Resolving to both hostname and UUID via Ironic (handles both directions) + 4. Querying ports bound to either hostname or UUID + 5. Finding 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: + 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: str = _get_gateway_agent_host(core_plugin, context) + + # Step 2: Resolve gateway host + gateway_host, gateway_node_uuid = _resolve_gateway_host(gateway_host) + + # Step 3: Build host filters (both hostname and UUID if applicable) + host_filters: list[str] = [gateway_host] + if gateway_node_uuid: + host_filters.append(gateway_node_uuid) + + # Step 4: Find ports bound to gateway host + gateway_ports: list[port_obj.Port] = _find_ports_bound_to_hosts( + context, host_filters + ) + + # Step 5: Find trunk using gateway ports + gateway_port_ids: list[str] = [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 diff --git a/python/neutron-understack/neutron_understack/routers.py b/python/neutron-understack/neutron_understack/routers.py index e972ac708..7e8404d4f 100644 --- a/python/neutron-understack/neutron_understack/routers.py +++ b/python/neutron-understack/neutron_understack/routers.py @@ -13,6 +13,7 @@ from oslo_config import cfg from neutron_understack import utils +from neutron_understack.network_node_trunk import fetch_network_node_trunk_id from .ml2_type_annotations import NetworkSegmentDict from .ml2_type_annotations import PortContext @@ -98,7 +99,7 @@ def add_subport_to_trunk(shared_port: PortDict, segment: NetworkSegmentDict) -> }, ] } - trunk_id = utils.fetch_network_node_trunk_id() + trunk_id = fetch_network_node_trunk_id() utils.fetch_trunk_plugin().add_subports( context=n_context.get_admin_context(), @@ -253,7 +254,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 = utils.fetch_network_node_trunk_id() + trunk_id = 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_network_node_trunk.py b/python/neutron-understack/neutron_understack/tests/test_network_node_trunk.py new file mode 100644 index 000000000..98a63bdd7 --- /dev/null +++ b/python/neutron-understack/neutron_understack/tests/test_network_node_trunk.py @@ -0,0 +1,425 @@ +"""Tests for network_node_trunk module.""" + +from unittest.mock import MagicMock + +import pytest + +from neutron_understack import network_node_trunk + + +class TestFetchNetworkNodeTrunkId: + """Tests for fetch_network_node_trunk_id function.""" + + @pytest.fixture(autouse=True) + def reset_cache(self) -> None: + """Reset the cache before each test.""" + network_node_trunk._cached_network_node_trunk_id = None + yield + network_node_trunk._cached_network_node_trunk_id = None + + def test_successful_discovery_with_hostname(self, mocker) -> None: + """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.network_node_trunk.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 = network_node_trunk.fetch_network_node_trunk_id() + + assert result == "trunk-456" + assert network_node_trunk._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) -> None: + """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.network_node_trunk.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 = network_node_trunk.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) -> None: + """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.network_node_trunk.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 = network_node_trunk.fetch_network_node_trunk_id() + assert result1 == "trunk-456" + + # Second call should use cache + result2 = network_node_trunk.fetch_network_node_trunk_id() + assert result2 == "trunk-456" + + assert mock_get_bindings.call_count == 2 + + def test_no_gateway_agents_found(self, mocker) -> None: + """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"): + network_node_trunk.fetch_network_node_trunk_id() + + def test_no_core_plugin(self, mocker) -> None: + """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"): + network_node_trunk.fetch_network_node_trunk_id() + + def test_ironic_resolution_fails_uuid_to_hostname(self, mocker) -> None: + """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.network_node_trunk.IronicClient", + return_value=mock_ironic, + ) + + with pytest.raises(Exception, match="Failed to resolve baremetal node UUID"): + network_node_trunk.fetch_network_node_trunk_id() + + def test_ironic_resolution_fails_hostname_to_uuid(self, mocker) -> None: + """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.network_node_trunk.IronicClient", + return_value=mock_ironic, + ) + + with pytest.raises(Exception, match="Failed to resolve hostname"): + network_node_trunk.fetch_network_node_trunk_id() + + def test_no_ports_bound_to_gateway(self, mocker) -> None: + """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.network_node_trunk.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"): + network_node_trunk.fetch_network_node_trunk_id() + + def test_no_trunk_found(self, mocker) -> None: + """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.network_node_trunk.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"): + network_node_trunk.fetch_network_node_trunk_id() + + def test_port_bound_to_resolved_hostname(self, mocker) -> None: + """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.network_node_trunk.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 = network_node_trunk.fetch_network_node_trunk_id() + + assert result == "trunk-456" + + def test_port_bound_to_uuid_when_agent_reports_hostname(self, mocker) -> None: + """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.network_node_trunk.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 = network_node_trunk.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/tests/test_routers.py b/python/neutron-understack/neutron_understack/tests/test_routers.py index 4530e11a1..facb6fdc9 100644 --- a/python/neutron-understack/neutron_understack/tests/test_routers.py +++ b/python/neutron-understack/neutron_understack/tests/test_routers.py @@ -37,7 +37,7 @@ def test_when_successful(self, mocker): port = {"id": "port-123"} segment = {"segmentation_id": 42} mocker.patch( - "neutron_understack.utils.fetch_network_node_trunk_id", + "neutron_understack.network_node_trunk.fetch_network_node_trunk_id", return_value=trunk_id, ) mocker.patch( @@ -70,7 +70,7 @@ def test_when_successful(self, mocker): class TestHandleSubportRemoval: def test_when_successful(self, mocker, port_id, trunk_id): mocker.patch( - "neutron_understack.utils.fetch_network_node_trunk_id", + "neutron_understack.network_node_trunk.fetch_network_node_trunk_id", return_value=str(trunk_id), ) mock_remove = mocker.patch("neutron_understack.utils.remove_subport_from_trunk") diff --git a/python/neutron-understack/neutron_understack/tests/test_trunk.py b/python/neutron-understack/neutron_understack/tests/test_trunk.py index a93c2e035..c6f09444d 100644 --- a/python/neutron-understack/neutron_understack/tests/test_trunk.py +++ b/python/neutron-understack/neutron_understack/tests/test_trunk.py @@ -341,7 +341,7 @@ def test_when_trunk_id_is_network_node_trunk_id( ): # Mock fetch_network_node_trunk_id to return the trunk_id mocker.patch( - "neutron_understack.utils.fetch_network_node_trunk_id", + "neutron_understack.network_node_trunk.fetch_network_node_trunk_id", return_value=str(trunk_id), ) # Mock to ensure the function returns early and doesn't call this @@ -364,7 +364,7 @@ def test_when_segmentation_id_is_in_allowed_range( ): # Mock fetch_network_node_trunk_id to return a different trunk ID mocker.patch( - "neutron_understack.utils.fetch_network_node_trunk_id", + "neutron_understack.network_node_trunk.fetch_network_node_trunk_id", return_value="different-trunk-id", ) allowed_ranges = mocker.patch( @@ -387,7 +387,7 @@ def test_when_segmentation_id_is_not_in_allowed_range( ): # Mock fetch_network_node_trunk_id to return a different trunk ID mocker.patch( - "neutron_understack.utils.fetch_network_node_trunk_id", + "neutron_understack.network_node_trunk.fetch_network_node_trunk_id", return_value="different-trunk-id", ) mocker.patch( diff --git a/python/neutron-understack/neutron_understack/tests/test_utils.py b/python/neutron-understack/neutron_understack/tests/test_utils.py index 5aaf1ce9a..bee1e315c 100644 --- a/python/neutron-understack/neutron_understack/tests/test_utils.py +++ b/python/neutron-understack/neutron_understack/tests/test_utils.py @@ -1,4 +1,3 @@ -from unittest.mock import MagicMock from unittest.mock import patch import pytest @@ -251,392 +250,3 @@ def test_hostname(self): 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 aa903c889..6c021d4ac 100644 --- a/python/neutron-understack/neutron_understack/trunk.py +++ b/python/neutron-understack/neutron_understack/trunk.py @@ -14,6 +14,7 @@ from oslo_log import log from neutron_understack import utils +from neutron_understack.network_node_trunk import fetch_network_node_trunk_id LOG = log.getLogger(__name__) @@ -129,7 +130,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 == utils.fetch_network_node_trunk_id(): + if trunk_id == 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 5fe58dcb0..d9707656e 100644 --- a/python/neutron-understack/neutron_understack/utils.py +++ b/python/neutron-understack/neutron_understack/utils.py @@ -1,11 +1,8 @@ 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 @@ -18,7 +15,6 @@ 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 @@ -108,270 +104,6 @@ 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",