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_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/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 97e649c7c..6f737c0df 100644 --- a/python/neutron-understack/neutron_understack/undersync_mech.py +++ b/python/neutron-understack/neutron_understack/undersync_mech.py @@ -4,6 +4,11 @@ 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 neutron_understack.undersync_trunk import UndersyncTrunkDriver from .ml2_type_annotations import PortContext @@ -18,7 +23,11 @@ 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) + 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)