Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions python/neutron-understack/neutron_understack/nautobot.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import inspect
from dataclasses import dataclass
from pprint import pformat
from urllib.parse import urljoin
from uuid import UUID

import pynautobot
import requests
from neutron_lib import exceptions as exc
from oslo_log import log
Expand All @@ -26,6 +28,27 @@ class NautobotCustomFieldNotFoundError(exc.NeutronException):
message = "Custom field with name %(cf_name)s not found for %(obj)s"


@dataclass
class VlanPayload:
id: str
network_id: str
vid: int
vlan_group_name: str
status: str = "Active"

def to_dict(self) -> dict:
return {
"id": self.id,
"vid": self.vid,
"name": self.id,
"vlan_group": self.vlan_group_name,
"status": self.status,
"relationships": {
"ucvni_vlans": {"source": {"objects": [{"id": self.network_id}]}}
},
}


def _truncated(message: str | bytes, maxlen=200) -> str:
input = str(message)
if len(input) <= maxlen:
Expand All @@ -41,6 +64,10 @@ def __init__(self, nb_url: str, nb_token: str):
self.base_url = nb_url
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Token {nb_token}"})
self.api = pynautobot.api(
url=nb_url,
token=nb_token,
)

def make_api_request(
self,
Expand Down Expand Up @@ -257,3 +284,21 @@ def check_vlan_availability(self, interface_id: str | UUID, vlan_tag: int) -> bo

response = self.make_api_request("GET", url, params=params) or {}
return response.get("available", False)

def delete_vlan(self, vlan_id: str):
return self.api.ipam.vlans.delete([vlan_id])

def create_vlan_and_associate_vlan_to_ucvni(self, vlan: VlanPayload):
try:
result = self.api.ipam.vlans.create(vlan.to_dict())
except pynautobot.core.query.RequestError as error:
LOG.error("Nautobot error: %(error)s", {"error": error})
raise NautobotRequestError(
code=error.req.status_code,
url=error.base,
method="POST",
payload=error.request_body,
body=vlan.to_dict(),
) from error
else:
return result
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import neutron_lib.api.definitions.portbindings as portbindings
from neutron.plugins.ml2.driver_context import PortContext
from neutron_lib import constants as p_const
from neutron_lib.api.definitions import segment as segment_def
from neutron_lib.callbacks import events
from neutron_lib.callbacks import registry
from neutron_lib.callbacks import resources
from neutron_lib.plugins.ml2 import api
from neutron_lib.plugins.ml2.api import MechanismDriver
from neutron_lib.plugins.ml2.api import NetworkContext
Expand All @@ -12,13 +16,13 @@
from neutron_understack import config
from neutron_understack import utils
from neutron_understack.nautobot import Nautobot
from neutron_understack.nautobot import VlanPayload
from neutron_understack.trunk import UnderStackTrunkDriver
from neutron_understack.undersync import Undersync
from neutron_understack.vlan_manager import VlanManager

LOG = logging.getLogger(__name__)


config.register_ml2_type_understack_opts(cfg.CONF)
config.register_ml2_understack_opts(cfg.CONF)

Expand All @@ -37,6 +41,21 @@ def initialize(self):
self.undersync = Undersync(conf.undersync_token, conf.undersync_url)
self.trunk_driver = UnderStackTrunkDriver.create(self)
self.vlan_manager = VlanManager(self.nb, conf)
self.subscribe()

def subscribe(self):
registry.subscribe(
self._create_segment,
resources.SEGMENT,
events.PRECOMMIT_CREATE,
cancellable=True,
)
registry.subscribe(
self._delete_segment,
resources.SEGMENT,
events.BEFORE_DELETE,
cancellable=True,
)

def create_network_precommit(self, context: NetworkContext):
if cfg.CONF.ml2_understack.enforce_unique_vlans_in_fabric:
Expand Down Expand Up @@ -107,6 +126,11 @@ def delete_network_postcommit(self, context):
dry_run=cfg.CONF.ml2_understack.undersync_dry_run,
)
elif provider_type == p_const.TYPE_VXLAN:
network_segments = utils.valid_network_segments(context.network_segments)
vlans_to_delete = [segment.get("id") for segment in network_segments]
self.nb.delete_vlans(
vlan_ids=vlans_to_delete,
)
self.nb.ucvni_delete(network_id)
else:
return
Expand Down Expand Up @@ -432,3 +456,37 @@ def _create_nautobot_namespace(
"shared_nautobot_namespace": shared_namespace,
},
)

def _create_segment(self, resource, event, trigger, payload):
self._create_vlan(payload.latest_state)

def _delete_segment(self, resource, event, trigger, payload):
self._delete_vlan(payload.latest_state)

def _create_vlan(self, segment):
if not utils.is_valid_vlan_network_segment(segment):
return

vlan_payload = VlanPayload(
id=segment.get("id"),
vid=segment.get(segment_def.SEGMENTATION_ID),
vlan_group_name=segment.get(segment_def.PHYSICAL_NETWORK),
network_id=segment.get("network_id"),
)

LOG.info(
"creating vlan in nautobot for segment %(segment)s",
{"segment": segment},
)
self.nb.create_vlan_and_associate_vlan_to_ucvni(vlan_payload)

def _delete_vlan(self, segment):
if not utils.is_valid_vlan_network_segment(segment):
return
LOG.info(
"deleting vlan in nautobot for segment %(segment)s",
{"segment": segment},
)
self.nb.delete_vlan(
vlan_id=segment.get("id"),
)
22 changes: 22 additions & 0 deletions python/neutron-understack/neutron_understack/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from neutron.db.models_v2 import Network
from neutron.db.models_v2 import Port as PortModel
from neutron.db.models_v2 import Subnet
from neutron.objects.network import NetworkSegment as SegmentObj
from neutron.objects.ports import Port as PortObject
from neutron.objects.trunk import SubPort
from neutron.objects.trunk import Trunk
Expand Down Expand Up @@ -53,6 +54,11 @@ def vlan_num() -> int:
return random.randint(1, 4094)


@pytest.fixture
def network_segment_id() -> uuid.UUID:
return uuid.uuid4()


@pytest.fixture
def patch_extend_subnet(mocker) -> None:
"""Ml2 Plugin extend subnet patch.
Expand Down Expand Up @@ -89,6 +95,22 @@ def network_segment() -> NetworkSegment:
return NetworkSegment(network_type="vxlan")


@pytest.fixture
def vlan_network_segment(request, network_segment_id, network_id) -> SegmentObj:
req = getattr(request, "param", {})
return SegmentObj(
id=network_segment_id,
network_type="vlan",
network_id=network_id,
physical_network=req.get("physical_network"),
revision_number=1,
segment_index=1,
is_dynamic=False,
name="puc-abc",
segmentation_id=req.get("segmentation_id", 1800),
)


@pytest.fixture
def network_context(ml2_plugin, network_dict, network_segment) -> NetworkContext:
return NetworkContext(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from unittest.mock import MagicMock
from unittest.mock import patch

import pytest

from neutron_understack.nautobot import Nautobot
from neutron_understack.nautobot import VlanPayload


@pytest.fixture
def mock_pynautobot_api():
with patch("neutron_understack.nautobot.pynautobot.api") as mock_api:
mock_ipam = MagicMock()
mock_ipam.vlans.delete = MagicMock()
mock_ipam.vlans.create = MagicMock()

mock_api.return_value.ipam = mock_ipam

yield mock_api, mock_ipam


@pytest.fixture
def nautobot(mock_pynautobot_api):
return Nautobot(nb_url="http://fake-nautobot", nb_token="fake-token") # noqa: S106


def test_delete_vlan(nautobot, mock_pynautobot_api):
_, mock_ipam = mock_pynautobot_api
vlan_id = "123"

nautobot.delete_vlan(vlan_id)

mock_ipam.vlans.delete.assert_called_once_with([vlan_id])


def test_create_vlan_and_associate_vlan_to_ucvni(nautobot, mock_pynautobot_api):
_, mock_ipam = mock_pynautobot_api

payload = VlanPayload(
id="vlan-123",
vid=101,
vlan_group_name="test-group",
network_id="net-456",
)

expected_payload_dict = {
"id": "vlan-123",
"vid": 101,
"vlan_group": {"name": "test-group"},
"name": "test-network",
"status": {"name": "Active"},
"relationships": {"ucvni_vlans": {"source": {"objects": [{"id": "net-456"}]}}},
}

payload.to_dict = lambda: expected_payload_dict

nautobot.create_vlan_and_associate_vlan_to_ucvni(payload)

mock_ipam.vlans.create.assert_called_once_with(expected_payload_dict)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from neutron_lib.api.definitions import portbindings

from neutron_understack import utils
from neutron_understack.nautobot import VlanPayload


class TestUpdateNautobot:
Expand Down Expand Up @@ -134,3 +135,51 @@ def test_delete_public(self, understack_driver, subnet_context):
understack_driver.delete_subnet_postcommit(subnet_context)

understack_driver.nb.subnet_delete.assert_called_once()


class TestNetworkSegmentEventCallbacks:
@pytest.mark.parametrize(
"vlan_network_segment", [{"physical_network": "f20-2-network"}], indirect=True
)
def test__create_vlan_valid_segment(
self, mocker, vlan_network_segment, understack_driver
):
mocker.patch(
"neutron_understack.utils.is_valid_vlan_network_segment", return_value=True
)

mock_create = mocker.patch.object(
understack_driver.nb, "create_vlan_and_associate_vlan_to_ucvni"
)

understack_driver._create_vlan(vlan_network_segment)

mock_create.assert_called_once()
vlan_payload: VlanPayload = mock_create.call_args[0][0]

assert vlan_payload.vid == 1800
assert vlan_payload.vlan_group_name == "f20-2-network"

def test__create_vlan_invalid_segment(
self, mocker, vlan_network_segment, understack_driver
):
mocker.patch(
"neutron_understack.utils.is_valid_vlan_network_segment", return_value=False
)
mock_create = mocker.patch.object(
understack_driver.nb, "create_vlan_and_associate_vlan_to_ucvni"
)

understack_driver._create_vlan(vlan_network_segment)

mock_create.assert_not_called()

@pytest.mark.parametrize(
"vlan_network_segment",
[{"physical_network": "f20-2-network", "segmentation_id": 100}],
indirect=True,
)
def test__delete_vlan(self, mocker, vlan_network_segment, understack_driver):
mock_delete = mocker.patch.object(understack_driver.nb, "delete_vlan")
understack_driver._delete_vlan(vlan_network_segment)
mock_delete.assert_called_once_with(vlan_id=vlan_network_segment.get("id"))
9 changes: 9 additions & 0 deletions python/neutron-understack/neutron_understack/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from neutron.objects import ports as port_obj
from neutron.plugins.ml2.driver_context import portbindings
from neutron_lib import constants
from neutron_lib import context as n_context
from neutron_lib.api.definitions import segment as segment_def


def fetch_port_object(port_id: str) -> port_obj.Port:
Expand Down Expand Up @@ -48,3 +50,10 @@ def parent_port_is_bound(port: port_obj.Port) -> bool:
def fetch_subport_network_id(subport_id: str) -> str:
neutron_port = fetch_port_object(subport_id)
return neutron_port.network_id


def is_valid_vlan_network_segment(network_segment: dict):
return (
network_segment.get(segment_def.NETWORK_TYPE) == constants.TYPE_VLAN
and network_segment.get(segment_def.PHYSICAL_NETWORK) is not None
)
Loading