Skip to content

Commit 637d1ea

Browse files
dynamic lookup network nodeId
1 parent 5cd9c8e commit 637d1ea

File tree

7 files changed

+259
-18
lines changed

7 files changed

+259
-18
lines changed

python/neutron-understack/neutron_understack/config.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,6 @@
5050
"is used to trunk all vlans used by a neutron router."
5151
),
5252
),
53-
cfg.StrOpt(
54-
"network_node_trunk_uuid",
55-
help=(
56-
"UUID of the trunk that is used to trunk all vlans used by a Neutron"
57-
" router."
58-
),
59-
),
6053
cfg.StrOpt(
6154
"network_node_switchport_physnet",
6255
help=(

python/neutron-understack/neutron_understack/ironic.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,10 @@ def _port_by_local_link(self, local_link_info: dict) -> BaremetalPort | None:
3232
)
3333
except StopIteration:
3434
return None
35+
36+
def baremetal_node_name(self, node_uuid: str) -> str | None:
37+
try:
38+
node = self.irclient.get_node(node_uuid)
39+
return node.name if node else None
40+
except Exception:
41+
return None

python/neutron-understack/neutron_understack/routers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,11 @@ def add_subport_to_trunk(shared_port: PortDict, segment: NetworkSegmentDict) ->
9898
},
9999
]
100100
}
101+
trunk_id = utils.fetch_network_node_trunk_id()
102+
101103
utils.fetch_trunk_plugin().add_subports(
102104
context=n_context.get_admin_context(),
103-
trunk_id=cfg.CONF.ml2_understack.network_node_trunk_uuid,
105+
trunk_id=trunk_id,
104106
subports=subports,
105107
)
106108

@@ -251,8 +253,7 @@ def handle_router_interface_removal(_resource, _event, trigger, payload) -> None
251253

252254
def handle_subport_removal(port: Port) -> None:
253255
"""Removes router's subport from a network node trunk."""
254-
# trunk_id will be discovered dynamically at some point
255-
trunk_id = cfg.CONF.ml2_understack.network_node_trunk_uuid
256+
trunk_id = utils.fetch_network_node_trunk_id()
256257
LOG.debug("Router, Removing subport: %s(port)s", {"port": port})
257258
port_id = port["id"]
258259
try:

python/neutron-understack/neutron_understack/tests/test_routers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ def test_when_successful(self, mocker):
3737
port = {"id": "port-123"}
3838
segment = {"segmentation_id": 42}
3939
mocker.patch(
40-
"oslo_config.cfg.CONF.ml2_understack.network_node_trunk_uuid",
41-
trunk_id,
40+
"neutron_understack.utils.fetch_network_node_trunk_id",
41+
return_value=trunk_id,
4242
)
4343
mocker.patch(
4444
"neutron_lib.context.get_admin_context", return_value="admin_context"
@@ -70,7 +70,8 @@ def test_when_successful(self, mocker):
7070
class TestHandleSubportRemoval:
7171
def test_when_successful(self, mocker, port_id, trunk_id):
7272
mocker.patch(
73-
"oslo_config.cfg.CONF.ml2_understack.network_node_trunk_uuid", str(trunk_id)
73+
"neutron_understack.utils.fetch_network_node_trunk_id",
74+
return_value=str(trunk_id),
7475
)
7576
mock_remove = mocker.patch("neutron_understack.utils.remove_subport_from_trunk")
7677
port = {"id": str(port_id)}

python/neutron-understack/neutron_understack/tests/test_trunk.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,13 +336,13 @@ class TestCheckSubportsSegmentationId:
336336
def test_when_trunk_id_is_network_node_trunk_id(
337337
self,
338338
mocker,
339-
oslo_config,
340339
understack_trunk_driver,
341340
trunk_id,
342341
):
343-
oslo_config.config(
344-
network_node_trunk_uuid=str(trunk_id),
345-
group="ml2_understack",
342+
# Mock fetch_network_node_trunk_id to return the trunk_id
343+
mocker.patch(
344+
"neutron_understack.utils.fetch_network_node_trunk_id",
345+
return_value=str(trunk_id),
346346
)
347347
# Mock to ensure the function returns early and doesn't call this
348348
allowed_ranges_mock = mocker.patch(
@@ -362,6 +362,11 @@ def test_when_segmentation_id_is_in_allowed_range(
362362
trunk_id,
363363
subport,
364364
):
365+
# Mock fetch_network_node_trunk_id to return a different trunk ID
366+
mocker.patch(
367+
"neutron_understack.utils.fetch_network_node_trunk_id",
368+
return_value="different-trunk-id",
369+
)
365370
allowed_ranges = mocker.patch(
366371
"neutron_understack.utils.allowed_tenant_vlan_id_ranges",
367372
return_value=[(1, 1500)],
@@ -380,6 +385,11 @@ def test_when_segmentation_id_is_not_in_allowed_range(
380385
trunk_id,
381386
subport,
382387
):
388+
# Mock fetch_network_node_trunk_id to return a different trunk ID
389+
mocker.patch(
390+
"neutron_understack.utils.fetch_network_node_trunk_id",
391+
return_value="different-trunk-id",
392+
)
383393
mocker.patch(
384394
"neutron_understack.utils.allowed_tenant_vlan_id_ranges",
385395
return_value=[(1, 1500)],

python/neutron-understack/neutron_understack/trunk.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def _check_subports_segmentation_id(
129129
segment VLAN tags allocated to the subports. Therefore, there is no
130130
possibility of conflict with the native VLAN.
131131
"""
132-
if trunk_id == cfg.CONF.ml2_understack.network_node_trunk_uuid:
132+
if trunk_id == utils.fetch_network_node_trunk_id():
133133
return
134134

135135
ns_ranges = utils.allowed_tenant_vlan_id_ranges()

python/neutron-understack/neutron_understack/utils.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import logging
2+
import uuid
13
from contextlib import contextmanager
24

5+
from neutron.common.ovn import constants as ovn_const
36
from neutron.db import models_v2
47
from neutron.objects import ports as port_obj
8+
from neutron.objects import trunk as trunk_obj
59
from neutron.objects.network import NetworkSegment
610
from neutron.objects.network_segment_range import NetworkSegmentRange
711
from neutron.plugins.ml2.driver_context import portbindings
@@ -14,10 +18,13 @@
1418
from neutron_lib.plugins.ml2 import api
1519
from oslo_config import cfg
1620

21+
from neutron_understack.ironic import IronicClient
1722
from neutron_understack.ml2_type_annotations import NetworkSegmentDict
1823
from neutron_understack.ml2_type_annotations import PortContext
1924
from neutron_understack.ml2_type_annotations import PortDict
2025

26+
LOG = logging.getLogger(__name__)
27+
2128

2229
def fetch_port_object(port_id: str) -> port_obj.Port:
2330
context = n_context.get_admin_context()
@@ -101,6 +108,228 @@ def fetch_trunk_plugin() -> TrunkPlugin:
101108
return trunk_plugin
102109

103110

111+
def _is_uuid(value: str) -> bool:
112+
"""Check if a string is a UUID."""
113+
try:
114+
uuid.UUID(value)
115+
return True
116+
except ValueError:
117+
return False
118+
119+
120+
def _get_gateway_agent_host(core_plugin, context):
121+
"""Get the host of an alive OVN Controller Gateway agent.
122+
123+
Args:
124+
core_plugin: Neutron core plugin instance
125+
context: Neutron context
126+
127+
Returns:
128+
str: Gateway agent host (may be hostname or UUID)
129+
130+
Raises:
131+
Exception: If no alive gateway agents found
132+
"""
133+
LOG.debug("Looking for OVN Controller Gateway agents")
134+
gateway_agents = core_plugin.get_agents(
135+
context,
136+
filters={"agent_type": [ovn_const.OVN_CONTROLLER_GW_AGENT], "alive": [True]},
137+
)
138+
139+
if not gateway_agents:
140+
raise Exception(
141+
"No alive OVN Controller Gateway agents found. "
142+
"Please ensure the network node is running and the "
143+
"OVN gateway agent is active."
144+
)
145+
146+
# Use the first gateway agent's host
147+
# TODO: In the future, support multiple gateway agents for HA
148+
gateway_host = gateway_agents[0]["host"]
149+
LOG.debug(
150+
"Found OVN Gateway agent on host: %s (agent_id: %s)",
151+
gateway_host,
152+
gateway_agents[0]["id"],
153+
)
154+
return gateway_host
155+
156+
157+
def _resolve_gateway_host(gateway_host):
158+
"""Resolve gateway host if it's a baremetal UUID.
159+
160+
Args:
161+
gateway_host: Gateway host (hostname or UUID)
162+
163+
Returns:
164+
tuple: (resolved_hostname, original_uuid or None)
165+
166+
Raises:
167+
Exception: If UUID resolution fails
168+
"""
169+
if not _is_uuid(gateway_host):
170+
return gateway_host, None
171+
172+
LOG.debug(
173+
"Gateway host %s is a baremetal UUID, resolving to hostname via Ironic",
174+
gateway_host,
175+
)
176+
gateway_node_uuid = gateway_host
177+
ironic_client = IronicClient()
178+
resolved_name = ironic_client.baremetal_node_name(gateway_node_uuid)
179+
180+
if not resolved_name:
181+
raise Exception(
182+
f"Failed to resolve baremetal node UUID {gateway_node_uuid} "
183+
"to hostname via Ironic"
184+
)
185+
186+
LOG.debug(
187+
"Resolved gateway baremetal node %s to hostname %s",
188+
gateway_node_uuid,
189+
resolved_name,
190+
)
191+
return resolved_name, gateway_node_uuid
192+
193+
194+
def _find_ports_bound_to_hosts(context, host_filters):
195+
"""Find ports bound to any of the specified hosts.
196+
197+
Args:
198+
context: Neutron context
199+
host_filters: List of hostnames/UUIDs to match
200+
201+
Returns:
202+
list: Port objects bound to the specified hosts
203+
204+
Raises:
205+
Exception: If no ports found
206+
"""
207+
LOG.debug("Searching for ports bound to hosts: %s", host_filters)
208+
209+
# Get all ports and filter by binding_host_id
210+
# Note: Port.get_objects doesn't support filtering by binding_host_id directly
211+
all_ports = port_obj.Port.get_objects(context)
212+
gateway_ports = []
213+
214+
for port in all_ports:
215+
# Check if port has bindings and get the host
216+
binding_host = None
217+
if port.bindings:
218+
binding_host = port.bindings[0].host
219+
220+
# Match against host_filters
221+
if binding_host and binding_host in host_filters:
222+
gateway_ports.append(port)
223+
LOG.debug("Found port %s bound to gateway host %s", port.id, binding_host)
224+
225+
if not gateway_ports:
226+
raise Exception(
227+
f"No ports found bound to gateway hosts (searched for: {host_filters})"
228+
)
229+
230+
LOG.debug("Found %d port(s) bound to gateway host", len(gateway_ports))
231+
return gateway_ports
232+
233+
234+
def _find_trunk_by_port_ids(context, port_ids, gateway_host):
235+
"""Find trunk whose parent port is in the given port IDs.
236+
237+
Args:
238+
context: Neutron context
239+
port_ids: List of port IDs to check
240+
gateway_host: Gateway hostname for logging
241+
242+
Returns:
243+
str: Trunk UUID
244+
245+
Raises:
246+
Exception: If no matching trunk found
247+
"""
248+
trunks = trunk_obj.Trunk.get_objects(context)
249+
250+
if not trunks:
251+
raise Exception("No trunks found in the system")
252+
253+
LOG.debug("Checking %d trunk(s) for parent ports in gateway ports", len(trunks))
254+
255+
for trunk in trunks:
256+
if trunk.port_id in port_ids:
257+
LOG.info(
258+
"Found network node trunk: %s (parent_port: %s, host: %s)",
259+
trunk.id,
260+
trunk.port_id,
261+
gateway_host,
262+
)
263+
return str(trunk.id)
264+
265+
# No matching trunk found
266+
raise Exception(
267+
f"Unable to find network node trunk on gateway host '{gateway_host}'. "
268+
f"Found {len(port_ids)} port(s) bound to gateway host and "
269+
f"{len(trunks)} trunk(s) in system, but no trunk uses any of the "
270+
f"gateway ports as parent port. "
271+
"Please ensure a trunk exists with a parent port on the network node."
272+
)
273+
274+
275+
_cached_network_node_trunk_id = None
276+
277+
278+
def fetch_network_node_trunk_id() -> str:
279+
"""Dynamically discover the network node trunk ID via OVN Gateway agent.
280+
281+
This function discovers the network node trunk by:
282+
1. Finding alive OVN Controller Gateway agents
283+
2. Getting the host of the gateway agent
284+
3. If host is a UUID, resolve it to hostname via Ironic (single call)
285+
4. Query ports bound to that host
286+
5. Find trunks that use those ports as parent ports
287+
288+
The network node trunk is used to connect router networks to the
289+
network node (OVN gateway) by adding subports for each VLAN.
290+
291+
Returns:
292+
str: The UUID of the network node trunk
293+
294+
Raises:
295+
Exception: If no gateway agent or suitable trunk is found
296+
297+
Example:
298+
>>> fetch_network_node_trunk_id()
299+
'2e558202-0bd0-4971-a9f8-61d1adea0427'
300+
"""
301+
global _cached_network_node_trunk_id
302+
if _cached_network_node_trunk_id:
303+
return _cached_network_node_trunk_id
304+
305+
context = n_context.get_admin_context()
306+
core_plugin = directory.get_plugin()
307+
308+
if not core_plugin:
309+
raise Exception("Unable to obtain core plugin")
310+
311+
# Step 1: Get gateway agent host
312+
gateway_host = _get_gateway_agent_host(core_plugin, context)
313+
314+
# Step 2: Resolve gateway host if it's a UUID (single Ironic call)
315+
gateway_host, gateway_node_uuid = _resolve_gateway_host(gateway_host)
316+
317+
# Step 3: Build host filters (both hostname and UUID if applicable)
318+
host_filters = [gateway_host]
319+
if gateway_node_uuid:
320+
host_filters.append(gateway_node_uuid)
321+
322+
# Step 4: Find ports bound to gateway host
323+
gateway_ports = _find_ports_bound_to_hosts(context, host_filters)
324+
325+
# Step 5: Find trunk using gateway ports
326+
gateway_port_ids = [port.id for port in gateway_ports]
327+
_cached_network_node_trunk_id = _find_trunk_by_port_ids(
328+
context, gateway_port_ids, gateway_host
329+
)
330+
return _cached_network_node_trunk_id
331+
332+
104333
def allocate_dynamic_segment(
105334
network_id: str,
106335
network_type: str = "vlan",

0 commit comments

Comments
 (0)