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"