From 1034124652f332732e9127e607085ff4551da869 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Mon, 26 Jan 2026 17:32:17 -0600 Subject: [PATCH] feat(neutron): split out mechanism for triggering switch updates This splits out a mechanism for just triggering switch updates by just gathering the information about the ports connected to the specific switch and delivering it to an endpoint for the switch update logic to run. --- .../etc/push_payload_schema.json | 409 +++++++++++ .../tests/test_undersync_mech.py | 334 +++++++++ .../neutron_understack/undersync.py | 27 + .../neutron_understack/undersync_mech.py | 649 ++++++++++++++++++ python/neutron-understack/pyproject.toml | 1 + 5 files changed, 1420 insertions(+) create mode 100644 python/neutron-understack/etc/push_payload_schema.json create mode 100644 python/neutron-understack/neutron_understack/tests/test_undersync_mech.py create mode 100644 python/neutron-understack/neutron_understack/undersync_mech.py diff --git a/python/neutron-understack/etc/push_payload_schema.json b/python/neutron-understack/etc/push_payload_schema.json new file mode 100644 index 000000000..d3aa02cf5 --- /dev/null +++ b/python/neutron-understack/etc/push_payload_schema.json @@ -0,0 +1,409 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://undersync.rackspace.net/schemas/push-payload-v1.json", + "title": "Undersync Push Payload v1", + "description": "Schema for push-based sync payloads from Neutron ML2 driver to Undersync", + "type": "object", + "required": ["vlan_group", "resources"], + "properties": { + "vlan_group": { + "type": "string", + "pattern": "^[\\w+/-]+$", + "description": "VLAN group name (physical_network) being synced" + }, + "trigger": { + "type": "object", + "description": "What triggered this sync (for debugging and auditing)", + "properties": { + "event": { + "type": "string", + "enum": ["port_create", "port_update", "port_delete", "trunk_subport_add", "trunk_subport_remove", "manual"], + "description": "The event type that triggered this sync" + }, + "port_id": { + "type": "string", + "format": "uuid", + "description": "UUID of the port that triggered the sync" + }, + "network_id": { + "type": "string", + "format": "uuid", + "description": "UUID of the network involved" + } + } + }, + "options": { + "type": "object", + "description": "Sync options", + "properties": { + "dry_run": { + "type": "boolean", + "default": false, + "description": "If true, calculate diff but don't apply changes" + }, + "force": { + "type": "boolean", + "default": false, + "description": "If true, apply changes even if they fail safety checks" + } + } + }, + "resources": { + "type": "object", + "description": "All Neutron resources needed to generate switch configuration", + "required": ["networks", "ports", "segments", "subnets"], + "properties": { + "networks": { + "type": "array", + "description": "Neutron networks", + "items": { + "type": "object", + "required": ["id", "name", "tags"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Network UUID" + }, + "name": { + "type": "string", + "description": "Network name (used for VLAN naming)" + }, + "tags": { + "type": "array", + "description": "Network tags. Special tags: UNDERSYNC_PROVISIONING, UNDERSYNC_DHCP_RELAY:", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "ports": { + "type": "array", + "description": "Neutron ports", + "items": { + "type": "object", + "required": ["id", "network_id"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Port UUID" + }, + "mac_address": { + "type": "string", + "pattern": "^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", + "description": "MAC address" + }, + "network_id": { + "type": "string", + "format": "uuid", + "description": "Network UUID (determines native/untagged VLAN)" + }, + "trunk_details": { + "type": ["object", "null"], + "description": "Trunk configuration if this is a trunk parent port", + "properties": { + "sub_ports": { + "type": "array", + "description": "Trunk sub-ports (tagged VLANs)", + "items": { + "type": "object", + "required": ["port_id", "segmentation_type"], + "properties": { + "port_id": { + "type": "string", + "format": "uuid", + "description": "Sub-port UUID" + }, + "segmentation_type": { + "type": "string", + "enum": ["vlan"], + "description": "Segmentation type (only 'vlan' is supported)" + }, + "segmentation_id": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 4094, + "description": "Tenant VLAN ID for VLAN translation" + } + }, + "additionalProperties": false + } + } + } + }, + "binding_profile": { + "type": "object", + "description": "Port binding profile", + "properties": { + "physical_network": { + "type": ["string", "null"], + "description": "VLAN group/physnet for this port binding" + }, + "local_link_information": { + "type": "array", + "description": "Physical switch connections for this port", + "items": { + "type": "object", + "properties": { + "switch_info": { + "type": "string", + "description": "Switch hostname" + }, + "port_id": { + "type": "string", + "description": "Switch port name" + }, + "switch_id": { + "type": "string", + "description": "Switch MAC or identifier" + } + } + } + } + } + }, + "device_owner": { + "type": "string", + "description": "Device owner (e.g., compute:nova, network:router_interface)" + }, + "device_id": { + "type": "string", + "description": "Device UUID (instance or router)" + }, + "fixed_ips": { + "type": "array", + "description": "IP addresses assigned to this port", + "items": { + "type": "object", + "properties": { + "subnet_id": { + "type": "string", + "format": "uuid" + }, + "ip_address": { + "type": "string", + "format": "ipv4" + } + } + } + } + }, + "additionalProperties": false + } + }, + "connected_ports": { + "type": "array", + "description": "Ports physically connected on this VLAN group", + "items": { + "type": "object", + "required": ["id", "network_id", "physical_network", "local_link_information"], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "network_id": { + "type": "string", + "format": "uuid" + }, + "physical_network": { + "type": "string", + "description": "VLAN group this port is connected to" + }, + "local_link_information": { + "type": "array", + "description": "Physical switch connections for this port", + "items": { + "type": "object", + "properties": { + "switch_info": { + "type": "string" + }, + "port_id": { + "type": "string" + }, + "switch_id": { + "type": "string" + } + } + } + } + }, + "additionalProperties": false + } + }, + "segments": { + "type": "array", + "description": "Neutron network segments", + "items": { + "type": "object", + "required": ["uuid", "network_id", "network_type"], + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "description": "Segment UUID" + }, + "network_id": { + "type": "string", + "format": "uuid", + "description": "Parent network UUID" + }, + "network_type": { + "type": "string", + "enum": ["vlan", "vxlan", "flat"], + "description": "Segment type" + }, + "physical_network": { + "type": ["string", "null"], + "description": "VLAN group name (for vlan segments)" + }, + "segmentation_id": { + "type": ["integer", "null"], + "description": "VLAN ID (for vlan) or VNI (for vxlan)" + } + }, + "additionalProperties": false + } + }, + "subnets": { + "type": "array", + "description": "Neutron subnets", + "items": { + "type": "object", + "required": ["id", "network_id", "cidr"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Subnet UUID" + }, + "network_id": { + "type": "string", + "format": "uuid", + "description": "Parent network UUID" + }, + "cidr": { + "type": "string", + "description": "Subnet CIDR (e.g., 10.0.0.0/24)" + }, + "gateway_ip": { + "type": ["string", "null"], + "format": "ipv4", + "description": "Gateway IP (becomes SVI primary IP)" + }, + "router_external": { + "type": "boolean", + "default": false, + "description": "Whether this is an external subnet (triggers SVI creation)" + }, + "tags": { + "type": "array", + "description": "Subnet tags. Special tag: UNDERSYNC_BGP", + "items": { + "type": "string" + } + }, + "subnetpool_id": { + "type": ["string", "null"], + "format": "uuid", + "description": "Subnet pool UUID (for VRF determination)" + } + }, + "additionalProperties": false + } + }, + "network_flavors": { + "type": "array", + "description": "Neutron network flavors (for SVI router matching)", + "items": { + "type": "object", + "required": ["id", "name", "service_type", "enabled"], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "service_type": { + "type": "string", + "description": "Must be L3_ROUTER_NAT for SVI flavors" + }, + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "routers": { + "type": "array", + "description": "Neutron routers", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "flavor_id": { + "type": ["string", "null"], + "format": "uuid", + "description": "Network flavor UUID (for SVI creation rules)" + } + }, + "additionalProperties": false + } + }, + "address_scopes": { + "type": "array", + "description": "Neutron address scopes (for VRF determination)", + "items": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Scope name (matched against servicenet_address_scope_name setting)" + } + }, + "additionalProperties": false + } + }, + "subnetpools": { + "type": "array", + "description": "Neutron subnet pools (for VRF lookup chain)", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "address_scope_id": { + "type": ["string", "null"], + "format": "uuid", + "description": "Parent address scope UUID" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/python/neutron-understack/neutron_understack/tests/test_undersync_mech.py b/python/neutron-understack/neutron_understack/tests/test_undersync_mech.py new file mode 100644 index 000000000..2ca0a3f1a --- /dev/null +++ b/python/neutron-understack/neutron_understack/tests/test_undersync_mech.py @@ -0,0 +1,334 @@ +"""Tests for the UndersyncMechanismDriver.""" + +import uuid + +import pytest +from neutron_lib.api.definitions import portbindings + +from neutron_understack.undersync_mech import UndersyncMechanismDriver +from neutron_understack.undersync_mech import UndersyncPayloadBuilder + + +@pytest.fixture +def undersync_driver(oslo_config): + """Create an UndersyncMechanismDriver instance for testing.""" + from neutron_understack.undersync import Undersync + + driver = UndersyncMechanismDriver() + driver.undersync = Undersync("test_token", "http://test-api") + driver.dry_run = False + return driver + + +@pytest.fixture +def port_with_local_link(port_id): + """Port dict with local_link_information in binding profile.""" + return { + "id": str(port_id), + "network_id": str(uuid.uuid4()), + "mac_address": "00:11:22:33:44:55", + portbindings.PROFILE: { + "local_link_information": [ + { + "port_id": "Ethernet1/13", + "switch_id": "aa:bb:cc:dd:ee:ff", + "switch_info": "leaf-1.example.com", + } + ], + "physical_network": "rack-1", + }, + portbindings.VNIC_TYPE: portbindings.VNIC_BAREMETAL, + } + + +@pytest.fixture +def port_without_local_link(port_id): + """Port dict without local_link_information.""" + return { + "id": str(port_id), + "network_id": str(uuid.uuid4()), + "mac_address": "00:11:22:33:44:55", + portbindings.PROFILE: {}, + portbindings.VNIC_TYPE: portbindings.VNIC_NORMAL, + } + + +class FakePortContext: + """Fake port context for testing.""" + + def __init__( + self, + current: dict, + original: dict | None = None, + segment: dict | None = None, + ): + self.current = current + self.original = original or current + self._segment = segment + self._plugin_context = FakePluginContext() + + @property + def bottom_bound_segment(self): + return self._segment + + @property + def top_bound_segment(self): + return self._segment + + +class FakePortContextNoPlugin: + """Fake port context without plugin context.""" + + def __init__(self, current: dict, segment: dict | None = None): + self.current = current + self.original = current + self._segment = segment + + @property + def bottom_bound_segment(self): + return self._segment + + @property + def top_bound_segment(self): + return self._segment + + +class FakePluginContext: + """Fake plugin context with session.""" + + def __init__(self): + self.session = FakeSession() + + +class FakeSession: + """Fake database session.""" + + def query(self, model): + return FakeQuery() + + +class FakeQuery: + """Fake SQLAlchemy query.""" + + def filter(self, *args, **kwargs): + return self + + def all(self): + return [] + + def first(self): + return None + + +class TestUndersyncMechanismDriver: + """Tests for UndersyncMechanismDriver.""" + + def test_should_process_with_local_link( + self, undersync_driver, port_with_local_link + ): + """Port with local_link_information should be processed.""" + context = FakePortContext(port_with_local_link) + assert undersync_driver._should_process(context) is True + + def test_should_process_without_local_link( + self, undersync_driver, port_without_local_link + ): + """Port without local_link_information should not be processed.""" + context = FakePortContext(port_without_local_link) + assert undersync_driver._should_process(context) is False + + def test_get_vlan_group_from_binding_profile( + self, undersync_driver, port_with_local_link + ): + """VLAN group should be extracted from binding profile.""" + context = FakePortContext(port_with_local_link) + vlan_group = undersync_driver._get_vlan_group(context) + assert vlan_group == "rack-1" + + def test_get_vlan_group_from_segment( + self, undersync_driver, port_without_local_link + ): + """VLAN group should fall back to segment physical_network.""" + segment = {"physical_network": "rack-2", "network_type": "vlan"} + context = FakePortContext(port_without_local_link, segment=segment) + vlan_group = undersync_driver._get_vlan_group(context) + assert vlan_group == "rack-2" + + def test_get_vlan_group_none(self, undersync_driver, port_without_local_link): + """Should return None if no VLAN group can be determined.""" + context = FakePortContext(port_without_local_link) + vlan_group = undersync_driver._get_vlan_group(context) + assert vlan_group is None + + +class TestCreatePortPostCommit: + """Tests for create_port_postcommit.""" + + def test_triggers_sync_with_local_link( + self, mocker, undersync_driver, port_with_local_link + ): + """Port create with local_link should trigger undersync.""" + mocker.patch.object(undersync_driver.undersync, "sync_with_payload") + context = FakePortContext(port_with_local_link) + + undersync_driver.create_port_postcommit(context) + + undersync_driver.undersync.sync_with_payload.assert_called_once() + + def test_skips_sync_without_local_link( + self, mocker, undersync_driver, port_without_local_link + ): + """Port create without local_link should not trigger undersync.""" + mocker.patch.object(undersync_driver.undersync, "sync_with_payload") + context = FakePortContext(port_without_local_link) + + undersync_driver.create_port_postcommit(context) + + undersync_driver.undersync.sync_with_payload.assert_not_called() + + def test_skips_sync_without_plugin_context( + self, mocker, undersync_driver, port_with_local_link + ): + """Port create without plugin context should not trigger undersync.""" + mocker.patch.object(undersync_driver.undersync, "sync_with_payload") + context = FakePortContextNoPlugin(port_with_local_link) + + undersync_driver.create_port_postcommit(context) + + undersync_driver.undersync.sync_with_payload.assert_not_called() + + +class TestUpdatePortPostCommit: + """Tests for update_port_postcommit.""" + + def test_triggers_sync_with_local_link( + self, mocker, undersync_driver, port_with_local_link + ): + """Port update with local_link should trigger undersync.""" + mocker.patch.object(undersync_driver.undersync, "sync_with_payload") + context = FakePortContext(port_with_local_link) + + undersync_driver.update_port_postcommit(context) + + undersync_driver.undersync.sync_with_payload.assert_called_once() + + def test_skips_sync_without_local_link( + self, mocker, undersync_driver, port_without_local_link + ): + """Port update without local_link should not trigger undersync.""" + mocker.patch.object(undersync_driver.undersync, "sync_with_payload") + context = FakePortContext(port_without_local_link) + + undersync_driver.update_port_postcommit(context) + + undersync_driver.undersync.sync_with_payload.assert_not_called() + + +class TestDeletePortPostCommit: + """Tests for delete_port_postcommit.""" + + def test_triggers_sync_with_local_link( + self, mocker, undersync_driver, port_with_local_link + ): + """Port delete with local_link should trigger undersync.""" + mocker.patch.object(undersync_driver.undersync, "sync_with_payload") + context = FakePortContext(port_with_local_link) + + undersync_driver.delete_port_postcommit(context) + + undersync_driver.undersync.sync_with_payload.assert_called_once() + + def test_uses_original_context_for_delete( + self, mocker, undersync_driver, port_with_local_link, port_without_local_link + ): + """Port delete should use original context when current is empty.""" + mocker.patch.object(undersync_driver.undersync, "sync_with_payload") + # Current port has no local_link, but original did + context = FakePortContext( + current=port_without_local_link, + original=port_with_local_link, + ) + + undersync_driver.delete_port_postcommit(context) + + undersync_driver.undersync.sync_with_payload.assert_called_once() + + +class TestUndersyncPayloadBuilder: + """Tests for UndersyncPayloadBuilder.""" + + def test_build_payload_structure(self): + """Payload should have correct structure.""" + context = FakePluginContext() + builder = UndersyncPayloadBuilder(context, "rack-1") + + payload = builder.build( + trigger_event="port_create", + trigger_port_id="test-port-id", + trigger_network_id="test-network-id", + dry_run=True, + ) + + assert payload["vlan_group"] == "rack-1" + assert payload["trigger"]["event"] == "port_create" + assert payload["trigger"]["port_id"] == "test-port-id" + assert payload["trigger"]["network_id"] == "test-network-id" + assert payload["options"]["dry_run"] is True + assert payload["options"]["force"] is False + assert "resources" in payload + + def test_build_payload_resources(self): + """Payload resources should include all required keys.""" + context = FakePluginContext() + builder = UndersyncPayloadBuilder(context, "rack-1") + + payload = builder.build(trigger_event="port_create") + + resources = payload["resources"] + assert "networks" in resources + assert "ports" in resources + assert "connected_ports" in resources + assert "segments" in resources + assert "subnets" in resources + assert "network_flavors" in resources + assert "routers" in resources + assert "address_scopes" in resources + assert "subnetpools" in resources + + def test_build_payload_with_force(self): + """Payload should reflect force option.""" + context = FakePluginContext() + builder = UndersyncPayloadBuilder(context, "rack-1") + + payload = builder.build(trigger_event="port_create", force=True) + + assert payload["options"]["force"] is True + + +class TestUndersyncClient: + """Tests for the Undersync client sync_with_payload method.""" + + def test_sync_with_payload(self, mocker): + """sync_with_payload should POST to /v2/sync.""" + from neutron_understack.undersync import Undersync + + undersync = Undersync("test_token", "http://test-api") + + mock_response = mocker.MagicMock() + mock_response.json.return_value = {"status": "ok"} + mock_response.raise_for_status = mocker.MagicMock() + + mocker.patch.object(undersync.client, "post", return_value=mock_response) + + payload = { + "vlan_group": "rack-1", + "resources": {}, + } + + undersync.sync_with_payload("rack-1", payload) + + undersync.client.post.assert_called_once_with( + "http://test-api/v2/sync", + json=payload, + timeout=90, + ) diff --git a/python/neutron-understack/neutron_understack/undersync.py b/python/neutron-understack/neutron_understack/undersync.py index a64a65a80..14ca3af92 100644 --- a/python/neutron-understack/neutron_understack/undersync.py +++ b/python/neutron-understack/neutron_understack/undersync.py @@ -77,3 +77,30 @@ def dry_run(self, vlan_group: str) -> requests.Response: def force(self, vlan_group: str) -> requests.Response: return self._undersync_post("force", vlan_group) + + def sync_with_payload(self, vlan_group: str, payload: dict) -> requests.Response: + """Push Neutron data to Undersync for switch configuration. + + This is the push-based API that sends all necessary Neutron data + in the payload, eliminating the need for Undersync to pull from + OpenStack APIs. + + Args: + vlan_group: The VLAN group (physical_network) being synced + payload: Complete payload conforming to Undersync push API v1 + (includes options.dry_run and options.force) + + Returns: + Response from Undersync API + """ + response = self.client.post( + f"{self.api_url}/v2/sync", + json=payload, + timeout=self.timeout, + ) + LOG.debug( + "undersync sync_with_payload resp: %(resp)s for vlan_group: %(vlan_group)s", + {"resp": response.json(), "vlan_group": vlan_group}, + ) + self._log_and_raise_for_status(response) + return response diff --git a/python/neutron-understack/neutron_understack/undersync_mech.py b/python/neutron-understack/neutron_understack/undersync_mech.py new file mode 100644 index 000000000..d7fa5bbdb --- /dev/null +++ b/python/neutron-understack/neutron_understack/undersync_mech.py @@ -0,0 +1,649 @@ +"""Undersync ML2 Mechanism Driver. + +This driver pushes port configuration data to Undersync when ports are +created, updated, or deleted. It gathers all necessary context from +Neutron to build a complete payload. + +This is a simplified driver that only handles the undersync integration, +leaving dynamic segment allocation to networking-baremetal. +""" + +import json +import logging +from typing import Any + +from neutron.objects.network import NetworkSegment +from neutron_lib.api.definitions import portbindings +from neutron_lib.plugins.ml2.api import MechanismDriver +from oslo_config import cfg + +from neutron_understack import config +from neutron_understack.undersync import Undersync + +LOG = logging.getLogger(__name__) + + +class UndersyncPayloadBuilder: + """Builds the Undersync API payload from Neutron data. + + This class gathers all necessary data from the Neutron database and + formats it according to the Undersync push API specification. + """ + + def __init__(self, context, vlan_group: str) -> None: + self.context = context + self.vlan_group = vlan_group + self._db_session = None + + @property + def db_session(self): + """Get database session from context.""" + if self._db_session is None: + self._db_session = self.context.session + return self._db_session + + def build( + self, + trigger_event: str, + trigger_port_id: str | None = None, + trigger_network_id: str | None = None, + dry_run: bool = False, + force: bool = False, + ) -> dict[str, Any]: + """Build the complete Undersync payload.""" + segments = self._get_segments_for_vlan_group() + segment_ids = {segment["uuid"] for segment in segments} + network_ids = {segment["network_id"] for segment in segments} + port_ids = self._get_bound_port_ids_for_segments(segment_ids) + ports = self._get_ports(network_ids, port_ids) + subnets = self._get_subnets(network_ids) + subnetpool_ids = { + subnet["subnetpool_id"] for subnet in subnets if subnet["subnetpool_id"] + } + subnetpools = self._get_subnetpools(subnetpool_ids) + address_scope_ids = { + pool["address_scope_id"] + for pool in subnetpools + if pool.get("address_scope_id") + } + routers = self._get_routers(network_ids) + router_flavor_ids = { + router["flavor_id"] for router in routers if router["flavor_id"] + } + + resources = { + "networks": self._get_networks(network_ids), + "ports": ports, + "connected_ports": self._get_connected_ports(ports), + "segments": segments, + "subnets": subnets, + "network_flavors": self._get_network_flavors(router_flavor_ids), + "routers": routers, + "address_scopes": self._get_address_scopes(address_scope_ids), + "subnetpools": subnetpools, + } + + payload = { + "vlan_group": self.vlan_group, + "trigger": { + "event": trigger_event, + "port_id": trigger_port_id, + "network_id": trigger_network_id, + }, + "options": { + "dry_run": dry_run, + "force": force, + }, + "resources": resources, + } + return payload + + def _get_segments_for_vlan_group(self) -> list[dict[str, Any]]: + """Get network segments for this VLAN group (physical_network).""" + segments = ( + self.db_session.query(NetworkSegment) + .filter(NetworkSegment.physical_network == self.vlan_group) + .all() + ) + + return [ + { + "uuid": seg.id, + "network_id": seg.network_id, + "network_type": seg.network_type, + "physical_network": seg.physical_network, + "segmentation_id": seg.segmentation_id, + } + for seg in segments + ] + + def _get_networks(self, network_ids: set[str]) -> list[dict[str, Any]]: + """Get networks with their tags.""" + from neutron.db import models_v2 + + if not network_ids: + return [] + + networks = ( + self.db_session.query(models_v2.Network) + .filter(models_v2.Network.id.in_(list(network_ids))) + .all() + ) + + return [ + { + "id": net.id, + "name": net.name, + "tags": self._get_resource_tags(net.standard_attr_id), + } + for net in networks + ] + + def _get_bound_port_ids_for_segments(self, segment_ids: set[str]) -> set[str]: + """Get port IDs bound to this VLAN group's network segments.""" + from neutron.plugins.ml2.models import PortBindingLevel + + if not segment_ids: + return set() + + rows = ( + self.db_session.query(PortBindingLevel.port_id) + .filter(PortBindingLevel.segment_id.in_(list(segment_ids))) + .distinct() + .all() + ) + return {port_id for (port_id,) in rows} + + def _get_ports( + self, network_ids: set[str], port_ids: set[str] + ) -> list[dict[str, Any]]: + """Get ports connected to this VLAN group's segments.""" + from neutron.db import models_v2 + + if not network_ids or not port_ids: + return [] + + ports = ( + self.db_session.query(models_v2.Port) + .filter( + models_v2.Port.network_id.in_(list(network_ids)), + models_v2.Port.id.in_(list(port_ids)), + ) + .all() + ) + port_id_list = [port.id for port in ports] + binding_profiles = self._get_binding_profiles_for_ports(port_id_list) + trunk_details = self._get_trunk_details_for_ports(port_id_list) + + return [ + { + "id": port.id, + "mac_address": port.mac_address, + "network_id": port.network_id, + "trunk_details": trunk_details.get(port.id), + "binding_profile": binding_profiles.get(port.id, {}), + "device_owner": port.device_owner, + "device_id": port.device_id, + "fixed_ips": [ + {"subnet_id": ip.subnet_id, "ip_address": ip.ip_address} + for ip in port.fixed_ips + ], + } + for port in ports + ] + + def _get_binding_profiles_for_ports( + self, port_ids: list[str] + ) -> dict[str, dict[str, Any]]: + """Get port binding profiles for a list of ports in one query.""" + from neutron.plugins.ml2.models import PortBinding + + if not port_ids: + return {} + + bindings = ( + self.db_session.query(PortBinding) + .filter(PortBinding.port_id.in_(port_ids)) + .all() + ) + + profiles: dict[str, dict[str, Any]] = {} + for binding in bindings: + profile = self._parse_binding_profile(binding.profile) + local_links = profile.get("local_link_information", []) + if not local_links and not profile.get("physical_network"): + continue + + existing = profiles.get(binding.port_id) + # Prefer profile with local link data, then with physical_network. + if existing: + existing_links = existing.get("local_link_information", []) + if existing_links: + continue + if not local_links and existing.get("physical_network"): + continue + profiles[binding.port_id] = profile + + return profiles + + def _parse_binding_profile(self, profile: str | dict | None) -> dict[str, Any]: + if not profile: + return {} + + parsed = json.loads(profile) if isinstance(profile, str) else profile + return { + "local_link_information": parsed.get("local_link_information", []), + "physical_network": parsed.get("physical_network"), + } + + def _get_trunk_details_for_ports( + self, port_ids: list[str] + ) -> dict[str, dict[str, Any] | None]: + """Get trunk details for ports in bulk to avoid per-port queries.""" + from neutron.services.trunk import models as trunk_models + + if not port_ids: + return {} + + trunks = ( + self.db_session.query(trunk_models.Trunk) + .filter(trunk_models.Trunk.port_id.in_(port_ids)) + .all() + ) + trunk_by_port_id = {trunk.port_id: trunk for trunk in trunks} + trunk_ids = [trunk.id for trunk in trunks] + + sub_ports_by_trunk_id: dict[str, list[dict[str, Any]]] = {} + if trunk_ids: + sub_ports = ( + self.db_session.query(trunk_models.SubPort) + .filter(trunk_models.SubPort.trunk_id.in_(trunk_ids)) + .all() + ) + for sub_port in sub_ports: + sub_ports_by_trunk_id.setdefault(sub_port.trunk_id, []).append( + { + "port_id": sub_port.port_id, + "segmentation_type": sub_port.segmentation_type, + "segmentation_id": sub_port.segmentation_id, + } + ) + + trunk_details: dict[str, dict[str, Any] | None] = {} + for port_id in port_ids: + trunk = trunk_by_port_id.get(port_id) + if not trunk: + trunk_details[port_id] = None + continue + trunk_details[port_id] = { + "sub_ports": sub_ports_by_trunk_id.get(trunk.id, []) + } + return trunk_details + + def _get_connected_ports(self, ports: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Get ports with physical switch connections on this VLAN group.""" + connected_ports: list[dict[str, Any]] = [] + for port in ports: + binding_profile = port.get("binding_profile") or {} + if not self._is_port_connected_to_vlan_group(binding_profile): + continue + + connected_ports.append( + { + "id": port["id"], + "network_id": port["network_id"], + "physical_network": binding_profile.get("physical_network"), + "local_link_information": binding_profile.get( + "local_link_information", [] + ), + } + ) + return connected_ports + + def _is_port_connected_to_vlan_group(self, binding_profile: dict[str, Any]) -> bool: + """Check if a port is physically connected to this VLAN group.""" + local_links = binding_profile.get("local_link_information", []) + if not local_links: + return False + return binding_profile.get("physical_network") == self.vlan_group + + def _get_subnets(self, network_ids: set[str]) -> list[dict[str, Any]]: + """Get subnets with tags.""" + from neutron.db import models_v2 + + if not network_ids: + return [] + + subnets = ( + self.db_session.query(models_v2.Subnet) + .filter(models_v2.Subnet.network_id.in_(list(network_ids))) + .all() + ) + + return [ + { + "id": sub.id, + "network_id": sub.network_id, + "cidr": sub.cidr, + "gateway_ip": sub.gateway_ip, + "router_external": self._is_network_external(sub.network_id), + "tags": self._get_resource_tags(sub.standard_attr_id), + "subnetpool_id": sub.subnetpool_id, + } + for sub in subnets + ] + + def _is_network_external(self, network_id: str) -> bool: + """Check if a network is external.""" + try: + from neutron.db import external_net_db + + ext = ( + self.db_session.query(external_net_db.ExternalNetwork) + .filter(external_net_db.ExternalNetwork.network_id == network_id) + .first() + ) + return ext is not None + except Exception: + return False + + def _get_network_flavors(self, flavor_ids: set[str]) -> list[dict[str, Any]]: + """Get network flavors used by routers in the VLAN group scope.""" + try: + from neutron.db import flavors_db + + if not flavor_ids: + return [] + + flavors = ( + self.db_session.query(flavors_db.Flavor) + .filter( + flavors_db.Flavor.id.in_(list(flavor_ids)), + flavors_db.Flavor.service_type == "L3_ROUTER_NAT", + ) + .all() + ) + + return [ + { + "id": f.id, + "name": f.name, + "service_type": f.service_type, + "enabled": f.enabled, + } + for f in flavors + ] + except Exception as e: + LOG.warning("Failed to fetch network flavors: %s", e) + return [] + + def _get_routers(self, network_ids: set[str]) -> list[dict[str, Any]]: + """Get routers that have interfaces in our networks.""" + from neutron.db import l3_db + from neutron.db import models_v2 + + if not network_ids: + return [] + + router_ports = ( + self.db_session.query(models_v2.Port) + .filter( + models_v2.Port.network_id.in_(list(network_ids)), + models_v2.Port.device_owner == "network:router_interface", + ) + .all() + ) + + router_ids = {p.device_id for p in router_ports} + if not router_ids: + return [] + + routers = ( + self.db_session.query(l3_db.Router) + .filter(l3_db.Router.id.in_(list(router_ids))) + .all() + ) + + return [ + {"id": r.id, "flavor_id": getattr(r, "flavor_id", None)} for r in routers + ] + + def _get_address_scopes(self, address_scope_ids: set[str]) -> list[dict[str, Any]]: + """Get address scopes used by subnet pools in scope.""" + try: + from neutron.db import address_scope_db + + if not address_scope_ids: + return [] + + scopes = ( + self.db_session.query(address_scope_db.AddressScope) + .filter(address_scope_db.AddressScope.id.in_(list(address_scope_ids))) + .all() + ) + return [{"id": s.id, "name": s.name} for s in scopes] + except Exception as e: + LOG.warning("Failed to fetch address scopes: %s", e) + return [] + + def _get_subnetpools(self, subnetpool_ids: set[str]) -> list[dict[str, Any]]: + """Get subnet pools referenced by subnets in scope.""" + try: + from neutron.db import models_v2 + + if not subnetpool_ids: + return [] + + pools = ( + self.db_session.query(models_v2.SubnetPool) + .filter(models_v2.SubnetPool.id.in_(list(subnetpool_ids))) + .all() + ) + return [{"id": p.id, "address_scope_id": p.address_scope_id} for p in pools] + except Exception as e: + LOG.warning("Failed to fetch subnet pools: %s", e) + return [] + + def _get_resource_tags(self, standard_attr_id: int) -> list[str]: + """Get tags for a resource by its standard_attr_id.""" + try: + from neutron.db import tag_db as tag_model + + tags = ( + self.db_session.query(tag_model.Tag) + .filter(tag_model.Tag.standard_attr_id == standard_attr_id) + .all() + ) + return [t.tag for t in tags] + except Exception: + return [] + + +class UndersyncMechanismDriver(MechanismDriver): + """ML2 Mechanism Driver that pushes port data to Undersync. + + This driver listens for port lifecycle events and pushes the relevant + configuration data to Undersync, which then configures the physical + switches. + + Unlike the full UnderstackDriver, this driver: + - Does NOT handle dynamic segment allocation (use networking-baremetal) + - Does NOT bind ports + - ONLY pushes Neutron state to undersync for switch configuration + """ + + @property + def connectivity(self): + # This driver doesn't provide connectivity itself + return None + + def initialize(self): + config.register_ml2_understack_opts(cfg.CONF) + conf = cfg.CONF.ml2_understack + self.undersync = Undersync(conf.undersync_token, conf.undersync_url) + self.dry_run = conf.undersync_dry_run + LOG.info("UndersyncMechanismDriver initialized") + + def _get_vlan_group(self, context, port: dict | None = None) -> str | None: + """Extract the VLAN group (physical_network) from port context. + + Args: + context: The ML2 port context + port: Optional port dict to use instead of context.current + (useful for delete operations where current may be empty) + """ + # Use provided port or fall back to context.current + if port is None: + port = context.current + + # Try to get from binding profile first + binding_profile = port.get(portbindings.PROFILE) or {} + physical_network = binding_profile.get("physical_network") + if physical_network: + return physical_network + + # Fall back to segment + segment = context.bottom_bound_segment or context.top_bound_segment + if segment: + return segment.get("physical_network") + + return None + + def _should_process(self, context) -> bool: + """Determine if this port event should trigger an Undersync sync.""" + port = context.current + binding_profile = port.get(portbindings.PROFILE) or {} + local_links = binding_profile.get("local_link_information", []) + + # Only process ports with physical switch bindings + if not local_links: + return False + + # Must have a VLAN group to sync + if not self._get_vlan_group(context): + return False + + return True + + def _trigger_sync(self, context, event: str, port: dict | None = None): + """Trigger an Undersync sync for the given port event.""" + if port is None: + port = context.current + + vlan_group = self._get_vlan_group(context, port=port) + if not vlan_group: + LOG.warning( + "Could not determine VLAN group for port %s", + port.get("id"), + ) + return + + plugin_context = getattr(context, "_plugin_context", None) + if plugin_context is None: + LOG.warning( + "Could not build Undersync payload for port %s: missing plugin context", + port.get("id"), + ) + return + + try: + builder = UndersyncPayloadBuilder( + plugin_context, + vlan_group, + ) + payload = builder.build( + trigger_event=event, + trigger_port_id=port.get("id"), + trigger_network_id=port.get("network_id"), + dry_run=self.dry_run, + ) + + self.undersync.sync_with_payload(vlan_group, payload) + LOG.info( + "Undersync sync completed for VLAN group %s (event=%s, port=%s)", + vlan_group, + event, + port.get("id"), + ) + + except Exception: + LOG.exception( + "Undersync sync failed for port %s", + port.get("id"), + ) + + # ========================================================================= + # ML2 API Methods + # ========================================================================= + + def create_network_precommit(self, context): + pass + + def create_network_postcommit(self, context): + pass + + def update_network_precommit(self, context): + pass + + def update_network_postcommit(self, context): + pass + + def delete_network_precommit(self, context): + pass + + def delete_network_postcommit(self, context): + pass + + def create_subnet_precommit(self, context): + pass + + def create_subnet_postcommit(self, context): + pass + + def update_subnet_precommit(self, context): + pass + + def update_subnet_postcommit(self, context): + pass + + def delete_subnet_precommit(self, context): + pass + + def delete_subnet_postcommit(self, context): + pass + + def create_port_precommit(self, context): + pass + + def create_port_postcommit(self, context): + """Called after port creation is committed.""" + if self._should_process(context): + self._trigger_sync(context, "port_create") + + def update_port_precommit(self, context): + pass + + def update_port_postcommit(self, context): + """Called after port update is committed.""" + if self._should_process(context): + self._trigger_sync(context, "port_update") + + def delete_port_precommit(self, context): + pass + + def delete_port_postcommit(self, context): + """Called after port deletion is committed.""" + # For delete, check original context since current may be empty + port = context.original or context.current + binding_profile = port.get(portbindings.PROFILE) or {} + local_links = binding_profile.get("local_link_information", []) + + if local_links: + self._trigger_sync(context, "port_delete", port=port) + + def bind_port(self, context): + # This driver doesn't bind ports - use networking-baremetal for that + pass + + def check_vlan_transparency(self, context): + pass diff --git a/python/neutron-understack/pyproject.toml b/python/neutron-understack/pyproject.toml index b13243353..49671dfa4 100644 --- a/python/neutron-understack/pyproject.toml +++ b/python/neutron-understack/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ [project.entry-points."neutron.ml2.mechanism_drivers"] understack = "neutron_understack.neutron_understack_mech:UnderstackDriver" +undersync = "neutron_understack.undersync_mech:UndersyncMechanismDriver" [project.entry-points."neutron.ml2.type_drivers"] understack_vxlan = "neutron_understack.type_understack_vxlan:UnderstackVxlanTypeDriver"