From e764d21ccb3b0e5264ef1daa08aa33cdc8883287 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Tue, 28 Jul 2026 09:09:50 -0500 Subject: [PATCH 1/2] feat(neutron-understack): initialize Undersync client in UndersyncDriver First step of moving Undersync sync() calls out of the understack mechanism driver and into the undersync mechanism driver. Copies the Undersync client initialization from UnderstackDriver.initialize(). --- .../tests/test_undersync_mech.py | 19 ++++++++++++++++++- .../neutron_understack/undersync_mech.py | 9 ++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/python/neutron-understack/neutron_understack/tests/test_undersync_mech.py b/python/neutron-understack/neutron_understack/tests/test_undersync_mech.py index bda708a1f..069700d7d 100644 --- a/python/neutron-understack/neutron_understack/tests/test_undersync_mech.py +++ b/python/neutron-understack/neutron_understack/tests/test_undersync_mech.py @@ -5,16 +5,33 @@ from neutron_lib.api.definitions import portbindings from neutron_lib.plugins.ml2 import api +from neutron_understack.undersync import Undersync from neutron_understack.undersync_mech import UndersyncDriver @pytest.fixture -def driver(): +def driver(mocker): + mocker.patch("neutron_understack.config.get_session") d = UndersyncDriver() d.initialize() return d +class TestInitialize: + def test_creates_undersync_client(self, mocker, oslo_config): + mocker.patch("neutron_understack.config.get_session") + oslo_config.config( + undersync_url="http://undersync.example.com", + group="ml2_understack", + ) + + driver = UndersyncDriver() + driver.initialize() + + assert isinstance(driver.undersync, Undersync) + assert driver.undersync.api_url == "http://undersync.example.com" + + def _make_context(vnic_type=portbindings.VNIC_BAREMETAL, segments=None): context = MagicMock() context.current = {"id": "port-1", portbindings.VNIC_TYPE: vnic_type} diff --git a/python/neutron-understack/neutron_understack/undersync_mech.py b/python/neutron-understack/neutron_understack/undersync_mech.py index 97e649c7c..b0b3e410e 100644 --- a/python/neutron-understack/neutron_understack/undersync_mech.py +++ b/python/neutron-understack/neutron_understack/undersync_mech.py @@ -4,6 +4,10 @@ from neutron_lib.api.definitions import portbindings from neutron_lib.plugins.ml2 import api from neutron_lib.plugins.ml2.api import MechanismDriver +from oslo_config import cfg + +from neutron_understack import config +from neutron_understack.undersync import Undersync from .ml2_type_annotations import PortContext @@ -18,7 +22,10 @@ def connectivity(self): # type: ignore return portbindings.CONNECTIVITY_L2 def initialize(self): - pass + config.register_ml2_understack_opts(cfg.CONF) + conf = cfg.CONF.ml2_understack + + self.undersync = Undersync(conf.undersync_url) def bind_port(self, context: PortContext) -> None: port = context.current From 31acc109da68fb9eee347270dbce973efb0387a0 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Tue, 28 Jul 2026 09:48:19 -0500 Subject: [PATCH 2/2] feat(neutron-understack): add trunk plugin to sync undersync on subport add/remove Adds UndersyncTrunkDriver, a trunk driver owned by the undersync mechanism (separate from UnderStackTrunkDriver in trunk.py). On subport add/remove it looks up the trunk's parent port, reads physical_network off its binding profile, and calls Undersync.sync(). --- .../neutron_understack/tests/conftest.py | 14 ++ .../tests/test_undersync_trunk.py | 121 +++++++++++++++++ .../neutron_understack/undersync_mech.py | 2 + .../neutron_understack/undersync_trunk.py | 122 ++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 python/neutron-understack/neutron_understack/tests/test_undersync_trunk.py create mode 100644 python/neutron-understack/neutron_understack/undersync_trunk.py diff --git a/python/neutron-understack/neutron_understack/tests/conftest.py b/python/neutron-understack/neutron_understack/tests/conftest.py index cfb5b4256..275fb4e1f 100644 --- a/python/neutron-understack/neutron_understack/tests/conftest.py +++ b/python/neutron-understack/neutron_understack/tests/conftest.py @@ -33,6 +33,8 @@ from neutron_understack.tests.helpers import extend_port_dict_with_trunk from neutron_understack.trunk import UnderStackTrunkDriver from neutron_understack.undersync import Undersync +from neutron_understack.undersync_mech import UndersyncDriver +from neutron_understack.undersync_trunk import UndersyncTrunkDriver @pytest.fixture @@ -291,6 +293,18 @@ def understack_trunk_driver(understack_driver) -> UnderStackTrunkDriver: return UnderStackTrunkDriver.create(understack_driver) +@pytest.fixture +def undersync_driver(oslo_config) -> UndersyncDriver: + driver = UndersyncDriver() + driver.undersync = MagicMock(spec_set=Undersync) + return driver + + +@pytest.fixture +def undersync_trunk_driver(undersync_driver) -> UndersyncTrunkDriver: + return UndersyncTrunkDriver.create(undersync_driver) + + @pytest.fixture def _ironic_baremetal_port_physical_network(mocker, understack_driver) -> None: mocker.patch.object( diff --git a/python/neutron-understack/neutron_understack/tests/test_undersync_trunk.py b/python/neutron-understack/neutron_understack/tests/test_undersync_trunk.py new file mode 100644 index 000000000..35e8317ce --- /dev/null +++ b/python/neutron-understack/neutron_understack/tests/test_undersync_trunk.py @@ -0,0 +1,121 @@ +from unittest.mock import MagicMock + +import pytest +from neutron_lib.api.definitions import portbindings +from neutron_lib.services.trunk import constants as trunk_consts + +from neutron_understack.undersync_trunk import PhysicalNetworkNotFoundError + + +@pytest.fixture +def bound_parent_port(): + binding = MagicMock() + binding.vif_type = portbindings.VIF_TYPE_OTHER + binding.vnic_type = portbindings.VNIC_BAREMETAL + binding.profile = {"physical_network": "physnet-a"} + + binding_level = MagicMock() + binding_level.driver = "undersync" + + port = MagicMock() + port.id = "parent-port-1" + port.bindings = [binding] + port.binding_levels = [binding_level] + return port + + +@pytest.fixture +def bound_parent_port_without_physnet(bound_parent_port): + bound_parent_port.bindings[0].profile = {"local_link_information": []} + return bound_parent_port + + +@pytest.fixture +def unbound_parent_port(bound_parent_port): + bound_parent_port.bindings[0].vif_type = portbindings.VIF_TYPE_UNBOUND + return bound_parent_port + + +@pytest.fixture +def parent_port_bound_by_other_driver(bound_parent_port): + bound_parent_port.binding_levels[0].driver = "understack" + return bound_parent_port + + +class TestSubportsAdded: + def test_calls_sync_parent_port_physical_network( + self, mocker, undersync_trunk_driver, trunk_payload, trunk + ): + sync = mocker.patch.object( + undersync_trunk_driver, "_sync_parent_port_physical_network" + ) + update = mocker.patch.object(trunk, "update") + + undersync_trunk_driver.subports_added("", "", "", trunk_payload) + + sync.assert_called_once_with(trunk) + update.assert_called_once_with(status=trunk_consts.TRUNK_ACTIVE_STATUS) + + +class TestSubportsDeleted: + def test_calls_sync_parent_port_physical_network( + self, mocker, undersync_trunk_driver, trunk_payload, trunk + ): + sync = mocker.patch.object( + undersync_trunk_driver, "_sync_parent_port_physical_network" + ) + + undersync_trunk_driver.subports_deleted("", "", "", trunk_payload) + + sync.assert_called_once_with(trunk) + + +class TestSyncParentPortPhysicalNetwork: + def test_syncs_when_parent_port_bound_with_physical_network( + self, mocker, undersync_trunk_driver, trunk, bound_parent_port + ): + mocker.patch( + "neutron_understack.utils.fetch_port_object", + return_value=bound_parent_port, + ) + + undersync_trunk_driver._sync_parent_port_physical_network(trunk) + + undersync_trunk_driver.undersync.sync.assert_called_once_with("physnet-a") + + def test_skips_when_parent_port_is_unbound( + self, mocker, undersync_trunk_driver, trunk, unbound_parent_port + ): + mocker.patch( + "neutron_understack.utils.fetch_port_object", + return_value=unbound_parent_port, + ) + + undersync_trunk_driver._sync_parent_port_physical_network(trunk) + + undersync_trunk_driver.undersync.sync.assert_not_called() + + def test_raises_when_physical_network_missing( + self, mocker, undersync_trunk_driver, trunk, bound_parent_port_without_physnet + ): + mocker.patch( + "neutron_understack.utils.fetch_port_object", + return_value=bound_parent_port_without_physnet, + ) + + with pytest.raises(PhysicalNetworkNotFoundError): + undersync_trunk_driver._sync_parent_port_physical_network(trunk) + + undersync_trunk_driver.undersync.sync.assert_not_called() + + def test_skips_when_parent_port_not_bound_by_undersync( + self, mocker, undersync_trunk_driver, trunk, parent_port_bound_by_other_driver + ): + mocker.patch( + "neutron_understack.utils.fetch_port_object", + return_value=parent_port_bound_by_other_driver, + ) + + undersync_trunk_driver._sync_parent_port_physical_network(trunk) + + undersync_trunk_driver.undersync.sync.assert_not_called() diff --git a/python/neutron-understack/neutron_understack/undersync_mech.py b/python/neutron-understack/neutron_understack/undersync_mech.py index b0b3e410e..6f737c0df 100644 --- a/python/neutron-understack/neutron_understack/undersync_mech.py +++ b/python/neutron-understack/neutron_understack/undersync_mech.py @@ -8,6 +8,7 @@ from neutron_understack import config from neutron_understack.undersync import Undersync +from neutron_understack.undersync_trunk import UndersyncTrunkDriver from .ml2_type_annotations import PortContext @@ -26,6 +27,7 @@ def initialize(self): conf = cfg.CONF.ml2_understack self.undersync = Undersync(conf.undersync_url) + self.trunk_driver = UndersyncTrunkDriver.create(self) def bind_port(self, context: PortContext) -> None: port = context.current diff --git a/python/neutron-understack/neutron_understack/undersync_trunk.py b/python/neutron-understack/neutron_understack/undersync_trunk.py new file mode 100644 index 000000000..cee042cfa --- /dev/null +++ b/python/neutron-understack/neutron_understack/undersync_trunk.py @@ -0,0 +1,122 @@ +from neutron.objects.trunk import Trunk +from neutron.services.trunk.drivers import base as trunk_base +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 +from neutron_lib.callbacks import resources +from neutron_lib.services.trunk import constants as trunk_consts +from oslo_config import cfg +from oslo_log import log + +from neutron_understack import utils + +LOG = log.getLogger(__name__) + +# name this driver is registered under in the `neutron.ml2.mechanism_drivers` +# entry point namespace, also recorded as PortBindingLevel.driver for any +# port level bound by this mechanism driver. +MECHANISM_DRIVER_NAME = "undersync" + +SUPPORTED_INTERFACES = (portbindings.VIF_TYPE_OTHER,) + +SUPPORTED_SEGMENTATION_TYPES = (trunk_consts.SEGMENTATION_TYPE_VLAN,) + + +class PhysicalNetworkNotFoundError(exc.NeutronException): + message = ( + "No physical_network found in binding profile for parent port " + "%(port_id)s of trunk %(trunk_id)s, cannot sync undersync." + ) + + +class UndersyncTrunkDriver(trunk_base.DriverBase): + def __init__( + self, + name, + interfaces, + segmentation_types, + agent_type=None, + can_trunk_bound_port=False, + ): + super().__init__( + name, + interfaces, + segmentation_types, + agent_type=agent_type, + can_trunk_bound_port=can_trunk_bound_port, + ) + self.undersync = self.plugin_driver.undersync + + @property + def is_loaded(self): + try: + return MECHANISM_DRIVER_NAME in cfg.CONF.ml2.mechanism_drivers + except cfg.NoSuchOptError: + return False + + @classmethod + def create(cls, plugin_driver): + cls.plugin_driver = plugin_driver + # can_trunk_bound_port means that a trunk can be added to an already + # bound port, which is possible in baremetal environments so always + # report this as true + return cls( + MECHANISM_DRIVER_NAME, + SUPPORTED_INTERFACES, + SUPPORTED_SEGMENTATION_TYPES, + None, + can_trunk_bound_port=True, + ) + + @registry.receives(resources.TRUNK_PLUGIN, [events.AFTER_INIT]) + def register(self, resource, event, trigger, payload=None): + super().register(resource, event, trigger, payload=payload) + + # events that we want to listen to and the functions they should + # call. cancellable=True means that an Exception raised will + # interrupt / fail the operation that's happening. + registry.subscribe( + self.subports_added, + resources.SUBPORTS, + events.AFTER_CREATE, + cancellable=True, + ) + registry.subscribe( + self.subports_deleted, + resources.SUBPORTS, + events.AFTER_DELETE, + cancellable=True, + ) + + def _sync_parent_port_physical_network(self, trunk: Trunk) -> None: + parent_port = utils.fetch_port_object(trunk.port_id) + if not utils.parent_port_is_bound(parent_port): + return + if not self._parent_port_bound_by_undersync(parent_port): + return + + binding_profile = parent_port.bindings[0].profile + vlan_group_name = binding_profile.get("physical_network") + if not vlan_group_name: + raise PhysicalNetworkNotFoundError( + port_id=parent_port.id, trunk_id=trunk.id + ) + + self.undersync.sync(vlan_group_name) + + @staticmethod + def _parent_port_bound_by_undersync(parent_port) -> bool: + return any( + level.driver == MECHANISM_DRIVER_NAME + for level in parent_port.binding_levels + ) + + def subports_added(self, resource, event, trunk_plugin, payload): + trunk = payload.states[0] + self._sync_parent_port_physical_network(trunk) + trunk.update(status=trunk_consts.TRUNK_ACTIVE_STATUS) + + def subports_deleted(self, resource, event, trunk_plugin, payload): + trunk = payload.states[0] + self._sync_parent_port_physical_network(trunk)