Skip to content

Commit 2d5f218

Browse files
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.
1 parent ac1272d commit 2d5f218

File tree

8 files changed

+538
-18
lines changed

8 files changed

+538
-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/tests/test_utils.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from unittest.mock import MagicMock
12
from unittest.mock import patch
23

34
import pytest
@@ -236,3 +237,281 @@ def test_single_range(
236237
expected_result = [(1, 499), (701, 2000)]
237238
result = utils.allowed_tenant_vlan_id_ranges()
238239
assert result == expected_result
240+
241+
242+
class TestIsUuid:
243+
def test_valid_uuid(self):
244+
assert utils._is_uuid("7ca98881-bca5-4c82-9369-66eb36292a95") is True
245+
246+
def test_invalid_uuid(self):
247+
assert utils._is_uuid("not-a-uuid") is False
248+
249+
def test_hostname(self):
250+
assert utils._is_uuid("1327172-hp1") is False
251+
252+
def test_empty_string(self):
253+
assert utils._is_uuid("") is False
254+
255+
256+
class TestFetchNetworkNodeTrunkId:
257+
@pytest.fixture(autouse=True)
258+
def reset_cache(self):
259+
"""Reset the cache before each test."""
260+
utils._cached_network_node_trunk_id = None
261+
yield
262+
utils._cached_network_node_trunk_id = None
263+
264+
def test_successful_discovery_with_hostname(self, mocker):
265+
"""Test successful trunk discovery when gateway host is a hostname."""
266+
# Mock context and plugin
267+
mock_context = MagicMock()
268+
mock_plugin = MagicMock()
269+
270+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
271+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
272+
273+
# Mock gateway agent with hostname
274+
mock_plugin.get_agents.return_value = [
275+
{"host": "gateway-host-1", "id": "agent-1"}
276+
]
277+
278+
# Mock port with binding
279+
mock_port = MagicMock()
280+
mock_port.id = "port-123"
281+
mock_port.bindings = [MagicMock(host="gateway-host-1")]
282+
283+
mocker.patch(
284+
"neutron.objects.ports.Port.get_objects", return_value=[mock_port]
285+
)
286+
287+
# Mock trunk
288+
mock_trunk = MagicMock()
289+
mock_trunk.id = "trunk-456"
290+
mock_trunk.port_id = "port-123"
291+
292+
mocker.patch(
293+
"neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk]
294+
)
295+
296+
result = utils.fetch_network_node_trunk_id()
297+
298+
assert result == "trunk-456"
299+
assert utils._cached_network_node_trunk_id == "trunk-456"
300+
301+
def test_successful_discovery_with_uuid(self, mocker):
302+
"""Test successful trunk discovery when gateway host is a UUID."""
303+
mock_context = MagicMock()
304+
mock_plugin = MagicMock()
305+
306+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
307+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
308+
309+
# Mock gateway agent with UUID
310+
gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95"
311+
mock_plugin.get_agents.return_value = [{"host": gateway_uuid, "id": "agent-1"}]
312+
313+
# Mock Ironic client
314+
mock_ironic = MagicMock()
315+
mock_ironic.baremetal_node_name.return_value = "gateway-host-1"
316+
mocker.patch(
317+
"neutron_understack.utils.IronicClient", return_value=mock_ironic
318+
)
319+
320+
# Mock port bound to UUID
321+
mock_port = MagicMock()
322+
mock_port.id = "port-123"
323+
mock_port.bindings = [MagicMock(host=gateway_uuid)]
324+
325+
mocker.patch(
326+
"neutron.objects.ports.Port.get_objects", return_value=[mock_port]
327+
)
328+
329+
# Mock trunk
330+
mock_trunk = MagicMock()
331+
mock_trunk.id = "trunk-456"
332+
mock_trunk.port_id = "port-123"
333+
334+
mocker.patch(
335+
"neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk]
336+
)
337+
338+
result = utils.fetch_network_node_trunk_id()
339+
340+
assert result == "trunk-456"
341+
mock_ironic.baremetal_node_name.assert_called_once_with(gateway_uuid)
342+
343+
def test_cache_returns_cached_value(self, mocker):
344+
"""Test that subsequent calls return cached value without querying."""
345+
mock_context = MagicMock()
346+
mock_plugin = MagicMock()
347+
348+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
349+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
350+
351+
mock_plugin.get_agents.return_value = [
352+
{"host": "gateway-host-1", "id": "agent-1"}
353+
]
354+
355+
mock_port = MagicMock()
356+
mock_port.id = "port-123"
357+
mock_port.bindings = [MagicMock(host="gateway-host-1")]
358+
359+
mock_get_objects = mocker.patch(
360+
"neutron.objects.ports.Port.get_objects", return_value=[mock_port]
361+
)
362+
363+
mock_trunk = MagicMock()
364+
mock_trunk.id = "trunk-456"
365+
mock_trunk.port_id = "port-123"
366+
367+
mocker.patch(
368+
"neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk]
369+
)
370+
371+
# First call
372+
result1 = utils.fetch_network_node_trunk_id()
373+
assert result1 == "trunk-456"
374+
375+
# Second call should use cache
376+
result2 = utils.fetch_network_node_trunk_id()
377+
assert result2 == "trunk-456"
378+
379+
# Port.get_objects should only be called once (first call)
380+
assert mock_get_objects.call_count == 1
381+
382+
def test_no_gateway_agents_found(self, mocker):
383+
"""Test exception when no alive gateway agents found."""
384+
mock_context = MagicMock()
385+
mock_plugin = MagicMock()
386+
387+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
388+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
389+
390+
mock_plugin.get_agents.return_value = []
391+
392+
with pytest.raises(Exception, match="No alive OVN Controller Gateway agents"):
393+
utils.fetch_network_node_trunk_id()
394+
395+
def test_no_core_plugin(self, mocker):
396+
"""Test exception when core plugin is not available."""
397+
mock_context = MagicMock()
398+
399+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
400+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=None)
401+
402+
with pytest.raises(Exception, match="Unable to obtain core plugin"):
403+
utils.fetch_network_node_trunk_id()
404+
405+
def test_ironic_resolution_fails(self, mocker):
406+
"""Test exception when Ironic fails to resolve UUID."""
407+
mock_context = MagicMock()
408+
mock_plugin = MagicMock()
409+
410+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
411+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
412+
413+
gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95"
414+
mock_plugin.get_agents.return_value = [{"host": gateway_uuid, "id": "agent-1"}]
415+
416+
mock_ironic = MagicMock()
417+
mock_ironic.baremetal_node_name.return_value = None
418+
mocker.patch(
419+
"neutron_understack.utils.IronicClient", return_value=mock_ironic
420+
)
421+
422+
with pytest.raises(Exception, match="Failed to resolve baremetal node UUID"):
423+
utils.fetch_network_node_trunk_id()
424+
425+
def test_no_ports_bound_to_gateway(self, mocker):
426+
"""Test exception when no ports are bound to gateway host."""
427+
mock_context = MagicMock()
428+
mock_plugin = MagicMock()
429+
430+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
431+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
432+
433+
mock_plugin.get_agents.return_value = [
434+
{"host": "gateway-host-1", "id": "agent-1"}
435+
]
436+
437+
# Mock port with different binding
438+
mock_port = MagicMock()
439+
mock_port.id = "port-123"
440+
mock_port.bindings = [MagicMock(host="different-host")]
441+
442+
mocker.patch(
443+
"neutron.objects.ports.Port.get_objects", return_value=[mock_port]
444+
)
445+
446+
with pytest.raises(Exception, match="No ports found bound to gateway hosts"):
447+
utils.fetch_network_node_trunk_id()
448+
449+
def test_no_trunk_found(self, mocker):
450+
"""Test exception when no trunk matches gateway ports."""
451+
mock_context = MagicMock()
452+
mock_plugin = MagicMock()
453+
454+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
455+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
456+
457+
mock_plugin.get_agents.return_value = [
458+
{"host": "gateway-host-1", "id": "agent-1"}
459+
]
460+
461+
mock_port = MagicMock()
462+
mock_port.id = "port-123"
463+
mock_port.bindings = [MagicMock(host="gateway-host-1")]
464+
465+
mocker.patch(
466+
"neutron.objects.ports.Port.get_objects", return_value=[mock_port]
467+
)
468+
469+
# Mock trunk with different parent port
470+
mock_trunk = MagicMock()
471+
mock_trunk.id = "trunk-456"
472+
mock_trunk.port_id = "different-port"
473+
474+
mocker.patch(
475+
"neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk]
476+
)
477+
478+
with pytest.raises(Exception, match="Unable to find network node trunk"):
479+
utils.fetch_network_node_trunk_id()
480+
481+
def test_port_bound_to_resolved_hostname(self, mocker):
482+
"""Test when port is bound to resolved hostname instead of UUID."""
483+
mock_context = MagicMock()
484+
mock_plugin = MagicMock()
485+
486+
mocker.patch("neutron_lib.context.get_admin_context", return_value=mock_context)
487+
mocker.patch("neutron_lib.plugins.directory.get_plugin", return_value=mock_plugin)
488+
489+
gateway_uuid = "7ca98881-bca5-4c82-9369-66eb36292a95"
490+
mock_plugin.get_agents.return_value = [{"host": gateway_uuid, "id": "agent-1"}]
491+
492+
mock_ironic = MagicMock()
493+
mock_ironic.baremetal_node_name.return_value = "gateway-host-1"
494+
mocker.patch(
495+
"neutron_understack.utils.IronicClient", return_value=mock_ironic
496+
)
497+
498+
# Port bound to hostname, not UUID
499+
mock_port = MagicMock()
500+
mock_port.id = "port-123"
501+
mock_port.bindings = [MagicMock(host="gateway-host-1")]
502+
503+
mocker.patch(
504+
"neutron.objects.ports.Port.get_objects", return_value=[mock_port]
505+
)
506+
507+
mock_trunk = MagicMock()
508+
mock_trunk.id = "trunk-456"
509+
mock_trunk.port_id = "port-123"
510+
511+
mocker.patch(
512+
"neutron.objects.trunk.Trunk.get_objects", return_value=[mock_trunk]
513+
)
514+
515+
result = utils.fetch_network_node_trunk_id()
516+
517+
assert result == "trunk-456"

0 commit comments

Comments
 (0)