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..d99ff4ce1 --- /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 # noqa: PLW0603 + 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 85960b997..f724bdf5e 100644 --- a/python/neutron-understack/neutron_understack/routers.py +++ b/python/neutron-understack/neutron_understack/routers.py @@ -14,6 +14,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 @@ -113,7 +114,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(), @@ -438,7 +439,7 @@ def handle_router_interface_after_delete(_resource, _event, trigger, payload) -> 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..c38112f64 --- /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 8f488478a..cec160be9 100644 --- a/python/neutron-understack/neutron_understack/tests/test_routers.py +++ b/python/neutron-understack/neutron_understack/tests/test_routers.py @@ -42,7 +42,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.routers.fetch_network_node_trunk_id", return_value=trunk_id, ) mocker.patch( @@ -75,7 +75,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.routers.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 1d0b80110..19edf5cdd 100644 --- a/python/neutron-understack/neutron_understack/tests/test_trunk.py +++ b/python/neutron-understack/neutron_understack/tests/test_trunk.py @@ -334,7 +334,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.trunk.fetch_network_node_trunk_id", return_value=str(trunk_id), ) # Mock to ensure the function returns early and doesn't call this @@ -357,7 +357,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.trunk.fetch_network_node_trunk_id", return_value="different-trunk-id", ) allowed_ranges = mocker.patch( @@ -380,7 +380,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.trunk.fetch_network_node_trunk_id", return_value="different-trunk-id", ) mocker.patch( diff --git a/python/neutron-understack/neutron_understack/trunk.py b/python/neutron-understack/neutron_understack/trunk.py index c09bf7010..9651db257 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()