From 18f628c569297f4e6088c150e399d3f395d14ade Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Mon, 6 Oct 2025 13:18:21 +0100 Subject: [PATCH 1/2] add subport's segmentation id check --- .../neutron_understack/config.py | 9 +++ .../neutron_understack/trunk.py | 40 ++++++++++++++ .../neutron_understack/utils.py | 55 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/python/neutron-understack/neutron_understack/config.py b/python/neutron-understack/neutron_understack/config.py index 6f6520967..0c2484060 100644 --- a/python/neutron-understack/neutron_understack/config.py +++ b/python/neutron-understack/neutron_understack/config.py @@ -82,6 +82,15 @@ "Nautobot." ), ), + cfg.ListOpt( + "default_tenant_vlan_id_range", + default=[1, 3799], + item_type=cfg.types.Integer(min=1, max=4094), + help=( + "List of 2 comma separated integers, that represents a VLAN range, that" + "will be used for mapped VLANs on the switches." + ), + ), ] l3_svc_cisco_asa_opts = [ diff --git a/python/neutron-understack/neutron_understack/trunk.py b/python/neutron-understack/neutron_understack/trunk.py index 8cc201670..716f59e15 100644 --- a/python/neutron-understack/neutron_understack/trunk.py +++ b/python/neutron-understack/neutron_understack/trunk.py @@ -4,6 +4,7 @@ from neutron.objects.trunk import SubPort from neutron.services.trunk.drivers import base as trunk_base from neutron.services.trunk.models import Trunk +from neutron_lib import exceptions as exc from neutron_lib.api.definitions import portbindings from neutron_lib.callbacks import events from neutron_lib.callbacks import registry @@ -21,6 +22,14 @@ SUPPORTED_SEGMENTATION_TYPES = (trunk_consts.SEGMENTATION_TYPE_VLAN,) +class SubportSegmentationIDError(exc.NeutronException): + message = ( + "Segmentation ID: %(seg_id)s cannot be set to the Subport: " + "%(subport_id)s as it falls outside of allowed ranges: " + "%(network_segment_ranges)s. Please use different Segmentation ID." + ) + + class UnderStackTrunkDriver(trunk_base.DriverBase): def __init__( self, @@ -96,6 +105,7 @@ def register(self, resource, event, trigger, payload=None): def _handle_tenant_vlan_id_and_switchport_config( self, subports: list[SubPort], trunk: Trunk ) -> None: + self._check_subports_segmentation_id(subports, trunk.id) parent_port_obj = utils.fetch_port_object(trunk.port_id) if utils.parent_port_is_bound(parent_port_obj): @@ -103,6 +113,35 @@ def _handle_tenant_vlan_id_and_switchport_config( parent_port_obj, subports ) + def _check_subports_segmentation_id( + self, subports: list[SubPort], trunk_id: str + ) -> None: + """Checks if a subport's segmentation_id is within the allowed range. + + A switchport cannot have a mapped VLAN ID equal to the native VLAN ID. + Since the user specifies the VLAN ID (segmentation_id) when adding a + subport, an error is raised if it falls within any VLAN network segment + range, as these ranges are used to allocate VLAN tags for all VLAN + segments, including native VLANs. + + The only case where this check is not required is for a network node + trunk, since its subport segmentation_ids are the same as the network + segment VLAN tags allocated to the subports. Therefore, there is no + possibility of conflict with the native VLAN. + """ + if trunk_id == cfg.CONF.ml2_understack.network_node_trunk_uuid: + return + + ns_ranges = utils.allowed_tenant_vlan_id_ranges() + for subport in subports: + seg_id = subport.segmentation_id + if not utils.segmentation_id_in_ranges(seg_id, ns_ranges): + raise SubportSegmentationIDError( + seg_id=seg_id, + subport_id=subport.port_id, + network_segment_ranges=utils.printable_ranges(ns_ranges), + ) + def configure_trunk(self, trunk_details: dict, port_id: str) -> None: parent_port_obj = utils.fetch_port_object(port_id) subports = trunk_details.get("sub_ports", []) @@ -146,6 +185,7 @@ def _add_subports_networks_to_parent_port_switchport( vlan_group_name = self.ironic_client.baremetal_port_physical_network( local_link_info ) + self._handle_segment_allocation(subports, vlan_group_name, binding_host) def clean_trunk( diff --git a/python/neutron-understack/neutron_understack/utils.py b/python/neutron-understack/neutron_understack/utils.py index d88af1217..8dba080c0 100644 --- a/python/neutron-understack/neutron_understack/utils.py +++ b/python/neutron-understack/neutron_understack/utils.py @@ -3,6 +3,7 @@ from neutron.db import models_v2 from neutron.objects import ports as port_obj from neutron.objects.network import NetworkSegment +from neutron.objects.network_segment_range import NetworkSegmentRange from neutron.plugins.ml2.driver_context import portbindings from neutron.services.trunk.plugin import TrunkPlugin from neutron_lib import constants @@ -11,6 +12,7 @@ from neutron_lib.api.definitions import segment as segment_def from neutron_lib.plugins import directory from neutron_lib.plugins.ml2 import api +from oslo_config import cfg from neutron_understack.ml2_type_annotations import NetworkSegmentDict from neutron_understack.ml2_type_annotations import PortContext @@ -240,3 +242,56 @@ def vlan_segment_for_physnet( and segment[api.PHYSICAL_NETWORK] == physnet ): return segment + + +def fetch_vlan_network_segment_ranges() -> list[NetworkSegmentRange]: + context = n_context.get_admin_context() + + return NetworkSegmentRange.get_objects(context, network_type="vlan", shared=True) + + +def allowed_tenant_vlan_id_ranges() -> list[tuple[int, int]]: + all_vlan_range_objects = fetch_vlan_network_segment_ranges() + all_vlan_ranges = [(vr.minimum, vr.maximum) for vr in all_vlan_range_objects] + merged_ranges = merge_overlapped_ranges(all_vlan_ranges) + default_range = tuple(cfg.CONF.ml2_understack.default_tenant_vlan_id_range) + return fetch_gaps_in_ranges(merged_ranges, default_range) + + +def merge_overlapped_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]: + merged = [] + for start, end in sorted(ranges): + if not merged or start > merged[-1][1] + 1: + merged.append([start, end]) + else: + merged[-1][1] = max(merged[-1][1], end) + return [tuple(lst) for lst in merged] + + +def fetch_gaps_in_ranges( + ranges: list[tuple[int, int]], default_range: tuple[int, int] +) -> list[tuple[int, int]]: + free_ranges = [] + prev_end = default_range[0] - 1 + for start, end in ranges: + if start > prev_end + 1: + free_ranges.append((prev_end + 1, start - 1)) + prev_end = end + if prev_end < default_range[1]: + free_ranges.append((prev_end + 1, default_range[1])) + return free_ranges + + +def segmentation_id_in_ranges( + segmentation_id: int, ranges: list[tuple[int, int]] +) -> bool: + return any(start <= segmentation_id <= end for start, end in ranges) + + +def printable_ranges(ranges: list[tuple[int, int]]) -> str: + return ",".join( + [ + f"{str(tpl[0])}-{str(tpl[1])}" if tpl[0] != tpl[1] else str(tpl[0]) + for tpl in ranges + ] + ) From 1d5ee84323f6de0b3be5cfbb717d4866936c9e7d Mon Sep 17 00:00:00 2001 From: Milan Fencik Date: Thu, 9 Oct 2025 16:05:07 +0100 Subject: [PATCH 2/2] add tests for utils and trunk --- .../neutron_understack/tests/test_trunk.py | 70 ++++++++++-- .../neutron_understack/tests/test_utils.py | 104 ++++++++++++++++++ 2 files changed, 167 insertions(+), 7 deletions(-) diff --git a/python/neutron-understack/neutron_understack/tests/test_trunk.py b/python/neutron-understack/neutron_understack/tests/test_trunk.py index 369ed3b06..c3b5d1ed0 100644 --- a/python/neutron-understack/neutron_understack/tests/test_trunk.py +++ b/python/neutron-understack/neutron_understack/tests/test_trunk.py @@ -3,6 +3,7 @@ from oslo_config import cfg from neutron_understack import utils +from neutron_understack.trunk import SubportSegmentationIDError class TestSubportsAdded: @@ -54,18 +55,22 @@ def test_when_subports_are_not_present( @pytest.mark.usefixtures("ironic_baremetal_port_physical_network") @pytest.mark.usefixtures("utils_fetch_subport_network_id_patch") class Test_HandleTenantVlanIDAndSwitchportConfig: - def test_when_ucvni_tenant_vlan_id_is_not_set_yet( + def test_that_check_subports_segmentation_id_is_called( self, mocker, understack_trunk_driver, trunk, subport, network_id, vlan_num ): mocker.patch("neutron_understack.utils.fetch_port_object") mocker.patch( "neutron_understack.utils.parent_port_is_bound", return_value=False ) - + subport_seg_id_check = mocker.patch.object( + understack_trunk_driver, "_check_subports_segmentation_id" + ) understack_trunk_driver._handle_tenant_vlan_id_and_switchport_config( [subport], trunk ) + subport_seg_id_check.assert_called_once() + def test_when_parent_port_is_bound( self, mocker, @@ -76,6 +81,7 @@ def test_when_parent_port_is_bound( port_id, vlan_network_segment, ): + mocker.patch.object(understack_trunk_driver, "_check_subports_segmentation_id") mocker.patch( "neutron_understack.utils.fetch_port_object", return_value=port_object ) @@ -87,9 +93,13 @@ def test_when_parent_port_is_bound( "neutron_understack.utils.network_segment_by_physnet", return_value=None ) mocker.patch("neutron_understack.utils.create_binding_profile_level") + add_subports_networks = mocker.patch.object( + understack_trunk_driver, "_add_subports_networks_to_parent_port_switchport" + ) understack_trunk_driver._handle_tenant_vlan_id_and_switchport_config( [subport], trunk ) + add_subports_networks.assert_called_once() def test_subports_add_post( self, @@ -114,20 +124,18 @@ def test_subports_add_post( def test_when_parent_port_is_unbound( self, mocker, understack_trunk_driver, trunk, subport, port_object ): + mocker.patch.object(understack_trunk_driver, "_check_subports_segmentation_id") port_object.bindings[0].vif_type = portbindings.VIF_TYPE_UNBOUND mocker.patch( "neutron_understack.utils.fetch_port_object", return_value=port_object ) - mocker.patch.object( + add_subports_networks = mocker.patch.object( understack_trunk_driver, "_add_subports_networks_to_parent_port_switchport" ) understack_trunk_driver._handle_tenant_vlan_id_and_switchport_config( [subport], trunk ) - - ( - understack_trunk_driver._add_subports_networks_to_parent_port_switchport.assert_not_called() - ) + add_subports_networks.assert_not_called() class TestSubportsDeleted: @@ -322,3 +330,51 @@ def test_that_handle_subports_removal_is_called( subports=[], invoke_undersync=False, ) + + +class TestCheckSubportsSegmentationId: + def test_when_trunk_id_is_network_node_trunk_id( + self, + mocker, + understack_trunk_driver, + trunk_id, + ): + mocker.patch( + "oslo_config.cfg.CONF.ml2_understack.network_node_trunk_uuid", + trunk_id, + ) + result = understack_trunk_driver._check_subports_segmentation_id([], trunk_id) + assert result is None + + def test_when_segmentation_id_is_in_allowed_range( + self, + mocker, + understack_trunk_driver, + trunk_id, + subport, + ): + allowed_ranges = mocker.patch( + "neutron_understack.utils.allowed_tenant_vlan_id_ranges", + return_value=[(1, 1500)], + ) + subport.segmentation_id = 500 + result = understack_trunk_driver._check_subports_segmentation_id( + [subport], trunk_id + ) + allowed_ranges.assert_called_once() + assert result is None + + def test_when_segmentation_id_is_not_in_allowed_range( + self, + mocker, + understack_trunk_driver, + trunk_id, + subport, + ): + mocker.patch( + "neutron_understack.utils.allowed_tenant_vlan_id_ranges", + return_value=[(1, 1500)], + ) + subport.segmentation_id = 1600 + with pytest.raises(SubportSegmentationIDError): + understack_trunk_driver._check_subports_segmentation_id([subport], trunk_id) diff --git a/python/neutron-understack/neutron_understack/tests/test_utils.py b/python/neutron-understack/neutron_understack/tests/test_utils.py index 42d8c4b18..58e028493 100644 --- a/python/neutron-understack/neutron_understack/tests/test_utils.py +++ b/python/neutron-understack/neutron_understack/tests/test_utils.py @@ -129,3 +129,107 @@ def test_router_interface_false_device_owner_missing(self): def test_router_interface_false_device_owner_none(self): context = PortContext(current={"device_owner": None}) assert not utils.is_router_interface(context) + + +class TestMergeOverlappedRanges: + def test_single_range(self): + assert utils.merge_overlapped_ranges([(1, 5)]) == [(1, 5)] + + def test_no_overlap(self): + ranges = [(1, 3), (5, 7), (9, 10)] + expected = [(1, 3), (5, 7), (9, 10)] + assert utils.merge_overlapped_ranges(ranges) == expected + + def test_simple_overlap(self): + ranges = [(1, 4), (2, 5)] + expected = [(1, 5)] + assert utils.merge_overlapped_ranges(ranges) == expected + + def test_multiple_ranges(self): + ranges = [(1, 3), (2, 6), (8, 10), (15, 18)] + expected = [(1, 6), (8, 10), (15, 18)] + assert utils.merge_overlapped_ranges(ranges) == expected + + def test_touching_ranges(self): + ranges = [(1, 4), (5, 7), (8, 10)] + expected = [(1, 10)] + assert utils.merge_overlapped_ranges(ranges) == expected + + def test_unsorted_input(self): + ranges = [(8, 10), (1, 3), (2, 7), (15, 18)] + expected = [(1, 10), (15, 18)] + assert utils.merge_overlapped_ranges(ranges) == expected + + +class TestFetchGapsInRanges: + def test_full_coverage(self): + assert utils.fetch_gaps_in_ranges([(1, 10)], (1, 10)) == [] + + def test_gap_at_start(self): + assert utils.fetch_gaps_in_ranges([(5, 8)], (1, 10)) == [(1, 4), (9, 10)] + + def test_gap_at_end(self): + assert utils.fetch_gaps_in_ranges([(1, 6)], (1, 10)) == [(7, 10)] + + def test_gap_in_middle(self): + assert utils.fetch_gaps_in_ranges([(1, 2), (5, 10)], (1, 10)) == [(3, 4)] + + def test_multiple_gaps(self): + result = utils.fetch_gaps_in_ranges([(1, 2), (4, 5), (8, 8)], (1, 10)) + assert result == [(3, 3), (6, 7), (9, 10)] + + +class DummyRange: + def __init__(self, minimum, maximum): + self.minimum = minimum + self.maximum = maximum + + +class TestAllowedTenantVlanIdRanges: + def test_multiple_non_overlapping_ranges( + self, + mocker, + ): + mocker.patch( + "oslo_config.cfg.CONF.ml2_understack.default_tenant_vlan_id_range", + [1, 2000], + ) + mocker.patch( + "neutron_understack.utils.fetch_vlan_network_segment_ranges", + return_value=[DummyRange(500, 700), DummyRange(900, 1200)], + ) + expected_result = [(1, 499), (701, 899), (1201, 2000)] + result = utils.allowed_tenant_vlan_id_ranges() + assert result == expected_result + + def test_multiple_overlapping_ranges( + self, + mocker, + ): + mocker.patch( + "oslo_config.cfg.CONF.ml2_understack.default_tenant_vlan_id_range", + [1, 2000], + ) + mocker.patch( + "neutron_understack.utils.fetch_vlan_network_segment_ranges", + return_value=[DummyRange(500, 700), DummyRange(600, 1200)], + ) + expected_result = [(1, 499), (1201, 2000)] + result = utils.allowed_tenant_vlan_id_ranges() + assert result == expected_result + + def test_single_range( + self, + mocker, + ): + mocker.patch( + "oslo_config.cfg.CONF.ml2_understack.default_tenant_vlan_id_range", + [1, 2000], + ) + mocker.patch( + "neutron_understack.utils.fetch_vlan_network_segment_ranges", + return_value=[DummyRange(500, 700)], + ) + expected_result = [(1, 499), (701, 2000)] + result = utils.allowed_tenant_vlan_id_ranges() + assert result == expected_result