diff --git a/python/neutron-understack/neutron_understack/nautobot.py b/python/neutron-understack/neutron_understack/nautobot.py index 423ffa6df..3ad956923 100644 --- a/python/neutron-understack/neutron_understack/nautobot.py +++ b/python/neutron-understack/neutron_understack/nautobot.py @@ -22,6 +22,10 @@ class NautobotNotFoundError(exc.NeutronException): message = "%(obj)s not found in Nautobot. ref=%(ref)s" +class NautobotCustomFieldNotFoundError(exc.NeutronException): + message = "Custom field with name %(cf_name)s not found for %(obj)s" + + class Nautobot: """Basic Nautobot wrapper because pynautobot doesn't expose plugin APIs.""" @@ -99,6 +103,19 @@ def ucvni_delete(self, network_id): url = f"/api/plugins/undercloud-vni/ucvnis/{network_id}/" return self.make_api_request("DELETE", url) + def fetch_ucvni(self, network_id: str) -> dict: + url = f"/api/plugins/undercloud-vni/ucvnis/{network_id}/" + return self.make_api_request("GET", url) + + def fetch_ucvni_tenant_vlan_id(self, network_id: str) -> int | None: + ucvni_data = self.fetch_ucvni(network_id=network_id) + custom_fields = ucvni_data.get("custom_fields", {}) + if "tenant_vlan_id" not in custom_fields: + raise NautobotCustomFieldNotFoundError( + cf_name="tenant_vlan_id", obj="UCVNI" + ) + return custom_fields.get("tenant_vlan_id") + def fetch_namespace_by_name(self, name: str) -> str: url = f"/api/ipam/namespaces/?name={name}&depth=1" resp_data = self.make_api_request("GET", url) @@ -139,6 +156,11 @@ def associate_subnet_with_network( } self.make_api_request("PATCH", url, payload) + def add_tenant_vlan_tag_to_ucvni(self, network_uuid: str, vlan_tag: int) -> dict: + url = f"/api/plugins/undercloud-vni/ucvnis/{network_uuid}/" + payload = {"custom_fields": {"tenant_vlan_id": vlan_tag}} + return self.make_api_request("PATCH", url, payload) + def subnet_delete(self, uuid: str) -> dict: return self.make_api_request("DELETE", f"/api/ipam/prefixes/{uuid}/") diff --git a/python/neutron-understack/neutron_understack/tests/test_trunk.py b/python/neutron-understack/neutron_understack/tests/test_trunk.py new file mode 100644 index 000000000..6cf9b6ac4 --- /dev/null +++ b/python/neutron-understack/neutron_understack/tests/test_trunk.py @@ -0,0 +1,89 @@ +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from neutron_understack.nautobot import Nautobot +from neutron_understack.neutron_understack_mech import UnderstackDriver +from neutron_understack.trunk import SubportSegmentationIDError +from neutron_understack.trunk import UnderStackTrunkDriver + + +@pytest.fixture +def subport() -> MagicMock: + return MagicMock(port_id="portUUID", segmentation_id=555) + + +@pytest.fixture +def trunk(subport) -> MagicMock: + return MagicMock(sub_ports=[subport]) + + +@pytest.fixture +def payload_metadata(subport) -> dict: + return {"subports": [subport]} + + +@pytest.fixture +def payload(payload_metadata, trunk) -> MagicMock: + return MagicMock(metadata=payload_metadata, states=[trunk]) + + +@pytest.fixture +def nautobot_client() -> Nautobot: + return MagicMock(spec_set=Nautobot) + + +driver = UnderstackDriver() +driver.nb = Nautobot("", "") +trunk_driver = UnderStackTrunkDriver.create(driver) + + +@patch("neutron_understack.utils.fetch_subport_network_id", return_value="112233") +def test_subports_added_when_ucvni_tenan_vlan_id_is_not_set_yet( + nautobot_client, payload +): + trunk_driver.nb = nautobot_client + attrs = {"fetch_ucvni_tenant_vlan_id.return_value": None} + nautobot_client.configure_mock(**attrs) + trunk_driver.subports_added("", "", "", payload) + + nautobot_client.add_tenant_vlan_tag_to_ucvni.assert_called_once_with( + network_uuid="112233", vlan_tag=555 + ) + + +@patch("neutron_understack.utils.fetch_subport_network_id", return_value="223344") +def test_subports_added_when_segmentation_id_is_different_to_tenant_vlan_id( + nautobot_client, payload +): + trunk_driver.nb = nautobot_client + attrs = {"fetch_ucvni_tenant_vlan_id.return_value": 123} + nautobot_client.configure_mock(**attrs) + with pytest.raises(SubportSegmentationIDError): + trunk_driver.subports_added("", "", "", payload) + + +@patch("neutron_understack.utils.fetch_subport_network_id", return_value="112233") +def test_trunk_created_when_ucvni_tenan_vlan_id_is_not_set_yet( + nautobot_client, payload +): + trunk_driver.nb = nautobot_client + attrs = {"fetch_ucvni_tenant_vlan_id.return_value": None} + nautobot_client.configure_mock(**attrs) + trunk_driver.trunk_created("", "", "", payload) + + nautobot_client.add_tenant_vlan_tag_to_ucvni.assert_called_once_with( + network_uuid="112233", vlan_tag=555 + ) + + +@patch("neutron_understack.utils.fetch_subport_network_id", return_value="223344") +def test_trunk_created_when_segmentation_id_is_different_to_tenant_vlan_id( + nautobot_client, payload +): + trunk_driver.nb = nautobot_client + attrs = {"fetch_ucvni_tenant_vlan_id.return_value": 123} + nautobot_client.configure_mock(**attrs) + with pytest.raises(SubportSegmentationIDError): + trunk_driver.trunk_created("", "", "", payload) diff --git a/python/neutron-understack/neutron_understack/trunk.py b/python/neutron-understack/neutron_understack/trunk.py index 78aebe73a..44bfdb6c8 100644 --- a/python/neutron-understack/neutron_understack/trunk.py +++ b/python/neutron-understack/neutron_understack/trunk.py @@ -1,14 +1,51 @@ +from neutron.objects.trunk import SubPort 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__) SUPPORTED_INTERFACES = (portbindings.VIF_TYPE_OTHER,) 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 there is already another Segmentation ID: " + "%(nb_seg_id)s in use by the Network: %(net_id)s that is " + "attached to the Subport. Please use %(nb_seg_id)s as " + "segmentation_id for this subport." + ) + + class UnderStackTrunkDriver(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.nb = self.plugin_driver.nb + @property def is_loaded(self): try: @@ -26,3 +63,68 @@ def create(cls, plugin_driver): 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) + + registry.subscribe( + self.subports_added, + resources.SUBPORTS, + events.AFTER_CREATE, + cancellable=True, + ) + registry.subscribe( + self.trunk_created, resources.TRUNK, events.AFTER_CREATE, cancellable=True + ) + + def _handle_segmentation_id_mismatch( + self, subport: SubPort, ucvni_uuid: str, tenant_vlan_id: int + ) -> None: + subport.delete() + raise SubportSegmentationIDError( + seg_id=subport.segmentation_id, + net_id=ucvni_uuid, + nb_seg_id=tenant_vlan_id, + subport_id=subport.port_id, + ) + + def _configure_tenant_vlan_id(self, ucvni_uuid: str, subport: SubPort) -> None: + subport_seg_id = subport.segmentation_id + self.nb.add_tenant_vlan_tag_to_ucvni( + network_uuid=ucvni_uuid, vlan_tag=subport_seg_id + ) + LOG.info( + "Segmentation ID: %(seg_id)s is now set on Nautobot's UCVNI " + "UUID: %(ucvni_uuid)s in the tenant_vlan_id custom field", + {"seg_id": subport_seg_id, "ucvni_uuid": ucvni_uuid}, + ) + + def _subports_added(self, subports: list[SubPort]) -> None: + for subport in subports: + subport_network_id = utils.fetch_subport_network_id( + subport_id=subport.port_id + ) + ucvni_tenant_vlan_id = self.nb.fetch_ucvni_tenant_vlan_id( + network_id=subport_network_id + ) + if not ucvni_tenant_vlan_id: + self._configure_tenant_vlan_id( + ucvni_uuid=subport_network_id, subport=subport + ) + elif ucvni_tenant_vlan_id != subport.segmentation_id: + self._handle_segmentation_id_mismatch( + subport=subport, + ucvni_uuid=subport_network_id, + tenant_vlan_id=ucvni_tenant_vlan_id, + ) + + def subports_added(self, resource, event, trunk_plugin, payload): + subports = payload.metadata["subports"] + self._subports_added(subports) + + def trunk_created(self, resource, event, trunk_plugin, payload): + trunk = payload.states[0] + subports = trunk.sub_ports + if subports: + self._subports_added(subports) diff --git a/python/neutron-understack/neutron_understack/utils.py b/python/neutron-understack/neutron_understack/utils.py new file mode 100644 index 000000000..9f5928399 --- /dev/null +++ b/python/neutron-understack/neutron_understack/utils.py @@ -0,0 +1,8 @@ +from neutron.objects import ports as port_obj +from neutron_lib import context as n_context + + +def fetch_subport_network_id(subport_id): + context = n_context.get_admin_context() + neutron_port = port_obj.Port.get_object(context, id=subport_id) + return neutron_port.network_id