From bf41c5493f6e0cabad770e22eb2321c1560afa88 Mon Sep 17 00:00:00 2001 From: Dai Dang Van Date: Fri, 30 May 2025 17:38:42 +0700 Subject: [PATCH 01/32] Add dns forwarder l2 extension Closes-Bug: #2112446 Change-Id: I4fe91d759c430c4d64cd22a940bd1c17cfa76d5b Signed-off-by: Dai, Dang Van --- devstack/lib/dns_forwarder_ovs_ext | 6 + devstack/plugin.sh | 6 + neutron/agent/l2/extensions/dns_forwarder.py | 251 ++++++++++++++++ neutron/conf/plugins/ml2/drivers/ovs_conf.py | 25 ++ .../agent/openflow/native/br_int.py | 51 +++- .../openvswitch/agent/ovs_neutron_agent.py | 5 +- .../agent/l2/extensions/test_dns_forwarder.py | 275 ++++++++++++++++++ .../agent/openflow/native/test_br_int.py | 39 ++- .../openvswitch/agent/test_ovs_tunnel.py | 3 +- pyproject.toml | 1 + ...warder-ovs-extension-1c0aaaf5c66be2a6.yaml | 5 + 11 files changed, 662 insertions(+), 5 deletions(-) create mode 100644 devstack/lib/dns_forwarder_ovs_ext create mode 100644 neutron/agent/l2/extensions/dns_forwarder.py create mode 100644 neutron/tests/unit/agent/l2/extensions/test_dns_forwarder.py create mode 100644 releasenotes/notes/add-dns-forwarder-ovs-extension-1c0aaaf5c66be2a6.yaml diff --git a/devstack/lib/dns_forwarder_ovs_ext b/devstack/lib/dns_forwarder_ovs_ext new file mode 100644 index 00000000000..1e6c318d4a3 --- /dev/null +++ b/devstack/lib/dns_forwarder_ovs_ext @@ -0,0 +1,6 @@ +function configure_ovs_dns_forwarder { + plugin_agent_add_l2_agent_extension "dns_forwarder" + iniset /$NEUTRON_CORE_PLUGIN_CONF dns_forwarder upstream_dns_server_ports $UPSTREAM_DNS_SERVER_PORTS + iniset /$NEUTRON_CORE_PLUGIN_CONF dns_forwarder upstream_dns_query_timeout $UPSTREAM_DNS_QUERY_TIMEOUT + iniset /$NEUTRON_CORE_PLUGIN_CONF dns_forwarder client_dns_server_ports $CLIENT_DNS_SERVER_PORTS +} \ No newline at end of file diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 5a2d0287e87..46403c00a29 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -21,6 +21,7 @@ source $LIBDIR/loki source $LIBDIR/local_ip source $LIBDIR/port_trusted_vif source $LIBDIR/frr +source $LIBDIR/dns_forwarder_ovs_ext # source the OVS/OVN compilation helper methods source $TOP_DIR/lib/neutron_plugins/ovs_source @@ -85,6 +86,11 @@ if [[ "$1" == "stack" ]]; then configure_ovs_metadata_path fi fi + if is_service_enabled q-dns-forwarder neutron-dns-forwarder; then + if [ $Q_AGENT = openvswitch ]; then + configure_ovs_dns_forwarder + fi + fi if is_service_enabled neutron-local-ip; then configure_local_ip fi diff --git a/neutron/agent/l2/extensions/dns_forwarder.py b/neutron/agent/l2/extensions/dns_forwarder.py new file mode 100644 index 00000000000..0b096716f63 --- /dev/null +++ b/neutron/agent/l2/extensions/dns_forwarder.py @@ -0,0 +1,251 @@ +# Copyright (c) 2025 OMZ Cloud +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import socket +import sys + +import netaddr +from neutron_lib.agent import l2_extension as l2_agent_extension +from neutron_lib import constants as lib_consts +from os_ken.base import app_manager +from os_ken.lib.packet import ethernet +from os_ken.lib.packet import ipv4 +from os_ken.lib.packet import ipv6 +from os_ken.lib.packet import packet +from os_ken.lib.packet import udp +from oslo_config import cfg +from oslo_log import log as logging + +from neutron.plugins.ml2.drivers.openvswitch.agent.openflow.native \ + import base_oskenapp + +LOG = logging.getLogger(__name__) + + +class DNSResponder(base_oskenapp.BaseNeutronAgentOSKenApp): + + def __init__(self, agent_api, *args, **kwargs): + super().__init__(*args, **kwargs) + self.agent_api = agent_api + self.int_br = self.agent_api.request_int_br() + self.name = "DNSResponder" + self.upstream_dns_server_ports = [] + + for ip_port in cfg.CONF.DNS_FORWARDER.upstream_dns_server_ports: + try: + ip_part, port_part = ip_port.rsplit(':', 1) + ip = ip_part.replace('[', '').replace(']', '') + self.upstream_dns_server_ports.append({ + 'ip_port': ip_port, + 'ip': ip, + 'netaddr_ip': netaddr.IPAddress(ip), + 'port': int(port_part) + }) + except (ValueError, netaddr.AddrFormatError): + LOG.error( + "Invalid upstream_dns_server_ports config: %s", + ip_port + ) + sys.exit(1) + + self.register_packet_in_handler(self._packet_in_handler) + + def forward_to_upstream(self, query_data): + """Forward DNS query to upstream servers""" + for dns_server in self.upstream_dns_server_ports: + LOG.debug( + "Connect to DNS upstream server: %s", + dns_server["ip_port"] + ) + try: + socket_family = ( + socket.AF_INET6 + if dns_server[ + 'netaddr_ip' + ].version == lib_consts.IP_VERSION_6 else socket.AF_INET + ) + + with socket.socket( + socket_family, socket.SOCK_DGRAM + ) as upstream_socket: + upstream_socket.settimeout( + cfg.CONF.DNS_FORWARDER.upstream_dns_query_timeout + ) + upstream_socket.sendto( + query_data, + (dns_server['ip'], dns_server['port']) + ) + response, _address = upstream_socket.recvfrom(4096) + return response + + except Exception as e: + LOG.debug( + "Failed to query upstream server %s: %s", + dns_server["ip_port"], e + ) + + def _packet_in_handler(self, event): + msg = event.msg + datapath = msg.datapath + ofproto = datapath.ofproto + + if msg.reason != ofproto.OFPR_ACTION: + LOG.debug("DNS Controller only handle the packet which " + "match the rules and the action is send to the " + "controller.") + return + + of_in_port = msg.match['in_port'] + LOG.debug("DNS Controller packet in OF port: %s", of_in_port) + pkt = packet.Packet(data=msg.data) + + LOG.debug('DNS Controller packet received: ' + 'buffer_id=%x total_len=%d reason=ACTION ' + 'table_id=%d cookie=%d match=%s pkt=%s', + msg.buffer_id, msg.total_len, + msg.table_id, msg.cookie, msg.match, + pkt) + + # Check for UDP packet protocol + udp_header = pkt.get_protocol(udp.udp) + if not udp_header: + LOG.debug("DNS Controller received packet is not a UDP packet") + return + + # DNS Forwarding + dns_payload, ip_version = self._get_dns_payload(pkt) + if not dns_payload: + return + + dns_result = self.forward_to_upstream(dns_payload) + if dns_result: + # Build complete response packet + response_pkt = self._build_dns_response_packet( + pkt, dns_result, ip_version + ) + + parser = datapath.ofproto_parser + actions = [parser.OFPActionOutput(port=of_in_port)] + out = parser.OFPPacketOut(datapath=datapath, + buffer_id=ofproto.OFP_NO_BUFFER, + in_port=ofproto.OFPP_CONTROLLER, + actions=actions, + data=response_pkt.data) + LOG.debug( + "DNS Controller packet out to OF port %s, %s", of_in_port, out + ) + datapath.send_msg(out) + + def _get_dns_payload(self, pkt): + """Extract DNS payload from the packet.""" + try: + # Get the raw packet data + pkt.serialize() + data = pkt.data + + # Parse Ethernet header (14 bytes) + eth_header_len = 14 + + # Parse IP header to get its length + ip_version = (data[eth_header_len] >> 4) & 0xF + + if ip_version == 4: + # IPv4 header length is in the lower 4 bits of the first byte + # multiplied by 4 + ip_header_len = (data[eth_header_len] & 0xF) * 4 + elif ip_version == 6: + # IPv6 header is fixed at 40 bytes + ip_header_len = 40 + else: + return None, None + + # UDP header is 8 bytes + udp_header_len = 8 + + # DNS payload starts after Ethernet + IP + UDP headers + dns_start = eth_header_len + ip_header_len + udp_header_len + + if len(data) <= dns_start: + return None, None + + return data[dns_start:], ip_version + except Exception as e: + LOG.error("Error extracting DNS payload: %s", e) + return None, None + + def _build_dns_response_packet( + self, original_pkt, dns_response, ip_version=4 + ): + """Build complete DNS response packet""" + eth_header = original_pkt.get_protocol(ethernet.ethernet) + udp_header = original_pkt.get_protocol(udp.udp) + + # Create response packet + response_pkt = packet.Packet() + + # Add Ethernet header (swap src/dst) + response_pkt.add_protocol(ethernet.ethernet( + ethertype=eth_header.ethertype, + dst=eth_header.src, + src=eth_header.dst + )) + + if ip_version == 6: + # IPv6 header + ip_header = original_pkt.get_protocol(ipv6.ipv6) + response_pkt.add_protocol(ipv6.ipv6( + dst=ip_header.src, + src=ip_header.dst, + nxt=ip_header.nxt + )) + else: + # IPv4 header + ip_header = original_pkt.get_protocol(ipv4.ipv4) + response_pkt.add_protocol(ipv4.ipv4( + dst=ip_header.src, + src=ip_header.dst, + proto=ip_header.proto + )) + + # Add UDP header (swap ports) + response_pkt.add_protocol(udp.udp( + dst_port=udp_header.src_port, + src_port=udp_header.dst_port + )) + + # Add DNS response as raw payload + response_pkt.add_protocol(dns_response) + response_pkt.serialize() + + return response_pkt + + +class DNSForwarderAgentExtension(l2_agent_extension.L2AgentExtension): + + def consume_api(self, agent_api): + self.agent_api = agent_api + + def initialize(self, connection, driver_type): + self.app_mgr = app_manager.AppManager.get_instance() + app = self.app_mgr.instantiate(DNSResponder, self.agent_api) + app.start() + + def handle_port(self, context, data): + """DNSForwarder do nothing when port is updated/created""" + pass + + def delete_port(self, context, data): + """DNSForwarder do nothing when port is deleted""" + pass diff --git a/neutron/conf/plugins/ml2/drivers/ovs_conf.py b/neutron/conf/plugins/ml2/drivers/ovs_conf.py index 3ff21017adf..5779a50befd 100644 --- a/neutron/conf/plugins/ml2/drivers/ovs_conf.py +++ b/neutron/conf/plugins/ml2/drivers/ovs_conf.py @@ -15,6 +15,7 @@ from neutron_lib import constants as n_const from neutron_lib.plugins.ml2 import ovs_constants from oslo_config import cfg +from oslo_config import types from neutron._i18n import _ from neutron.conf.agent import common @@ -285,6 +286,29 @@ ] +dns_forwarder_opts = [ + cfg.ListOpt('upstream_dns_server_ports', + default=[], + item_type=types.String( + regex=r'^(?:\d+(?:\.\d+){3}|\[[0-9a-fA-F:]+\]):\d+$', + ), + help=_("Comma-separated list of the Upstream DNS server " + "in IP:port format which will be used as resolvers. " + "Example: '1.1.1.1:53, [2606:4700:4700::1111]:53'")), + cfg.IntOpt('upstream_dns_query_timeout', default=5, + help=_("Query timeout in seconds for each " + "upstream DNS servers")), + cfg.ListOpt('client_dns_server_ports', + default=['169.254.169.254:53', '[fd00::254]:53'], + item_type=types.String( + regex=r'^(?:\d+(?:\.\d+){3}|\[[0-9a-fA-F:]+\]):\d+$', + ), + help=_("Comma-separated list of the Client DNS server " + "in IP:port format which will be used " + "inside client instances.")), +] + + def register_ovs_agent_opts(cfg=cfg.CONF): cfg.register_opts(ovs_opts, "OVS") cfg.register_opts(agent_opts, "AGENT") @@ -293,6 +317,7 @@ def register_ovs_agent_opts(cfg=cfg.CONF): cfg.register_opts(local_ip_opts, "LOCAL_IP") cfg.register_opts(meta_conf.METADATA_PROXY_HANDLER_OPTS, "METADATA") cfg.register_opts(metadata_opts, "METADATA") + cfg.register_opts(dns_forwarder_opts, "DNS_FORWARDER") def register_ovs_opts(cfg=cfg.CONF): diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py index 3916b2b01e9..5f546087637 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py @@ -20,13 +20,15 @@ ** OVS agent https://wiki.openstack.org/wiki/Ovs-flow-logic """ -import netaddr +import sys +import netaddr from neutron_lib import constants as lib_consts from neutron_lib.plugins.ml2 import ovs_constants as constants from os_ken.lib.packet import ether_types from os_ken.lib.packet import icmpv6 from os_ken.lib.packet import in_proto +from oslo_config import cfg from oslo_log import log as logging from neutron.plugins.ml2.common import constants as comm_consts @@ -52,7 +54,7 @@ class OVSIntegrationBridge(ovs_bridge.OVSAgentBridge, of_tables = constants.INT_BR_ALL_TABLES def setup_default_table(self, enable_openflow_dhcp=False, - enable_dhcpv6=False): + enable_dhcpv6=False, enable_dns_forwarder=False): (_dp, ofp, ofpp) = self._get_dp() self.setup_canary_table() self.install_goto(dest_table_id=PACKET_RATE_LIMIT) @@ -79,6 +81,9 @@ def setup_default_table(self, enable_openflow_dhcp=False, table_id=constants.LOCAL_EGRESS_TABLE) self.install_goto(dest_table_id=PACKET_RATE_LIMIT, table_id=constants.LOCAL_IP_TABLE) + # DNS Forwarder defaults + if enable_dns_forwarder: + self.init_dns_forwarder() def init_dhcp(self, enable_openflow_dhcp=False, enable_dhcpv6=False): if not enable_openflow_dhcp: @@ -172,6 +177,48 @@ def check_canary_table(self): return constants.OVS_DEAD return constants.OVS_NORMAL if flows else constants.OVS_RESTARTED + def init_dns_forwarder(self): + """Initialize DNS Forwarder flows.""" + for ip_port in cfg.CONF.DNS_FORWARDER.client_dns_server_ports: + try: + ip_part, port_part = ip_port.rsplit(':', 1) + ip = ip_part.replace('[', '').replace(']', '') + port = int(port_part) + netaddr_ip = netaddr.IPAddress(ip) + except (ValueError, netaddr.AddrFormatError): + LOG.error( + "Invalid client_dns_server_ports config: %s", ip_port + ) + sys.exit(1) + (_dp, ofp, ofpp) = self._get_dp() + if netaddr_ip.version == lib_consts.IP_VERSION_6: + match = ofpp.OFPMatch( + eth_type=ether_types.ETH_TYPE_IPV6, + ip_proto=in_proto.IPPROTO_UDP, + ipv6_dst=ip, + udp_dst=port + ) + else: + match = ofpp.OFPMatch( + eth_type=ether_types.ETH_TYPE_IP, + ip_proto=in_proto.IPPROTO_UDP, + ipv4_dst=ip, + udp_dst=port + ) + + actions = [ + ofpp.OFPActionOutput(ofp.OFPP_CONTROLLER, 0), + ] + instructions = [ + ofpp.OFPInstructionActions(ofp.OFPIT_APPLY_ACTIONS, actions), + ] + self.install_instructions( + table_id=constants.TRANSIENT_TABLE, + priority=102, + instructions=instructions, + match=match + ) + @staticmethod def _local_vlan_match(_ofp, ofpp, port, vlan_vid): return ofpp.OFPMatch(in_port=port, vlan_vid=vlan_vid) diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py index 53f73f080b3..c5b89e86eab 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py @@ -168,6 +168,7 @@ def __init__(self, bridge_classes, ext_manager, conf=None, self.enable_local_ips = 'local_ip' in self.ext_manager.names() self.enable_openflow_metadata = ( 'metadata_path' in self.ext_manager.names()) + self.enable_dns_forwarder = 'dns_forwarder' in self.ext_manager.names() self.register_signal = register_signal @@ -1536,7 +1537,9 @@ def setup_integration_br(self): self.int_br.setup_default_table( enable_openflow_dhcp=self.enable_openflow_dhcp, - enable_dhcpv6=self.conf.DHCP.enable_ipv6) + enable_dhcpv6=self.conf.DHCP.enable_ipv6, + enable_dns_forwarder=self.enable_dns_forwarder + ) def setup_ancillary_bridges(self, integ_br, tun_br): '''Setup ancillary bridges - for example br-ex.''' diff --git a/neutron/tests/unit/agent/l2/extensions/test_dns_forwarder.py b/neutron/tests/unit/agent/l2/extensions/test_dns_forwarder.py new file mode 100644 index 00000000000..0fc09cbb943 --- /dev/null +++ b/neutron/tests/unit/agent/l2/extensions/test_dns_forwarder.py @@ -0,0 +1,275 @@ +# Copyright (c) 2025 OMZ Cloud +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import socket +from unittest import mock + +from os_ken.lib.packet import ether_types +from os_ken.lib.packet import ethernet +from os_ken.lib.packet import in_proto as inet +from os_ken.lib.packet import ipv4 +from os_ken.lib.packet import ipv6 +from os_ken.lib.packet import packet +from os_ken.lib.packet import udp +from oslo_config import cfg + +from neutron.agent.l2.extensions.dns_forwarder import DNSResponder +from neutron.plugins.ml2.drivers.openvswitch.agent \ + import ovs_agent_extension_api as ovs_ext_api +from neutron.tests import base + + +class FakeOF: + OFPR_NO_MATCH = 0 + OFPR_ACTION = 1 + FPR_INVALID_TTL = 2 + OFP_NO_BUFFER = 3 + OFPP_CONTROLLER = 4 + + +class FakeDatapath: + ofproto = FakeOF() + + +class FakeMsg: + datapath = mock.Mock() + reason = datapath.ofproto.OFPR_ACTION + match = {'in_port': 1} + data = "" + buffer_id = 1 + total_len = 1 + table_id = 60 + cookie = 1 + + def set_data(self, packet): + packet.serialize() + self.data = packet.data + + +class DNSForwarderResponderTestCase(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.int_br = mock.Mock() + self.tun_br = mock.Mock() + self.plugin_rpc = mock.Mock() + self.remote_resource_cache = mock.Mock() + self.plugin_rpc.remote_resource_cache = self.remote_resource_cache + self.agent_api = ovs_ext_api.OVSAgentExtensionAPI( + self.int_br, + self.tun_br, + phys_brs=None, + plugin_rpc=self.plugin_rpc) + self.dns_forwarder = DNSResponder(self.agent_api) + self.dns_question_data = ( + b'\x124\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00' + b'\nopenstack\x03org\x00\x00\x01\x00\x01' + ) + + def _build_dns_question_packet(self, for_ipv6=False): + """Build a DNS question packet to ask about openstack.org""" + ret_pkt = packet.Packet() + if for_ipv6: + ret_pkt.add_protocol(ethernet.ethernet( + dst="11:22:33:44:55:66", + src="aa:bb:cc:dd:ee:ff", + ethertype=ether_types.ETH_TYPE_IPV6 + )) + ret_pkt.add_protocol( + ipv6.ipv6(dst="fd00::254", + src="2001:db8::1", + nxt=inet.IPPROTO_UDP)) + else: + ret_pkt.add_protocol(ethernet.ethernet( + dst="11:22:33:44:55:66", + src="aa:bb:cc:dd:ee:ff", + ethertype=ether_types.ETH_TYPE_IP + )) + ret_pkt.add_protocol( + ipv4.ipv4(dst="169.254.169.254", + src="192.168.100.122", + proto=inet.IPPROTO_UDP)) + ret_pkt.add_protocol(udp.udp( + src_port=35678, + dst_port=53, + total_length=8 + len(self.dns_question_data))) + ret_pkt.add_protocol(self.dns_question_data) + return ret_pkt + + @mock.patch('neutron.agent.l2.extensions.dns_forwarder.socket.socket') + def test_forward_to_upstream_valid_ipv4(self, mock_socket_class): + cfg.CONF.set_override( + 'upstream_dns_server_ports', ['8.8.8.8:53'], + group='DNS_FORWARDER' + ) + self.dns_forwarder = DNSResponder(self.agent_api) + + mock_socket = mock.MagicMock() + mock_socket.recvfrom.return_value = ( + b'response_bytes', ('8.8.8.8', 53) + ) + mock_socket_class.return_value.__enter__.return_value = mock_socket + + self.dns_forwarder.forward_to_upstream(self.dns_question_data) + + mock_socket_class.assert_called_with(socket.AF_INET, socket.SOCK_DGRAM) + mock_socket.settimeout.assert_called_once() + mock_socket.sendto.assert_called_once_with( + self.dns_question_data, ('8.8.8.8', 53) + ) + mock_socket.recvfrom.assert_called_once_with(4096) + + @mock.patch('neutron.agent.l2.extensions.dns_forwarder.socket.socket') + def test_forward_to_upstream_valid_ipv6(self, mock_socket_class): + cfg.CONF.set_override( + 'upstream_dns_server_ports', ['[2001:4860:4860::8888]:53'], + group='DNS_FORWARDER' + ) + self.dns_forwarder = DNSResponder(self.agent_api) + + mock_socket = mock.MagicMock() + mock_socket.recvfrom.return_value = ( + b'response_bytes', ('2001:4860:4860::8888', 53) + ) + mock_socket_class.return_value.__enter__.return_value = mock_socket + + self.dns_forwarder.forward_to_upstream(self.dns_question_data) + + mock_socket_class.assert_called_with( + socket.AF_INET6, socket.SOCK_DGRAM + ) + mock_socket.settimeout.assert_called_once() + mock_socket.sendto.assert_called_once_with( + self.dns_question_data, ('2001:4860:4860::8888', 53) + ) + mock_socket.recvfrom.assert_called_once_with(4096) + + @mock.patch('neutron.agent.l2.extensions.dns_forwarder.sys.exit') + def test_forward_to_upstream_sys_exit(self, mock_exit): + cfg.CONF.set_override( + 'upstream_dns_server_ports', + ['8.8.8.999:53', '[2001:4860:4860::8888]:53'], + group='DNS_FORWARDER' + ) + + with self.assertLogs( + 'neutron.agent.l2.extensions.dns_forwarder', level='ERROR' + ) as cm: + self.dns_forwarder = DNSResponder(self.agent_api) + self.assertIn( + "Invalid upstream_dns_server_ports config", + cm.output[0] + ) + self.assertEqual(len(cm.output), 1) + mock_exit.assert_called_once_with(1) + + def test_forward_to_upstream_value_error(self): + self.assertRaises( + ValueError, + cfg.CONF.set_override, + 'upstream_dns_server_ports', + ['8.8.8.999', '[2001:4860:4860::8888]:53'], + group='DNS_FORWARDER' + ) + + def test__get_dns_payload(self): + ret_pkt = self._build_dns_question_packet() + payload, _ip_version = self.dns_forwarder._get_dns_payload(ret_pkt) + self.assertEqual(self.dns_question_data, payload) + + def test__build_dns_response_packet_ipv4(self): + original_ipv4_pkt = self._build_dns_question_packet() + fake_dns_response = b'response_bytes' + + response_pkt = self.dns_forwarder._build_dns_response_packet( + original_ipv4_pkt, + dns_response=fake_dns_response, + ip_version=4 + ) + + protocols = {type(p): p for p in response_pkt.protocols} + + # MAC Address swapped + eth = protocols[ethernet.ethernet] + self.assertEqual(eth.src, '11:22:33:44:55:66') + self.assertEqual(eth.dst, 'aa:bb:cc:dd:ee:ff') + + # IPs swapped + ip = protocols[ipv4.ipv4] + self.assertEqual(ip.src, '169.254.169.254') + self.assertEqual(ip.dst, '192.168.100.122') + + # UDP ports swapped + udp_hdr = protocols[udp.udp] + self.assertEqual(udp_hdr.src_port, 53) + self.assertEqual(udp_hdr.dst_port, 35678) + + # Payload match + self.assertIn(fake_dns_response, response_pkt.data) + + def test__build_dns_response_packet_ipv6(self): + original_ipv6_pkt = self._build_dns_question_packet(for_ipv6=True) + fake_dns_response = b'response_bytes' + + response_pkt = self.dns_forwarder._build_dns_response_packet( + original_ipv6_pkt, + dns_response=fake_dns_response, + ip_version=6 + ) + + protocols = {type(p): p for p in response_pkt.protocols} + + # MAC Address swapped + eth = protocols[ethernet.ethernet] + self.assertEqual(eth.src, '11:22:33:44:55:66') + self.assertEqual(eth.dst, 'aa:bb:cc:dd:ee:ff') + + # IPs swapped + ip = protocols[ipv6.ipv6] + self.assertEqual(ip.src, 'fd00::254') + self.assertEqual(ip.dst, '2001:db8::1') + + # UDP ports swapped + udp_hdr = protocols[udp.udp] + self.assertEqual(udp_hdr.src_port, 53) + self.assertEqual(udp_hdr.dst_port, 35678) + + # Payload match + self.assertIn(fake_dns_response, response_pkt.data) + + def _test__packet_in_handler(self, for_ipv6=False): + pkt = self._build_dns_question_packet() + ev = mock.Mock() + ev.msg = FakeMsg() + ev.msg.set_data(pkt) + + with self.assertLogs( + 'neutron.agent.l2.extensions.dns_forwarder', level='DEBUG' + ) as cm: + self.dns_forwarder._packet_in_handler(ev) + self.assertIn( + "DNS Controller packet out to OF port", cm.output[-1] + ) + + @mock.patch('neutron.agent.l2.extensions' + '.dns_forwarder.DNSResponder.forward_to_upstream') + def test__packet_in_handler_ipv4(self, mock_forward_to_upstream): + mock_forward_to_upstream.return_value = b'response_bytes' + self._test__packet_in_handler(for_ipv6=False) + + @mock.patch('neutron.agent.l2.extensions' + '.dns_forwarder.DNSResponder.forward_to_upstream') + def test__packet_in_handler_ipv6(self, mock_forward_to_upstream): + mock_forward_to_upstream.return_value = b'response_bytes' + self._test__packet_in_handler(for_ipv6=True) diff --git a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py index a64e1ff48a6..0f83156f581 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py +++ b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py @@ -37,7 +37,8 @@ def setUp(self): def test_setup_default_table(self): self.br.setup_default_table(enable_openflow_dhcp=True, - enable_dhcpv6=True) + enable_dhcpv6=True, + enable_dns_forwarder=True) (dp, ofp, ofpp) = self._get_dp() expected = [ call._send_msg(ofpp.OFPFlowMod(dp, @@ -185,6 +186,42 @@ def test_setup_default_table(self): priority=0, table_id=31), active_bundle=None), + call._send_msg( + ofpp.OFPFlowMod( + dp, + cookie=self.stamp, + instructions=[ + ofpp.OFPInstructionActions( + ofp.OFPIT_APPLY_ACTIONS, [ + ofpp.OFPActionOutput(ofp.OFPP_CONTROLLER, 0) + ]), + ], + match=ofpp.OFPMatch( + eth_type=self.ether_types.ETH_TYPE_IP, + ip_proto=self.in_proto.IPPROTO_UDP, + ipv4_dst="169.254.169.254", + udp_dst=53), + priority=102, + table_id=ovs_constants.TRANSIENT_TABLE), + active_bundle=None), + call._send_msg( + ofpp.OFPFlowMod( + dp, + cookie=self.stamp, + instructions=[ + ofpp.OFPInstructionActions( + ofp.OFPIT_APPLY_ACTIONS, [ + ofpp.OFPActionOutput(ofp.OFPP_CONTROLLER, 0) + ]), + ], + match=ofpp.OFPMatch( + eth_type=self.ether_types.ETH_TYPE_IPV6, + ip_proto=self.in_proto.IPPROTO_UDP, + ipv6_dst="fd00::254", + udp_dst=53), + priority=102, + table_id=ovs_constants.TRANSIENT_TABLE), + active_bundle=None), ] self.assertEqual(expected, self.mock.mock_calls) diff --git a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_tunnel.py b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_tunnel.py index 45698ca7d6f..c5029fbfa7a 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_tunnel.py +++ b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_tunnel.py @@ -208,7 +208,8 @@ def _define_expected_calls( mock.call.setup_controllers(mock.ANY), mock.call.set_igmp_snooping_state(igmp_snooping), mock.call.setup_default_table(enable_openflow_dhcp=False, - enable_dhcpv6=False), + enable_dhcpv6=False, + enable_dns_forwarder=False), ] self.mock_map_tun_bridge_expected = [ diff --git a/pyproject.toml b/pyproject.toml index 443cddea08d..3652a166235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,6 +156,7 @@ qos = "neutron.agent.l2.extensions.qos:QosAgentExtension" fdb = "neutron.agent.l2.extensions.fdb_population:FdbPopulationAgentExtension" log = "neutron.services.logapi.agent.log_extension:LoggingExtension" dhcp = "neutron.agent.l2.extensions.dhcp.extension:DHCPAgentExtension" +dns_forwarder = "neutron.agent.l2.extensions.dns_forwarder:DNSForwarderAgentExtension" local_ip = "neutron.agent.l2.extensions.local_ip:LocalIPAgentExtension" metadata_path = "neutron.agent.l2.extensions.metadata.metadata_path:MetadataPathAgentExtension" diff --git a/releasenotes/notes/add-dns-forwarder-ovs-extension-1c0aaaf5c66be2a6.yaml b/releasenotes/notes/add-dns-forwarder-ovs-extension-1c0aaaf5c66be2a6.yaml new file mode 100644 index 00000000000..0425529bd99 --- /dev/null +++ b/releasenotes/notes/add-dns-forwarder-ovs-extension-1c0aaaf5c66be2a6.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add a DNS forwarder OVS extension that enables internal DNS resolution, + allowing instances to resolve domain names without relying on external connectivity. From d10035dc0c04c662fb1ef73ec1f30f9754e87f2d Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 3 Jun 2026 11:43:44 +0200 Subject: [PATCH 02/32] ovn: pass gateway LRPs to _check_external_ips_changed The caller ``update_router()`` already computes ``ovn_router_ext_gw_lrps`` by filtering ``ovn_router.ports`` for gateway LRPs. Pass this list into ``_check_external_ips_changed`` so the no-subnet edge case can use the already-fetched LRP objects instead of re-querying OVN NB via ``get_lrouter_port()``. This eliminates one OVN NB round-trip per gateway port in the no-subnet edge case during router update. The LRP external_ids already contain the network name needed for the comparison. Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Ibf5bbce04c469524ea4284b7662f599605c27943 --- .../ovn/mech_driver/ovsdb/ovn_client.py | 16 +- .../ovn/mech_driver/ovsdb/test_ovn_client.py | 148 ++++++++++++++++++ 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index e91eac7fd53..3252b0e2b9a 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -1403,19 +1403,24 @@ def _add_router_ext_gw(self, context, router, txn): return added_ports def _check_external_ips_changed(self, context, ovn_snats, - ovn_static_routes, router): + ovn_static_routes, router, + ovn_gw_lrps): admin_context = context.elevated() ovn_gw_subnets = [ getattr(route, 'external_ids', {}).get( ovn_const.OVN_SUBNET_EXT_ID_KEY) for route in ovn_static_routes] + lrp_by_name = {lrp.name: lrp for lrp in ovn_gw_lrps} + for gw_port in self._get_router_gw_ports(admin_context, router['id']): gw_infos = self._get_gw_info(admin_context, gw_port) if not gw_infos: - # The router is attached to a external network without a subnet - lrp = self._nb_idl.get_lrouter_port( - utils.ovn_lrouter_port_name(gw_port['id'])) + # The router is attached to an external network without + # a subnet; use the already-fetched LRP to check if the + # network name has changed. + lrp_name = utils.ovn_lrouter_port_name(gw_port['id']) + lrp = lrp_by_name.get(lrp_name) if not lrp: continue lrp_ext_ids = getattr(lrp, 'external_ids', {}) @@ -1606,7 +1611,8 @@ def update_router(self, context, new_router, router_object=None): ] if (len(gateway_new) != len(ovn_router_ext_gw_lrps) or self._check_external_ips_changed( - context, ovn_snats, gateway_old, new_router)): + context, ovn_snats, gateway_old, new_router, + ovn_router_ext_gw_lrps)): txn.add(self._nb_idl.delete_lrouter_ext_gw( router_name)) if router_object: diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py index 3f35d1abb41..a4e9bba9281 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -395,6 +395,154 @@ def test__get_snat_cidrs_for_external_router_nested_snat_on(self): ctx, 'fake-id') self.assertEqual([const.IPv4_ANY], cidrs) + def _make_ovn_lrp(self, port_id, network_name, subnet_ids='', + networks=None): + lrp = mock.Mock() + lrp.name = 'lrp-' + port_id + lrp.networks = networks or [] + lrp.external_ids = { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: network_name, + constants.OVN_ROUTER_IS_EXT_GW: 'True', + constants.OVN_SUBNET_EXT_IDS_KEY: subnet_ids, + } + return lrp + + def test__check_external_ips_changed_no_change(self): + """No change detected when new ports match OVN state.""" + plugin = mock.MagicMock() + self.get_plugin.return_value = plugin + subnet = {'id': 'sub1', 'gateway_ip': '10.0.0.1', + 'ip_version': const.IP_VERSION_4} + plugin.get_subnets_by_network.return_value = [subnet] + gw_port = fakes.FakePort().create_one_port( + attrs={'id': 'gw-port-1', 'network_id': 'ext-net', + 'fixed_ips': [{'subnet_id': 'sub1', + 'ip_address': '10.0.0.5'}]}) + self.ovn_client._get_router_gw_ports = mock.Mock( + return_value=[gw_port]) + + ovn_snat = mock.Mock(external_ip='10.0.0.5') + ovn_route = mock.Mock( + external_ids={constants.OVN_SUBNET_EXT_ID_KEY: 'sub1'}, + bfd=[]) + ovn_lrp = self._make_ovn_lrp('gw-port-1', 'neutron-ext-net', + subnet_ids='sub1') + router = {'id': 'rtr1'} + ctx = mock.MagicMock() + + result = self.ovn_client._check_external_ips_changed( + ctx, [ovn_snat], [ovn_route], router, [ovn_lrp]) + self.assertFalse(result) + self.nb_idl.get_lrouter_port.assert_not_called() + + def test__check_external_ips_changed_subnet_changed(self): + """Detected when new port has a subnet not in OVN routes.""" + plugin = mock.MagicMock() + self.get_plugin.return_value = plugin + subnet = {'id': 'sub-new', 'gateway_ip': '10.0.0.1', + 'ip_version': const.IP_VERSION_4} + plugin.get_subnets_by_network.return_value = [subnet] + gw_port = fakes.FakePort().create_one_port( + attrs={'id': 'gw-port-1', 'network_id': 'ext-net', + 'fixed_ips': [{'subnet_id': 'sub-new', + 'ip_address': '10.0.0.5'}]}) + self.ovn_client._get_router_gw_ports = mock.Mock( + return_value=[gw_port]) + + ovn_route = mock.Mock( + external_ids={constants.OVN_SUBNET_EXT_ID_KEY: 'sub-old'}, + bfd=[]) + ovn_lrp = self._make_ovn_lrp('gw-port-1', 'neutron-ext-net') + router = {'id': 'rtr1'} + ctx = mock.MagicMock() + + result = self.ovn_client._check_external_ips_changed( + ctx, [], [ovn_route], router, [ovn_lrp]) + self.assertTrue(result) + + def test__check_external_ips_changed_snat_ip_changed(self): + """Detected when SNAT external_ip differs from new router IP.""" + plugin = mock.MagicMock() + self.get_plugin.return_value = plugin + subnet = {'id': 'sub1', 'gateway_ip': '10.0.0.1', + 'ip_version': const.IP_VERSION_4} + plugin.get_subnets_by_network.return_value = [subnet] + gw_port = fakes.FakePort().create_one_port( + attrs={'id': 'gw-port-1', 'network_id': 'ext-net', + 'fixed_ips': [{'subnet_id': 'sub1', + 'ip_address': '10.0.0.99'}]}) + self.ovn_client._get_router_gw_ports = mock.Mock( + return_value=[gw_port]) + + ovn_snat = mock.Mock(external_ip='10.0.0.5') + ovn_route = mock.Mock( + external_ids={constants.OVN_SUBNET_EXT_ID_KEY: 'sub1'}, + bfd=[]) + ovn_lrp = self._make_ovn_lrp('gw-port-1', 'neutron-ext-net') + router = {'id': 'rtr1'} + ctx = mock.MagicMock() + + result = self.ovn_client._check_external_ips_changed( + ctx, [ovn_snat], [ovn_route], router, [ovn_lrp]) + self.assertTrue(result) + + def test__check_external_ips_changed_no_subnet_network_changed(self): + """No-subnet edge case uses passed-in LRP, not OVN re-fetch.""" + plugin = mock.MagicMock() + self.get_plugin.return_value = plugin + plugin.get_subnets_by_network.return_value = [] + gw_port = fakes.FakePort().create_one_port( + attrs={'id': 'gw-port-1', 'network_id': 'new-ext-net', + 'fixed_ips': []}) + self.ovn_client._get_router_gw_ports = mock.Mock( + return_value=[gw_port]) + + ovn_lrp = self._make_ovn_lrp( + 'gw-port-1', 'neutron-old-ext-net') + router = {'id': 'rtr1'} + ctx = mock.MagicMock() + + result = self.ovn_client._check_external_ips_changed( + ctx, [], [], router, [ovn_lrp]) + self.assertTrue(result) + self.nb_idl.get_lrouter_port.assert_not_called() + + def test__check_external_ips_changed_no_subnet_network_same(self): + """No-subnet edge case returns False when network matches.""" + plugin = mock.MagicMock() + self.get_plugin.return_value = plugin + plugin.get_subnets_by_network.return_value = [] + gw_port = fakes.FakePort().create_one_port( + attrs={'id': 'gw-port-1', 'network_id': 'ext-net', + 'fixed_ips': []}) + self.ovn_client._get_router_gw_ports = mock.Mock( + return_value=[gw_port]) + + ovn_lrp = self._make_ovn_lrp('gw-port-1', 'neutron-ext-net') + router = {'id': 'rtr1'} + ctx = mock.MagicMock() + + result = self.ovn_client._check_external_ips_changed( + ctx, [], [], router, [ovn_lrp]) + self.assertFalse(result) + self.nb_idl.get_lrouter_port.assert_not_called() + + def test__check_external_ips_changed_bfd_mismatch(self): + """BFD state change detected.""" + plugin = mock.MagicMock() + self.get_plugin.return_value = plugin + plugin.get_subnets_by_network.return_value = [] + self.ovn_client._get_router_gw_ports = mock.Mock(return_value=[]) + + ovn_route = mock.Mock( + external_ids={}, bfd=[]) + router = {'id': 'rtr1', 'enable_default_route_bfd': True} + ctx = mock.MagicMock() + + result = self.ovn_client._check_external_ips_changed( + ctx, [], [ovn_route], router, []) + self.assertTrue(result) + def test__get_nets_and_ipv6_ra_confs_ipv4_only(self): """Single bulk get_subnets call for all fixed IPs.""" plugin = mock.MagicMock() From 26c1405d96c1dd296d12d5abaee1b851cb4c4b62 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 3 Jun 2026 16:51:39 +0200 Subject: [PATCH 03/32] ovn: avoid nested transaction in ``_delete_port`` virtual parent check When deleting a non-virtual port, ``_delete_port()`` fetched the Logical_Switch via ``ls_get().execute()``, which created a separate read transaction nested inside the existing write transaction. This is unnecessary since the IDL maintains an in-memory replica of the OVN NB database. Replace ``ls_get().execute()`` with a direct ``lookup()`` call, which performs an O(1) in-memory IDL access using the name index, eliminating the command/transaction overhead on every port deletion. Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I1fd990d2d447e947ef23fdc0fe2f48aae2d4adec --- .../ovn/mech_driver/ovsdb/ovn_client.py | 9 +- .../ovn/mech_driver/ovsdb/test_ovn_client.py | 106 ++++++++++++++++++ .../ovn/mech_driver/test_mech_driver.py | 3 +- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index e91eac7fd53..3556cd7c2b9 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -871,10 +871,13 @@ def _delete_port(self, context, port_id, port_object=None): if port_object and self.is_dns_required_for_port(port_object): self.add_txns_to_remove_port_dns_records(txn, port_object) - # Check if the port being deleted is a virtual parent + # Check if the port being deleted is a virtual parent. + # Use lookup() instead of ls_get().execute() to avoid creating + # a nested read transaction; lookup() is a direct in-memory + # IDL access. if ovn_port.type != ovn_const.LSP_TYPE_VIRTUAL: - ls = self._nb_idl.ls_get(ovn_network_name).execute( - check_error=True) + ls = self._nb_idl.lookup('Logical_Switch', + ovn_network_name) cmd = self._nb_idl.unset_lswitch_port_to_virtual_type for lsp in ls.ports: if lsp.type != ovn_const.LSP_TYPE_VIRTUAL: diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py index 3f35d1abb41..5c9233546be 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -564,6 +564,112 @@ def test__get_nets_and_ipv6_ra_confs_empty_fixed_ips(self): ctx, filters={'id': []}) plugin.get_network.assert_not_called() + def _make_fake_lsp(self, name, lsp_type='', options=None): + lsp = mock.Mock() + lsp.name = name + lsp.type = lsp_type + lsp.options = options or {} + return lsp + + def _setup_delete_port_mocks(self, ovn_port, ls): + def _lookup(table, name, **kwargs): + if table == 'Logical_Switch_Port': + return ovn_port + if table == 'Logical_Switch': + return ls + raise ValueError("Unexpected lookup: %s %s" % (table, name)) + + self.nb_idl.lookup.side_effect = _lookup + self.ovn_client._qos_driver = mock.Mock() + + def test__delete_port_unsets_virtual_children(self): + """Deleting a non-virtual port unsets it from virtual children.""" + port_id = 'parent-port' + ovn_network_name = 'neutron-net1' + + ovn_port = self._make_fake_lsp(port_id) + ovn_port.external_ids = { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: ovn_network_name} + + virtual_lsp = self._make_fake_lsp( + 'virtual-port', constants.LSP_TYPE_VIRTUAL, + {constants.LSP_OPTIONS_VIRTUAL_PARENTS_KEY: + 'parent-port,other-port'}) + normal_lsp = self._make_fake_lsp('normal-port') + ls = mock.Mock() + ls.ports = [normal_lsp, virtual_lsp] + + self._setup_delete_port_mocks(ovn_port, ls) + + ctx = ncontext.Context() + self.ovn_client._delete_port(ctx, port_id) + + self.nb_idl.unset_lswitch_port_to_virtual_type.assert_called_once_with( + 'virtual-port', port_id, if_exists=True) + self.nb_idl.ls_get.assert_not_called() + + def test__delete_port_no_virtual_children(self): + """No virtual ports on the LS means no unset call.""" + port_id = 'normal-port' + ovn_network_name = 'neutron-net1' + + ovn_port = self._make_fake_lsp(port_id) + ovn_port.external_ids = { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: ovn_network_name} + + other_lsp = self._make_fake_lsp('other-port') + ls = mock.Mock() + ls.ports = [other_lsp] + + self._setup_delete_port_mocks(ovn_port, ls) + + ctx = ncontext.Context() + self.ovn_client._delete_port(ctx, port_id) + + self.nb_idl.unset_lswitch_port_to_virtual_type.assert_not_called() + + def test__delete_port_virtual_port_skips_parent_check(self): + """Deleting a virtual port skips the parent check entirely.""" + port_id = 'virtual-port' + ovn_network_name = 'neutron-net1' + + ovn_port = self._make_fake_lsp( + port_id, constants.LSP_TYPE_VIRTUAL) + ovn_port.external_ids = { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: ovn_network_name} + + self._setup_delete_port_mocks(ovn_port, ls=None) + + ctx = ncontext.Context() + self.ovn_client._delete_port(ctx, port_id) + + calls = [c for c in self.nb_idl.lookup.call_args_list + if c[0][0] == 'Logical_Switch'] + self.assertEqual([], calls) + self.nb_idl.unset_lswitch_port_to_virtual_type.assert_not_called() + + def test__delete_port_virtual_child_different_parent(self): + """Virtual port referencing a different parent is not affected.""" + port_id = 'my-port' + ovn_network_name = 'neutron-net1' + + ovn_port = self._make_fake_lsp(port_id) + ovn_port.external_ids = { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: ovn_network_name} + + virtual_lsp = self._make_fake_lsp( + 'virtual-port', constants.LSP_TYPE_VIRTUAL, + {constants.LSP_OPTIONS_VIRTUAL_PARENTS_KEY: 'other-parent'}) + ls = mock.Mock() + ls.ports = [virtual_lsp] + + self._setup_delete_port_mocks(ovn_port, ls) + + ctx = ncontext.Context() + self.ovn_client._delete_port(ctx, port_id) + + self.nb_idl.unset_lswitch_port_to_virtual_type.assert_not_called() + class TestOVNClientFairMeter(TestOVNClientBase, test_log_driver.TestOVNDriverBase): diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 318ba9bc6ab..77ec9e272af 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -5471,8 +5471,7 @@ def test_delete_virtual_port_parent(self): 'type': ovn_const.LSP_TYPE_VIRTUAL, 'options': {ovn_const.LSP_OPTIONS_VIRTUAL_PARENTS_KEY: parent['id']}}) - self.nb_idl.ls_get.return_value.execute.return_value = ( - mock.Mock(ports=[fake_row])) + self.nb_idl.lookup.return_value = mock.Mock(ports=[fake_row]) self.mech_driver._ovn_client.delete_port(self.context, parent['id']) self.nb_idl.unset_lswitch_port_to_virtual_type.assert_called_once_with( From 637444bd1c01061b89c8e407f9cf29165efe5960 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 4 Jun 2026 10:06:12 +0200 Subject: [PATCH 04/32] ovn: read LSP ``up`` state from IDL cache in ``update_lsp_host_info`` ``update_lsp_host_info()`` first called ``lookup()`` to check whether the Logical_Switch_Port exists, discarding the returned row, then called ``lsp_get_up().execute()`` which internally looked up the same LSP again inside a separate read transaction. Reuse the row already returned by ``lookup()`` and read the ``up`` column directly from the in-memory IDL replica, removing the redundant ``lsp_get_up().execute()`` round-trip on every port status change. Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I6f825d9ff1b678d29650a0544635bacceb9b0f1c --- .../ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 13 +++++-------- .../ovn/mech_driver/ovsdb/test_ovn_client.py | 7 ++++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index e91eac7fd53..e4b2c2604ae 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -310,16 +310,13 @@ def update_lsp_host_info(self, context, db_port, up=True): # NOTE(ralonsoh): OVN subports don't have host ID information. return - # NOTE(ralonsoh): instead of checking first the presence of the - # `Logical_Switch_Port`, the `lsp_get_up` could implement a `if_exists` - # check. This check is better than catching the `RowNotFound` exception - # in the middle of a transaction. - if not self._nb_idl.lookup('Logical_Switch_Port', db_port.id, - default=None): + lsp = self._nb_idl.lookup('Logical_Switch_Port', db_port.id, + default=None) + if not lsp: return - port_up = self._nb_idl.lsp_get_up(db_port.id).execute( - check_error=True) + # 'up' is optional in the OVN schema (list of 0 or 1 booleans). + port_up = next(iter(lsp.up), False) if up: if not port_up: LOG.warning('Logical_Switch_Port %s host information not ' diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py index 3f35d1abb41..e02dee5f5f6 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -256,12 +256,14 @@ def test_update_lsp_host_info_up(self): port_binding = mock.Mock(host=host_id) db_port = mock.Mock(id=port_id, port_bindings=[port_binding]) self.get_pb_bsah.return_value = port_binding + self.nb_idl.lookup.return_value = mock.Mock(up=[True]) self.ovn_client.update_lsp_host_info(context, db_port) self.nb_idl.db_set.assert_called_once_with( 'Logical_Switch_Port', port_id, ('external_ids', {constants.OVN_HOST_ID_EXT_ID_KEY: host_id})) + self.nb_idl.lsp_get_up.assert_not_called() def test_update_lsp_host_info_up_retry(self): context = mock.MagicMock() @@ -272,6 +274,7 @@ def test_update_lsp_host_info_up_retry(self): db_port_no_host = mock.Mock( id=port_id, port_bindings=[port_binding_no_host]) self.get_pb_bsah.return_value = None + self.nb_idl.lookup.return_value = mock.Mock(up=[True]) with mock.patch.object( self.ovn_client, @@ -293,6 +296,7 @@ def test_update_lsp_host_info_up_retry_fail(self): db_port_no_host = mock.Mock( id=port_id, port_bindings=[mock.Mock(host="")]) self.get_pb_bsah.return_value = None + self.nb_idl.lookup.return_value = mock.Mock(up=[True]) with mock.patch.object( self.ovn_client, @@ -310,13 +314,14 @@ def test_update_lsp_host_info_down(self): context = mock.MagicMock() port_id = 'fake-port-id' db_port = mock.Mock(id=port_id) - self.nb_idl.lsp_get_up.return_value.execute.return_value = False + self.nb_idl.lookup.return_value = mock.Mock(up=[False]) self.ovn_client.update_lsp_host_info(context, db_port, up=False) self.nb_idl.db_remove.assert_called_once_with( 'Logical_Switch_Port', port_id, 'external_ids', constants.OVN_HOST_ID_EXT_ID_KEY, if_exists=True) + self.nb_idl.lsp_get_up.assert_not_called() def test_update_lsp_host_info_trunk_subport(self): context = mock.MagicMock() From d2732d3fc0a0f74005f5eb7cc90bbdca9aadab36 Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Fri, 29 May 2026 19:29:26 +0000 Subject: [PATCH 05/32] Add generic RangeAllocator and VNI/VLAN allocation This adds a generic RangeAllocator that uses the DB to select an unused integer value from a gap in the existing rows. It should work across all supported DBs. On top of this, a VNIVLANAllocator pairs a VNI and VLAN allocation together through a mapping table, providing a single interface for allocating and deallocating VNI/VLAN pairs scoped by physical network. The EVPN plugin uses VNIVLANAllocator via EVPNDbHelper to manage per-router VNI/VLAN assignments. The schema uses RESTRICT FKs from the mapping to allocations and CASCADE from evpn_l3_instances to the mapping, ensuring clean lifecycle management. Co-Authored-By: Jakub Libosvar Assisted-By: Claude Opus 4.6 Change-Id: I62da7a1263aaf605b295af0c3e019754b4e4ecda Signed-off-by: Terry Wilson --- neutron/db/evpn_db.py | 93 ++++--- neutron/db/rangeallocator.py | 132 ++++++++++ neutron/db/vni_vlan_allocator.py | 170 +++++++++++++ neutron/services/evpn/exceptions.py | 15 ++ neutron/services/evpn/plugin.py | 2 +- .../functional/db/test_rangeallocator.py | 226 ++++++++++++++++++ neutron/tests/unit/db/test_evpn_db.py | 31 +++ .../tests/unit/db/test_vni_vlan_allocator.py | 151 ++++++++++++ 8 files changed, 766 insertions(+), 54 deletions(-) create mode 100644 neutron/db/rangeallocator.py create mode 100644 neutron/db/vni_vlan_allocator.py create mode 100644 neutron/tests/functional/db/test_rangeallocator.py create mode 100644 neutron/tests/unit/db/test_vni_vlan_allocator.py diff --git a/neutron/db/evpn_db.py b/neutron/db/evpn_db.py index def8197fe13..99b29064bbc 100644 --- a/neutron/db/evpn_db.py +++ b/neutron/db/evpn_db.py @@ -13,29 +13,42 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib import constants as n_const from neutron_lib.db import api as db_api -from oslo_db import exception as os_db_exc from oslo_log import log as logging -from neutron._i18n import _ from neutron.db.models import evpn as evpn_models -from neutron.db.models import vxlan_vlan_allocations as alloc_models from neutron.db import models_v2 +from neutron.db import vni_vlan_allocator from neutron.services.evpn import exceptions as evpn_exc LOG = logging.getLogger(__name__) _EVPN_PHYSNET = 'ovn-evpn' +_MIN_VNI = 1 +_MAX_VNI = n_const.MAX_VXLAN_VNI +_MIN_VLAN = n_const.MIN_VLAN_TAG +_MAX_VLAN = n_const.MAX_VLAN_TAG -class EVPNVNIDbHelper: - """Database helper for EVPN VNI allocation operations. +class EVPNDbHelper: + """Database helper for EVPN allocation operations. - This class provides VNI allocation/deallocation for routers. It is - designed to be used via composition rather than inheritance. + This class provides VNI/VLAN allocation/deallocation for routers. + It delegates to VNIVLANAllocator for the generic allocation logic + and owns the EVPN-specific L3 instance lifecycle. + Designed to be used via composition rather than inheritance. """ + def __init__(self): + self._allocator = vni_vlan_allocator.VNIVLANAllocator( + vni_exhausted_exc=evpn_exc.EVPNNoVniAvailable, + vlan_exhausted_exc=evpn_exc.EVPNNoVlanAvailable, + vni_in_use_exc=evpn_exc.EVPNVNIInUse, + ) + + @db_api.retry_if_session_inactive() @db_api.CONTEXT_WRITER def allocate_vni_for_router(self, context, router_id, vni): """Allocate a VNI for a router. @@ -55,40 +68,18 @@ def allocate_vni_for_router(self, context, router_id, vni): def _allocate_specific_vni(self, context, router_id, vni): """Allocate a specific VNI and a VLAN for a router. - Creates VNI allocation, VLAN allocation, mapping, and - EVPN L3 instance in the correct order. - :param context: Neutron request context (with active session) :param router_id: UUID of the router :param vni: VNI to allocate :returns: The allocated VNI (integer) :raises EVPNVNIInUse: If VNI is already allocated """ - vni_allocation = alloc_models.VNIAllocation( - vni=vni, physnet=_EVPN_PHYSNET) - context.session.add(vni_allocation) - - try: - context.session.flush() - except os_db_exc.DBDuplicateEntry: - raise evpn_exc.EVPNVNIInUse(vni=vni) - - # TODO(jlibosva): Auto-allocate VLAN from range (Patch 2). - # For now, use the VNI value as a placeholder VLAN ID. - vlan_allocation = alloc_models.VLANAllocation( - vlan_id=vni, physnet=_EVPN_PHYSNET) - context.session.add(vlan_allocation) - context.session.flush() - - mapping = alloc_models.VNIVLANMapping( - vni_allocation_id=vni_allocation.id, - vlan_allocation_id=vlan_allocation.id) - context.session.add(mapping) - context.session.flush() + mapping_id, vni, _vlan_id = self._allocator.allocate_specific_vni( + context, vni, _MIN_VLAN, _MAX_VLAN, _EVPN_PHYSNET) instance = evpn_models.EVPNL3Instance( router_id=router_id, - mapping_id=mapping.id) + mapping_id=mapping_id) context.session.add(instance) LOG.debug("Allocated EVPN VNI %s for router %s", vni, router_id) @@ -100,18 +91,26 @@ def _allocate_auto_vni(self, context, router_id): :param context: Neutron request context (with active session) :param router_id: UUID of the router :returns: The allocated VNI (integer) + :raises EVPNNoVniAvailable: if no VNI remains in the range """ - # TODO(jlibosva): Implement auto-allocation from configured range - raise NotImplementedError( - _("EVPN VNI auto-allocation not yet implemented. " - "Specify an explicit VNI.")) + mapping_id, vni, _vlan_id = self._allocator.allocate( + context, _MIN_VNI, _MAX_VNI, _MIN_VLAN, _MAX_VLAN, _EVPN_PHYSNET) + + instance = evpn_models.EVPNL3Instance( + router_id=router_id, + mapping_id=mapping_id) + context.session.add(instance) + LOG.debug("Auto-allocated EVPN VNI %s for router %s", vni, router_id) + return vni + + @db_api.retry_if_session_inactive() @db_api.CONTEXT_WRITER def deallocate_vni_for_router(self, context, router_id): """Remove VNI/VLAN allocation for a router. - Deletes the mapping row (CASCADE removes evpn_l3_instances), - then deletes both allocation rows (RESTRICT is now clear). + Delegates to VNIVLANAllocator.deallocate which deletes the mapping + (CASCADE removes evpn_l3_instances) and both allocation rows. Safe to call if no VNI was allocated (e.g. router without EVPN). :param context: Neutron request context (with active session) @@ -123,22 +122,10 @@ def deallocate_vni_for_router(self, context, router_id): if not instance: return - mapping = instance.mapping - vni_alloc_id = mapping.vni_allocation_id - vlan_alloc_id = mapping.vlan_allocation_id - - context.session.query( - alloc_models.VNIVLANMapping - ).filter_by(id=mapping.id).delete(synchronize_session=False) - - context.session.query( - alloc_models.VNIAllocation - ).filter_by(id=vni_alloc_id).delete(synchronize_session=False) - - context.session.query( - alloc_models.VLANAllocation - ).filter_by(id=vlan_alloc_id).delete(synchronize_session=False) + mapping_id = instance.mapping_id + context.session.delete(instance) + self._allocator.deallocate(context, mapping_id) LOG.debug("Deallocated EVPN VNI for router %s", router_id) @db_api.retry_if_session_inactive() diff --git a/neutron/db/rangeallocator.py b/neutron/db/rangeallocator.py new file mode 100644 index 00000000000..625244cca16 --- /dev/null +++ b/neutron/db/rangeallocator.py @@ -0,0 +1,132 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_utils import uuidutils +import sqlalchemy as sa + + +class RangeAllocator: + """Allocator for a scoped integer column in a HasId-style table. + + Atomically claims the smallest unoccupied integer in [min_val, max_val] + within a scope (e.g. physnet for VNIs). + + The target table must follow Neutron's HasId convention: + + - A UUID 'id' primary key (String(36), default=uuidutils.generate_uuid) + - An integer value column + - A scope column + - A UNIQUE constraint on (value_col, scope_col) + + Because the UUID is generated in Python and included in the INSERT, + no RETURNING clause or lastrowid is required. The statement works + identically on all supported databases (SQLite, PostgreSQL, MySQL, + MariaDB). + + The INSERT...SELECT is atomic under the UNIQUE constraint. Concurrent + transactions that land on the same value race on the INSERT; the loser + gets a DBDuplicateEntry which propagates to the caller. Apply + @db_api.retry_if_session_inactive() above @db_api.CONTEXT_WRITER so + that each retry opens a fresh transaction with a new UUID. + + SQL statements are built once at construction time. + """ + + def __init__(self, table, value_col_name, scope_col_name, + scope_param_type, exception_class): + """:param table: SQLAlchemy Table object (reqs HasId-style UUID id) + :param value_col_name: name of the integer column to allocate from + :param scope_col_name: name of the column that scopes uniqueness + :param scope_param_type: SQLAlchemy type for the scope bind parameter + :param exception_class: raised when no value is available; must + accept (min_val, max_val) positional arguments + """ + self._table = table + self._value_col_name = value_col_name + self._scope_col_name = scope_col_name + self._exception_class = exception_class + + self._value_col = table.c[value_col_name] + self._scope_col = table.c[scope_col_name] + self._min_val = sa.bindparam('min_val', type_=sa.Integer) + self._max_val = sa.bindparam('max_val', type_=sa.Integer) + self._scope_p = sa.bindparam('scope_val', type_=scope_param_type) + self._id_p = sa.bindparam('allocation_id', type_=sa.String(36)) + + source = self._gap_subquery() + self._stmt = ( + table.insert() + .from_select( + ['id', value_col_name, scope_col_name], + sa.select(self._id_p, source.c.next_val, self._scope_p) + .where(source.c.next_val.isnot(None)) + ) + ) + + def _gap_subquery(self): + """Subquery returning the minimum unoccupied value in the range.""" + range_start = sa.select(self._min_val.label('candidate')) + + after_existing = sa.select( + (self._value_col + 1).label('candidate') + ).where( + sa.and_(self._value_col >= self._min_val, + self._value_col < self._max_val, + self._scope_col == self._scope_p) + ) + + candidates = sa.union_all(range_start, after_existing).subquery() + + return sa.select( + sa.func.min(candidates.c.candidate).label('next_val') + ).where( + sa.and_( + candidates.c.candidate <= self._max_val, + candidates.c.candidate.notin_( + sa.select(self._value_col) + .where(self._scope_col == self._scope_p) + ) + ) + ).subquery() + + def allocate(self, context, min_val, max_val, scope_val): + """Claim the next available value in [min_val, max_val] for scope_val. + + Returns (allocation_id, allocated_value) where allocation_id is a + Python-generated UUID suitable for use as a foreign key. + + Raises self._exception_class(min_val, max_val) if the range is + exhausted. Lets DBDuplicateEntry propagate for retry handling by + the caller. + """ + allocation_id = uuidutils.generate_uuid() + params = { + 'min_val': min_val, + 'max_val': max_val, + 'scope_val': scope_val, + 'allocation_id': allocation_id, + } + + context.session.execute(self._stmt, params) + + row = context.session.execute( + sa.select(self._table.c[self._value_col_name]) + .where(self._table.c.id == allocation_id) + ).fetchone() + + if row is None: + raise self._exception_class(min_val, max_val) + + return allocation_id, getattr(row, self._value_col_name) diff --git a/neutron/db/vni_vlan_allocator.py b/neutron/db/vni_vlan_allocator.py new file mode 100644 index 00000000000..fad0f2a9c72 --- /dev/null +++ b/neutron/db/vni_vlan_allocator.py @@ -0,0 +1,170 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron_lib.db import api as db_api +from oslo_db import exception as os_db_exc +from oslo_log import log as logging +import sqlalchemy as sa + +from neutron.db.models import vxlan_vlan_allocations as alloc_models +from neutron.db import rangeallocator + +LOG = logging.getLogger(__name__) + + +class VNIVLANAllocator: + """Allocates paired VNI + VLAN IDs and manages their mapping. + + This is a generic allocator that can be used by any component needing + a VNI/VLAN pair scoped by physnet. It owns the full lifecycle of the + vni_allocations, vlan_allocations, and vni_vlan_mapping rows. + + Callers provide exception classes so that errors are domain-specific. + """ + + def __init__(self, vni_exhausted_exc, vlan_exhausted_exc, vni_in_use_exc): + """Initialize the allocator with two RangeAllocators. + + :param vni_exhausted_exc: Exception class raised when VNI range is + exhausted. Must accept (min_val, max_val) positional args. + :param vlan_exhausted_exc: Exception class raised when VLAN range is + exhausted. Must accept (min_val, max_val) positional args. + :param vni_in_use_exc: Exception class raised when a specific VNI + is already allocated. Must accept vni= keyword arg. + """ + self._vni_in_use_exc = vni_in_use_exc + self._vni_allocator = rangeallocator.RangeAllocator( + table=alloc_models.VNIAllocation.__table__, + value_col_name='vni', + scope_col_name='physnet', + scope_param_type=sa.String, + exception_class=vni_exhausted_exc, + ) + self._vlan_allocator = rangeallocator.RangeAllocator( + table=alloc_models.VLANAllocation.__table__, + value_col_name='vlan_id', + scope_col_name='physnet', + scope_param_type=sa.String, + exception_class=vlan_exhausted_exc, + ) + + @db_api.retry_if_session_inactive() + @db_api.CONTEXT_WRITER + def allocate(self, context, min_vni, max_vni, min_vlan, max_vlan, + physnet): + """Auto-allocate a VNI and VLAN pair, creating the mapping. + + :param context: Neutron request context (with active session) + :param min_vni: Minimum VNI value (inclusive) + :param max_vni: Maximum VNI value (inclusive) + :param min_vlan: Minimum VLAN ID (inclusive) + :param max_vlan: Maximum VLAN ID (inclusive) + :param physnet: Physical network scope + :returns: (mapping_id, vni, vlan_id) + """ + vni_alloc_id, vni = self._vni_allocator.allocate( + context, min_vni, max_vni, physnet) + + mapping_id, vlan_id = self._create_mapping( + context, vni_alloc_id, min_vlan, max_vlan, physnet) + + LOG.debug("Allocated VNI %s / VLAN %s (mapping %s) on physnet %s", + vni, vlan_id, mapping_id, physnet) + return mapping_id, vni, vlan_id + + @db_api.retry_if_session_inactive() + @db_api.CONTEXT_WRITER + def allocate_specific_vni(self, context, vni, min_vlan, max_vlan, + physnet): + """Allocate a specific VNI and auto-allocate a VLAN, creating mapping. + + :param context: Neutron request context (with active session) + :param vni: The specific VNI to allocate + :param min_vlan: Minimum VLAN ID (inclusive) + :param max_vlan: Maximum VLAN ID (inclusive) + :param physnet: Physical network scope + :returns: (mapping_id, vni, vlan_id) + :raises: vni_in_use_exc if the VNI is already allocated + """ + vni_allocation = alloc_models.VNIAllocation( + vni=vni, physnet=physnet) + context.session.add(vni_allocation) + + try: + # Flush to trigger the UNIQUE constraint check immediately + # so we can catch the duplicate and raise a domain exception. + context.session.flush() + except os_db_exc.DBDuplicateEntry: + raise self._vni_in_use_exc(vni=vni) + + mapping_id, vlan_id = self._create_mapping( + context, vni_allocation.id, min_vlan, max_vlan, physnet) + + LOG.debug("Allocated specific VNI %s / VLAN %s (mapping %s) " + "on physnet %s", vni, vlan_id, mapping_id, physnet) + return mapping_id, vni, vlan_id + + def _create_mapping(self, context, vni_alloc_id, min_vlan, max_vlan, + physnet): + """Allocate a VLAN and create a VNI-VLAN mapping row. + + :returns: (mapping_id, vlan_id) + """ + vlan_alloc_id, vlan_id = self._vlan_allocator.allocate( + context, min_vlan, max_vlan, physnet) + + mapping = alloc_models.VNIVLANMapping( + vni_allocation_id=vni_alloc_id, + vlan_allocation_id=vlan_alloc_id) + context.session.add(mapping) + context.session.flush() + + return mapping.id, vlan_id + + @db_api.retry_if_session_inactive() + @db_api.CONTEXT_WRITER + def deallocate(self, context, mapping_id): + """Remove a VNI/VLAN mapping and its allocation rows. + + Deletes the mapping row (CASCADE removes any child rows like + evpn_l3_instances), then deletes both allocation rows. + Safe to call if the mapping does not exist. + + :param context: Neutron request context (with active session) + :param mapping_id: ID of the vni_vlan_mapping row + """ + mapping = context.session.query( + alloc_models.VNIVLANMapping + ).filter_by(id=mapping_id).first() + if not mapping: + return + + vni_alloc_id = mapping.vni_allocation_id + vlan_alloc_id = mapping.vlan_allocation_id + + context.session.query( + alloc_models.VNIVLANMapping + ).filter_by(id=mapping_id).delete(synchronize_session=False) + + context.session.query( + alloc_models.VNIAllocation + ).filter_by(id=vni_alloc_id).delete(synchronize_session=False) + + context.session.query( + alloc_models.VLANAllocation + ).filter_by(id=vlan_alloc_id).delete(synchronize_session=False) + + LOG.debug("Deallocated mapping %s (VNI alloc %s, VLAN alloc %s)", + mapping_id, vni_alloc_id, vlan_alloc_id) diff --git a/neutron/services/evpn/exceptions.py b/neutron/services/evpn/exceptions.py index 4618ece3d3f..f0ced052c8f 100644 --- a/neutron/services/evpn/exceptions.py +++ b/neutron/services/evpn/exceptions.py @@ -23,3 +23,18 @@ class EVPNVNIInUse(exceptions.Conflict): class EVPNVNINotFound(exceptions.NotFound): message = _("EVPN VNI not found for router %(router_id)s.") + + +class EVPNNoVniAvailable(exceptions.Conflict): + message = _("No EVPN VNI available in range [%(min_vni)s, %(max_vni)s].") + + def __init__(self, min_val, max_val): + super().__init__(min_vni=min_val, max_vni=max_val) + + +class EVPNNoVlanAvailable(exceptions.Conflict): + message = _("No EVPN VLAN ID available in range " + "[%(min_vlan)s, %(max_vlan)s].") + + def __init__(self, min_val, max_val): + super().__init__(min_vlan=min_val, max_vlan=max_val) diff --git a/neutron/services/evpn/plugin.py b/neutron/services/evpn/plugin.py index 7f412f62e80..49b620e5b91 100644 --- a/neutron/services/evpn/plugin.py +++ b/neutron/services/evpn/plugin.py @@ -48,7 +48,7 @@ class EVPNPlugin(service_base.ServicePluginBase): def __init__(self): super().__init__() - self._evpn_db = evpn_db.EVPNVNIDbHelper() + self._evpn_db = evpn_db.EVPNDbHelper() LOG.info("Starting EVPN service plugin") def get_plugin_description(self): diff --git a/neutron/tests/functional/db/test_rangeallocator.py b/neutron/tests/functional/db/test_rangeallocator.py new file mode 100644 index 00000000000..1106e4063a3 --- /dev/null +++ b/neutron/tests/functional/db/test_rangeallocator.py @@ -0,0 +1,226 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from concurrent import futures + +from neutron_lib import context +from neutron_lib.db import api as db_api +import sqlalchemy as sa + +from neutron.db.models import vxlan_vlan_allocations as alloc_models +from neutron.db import rangeallocator +from neutron.services.evpn import exceptions as evpn_exc +from neutron.tests.unit import testlib_api + +# required for testresources to optimise same-backend tests together +load_tests = testlib_api.module_load_tests + +_PHYSNET = 'test-physnet' +_OTHER_PHYSNET = 'other-physnet' + + +class TestRangeAllocator(testlib_api.SqlTestCase): + """Tests for RangeAllocator against a real SQL engine. + + Runs against SQLite by default (RETURNING path). + TestRangeAllocatorMySQL runs the same suite against MySQL + (LAST_INSERT_ID path). + """ + + def setUp(self): + super().setUp() + self.ctx = context.Context( + user_id=None, project_id=None, is_admin=True, overwrite=False) + self.table = alloc_models.VNIAllocation.__table__ + self.allocator = rangeallocator.RangeAllocator( + table=self.table, + value_col_name='vni', + scope_col_name='physnet', + scope_param_type=sa.String, + exception_class=evpn_exc.EVPNNoVniAvailable, + ) + + def _allocate(self, min_vni=1, max_vni=100, physnet=_PHYSNET): + with db_api.CONTEXT_WRITER.using(self.ctx): + return self.allocator.allocate( + self.ctx, min_vni, max_vni, physnet) + + def _insert(self, vni, physnet=_PHYSNET): + """Directly insert a VNI to set up a specific allocation state.""" + with db_api.CONTEXT_WRITER.using(self.ctx): + result = self.ctx.session.execute( + self.table.insert().values(vni=vni, physnet=physnet)) + return result.inserted_primary_key[0] + + def _delete(self, allocation_id): + with db_api.CONTEXT_WRITER.using(self.ctx): + self.ctx.session.execute( + self.table.delete().where(self.table.c.id == allocation_id)) + + def _all_vnis(self, physnet=_PHYSNET): + with db_api.CONTEXT_READER.using(self.ctx): + rows = self.ctx.session.execute( + sa.select(self.table.c.vni) + .where(self.table.c.physnet == physnet) + .order_by(self.table.c.vni) + ).fetchall() + return [r.vni for r in rows] + + def test_allocate_from_empty_gets_min(self): + alloc_id, vni = self._allocate(min_vni=10, max_vni=100) + self.assertEqual(10, vni) + self.assertIsNotNone(alloc_id) + + def test_allocate_sequential_fills_in_order(self): + _, vni1 = self._allocate(min_vni=1, max_vni=5) + _, vni2 = self._allocate(min_vni=1, max_vni=5) + _, vni3 = self._allocate(min_vni=1, max_vni=5) + self.assertEqual([1, 2, 3], sorted([vni1, vni2, vni3])) + + def test_allocate_fills_freed_gap(self): + alloc_id1, vni1 = self._allocate(min_vni=1, max_vni=5) + _, vni2 = self._allocate(min_vni=1, max_vni=5) + _, vni3 = self._allocate(min_vni=1, max_vni=5) + self.assertEqual(1, vni1) + self.assertEqual(2, vni2) + self.assertEqual(3, vni3) + + self._delete(alloc_id1) + _, reused = self._allocate(min_vni=1, max_vni=5) + self.assertEqual(1, reused) + + def test_allocate_skips_existing(self): + # Pre-populate with a gap: 1, 3 — allocator should return 2 + self._insert(1) + self._insert(3) + _, vni = self._allocate(min_vni=1, max_vni=5) + self.assertEqual(2, vni) + + def test_allocate_above_contiguous_block(self): + self._insert(1) + self._insert(2) + self._insert(3) + _, vni = self._allocate(min_vni=1, max_vni=5) + self.assertEqual(4, vni) + + def test_allocate_range_exhausted_raises(self): + self._insert(1) + self._insert(2) + self._insert(3) + self.assertRaises( + evpn_exc.EVPNNoVniAvailable, + self._allocate, min_vni=1, max_vni=3) + + def test_allocate_scope_isolated(self): + # Filling _PHYSNET should not affect _OTHER_PHYSNET allocation + self._insert(1, physnet=_PHYSNET) + self._insert(2, physnet=_PHYSNET) + + _, vni = self._allocate(min_vni=1, max_vni=5, physnet=_OTHER_PHYSNET) + self.assertEqual(1, vni) + + def test_allocate_scope_does_not_cross_contaminate(self): + # Allocating in one scope leaves the other untouched + self._allocate(min_vni=1, max_vni=5, physnet=_PHYSNET) + self._allocate(min_vni=1, max_vni=5, physnet=_OTHER_PHYSNET) + + self.assertEqual([1], self._all_vnis(_PHYSNET)) + self.assertEqual([1], self._all_vnis(_OTHER_PHYSNET)) + + def test_allocation_id_is_usable_as_foreign_key(self): + # allocation_id must be a valid surrogate PK for use in + # evpn_l3_instances.allocation_id + alloc_id, vni = self._allocate() + self.assertIsNotNone(alloc_id) + with db_api.CONTEXT_READER.using(self.ctx): + row = self.ctx.session.execute( + sa.select(self.table).where(self.table.c.id == alloc_id) + ).fetchone() + self.assertIsNotNone(row) + self.assertEqual(vni, row.vni) + + def test_custom_allocator_generic(self): + """RangeAllocator works with any table matching the contract.""" + # Build a minimal in-memory table to confirm the allocator is + # not tied to VXLANVNIAllocation specifically. + meta = sa.MetaData() + test_table = sa.Table( + 'test_alloc_generic', meta, + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('val', sa.Integer, nullable=False), + sa.Column('scope', sa.String(64), nullable=False), + sa.UniqueConstraint('val', 'scope'), + ) + with db_api.CONTEXT_WRITER.using(self.ctx): + test_table.create(self.ctx.session.get_bind()) + + alloc = rangeallocator.RangeAllocator( + table=test_table, + value_col_name='val', + scope_col_name='scope', + scope_param_type=sa.String, + exception_class=evpn_exc.EVPNNoVniAvailable, + ) + + with db_api.CONTEXT_WRITER.using(self.ctx): + alloc_id, val = alloc.allocate(self.ctx, 5, 10, 'myscope') + self.assertEqual(5, val) + self.assertIsNotNone(alloc_id) + + +class TestRangeAllocatorMySQL(testlib_api.MySQLTestCaseMixin, + TestRangeAllocator): + """Re-runs the full suite against MySQL (LAST_INSERT_ID path). + + Skipped automatically if MySQL is unavailable. + """ + + def test_engine_is_mysql(self): + # @@version_comment is a MySQL/MariaDB system variable that does + # not exist in SQLite. If this query succeeds the test is + # genuinely running against MySQL/MariaDB. + with db_api.CONTEXT_READER.using(self.ctx): + row = self.ctx.session.execute( + sa.text('SELECT @@version_comment')).fetchone() + self.assertIsNotNone(row) + + def test_allocate_concurrent_no_duplicates(self): + """Two threads allocating simultaneously must get distinct VNIs. + + Exercises the UNIQUE constraint race under real concurrent writes. + SQLite serialises writes so this test only runs against MySQL where + true concurrency and deadlocks can occur. retry_db_errors handles + DBDeadlock and DBDuplicateEntry transparently. + """ + results = [] + + @db_api.retry_db_errors + def allocate(): + ctx = context.Context( + user_id=None, project_id=None, + is_admin=True, overwrite=False) + with db_api.CONTEXT_WRITER.using(ctx): + alloc_id, vni = self.allocator.allocate( + ctx, 1, 10, _PHYSNET) + results.append(vni) + + with futures.ThreadPoolExecutor(max_workers=2) as pool: + futs = [pool.submit(allocate), pool.submit(allocate)] + for f in futures.as_completed(futs): + f.result() + + self.assertEqual(2, len(results)) + self.assertEqual(2, len(set(results)), "concurrent allocations must " + "produce distinct VNIs, got %s" % results) diff --git a/neutron/tests/unit/db/test_evpn_db.py b/neutron/tests/unit/db/test_evpn_db.py index fe6cce6798f..44c01b06b01 100644 --- a/neutron/tests/unit/db/test_evpn_db.py +++ b/neutron/tests/unit/db/test_evpn_db.py @@ -145,6 +145,37 @@ def test_router_interface_add_without_advertise_host(self): ).one_or_none() self.assertIsNone(evpn_net) + def test_router_create_auto_vni(self): + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as router: + vni = router['router'][evpn_apidef.EVPN_VNI] + self.assertIsNotNone(vni) + self.assertGreater(vni, 0) + + def test_router_create_auto_vni_twice_distinct(self): + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as r1, \ + self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as r2: + self.assertNotEqual(r1['router'][evpn_apidef.EVPN_VNI], + r2['router'][evpn_apidef.EVPN_VNI]) + + def test_router_create_auto_vni_reuses_freed_slot(self): + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as router: + first_vni = router['router'][evpn_apidef.EVPN_VNI] + self._delete('routers', router['router']['id']) + + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as router: + self.assertEqual(first_vni, + router['router'][evpn_apidef.EVPN_VNI]) + def test_router_interface_remove_cleans_evpn_network(self): with self.router(as_admin=True, arg_list=(evpn_apidef.EVPN_VNI,), diff --git a/neutron/tests/unit/db/test_vni_vlan_allocator.py b/neutron/tests/unit/db/test_vni_vlan_allocator.py new file mode 100644 index 00000000000..99ab940d0d8 --- /dev/null +++ b/neutron/tests/unit/db/test_vni_vlan_allocator.py @@ -0,0 +1,151 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron_lib import context +from neutron_lib.db import api as db_api + +from neutron.db.models import vxlan_vlan_allocations as alloc_models +from neutron.db import vni_vlan_allocator +from neutron.services.evpn import exceptions as evpn_exc +from neutron.tests.unit import testlib_api + +load_tests = testlib_api.module_load_tests + +_PHYSNET = 'test-physnet' +_OTHER_PHYSNET = 'other-physnet' +_MIN_VNI = 1 +_MAX_VNI = 100 +_MIN_VLAN = 1 +_MAX_VLAN = 50 + + +class TestVNIVLANAllocator(testlib_api.SqlTestCase): + + def setUp(self): + super().setUp() + self.ctx = context.Context( + user_id=None, project_id=None, is_admin=True, overwrite=False) + self.allocator = vni_vlan_allocator.VNIVLANAllocator( + vni_exhausted_exc=evpn_exc.EVPNNoVniAvailable, + vlan_exhausted_exc=evpn_exc.EVPNNoVlanAvailable, + vni_in_use_exc=evpn_exc.EVPNVNIInUse, + ) + + def _allocate(self, min_vni=_MIN_VNI, max_vni=_MAX_VNI, + min_vlan=_MIN_VLAN, max_vlan=_MAX_VLAN, + physnet=_PHYSNET): + with db_api.CONTEXT_WRITER.using(self.ctx): + return self.allocator.allocate( + self.ctx, min_vni, max_vni, min_vlan, max_vlan, physnet) + + def _allocate_specific(self, vni, min_vlan=_MIN_VLAN, max_vlan=_MAX_VLAN, + physnet=_PHYSNET): + with db_api.CONTEXT_WRITER.using(self.ctx): + return self.allocator.allocate_specific_vni( + self.ctx, vni, min_vlan, max_vlan, physnet) + + def _deallocate(self, mapping_id): + with db_api.CONTEXT_WRITER.using(self.ctx): + self.allocator.deallocate(self.ctx, mapping_id) + + def _get_mapping(self, mapping_id): + with db_api.CONTEXT_READER.using(self.ctx): + return self.ctx.session.query( + alloc_models.VNIVLANMapping + ).filter_by(id=mapping_id).one_or_none() + + def _count_vni_allocations(self, physnet=_PHYSNET): + with db_api.CONTEXT_READER.using(self.ctx): + return self.ctx.session.query( + alloc_models.VNIAllocation + ).filter_by(physnet=physnet).count() + + def _count_vlan_allocations(self, physnet=_PHYSNET): + with db_api.CONTEXT_READER.using(self.ctx): + return self.ctx.session.query( + alloc_models.VLANAllocation + ).filter_by(physnet=physnet).count() + + def test_allocate_returns_valid_tuple(self): + mapping_id, vni, vlan_id = self._allocate() + self.assertIsNotNone(mapping_id) + self.assertGreaterEqual(vni, _MIN_VNI) + self.assertLessEqual(vni, _MAX_VNI) + self.assertGreaterEqual(vlan_id, _MIN_VLAN) + self.assertLessEqual(vlan_id, _MAX_VLAN) + + def test_allocate_creates_mapping_row(self): + mapping_id, vni, vlan_id = self._allocate() + mapping = self._get_mapping(mapping_id) + self.assertIsNotNone(mapping) + self.assertEqual(vni, mapping.vni_allocation.vni) + self.assertEqual(vlan_id, mapping.vlan_allocation.vlan_id) + + def test_allocate_sequential_distinct(self): + _, vni1, vlan1 = self._allocate() + _, vni2, vlan2 = self._allocate() + self.assertNotEqual(vni1, vni2) + self.assertNotEqual(vlan1, vlan2) + + def test_allocate_specific_vni_uses_requested_value(self): + mapping_id, vni, vlan_id = self._allocate_specific(42) + self.assertEqual(42, vni) + self.assertIsNotNone(mapping_id) + self.assertGreaterEqual(vlan_id, _MIN_VLAN) + + def test_allocate_specific_vni_duplicate_raises(self): + self._allocate_specific(42) + self.assertRaises( + evpn_exc.EVPNVNIInUse, self._allocate_specific, 42) + + def test_allocate_vni_exhausted_raises(self): + self._allocate(min_vni=1, max_vni=1) + self.assertRaises( + evpn_exc.EVPNNoVniAvailable, + self._allocate, min_vni=1, max_vni=1) + + def test_allocate_vlan_exhausted_raises(self): + self._allocate(min_vlan=1, max_vlan=1) + self.assertRaises( + evpn_exc.EVPNNoVlanAvailable, + self._allocate, min_vlan=1, max_vlan=1) + + def test_deallocate_removes_all_rows(self): + mapping_id, _, _ = self._allocate() + self._deallocate(mapping_id) + + self.assertIsNone(self._get_mapping(mapping_id)) + self.assertEqual(0, self._count_vni_allocations()) + self.assertEqual(0, self._count_vlan_allocations()) + + def test_deallocate_nonexistent_is_safe(self): + self._deallocate(99999) + + def test_allocate_scoped_by_physnet(self): + mapping_a, vni_a, _ = self._allocate(min_vni=1, max_vni=1, + physnet=_PHYSNET) + mapping_b, vni_b, _ = self._allocate(min_vni=1, max_vni=1, + physnet=_OTHER_PHYSNET) + self.assertEqual(vni_a, vni_b) + self.assertNotEqual(mapping_a, mapping_b) + + def test_deallocate_then_reallocate_reuses_slot(self): + mapping_id, vni, vlan = self._allocate(min_vni=1, max_vni=1, + min_vlan=1, max_vlan=1) + self._deallocate(mapping_id) + _mapping_id2, vni2, vlan2 = self._allocate(min_vni=1, max_vni=1, + min_vlan=1, max_vlan=1) + self.assertEqual(vni, vni2) + self.assertEqual(vlan, vlan2) From c695003d1012b911aa7c3f604d6d169d2567521c Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 3 Jun 2026 15:02:41 +0200 Subject: [PATCH 06/32] Use idl.has_lock instead of idl.is_lock_contended for OVSDB lock checks The OVN maintenance worker and the BGP topology reconciler used ``not idl.is_lock_contended`` to determine whether the current process holds the OVSDB lock. This is incorrect because ``is_lock_contended`` and ``has_lock`` are two independent boolean flags in the OVS IDL, not complementary ones. When the lock has been requested but the server has not yet replied, both flags are ``False``, so ``not is_lock_contended`` evaluates to ``True`` even though the lock is not held. During neutron-server startup or OVSDB reconnection, this race window could allow maintenance tasks (configured with ``run_immediately=True``) or BGP topology synchronization to be processed by a worker that does not actually own the lock, potentially causing duplicate or conflicting operations against the OVN Northbound DB. Replace all occurrences with ``idl.has_lock``, which is only ``True`` when the server has explicitly confirmed lock ownership. This is consistent with the approach already used in the BGP service IDL (``neutron/services/bgp/ovn.py``) and with the semantics documented in the upstream OVS IDL class. Closes-Bug: #2155155 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I80e74a399b7c3420baf49e0cbc50ddfee0a070e0 --- .../ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py | 2 +- neutron/services/bgp/reconciler.py | 2 +- .../ovn/mech_driver/ovsdb/test_maintenance.py | 5 ++++- ...ock-check-is-lock-contended-c52bee5f582babad.yaml | 12 ++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/fix-ovn-lock-check-is-lock-contended-c52bee5f582babad.yaml diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index faddf15adc0..792a9e73f61 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -293,7 +293,7 @@ def __init__(self, ovn_client): @property def has_lock(self): - return not self._idl.is_lock_contended + return self._idl.has_lock def nbdb_schema_updated_hook(self): if not self.has_lock: diff --git a/neutron/services/bgp/reconciler.py b/neutron/services/bgp/reconciler.py index d1e1311728c..abd3423cea3 100644 --- a/neutron/services/bgp/reconciler.py +++ b/neutron/services/bgp/reconciler.py @@ -89,7 +89,7 @@ def full_sync(self): LOG.info("Waiting for BGP topology reconciler to start") self._started.wait() LOG.info("BGP topology reconciler is ready") - if not self.nb_api.ovsdb_connection.idl.is_lock_contended: + if self.nb_api.ovsdb_connection.idl.has_lock: LOG.info("Full BGP topology synchronization started") # First make sure all chassis are indexed commands.FullSyncBGPTopologyCommand( diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index e3366414f18..71c46a1cd4e 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -59,8 +59,11 @@ def setUp(self): ext_mgr = test_extraroute.ExtraRouteTestExtensionManager() self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr) self.maint = maintenance.DBInconsistenciesPeriodics(self._ovn_client) - # Release the unneeded lock + # Release the unneeded lock and simulate holding it for tests self.maint._idl.set_lock(None) + mock.patch.object(type(self.maint), 'has_lock', + new_callable=mock.PropertyMock, + return_value=True).start() self.context = n_context.get_admin_context() # Always verify inconsistencies for all objects. db_rev.INCONSISTENCIES_OLDER_THAN = -1 diff --git a/releasenotes/notes/fix-ovn-lock-check-is-lock-contended-c52bee5f582babad.yaml b/releasenotes/notes/fix-ovn-lock-check-is-lock-contended-c52bee5f582babad.yaml new file mode 100644 index 00000000000..350a0b83661 --- /dev/null +++ b/releasenotes/notes/fix-ovn-lock-check-is-lock-contended-c52bee5f582babad.yaml @@ -0,0 +1,12 @@ +--- +fixes: + - | + Fixed the OVN maintenance worker and the BGP topology reconciler + to use ``idl.has_lock`` instead of ``not idl.is_lock_contended`` + when checking OVSDB lock ownership. The two IDL flags are not + complementary: both are ``False`` when the lock has been requested + but the server has not yet replied, causing the old check to + incorrectly report the lock as held. This could lead to maintenance + tasks or BGP topology synchronization being processed by a worker + that does not actually own the lock, especially during startup or + OVSDB reconnection. From b50aa8525d9f91b70f7847a86f74b1a7173041ef Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Thu, 4 Jun 2026 23:01:08 +0900 Subject: [PATCH 07/32] Validate [DEFAULT] setproctitle while loading config files ... to detect unsupported values early. Also use the native interface to document available choices. Change-Id: I43848aebddc1819101f969a452560b5b37dc839b Signed-off-by: Takashi Kajinami --- neutron/conf/common.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/neutron/conf/common.py b/neutron/conf/common.py index 78a3052a1ea..e2f95b9dcbc 100644 --- a/neutron/conf/common.py +++ b/neutron/conf/common.py @@ -114,13 +114,15 @@ cfg.IntOpt('send_events_interval', default=2, help=_('Number of seconds between sending events to Nova if ' 'there are any events to send.')), - cfg.StrOpt('setproctitle', default='on', - help=_("Set process name to match child worker role. " - "Available options are: 'off' - retains the previous " - "behavior; 'on' - renames processes to " - "'neutron-server: role (original string)'; " - "'brief' - renames the same as 'on', but without the " - "original string, such as 'neutron-server: role'.")), + cfg.StrOpt('setproctitle', + default='on', + choices=[('off', 'retains the previous behavior'), + ('on', 'renames processes to \'neutron-server: role ' + '(original string)\''), + ('brief', 'renames the same as \'on\', but without ' + 'the original string, such as ' + '\'neutron-server: role\'')], + help=_("Set process name to match child worker role.")), cfg.StrOpt('ipam_driver', default='internal', help=_("Neutron IPAM (IP address management) driver to use. " "By default, the reference implementation of the " From db196cfd91950390ec3f461a1a21d67960908211 Mon Sep 17 00:00:00 2001 From: Elvira Garcia Date: Wed, 3 Jun 2026 10:43:18 +0200 Subject: [PATCH 08/32] Fix Update response after updating PVLAN properties Previously, PUT responses would input the old values of pvlan, pvlan_type and pvlan_community. The cause of this is that in plugin.py, _make_port_dict is calling _extend_port_pvlan before the DB is actually updated, so the old values are the ones being used. The modification of the desired state solves this timing issue. Closes-Bug: #2155636 Assisted-By: Claude Opus 4.6 Change-Id: I0d2b10a2ad8ece4738a4c2225dee42ad55740003 Signed-off-by: Elvira Garcia --- neutron/services/pvlan/pvlan_plugin.py | 10 ++++++++ .../unit/services/pvlan/test_pvlan_plugin.py | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/neutron/services/pvlan/pvlan_plugin.py b/neutron/services/pvlan/pvlan_plugin.py index 4460eb9f0fe..f5bc119e5c0 100644 --- a/neutron/services/pvlan/pvlan_plugin.py +++ b/neutron/services/pvlan/pvlan_plugin.py @@ -146,6 +146,10 @@ def pvlan_network_update(self, resource, event, trigger, payload=None): context, network_id=network_id, pvlan=enable_pvlan ).create() + # Update the desired state for a correct PUT response. + if payload and payload.desired_state: + payload.desired_state[pvlan_const.PVLAN] = enable_pvlan + def _pvlan_port_driver_update(self, resource, event, trigger, payload=None, **kwargs): """Call the driver after the port is created, updated or deleted.""" @@ -287,6 +291,12 @@ def _pvlan_port_update(self, payload=None, port=None, network=None, pvlan_type=pvlan_type, pvlan_community=pvlan_community, ).create() + + # Update the desired state for a correct PUT response. + if payload and payload.desired_state: + payload.desired_state[pvlan_const.PVLAN_TYPE] = pvlan_type + payload.desired_state[pvlan_const.PVLAN_COMMUNITY] = ( + pvlan_community) return True def _check_port_security(self, network, port_data): diff --git a/neutron/tests/unit/services/pvlan/test_pvlan_plugin.py b/neutron/tests/unit/services/pvlan/test_pvlan_plugin.py index 65144b2d0a6..617bf3fa429 100644 --- a/neutron/tests/unit/services/pvlan/test_pvlan_plugin.py +++ b/neutron/tests/unit/services/pvlan/test_pvlan_plugin.py @@ -54,6 +54,8 @@ def _make_payload(self, resource_id, request_body=None, payload.context.session = mock.Mock() payload.resource_id = resource_id payload.request_body = request_body or {} + payload.desired_state = None + payload.states = [{}] if network_id: payload.metadata = {'network_id': network_id} return payload @@ -426,3 +428,25 @@ def test_port_update_community_name_change(self): {'pvlan_type': pvlan_const.COMMUNITY_TYPE, 'pvlan_community': 'new_comm'}, port_id=port_id) + + def test_port_update_desired_state_reflects_new_pvlan(self): + port_id = uuidutils.generate_uuid() + network_id = uuidutils.generate_uuid() + payload = self._make_payload( + port_id, + request_body={ + pvlan_const.PVLAN_TYPE: pvlan_const.ISOLATED_TYPE}) + payload.desired_state = { + pvlan_const.PVLAN_TYPE: pvlan_const.PROMISCUOUS_TYPE, + pvlan_const.PVLAN_COMMUNITY: None} + + mocks, _ = self._mock_port_and_network( + port_id, network_id, + pvlan_type=pvlan_const.PROMISCUOUS_TYPE, + network_pvlan=True) + with mocks['port_obj'], mocks['net_obj'], mocks['portpvlan_cls']: + self.plugin._pvlan_port_update(payload=payload) + self.assertEqual(pvlan_const.ISOLATED_TYPE, + payload.desired_state[pvlan_const.PVLAN_TYPE]) + self.assertIsNone( + payload.desired_state[pvlan_const.PVLAN_COMMUNITY]) From f0aad944fbfe2e5864e0b29f439719b02f9d7ff9 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Mon, 8 Jun 2026 15:36:43 +0200 Subject: [PATCH 09/32] Use policy check strings from neutron-lib Those base check strings were moved to neutron-lib with patch [1]. [1] https://review.opendev.org/c/openstack/neutron-lib/+/983214 Related-bug: #2143895 Change-Id: Ifaf22a010911de9c1dc7ccef6fd2aaaf73875b1d Signed-off-by: Slawek Kaplonski --- neutron/conf/policies/address_group.py | 15 ++- neutron/conf/policies/address_scope.py | 17 ++- neutron/conf/policies/agent.py | 29 +++-- .../conf/policies/auto_allocated_topology.py | 7 +- neutron/conf/policies/base.py | 62 +--------- .../policies/default_security_group_rules.py | 7 +- neutron/conf/policies/evpn.py | 7 +- neutron/conf/policies/flavor.py | 23 ++-- neutron/conf/policies/floatingip.py | 29 +++-- neutron/conf/policies/floatingip_pools.py | 5 +- .../policies/floatingip_port_forwarding.py | 11 +- neutron/conf/policies/l3_conntrack_helper.py | 11 +- neutron/conf/policies/local_ip.py | 11 +- neutron/conf/policies/local_ip_association.py | 9 +- neutron/conf/policies/logging.py | 13 +- neutron/conf/policies/metering.py | 15 ++- neutron/conf/policies/ndp_proxy.py | 11 +- neutron/conf/policies/network.py | 69 +++++------ .../conf/policies/network_ip_availability.py | 5 +- .../conf/policies/network_segment_range.py | 27 ++-- neutron/conf/policies/port.py | 117 +++++++++--------- neutron/conf/policies/port_bindings.py | 11 +- neutron/conf/policies/qos.py | 85 +++++++------ neutron/conf/policies/quotas.py | 9 +- neutron/conf/policies/rbac.py | 19 ++- neutron/conf/policies/router.py | 95 +++++++------- neutron/conf/policies/security_group.py | 25 ++-- neutron/conf/policies/segment.py | 19 ++- neutron/conf/policies/subnet.py | 35 +++--- neutron/conf/policies/subnetpool.py | 39 +++--- neutron/conf/policies/trunk.py | 33 +++-- 31 files changed, 397 insertions(+), 473 deletions(-) diff --git a/neutron/conf/policies/address_group.py b/neutron/conf/policies/address_group.py index c6ac4ae0d0d..32cf50c8e7e 100644 --- a/neutron/conf/policies/address_group.py +++ b/neutron/conf/policies/address_group.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - AG_COLLECTION_PATH = '/address-groups' AG_RESOURCE_PATH = '/address-groups/{id}' @@ -34,7 +33,7 @@ ), policy.DocumentedRuleDefault( name='create_address_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create an address group', operations=[ { @@ -52,7 +51,7 @@ policy.DocumentedRuleDefault( name='get_address_group', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_address_groups'), description='Get an address group', operations=[ @@ -76,7 +75,7 @@ ), policy.DocumentedRuleDefault( name='update_address_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update an address group', operations=[ { @@ -93,7 +92,7 @@ ), policy.DocumentedRuleDefault( name='delete_address_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete an address group', operations=[ { @@ -110,7 +109,7 @@ ), policy.DocumentedRuleDefault( name='add_addresses', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Add addresses to an address group', operations=[ { @@ -127,7 +126,7 @@ ), policy.DocumentedRuleDefault( name='remove_addresses', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Remove addresses from an address group', operations=[ { diff --git a/neutron/conf/policies/address_scope.py b/neutron/conf/policies/address_scope.py index a376376a905..b2bfc0e493f 100644 --- a/neutron/conf/policies/address_scope.py +++ b/neutron/conf/policies/address_scope.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/address-scopes' RESOURCE_PATH = '/address-scopes/{id}' @@ -32,7 +31,7 @@ ), policy.DocumentedRuleDefault( name='create_address_scope', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create an address scope', operations=[ { @@ -49,7 +48,7 @@ ), policy.DocumentedRuleDefault( name='create_address_scope:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create a shared address scope', operations=[ { @@ -67,8 +66,8 @@ policy.DocumentedRuleDefault( name='get_address_scope', check_str=neutron_policy.policy_or( - base.ADMIN, - base.PROJECT_READER, + lib_rules.ADMIN, + lib_rules.PROJECT_READER, 'rule:shared_address_scopes'), description='Get an address scope', operations=[ @@ -92,7 +91,7 @@ ), policy.DocumentedRuleDefault( name='update_address_scope', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update an address scope', operations=[ { @@ -109,7 +108,7 @@ ), policy.DocumentedRuleDefault( name='update_address_scope:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update ``shared`` attribute of an address scope', operations=[ { @@ -126,7 +125,7 @@ ), policy.DocumentedRuleDefault( name='delete_address_scope', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete an address scope', operations=[ { diff --git a/neutron/conf/policies/agent.py b/neutron/conf/policies/agent.py index 5cd40265226..1a44d2cb60f 100644 --- a/neutron/conf/policies/agent.py +++ b/neutron/conf/policies/agent.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/agents' RESOURCE_PATH = '/agents/{id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create an agent', operations=[ { @@ -38,7 +37,7 @@ ), policy.DocumentedRuleDefault( name='get_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Get an agent', operations=[ { @@ -59,7 +58,7 @@ ), policy.DocumentedRuleDefault( name='update_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update an agent', operations=[ { @@ -76,7 +75,7 @@ ), policy.DocumentedRuleDefault( name='delete_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Delete an agent', operations=[ { @@ -93,7 +92,7 @@ ), policy.DocumentedRuleDefault( name='create_dhcp-network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Add a network to a DHCP agent', operations=[ { @@ -110,7 +109,7 @@ ), policy.DocumentedRuleDefault( name='get_dhcp-networks', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List networks on a DHCP agent', operations=[ { @@ -127,7 +126,7 @@ ), policy.DocumentedRuleDefault( name='delete_dhcp-network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Remove a network from a DHCP agent', operations=[ { @@ -144,7 +143,7 @@ ), policy.DocumentedRuleDefault( name='create_l3-router', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Add a router to an L3 agent', operations=[ { @@ -161,7 +160,7 @@ ), policy.DocumentedRuleDefault( name='get_l3-routers', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List routers on an L3 agent', operations=[ { @@ -178,7 +177,7 @@ ), policy.DocumentedRuleDefault( name='update_l3-router', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update a router in an L3 agent', operations=[ { @@ -195,7 +194,7 @@ ), policy.DocumentedRuleDefault( name='delete_l3-router', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Remove a router from an L3 agent', operations=[ { @@ -212,7 +211,7 @@ ), policy.DocumentedRuleDefault( name='get_dhcp-agents', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List DHCP agents hosting a network', operations=[ { @@ -229,7 +228,7 @@ ), policy.DocumentedRuleDefault( name='get_l3-agents', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List L3 agents hosting a router', operations=[ { diff --git a/neutron/conf/policies/auto_allocated_topology.py b/neutron/conf/policies/auto_allocated_topology.py index 00977a0c4e1..e6c044b924c 100644 --- a/neutron/conf/policies/auto_allocated_topology.py +++ b/neutron/conf/policies/auto_allocated_topology.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - RESOURCE_PATH = '/auto-allocated-topology/{project_id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_auto_allocated_topology', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description="Get a project's auto-allocated topology", operations=[ { @@ -43,7 +42,7 @@ ), policy.DocumentedRuleDefault( name='delete_auto_allocated_topology', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description="Delete a project's auto-allocated topology", operations=[ { diff --git a/neutron/conf/policies/base.py b/neutron/conf/policies/base.py index 63765c80154..5f861718e22 100644 --- a/neutron/conf/policies/base.py +++ b/neutron/conf/policies/base.py @@ -11,65 +11,13 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -# This role is used only for communication between services, it shouldn't be -# used by human users -SERVICE = 'rule:service_api' - -# For completion of the phase 1 -# https://governance.openstack.org/tc/goals/selected/consistent-and-secure-rbac.html#phase-1 -# there is now ADMIN role -ADMIN = "rule:admin_only" - -# This check string is the primary use case for the project's manager who is -# more privileged user then typical MEMBER of the project. -PROJECT_MANAGER = 'role:manager and project_id:%(project_id)s' - -# This check string is the primary use case for typical end-users, who are -# working with resources that belong to a project (e.g., creating ports and -# routers). -PROJECT_MEMBER = 'role:member and project_id:%(project_id)s' - -# This check string should only be used to protect read-only project-specific -# resources. It should not be used to protect APIs that make writable changes -# (e.g., updating a router or deleting a port). -PROJECT_READER = 'role:reader and project_id:%(project_id)s' - -# The following are common composite check strings that are useful for -# protecting APIs designed to operate with multiple scopes (e.g., -# an administrator should be able to delete any router in the deployment, a -# project member should only be able to delete routers in their project). -ADMIN_OR_SERVICE = ( - '(' + ADMIN + ') or (' + SERVICE + ')') -ADMIN_OR_PROJECT_MANAGER = ( - '(' + ADMIN + ') or (' + PROJECT_MANAGER + ')') -ADMIN_OR_PROJECT_MEMBER = ( - '(' + ADMIN + ') or (' + PROJECT_MEMBER + ')') -ADMIN_OR_PROJECT_READER = ( - '(' + ADMIN + ') or (' + PROJECT_READER + ')') - # Additional rules needed in Neutron RULE_NET_OWNER = 'rule:network_owner' -RULE_PARENT_OWNER = 'rule:ext_parent_owner' RULE_SG_OWNER = 'rule:sg_owner' -# In some cases we need to check owner of the parent resource, it's like that -# for example for QoS rules (check owner of QoS policy rule belongs to) or -# Floating IP port forwarding (check owner of FIP which PF is using). It's like -# that becasue those resources (QOS rules, FIP PFs) don't have project_id -# attribute at all and they belongs to the same project as parent resource (QoS -# policy, FIP). -PARENT_OWNER_MANAGER = 'role:manager and ' + RULE_PARENT_OWNER -PARENT_OWNER_MEMBER = 'role:member and ' + RULE_PARENT_OWNER -PARENT_OWNER_READER = 'role:reader and ' + RULE_PARENT_OWNER -ADMIN_OR_PARENT_OWNER_MANAGER = ( - '(' + ADMIN + ') or (' + PARENT_OWNER_MANAGER + ')') -ADMIN_OR_PARENT_OWNER_MEMBER = ( - '(' + ADMIN + ') or (' + PARENT_OWNER_MEMBER + ')') -ADMIN_OR_PARENT_OWNER_READER = ( - '(' + ADMIN + ') or (' + PARENT_OWNER_READER + ')') - # Those rules related to the network owner are very similar (almost the same) # as parent owner defined above. The only reason why they are kept here is that # in case of some resources like ports or subnets neutron have got policies @@ -80,9 +28,9 @@ NET_OWNER_MEMBER = 'role:member and ' + RULE_NET_OWNER NET_OWNER_READER = 'role:reader and ' + RULE_NET_OWNER ADMIN_OR_NET_OWNER_MEMBER = ( - '(' + ADMIN + ') or (' + NET_OWNER_MEMBER + ')') + '(' + lib_rules.ADMIN + ') or (' + NET_OWNER_MEMBER + ')') ADMIN_OR_NET_OWNER_READER = ( - '(' + ADMIN + ') or (' + NET_OWNER_READER + ')') + '(' + lib_rules.ADMIN + ') or (' + NET_OWNER_READER + ')') # Those rules for the SG owner are needed for the policies related to the # Security Group rules and are very similar to the parent owner rules defined @@ -94,9 +42,9 @@ SG_OWNER_MEMBER = 'role:member and ' + RULE_SG_OWNER SG_OWNER_READER = 'role:reader and ' + RULE_SG_OWNER ADMIN_OR_SG_OWNER_MEMBER = ( - '(' + ADMIN + ') or (' + SG_OWNER_MEMBER + ')') + '(' + lib_rules.ADMIN + ') or (' + SG_OWNER_MEMBER + ')') ADMIN_OR_SG_OWNER_READER = ( - '(' + ADMIN + ') or (' + SG_OWNER_READER + ')') + '(' + lib_rules.ADMIN + ') or (' + SG_OWNER_READER + ')') rules = [ policy.RuleDefault( diff --git a/neutron/conf/policies/default_security_group_rules.py b/neutron/conf/policies/default_security_group_rules.py index 23824f1e6da..8145fa633ec 100644 --- a/neutron/conf/policies/default_security_group_rules.py +++ b/neutron/conf/policies/default_security_group_rules.py @@ -11,10 +11,9 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = ( "The default security group rules API supports " "system scope and default roles.") @@ -27,7 +26,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_default_security_group_rule', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a templated of the security group rule', operations=[ @@ -69,7 +68,7 @@ ), policy.DocumentedRuleDefault( name='delete_default_security_group_rule', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete a templated of the security group rule', operations=[ diff --git a/neutron/conf/policies/evpn.py b/neutron/conf/policies/evpn.py index 5e23950a181..d41ac99537a 100644 --- a/neutron/conf/policies/evpn.py +++ b/neutron/conf/policies/evpn.py @@ -10,10 +10,9 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/routers' RESOURCE_PATH = '/routers/{id}' @@ -29,14 +28,14 @@ rules = [ policy.DocumentedRuleDefault( name='create_router:evpn_vni', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``evpn_vni`` attribute when creating a router', operations=ACTION_POST, ), policy.DocumentedRuleDefault( name='get_router:evpn_vni', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get ``evpn_vni`` attribute of a router', operations=ACTION_GET, diff --git a/neutron/conf/policies/flavor.py b/neutron/conf/policies/flavor.py index 6540d10aba7..2b43c3e4e70 100644 --- a/neutron/conf/policies/flavor.py +++ b/neutron/conf/policies/flavor.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - FLAVOR_COLLECTION_PATH = '/flavors' FLAVOR_RESOURCE_PATH = '/flavors/{id}' @@ -31,7 +30,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_flavor', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create a flavor', operations=[ { @@ -73,7 +72,7 @@ ), policy.DocumentedRuleDefault( name='update_flavor', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update a flavor', operations=[ { @@ -90,7 +89,7 @@ ), policy.DocumentedRuleDefault( name='delete_flavor', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Delete a flavor', operations=[ { @@ -108,7 +107,7 @@ policy.DocumentedRuleDefault( name='create_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create a service profile', operations=[ { @@ -125,7 +124,7 @@ ), policy.DocumentedRuleDefault( name='get_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Get a service profile', operations=[ { @@ -146,7 +145,7 @@ ), policy.DocumentedRuleDefault( name='update_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update a service profile', operations=[ { @@ -163,7 +162,7 @@ ), policy.DocumentedRuleDefault( name='delete_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Delete a service profile', operations=[ { @@ -181,7 +180,7 @@ policy.RuleDefault( name='get_flavor_service_profile', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description=( 'Get a flavor associated with a given service profiles. ' 'There is no corresponding GET operations in API currently. ' @@ -196,7 +195,7 @@ ), policy.DocumentedRuleDefault( name='create_flavor_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Associate a flavor with a service profile', operations=[ { @@ -213,7 +212,7 @@ ), policy.DocumentedRuleDefault( name='delete_flavor_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Disassociate a flavor with a service profile', operations=[ { diff --git a/neutron/conf/policies/floatingip.py b/neutron/conf/policies/floatingip.py index e2cad4a005b..c4519181a0f 100644 --- a/neutron/conf/policies/floatingip.py +++ b/neutron/conf/policies/floatingip.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/floatingips' RESOURCE_PATH = '/floatingips/{id}' @@ -44,7 +43,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_floatingip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create a floating IP', operations=[ { @@ -61,7 +60,7 @@ ), policy.DocumentedRuleDefault( name='create_floatingip:floating_ip_address', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, description='Create a floating IP with a specific IP address', operations=[ { @@ -78,19 +77,19 @@ ), policy.DocumentedRuleDefault( name='create_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create the floating IP tags', operations=ACTION_POST_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='create_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_floatingip', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get a floating IP', operations=[ { @@ -111,20 +110,20 @@ ), policy.DocumentedRuleDefault( name='get_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get the floating IP tags', operations=ACTION_GET_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='get_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_floatingip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update a floating IP', operations=[ { @@ -141,20 +140,20 @@ ), policy.DocumentedRuleDefault( name='update_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update the floating IP tags', operations=ACTION_PUT_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='update_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_floatingip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete a floating IP', operations=[ { @@ -171,13 +170,13 @@ ), policy.DocumentedRuleDefault( name='delete_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete the floating IP tags', operations=ACTION_DELETE_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='delete_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), diff --git a/neutron/conf/policies/floatingip_pools.py b/neutron/conf/policies/floatingip_pools.py index ecea2030b3e..f71c8e25fef 100644 --- a/neutron/conf/policies/floatingip_pools.py +++ b/neutron/conf/policies/floatingip_pools.py @@ -11,18 +11,17 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = ( "The Floating IP Pool API now supports system scope and default roles.") rules = [ policy.DocumentedRuleDefault( name='get_floatingip_pool', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get floating IP pools', operations=[ { diff --git a/neutron/conf/policies/floatingip_port_forwarding.py b/neutron/conf/policies/floatingip_port_forwarding.py index 685cb45174e..f523499daf3 100644 --- a/neutron/conf/policies/floatingip_port_forwarding.py +++ b/neutron/conf/policies/floatingip_port_forwarding.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The floating IP port forwarding API now supports system scope and default @@ -30,7 +29,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a floating IP port forwarding', operations=[ @@ -47,7 +46,7 @@ ), policy.DocumentedRuleDefault( name='get_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a floating IP port forwarding', operations=[ @@ -68,7 +67,7 @@ ), policy.DocumentedRuleDefault( name='update_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Update a floating IP port forwarding', operations=[ @@ -85,7 +84,7 @@ ), policy.DocumentedRuleDefault( name='delete_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a floating IP port forwarding', operations=[ diff --git a/neutron/conf/policies/l3_conntrack_helper.py b/neutron/conf/policies/l3_conntrack_helper.py index 58e2c8df5af..7912e4693e4 100644 --- a/neutron/conf/policies/l3_conntrack_helper.py +++ b/neutron/conf/policies/l3_conntrack_helper.py @@ -12,11 +12,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The router conntrack API now supports system scope and default roles. @@ -30,7 +29,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a router conntrack helper', operations=[ @@ -47,7 +46,7 @@ ), policy.DocumentedRuleDefault( name='get_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a router conntrack helper', operations=[ @@ -68,7 +67,7 @@ ), policy.DocumentedRuleDefault( name='update_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Update a router conntrack helper', operations=[ @@ -85,7 +84,7 @@ ), policy.DocumentedRuleDefault( name='delete_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a router conntrack helper', operations=[ diff --git a/neutron/conf/policies/local_ip.py b/neutron/conf/policies/local_ip.py index 40773d67ca0..9e722fc656c 100644 --- a/neutron/conf/policies/local_ip.py +++ b/neutron/conf/policies/local_ip.py @@ -12,11 +12,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/local-ips' RESOURCE_PATH = '/local-ips/{id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_local_ip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create a Local IP', operations=[ { @@ -43,7 +42,7 @@ ), policy.DocumentedRuleDefault( name='get_local_ip', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get a Local IP', operations=[ { @@ -64,7 +63,7 @@ ), policy.DocumentedRuleDefault( name='update_local_ip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update a Local IP', operations=[ { @@ -81,7 +80,7 @@ ), policy.DocumentedRuleDefault( name='delete_local_ip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete a Local IP', operations=[ { diff --git a/neutron/conf/policies/local_ip_association.py b/neutron/conf/policies/local_ip_association.py index e223445a4d1..db35bfe31bd 100644 --- a/neutron/conf/policies/local_ip_association.py +++ b/neutron/conf/policies/local_ip_association.py @@ -12,11 +12,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/local_ips/{local_ip_id}/port_associations' RESOURCE_PATH = ('/local_ips/{local_ip_id}' '/port_associations/{fixed_port_id}') @@ -27,7 +26,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_local_ip_port_association', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a Local IP port association', operations=[ @@ -44,7 +43,7 @@ ), policy.DocumentedRuleDefault( name='get_local_ip_port_association', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a Local IP port association', operations=[ @@ -65,7 +64,7 @@ ), policy.DocumentedRuleDefault( name='delete_local_ip_port_association', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a Local IP port association', operations=[ diff --git a/neutron/conf/policies/logging.py b/neutron/conf/policies/logging.py index 7b7f37d51ae..1fdcd78757f 100644 --- a/neutron/conf/policies/logging.py +++ b/neutron/conf/policies/logging.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The logging API now supports project scope and default roles. @@ -28,7 +27,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_loggable_resource', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Get loggable resources', operations=[ @@ -45,7 +44,7 @@ ), policy.DocumentedRuleDefault( name='create_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create a network log', operations=[ @@ -62,7 +61,7 @@ ), policy.DocumentedRuleDefault( name='get_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Get a network log', operations=[ @@ -83,7 +82,7 @@ ), policy.DocumentedRuleDefault( name='update_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Update a network log', operations=[ @@ -100,7 +99,7 @@ ), policy.DocumentedRuleDefault( name='delete_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete a network log', operations=[ diff --git a/neutron/conf/policies/metering.py b/neutron/conf/policies/metering.py index 899b9b127ba..542f55184f5 100644 --- a/neutron/conf/policies/metering.py +++ b/neutron/conf/policies/metering.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The metering API now supports system scope and default roles. """ @@ -30,7 +29,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_metering_label', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create a metering label', operations=[ @@ -47,7 +46,7 @@ ), policy.DocumentedRuleDefault( name='get_metering_label', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get a metering label', operations=[ @@ -68,7 +67,7 @@ ), policy.DocumentedRuleDefault( name='delete_metering_label', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete a metering label', operations=[ @@ -85,7 +84,7 @@ ), policy.DocumentedRuleDefault( name='create_metering_label_rule', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create a metering label rule', operations=[ @@ -102,7 +101,7 @@ ), policy.DocumentedRuleDefault( name='get_metering_label_rule', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get a metering label rule', operations=[ @@ -123,7 +122,7 @@ ), policy.DocumentedRuleDefault( name='delete_metering_label_rule', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete a metering label rule', operations=[ diff --git a/neutron/conf/policies/ndp_proxy.py b/neutron/conf/policies/ndp_proxy.py index 30c46f3c983..725cebf4a2e 100644 --- a/neutron/conf/policies/ndp_proxy.py +++ b/neutron/conf/policies/ndp_proxy.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/ndp_proxies' RESOURCE_PATH = '/ndp_proxies/{id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create a ndp proxy', operations=[ { @@ -43,7 +42,7 @@ ), policy.DocumentedRuleDefault( name='get_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get a ndp proxy', operations=[ { @@ -64,7 +63,7 @@ ), policy.DocumentedRuleDefault( name='update_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update a ndp proxy', operations=[ { @@ -81,7 +80,7 @@ ), policy.DocumentedRuleDefault( name='delete_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete a ndp proxy', operations=[ { diff --git a/neutron/conf/policies/network.py b/neutron/conf/policies/network.py index b988e40192f..0348e881e15 100644 --- a/neutron/conf/policies/network.py +++ b/neutron/conf/policies/network.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The network API now supports system scope and default roles. """ @@ -63,7 +62,7 @@ policy.DocumentedRuleDefault( name='create_network', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create a network', operations=ACTION_POST, @@ -75,7 +74,7 @@ ), policy.DocumentedRuleDefault( name='create_network:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a shared network', operations=ACTION_POST, @@ -87,7 +86,7 @@ ), policy.DocumentedRuleDefault( name='create_network:router:external', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create an external network', operations=ACTION_POST, @@ -99,7 +98,7 @@ ), policy.DocumentedRuleDefault( name='create_network:is_default', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``is_default`` attribute when creating a network', operations=ACTION_POST, @@ -111,7 +110,7 @@ ), policy.DocumentedRuleDefault( name='create_network:port_security_enabled', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description=( 'Specify ``port_security_enabled`` ' @@ -126,7 +125,7 @@ ), policy.DocumentedRuleDefault( name='create_network:segments', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``segments`` attribute when creating a network', operations=ACTION_POST, @@ -138,7 +137,7 @@ ), policy.DocumentedRuleDefault( name='create_network:provider:network_type', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``provider:network_type`` ' @@ -153,7 +152,7 @@ ), policy.DocumentedRuleDefault( name='create_network:provider:physical_network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``provider:physical_network`` ' @@ -168,7 +167,7 @@ ), policy.DocumentedRuleDefault( name='create_network:provider:segmentation_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``provider:segmentation_id`` when creating a network' @@ -182,13 +181,13 @@ ), policy.DocumentedRuleDefault( name='create_network:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create the network tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_networks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), @@ -196,8 +195,8 @@ policy.DocumentedRuleDefault( name='get_network', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, - base.SERVICE, + lib_rules.ADMIN_OR_PROJECT_READER, + lib_rules.SERVICE, 'rule:shared', 'rule:external', neutron_policy.RULE_ADVSVC @@ -217,7 +216,7 @@ ), policy.DocumentedRuleDefault( name='get_network:segments', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``segments`` attribute of a network', operations=ACTION_GET, @@ -229,7 +228,7 @@ ), policy.DocumentedRuleDefault( name='get_network:provider:network_type', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``provider:network_type`` attribute of a network', operations=ACTION_GET, @@ -241,7 +240,7 @@ ), policy.DocumentedRuleDefault( name='get_network:provider:physical_network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``provider:physical_network`` attribute of a network', operations=ACTION_GET, @@ -253,7 +252,7 @@ ), policy.DocumentedRuleDefault( name='get_network:provider:segmentation_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``provider:segmentation_id`` attribute of a network', operations=ACTION_GET, @@ -266,7 +265,7 @@ policy.DocumentedRuleDefault( name='get_network:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared', 'rule:external', neutron_policy.RULE_ADVSVC @@ -276,14 +275,14 @@ operations=ACTION_GET_TAGS, deprecated_rule=policy.DeprecatedRule( name='get_networks_tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_network', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update a network', operations=ACTION_PUT, @@ -295,7 +294,7 @@ ), policy.DocumentedRuleDefault( name='update_network:segments', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``segments`` attribute of a network', operations=ACTION_PUT, @@ -307,7 +306,7 @@ ), policy.DocumentedRuleDefault( name='update_network:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``shared`` attribute of a network', operations=ACTION_PUT, @@ -319,7 +318,7 @@ ), policy.DocumentedRuleDefault( name='update_network:provider:network_type', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``provider:network_type`` attribute of a network', operations=ACTION_PUT, @@ -331,7 +330,7 @@ ), policy.DocumentedRuleDefault( name='update_network:provider:physical_network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Update ``provider:physical_network`` ' @@ -346,7 +345,7 @@ ), policy.DocumentedRuleDefault( name='update_network:provider:segmentation_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Update ``provider:segmentation_id`` ' @@ -361,7 +360,7 @@ ), policy.DocumentedRuleDefault( name='update_network:router:external', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``router:external`` attribute of a network', operations=ACTION_PUT, @@ -373,7 +372,7 @@ ), policy.DocumentedRuleDefault( name='update_network:is_default', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``is_default`` attribute of a network', operations=ACTION_PUT, @@ -385,7 +384,7 @@ ), policy.DocumentedRuleDefault( name='update_network:port_security_enabled', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update ``port_security_enabled`` attribute of a network', operations=ACTION_PUT, @@ -397,20 +396,20 @@ ), policy.DocumentedRuleDefault( name='update_network:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update the network tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_networks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_network', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete a network', operations=ACTION_DELETE, @@ -423,13 +422,13 @@ policy.DocumentedRuleDefault( # This should be just "update_network:tags" probably name='delete_network:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete the network tags', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_networks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), diff --git a/neutron/conf/policies/network_ip_availability.py b/neutron/conf/policies/network_ip_availability.py index 6b84b006a68..3faa622f25b 100644 --- a/neutron/conf/policies/network_ip_availability.py +++ b/neutron/conf/policies/network_ip_availability.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The network IP availability API now support project scope and default roles. """ @@ -24,7 +23,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_network_ip_availability', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get network IP availability', operations=[ diff --git a/neutron/conf/policies/network_segment_range.py b/neutron/conf/policies/network_segment_range.py index 06be8fec863..b7e501ce378 100644 --- a/neutron/conf/policies/network_segment_range.py +++ b/neutron/conf/policies/network_segment_range.py @@ -14,11 +14,10 @@ # from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The network segment range API now supports project scope and default roles. """ @@ -48,7 +47,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a network segment range', operations=[ @@ -65,20 +64,20 @@ ), policy.DocumentedRuleDefault( name='create_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create the network segment range tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get a network segment range', operations=[ @@ -99,20 +98,20 @@ ), policy.DocumentedRuleDefault( name='get_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get the network segment range tags', operations=ACTION_GET_TAGS, deprecated_rule=policy.DeprecatedRule( name='get_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update a network segment range', operations=[ @@ -129,20 +128,20 @@ ), policy.DocumentedRuleDefault( name='update_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update the network segment range tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete a network segment range', operations=[ @@ -159,13 +158,13 @@ ), policy.DocumentedRuleDefault( name='delete_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete the network segment range tags', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), diff --git a/neutron/conf/policies/port.py b/neutron/conf/policies/port.py index 747805f6b9e..5bd8ff56539 100644 --- a/neutron/conf/policies/port.py +++ b/neutron/conf/policies/port.py @@ -11,6 +11,7 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy @@ -70,8 +71,8 @@ policy.DocumentedRuleDefault( name='create_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description='Create a port', operations=ACTION_POST, @@ -84,8 +85,8 @@ policy.DocumentedRuleDefault( name='create_port:device_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description='Specify ``device_id`` attribute when creating a port', operations=ACTION_POST, @@ -99,7 +100,7 @@ name='create_port:device_owner', check_str=neutron_policy.policy_or( 'not rule:network_device', - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -117,7 +118,7 @@ policy.DocumentedRuleDefault( name='create_port:mac_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER), scope_types=['project'], description='Specify ``mac_address`` attribute when creating a port', @@ -133,7 +134,7 @@ policy.DocumentedRuleDefault( name='create_port:fixed_ips', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, 'rule:shared'), scope_types=['project'], @@ -151,7 +152,7 @@ policy.DocumentedRuleDefault( name='create_port:fixed_ips:ip_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER), scope_types=['project'], description='Specify IP address in ``fixed_ips`` when creating a port', @@ -167,7 +168,7 @@ policy.DocumentedRuleDefault( name='create_port:fixed_ips:subnet_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, 'rule:shared'), scope_types=['project'], @@ -185,7 +186,7 @@ policy.DocumentedRuleDefault( name='create_port:port_security_enabled', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER), scope_types=['project'], description=( @@ -203,7 +204,7 @@ ), policy.DocumentedRuleDefault( name='create_port:binding:host_id', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description=( 'Specify ``binding:host_id`` ' @@ -218,7 +219,7 @@ ), policy.DocumentedRuleDefault( name='create_port:binding:profile', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description=( 'Specify ``binding:profile`` attribute ' @@ -234,8 +235,8 @@ policy.DocumentedRuleDefault( name='create_port:binding:vnic_type', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``binding:vnic_type`` ' @@ -252,7 +253,7 @@ name='create_port:allowed_address_pairs', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``allowed_address_pairs`` ' @@ -269,7 +270,7 @@ name='create_port:allowed_address_pairs:mac_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``mac_address` of `allowed_address_pairs`` ' @@ -286,7 +287,7 @@ name='create_port:allowed_address_pairs:ip_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``ip_address`` of ``allowed_address_pairs`` ' @@ -301,7 +302,7 @@ ), policy.DocumentedRuleDefault( name='create_port:hints', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``hints`` attribute when creating a port' @@ -310,7 +311,7 @@ ), policy.DocumentedRuleDefault( name='create_port:trusted', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``trusted`` attribute when creating a port' @@ -320,7 +321,7 @@ policy.DocumentedRuleDefault( name='create_port:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), scope_types=['project'], @@ -329,7 +330,7 @@ deprecated_rule=policy.DeprecatedRule( name='create_ports_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), deprecated_reason="Name of the rule is changed.", @@ -339,9 +340,9 @@ policy.DocumentedRuleDefault( name='get_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_READER, - base.PROJECT_READER + lib_rules.PROJECT_READER ), scope_types=['project'], description='Get a port', @@ -356,7 +357,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:vif_type', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:vif_type`` attribute of a port', operations=ACTION_GET, @@ -368,7 +369,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:vif_details', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:vif_details`` attribute of a port', operations=ACTION_GET, @@ -380,7 +381,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:host_id', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:host_id`` attribute of a port', operations=ACTION_GET, @@ -392,7 +393,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:profile', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:profile`` attribute of a port', operations=ACTION_GET, @@ -404,7 +405,7 @@ ), policy.DocumentedRuleDefault( name='get_port:resource_request', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``resource_request`` attribute of a port', operations=ACTION_GET, @@ -416,14 +417,14 @@ ), policy.DocumentedRuleDefault( name='get_port:hints', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``hints`` attribute of a port', operations=ACTION_GET, ), policy.DocumentedRuleDefault( name='get_port:trusted', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``trusted`` attribute of a port', operations=ACTION_GET, @@ -433,7 +434,7 @@ check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, base.ADMIN_OR_NET_OWNER_READER, - base.PROJECT_READER + lib_rules.PROJECT_READER ), scope_types=['project'], description='Get the port tags', @@ -443,7 +444,7 @@ check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, base.ADMIN_OR_NET_OWNER_READER, - base.PROJECT_READER + lib_rules.PROJECT_READER ), deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") @@ -454,8 +455,8 @@ policy.DocumentedRuleDefault( name='update_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, - base.PROJECT_MEMBER, + lib_rules.ADMIN_OR_SERVICE, + lib_rules.PROJECT_MEMBER, ), scope_types=['project'], description='Update a port', @@ -471,8 +472,8 @@ policy.DocumentedRuleDefault( name='update_port:device_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description='Update ``device_id`` attribute of a port', operations=ACTION_PUT, @@ -486,7 +487,7 @@ name='update_port:device_owner', check_str=neutron_policy.policy_or( 'not rule:network_device', - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, ), scope_types=['project'], @@ -504,7 +505,7 @@ policy.DocumentedRuleDefault( name='update_port:mac_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MANAGER, ), scope_types=['project'], @@ -521,7 +522,7 @@ policy.DocumentedRuleDefault( name='update_port:fixed_ips', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -538,7 +539,7 @@ policy.DocumentedRuleDefault( name='update_port:fixed_ips:ip_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -558,7 +559,7 @@ policy.DocumentedRuleDefault( name='update_port:fixed_ips:subnet_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, 'rule:shared' ), @@ -580,7 +581,7 @@ policy.DocumentedRuleDefault( name='update_port:port_security_enabled', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -596,7 +597,7 @@ ), policy.DocumentedRuleDefault( name='update_port:binding:host_id', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Update ``binding:host_id`` attribute of a port', operations=ACTION_PUT, @@ -608,7 +609,7 @@ ), policy.DocumentedRuleDefault( name='update_port:binding:profile', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Update ``binding:profile`` attribute of a port', operations=ACTION_PUT, @@ -621,8 +622,8 @@ policy.DocumentedRuleDefault( name='update_port:binding:vnic_type', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, - base.PROJECT_MEMBER, + lib_rules.ADMIN_OR_SERVICE, + lib_rules.PROJECT_MEMBER, ), scope_types=['project'], description='Update ``binding:vnic_type`` attribute of a port', @@ -639,7 +640,7 @@ name='update_port:allowed_address_pairs', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description='Update ``allowed_address_pairs`` attribute of a port', operations=ACTION_PUT, @@ -653,7 +654,7 @@ name='update_port:allowed_address_pairs:mac_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Update ``mac_address`` of ``allowed_address_pairs`` ' @@ -670,7 +671,7 @@ name='update_port:allowed_address_pairs:ip_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Update ``ip_address`` of ``allowed_address_pairs`` ' @@ -686,7 +687,7 @@ policy.DocumentedRuleDefault( name='update_port:data_plane_status', check_str=neutron_policy.policy_or( - base.ADMIN, + lib_rules.ADMIN, 'role:data_plane_integrator'), scope_types=['project'], description='Update ``data_plane_status`` attribute of a port', @@ -699,14 +700,14 @@ ), policy.DocumentedRuleDefault( name='update_port:hints', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``hints`` attribute of a port', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_port:trusted', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``trusted`` attribute of a port', operations=ACTION_PUT, @@ -714,7 +715,7 @@ policy.DocumentedRuleDefault( name='update_port:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), scope_types=['project'], @@ -723,7 +724,7 @@ deprecated_rule=policy.DeprecatedRule( name='update_ports_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), deprecated_reason="Name of the rule is changed.", @@ -733,9 +734,9 @@ policy.DocumentedRuleDefault( name='delete_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, ), scope_types=['project'], description='Delete a port', @@ -752,7 +753,7 @@ name='delete_port:tags', check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER ), scope_types=['project'], @@ -762,7 +763,7 @@ name='delete_ports_tags', check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER ), deprecated_reason="Name of the rule is changed.", diff --git a/neutron/conf/policies/port_bindings.py b/neutron/conf/policies/port_bindings.py index 74ae80ea771..5a5f5463816 100644 --- a/neutron/conf/policies/port_bindings.py +++ b/neutron/conf/policies/port_bindings.py @@ -10,10 +10,9 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -from neutron.conf.policies import base - BINDING_PATH = '/ports/{port_id}/bindings/' ACTIVATE_BINDING_PATH = '/ports/{port_id}/bindings/{host}' @@ -22,7 +21,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_port_binding', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get port binding information', operations=[ @@ -34,7 +33,7 @@ ), policy.DocumentedRuleDefault( name='create_port_binding', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Create port binding on the host', operations=[ @@ -46,7 +45,7 @@ ), policy.DocumentedRuleDefault( name='delete_port_binding', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Delete port binding on the host', operations=[ @@ -58,7 +57,7 @@ ), policy.DocumentedRuleDefault( name='activate', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Activate port binding on the host', operations=[ diff --git a/neutron/conf/policies/qos.py b/neutron/conf/policies/qos.py index e0d1b61cfed..570e193dcfd 100644 --- a/neutron/conf/policies/qos.py +++ b/neutron/conf/policies/qos.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The QoS API now supports project scope and default roles. """ @@ -48,7 +47,7 @@ policy.DocumentedRuleDefault( name='get_policy', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_qos_policy' ), scope_types=['project'], @@ -72,7 +71,7 @@ policy.DocumentedRuleDefault( name='get_policy:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_qos_policy' ), scope_types=['project'], @@ -81,7 +80,7 @@ deprecated_rule=policy.DeprecatedRule( name='get_policies_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_qos_policy' ), deprecated_reason="Name of the rule is changed.", @@ -89,7 +88,7 @@ ), policy.DocumentedRuleDefault( name='create_policy', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create a QoS policy', operations=[ @@ -106,19 +105,19 @@ ), policy.DocumentedRuleDefault( name='create_policy:tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create the QoS policy tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_policies_tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_policy', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Update a QoS policy', operations=[ @@ -135,19 +134,19 @@ ), policy.DocumentedRuleDefault( name='update_policy:tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Update the QoS policy tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_policies_tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_policy', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete a QoS policy', operations=[ @@ -164,13 +163,13 @@ ), policy.DocumentedRuleDefault( name='delete_policy:tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete the QoS policy tags', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_policies_tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), @@ -203,7 +202,7 @@ policy.DocumentedRuleDefault( name='get_policy_bandwidth_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS bandwidth limit rule', operations=[ @@ -225,7 +224,7 @@ ), policy.DocumentedRuleDefault( name='create_policy_bandwidth_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Create a QoS bandwidth limit rule', operations=[ @@ -242,7 +241,7 @@ ), policy.DocumentedRuleDefault( name='update_policy_bandwidth_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS bandwidth limit rule', operations=[ @@ -260,7 +259,7 @@ ), policy.DocumentedRuleDefault( name='delete_policy_bandwidth_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS bandwidth limit rule', operations=[ @@ -279,7 +278,7 @@ policy.DocumentedRuleDefault( name='get_policy_packet_rate_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS packet rate limit rule', operations=[ @@ -296,7 +295,7 @@ ), policy.DocumentedRuleDefault( name='create_policy_packet_rate_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Create a QoS packet rate limit rule', operations=[ @@ -308,7 +307,7 @@ ), policy.DocumentedRuleDefault( name='update_policy_packet_rate_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS packet rate limit rule', operations=[ @@ -321,7 +320,7 @@ ), policy.DocumentedRuleDefault( name='delete_policy_packet_rate_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS packet rate limit rule', operations=[ @@ -335,7 +334,7 @@ policy.DocumentedRuleDefault( name='get_policy_dscp_marking_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS DSCP marking rule', operations=[ @@ -357,7 +356,7 @@ ), policy.DocumentedRuleDefault( name='create_policy_dscp_marking_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Create a QoS DSCP marking rule', operations=[ @@ -374,7 +373,7 @@ ), policy.DocumentedRuleDefault( name='update_policy_dscp_marking_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS DSCP marking rule', operations=[ @@ -392,7 +391,7 @@ ), policy.DocumentedRuleDefault( name='delete_policy_dscp_marking_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS DSCP marking rule', operations=[ @@ -411,7 +410,7 @@ policy.DocumentedRuleDefault( name='get_policy_minimum_bandwidth_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS minimum bandwidth rule', operations=[ @@ -433,7 +432,7 @@ ), policy.DocumentedRuleDefault( name='create_policy_minimum_bandwidth_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Create a QoS minimum bandwidth rule', operations=[ @@ -450,7 +449,7 @@ ), policy.DocumentedRuleDefault( name='update_policy_minimum_bandwidth_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS minimum bandwidth rule', operations=[ @@ -468,7 +467,7 @@ ), policy.DocumentedRuleDefault( name='delete_policy_minimum_bandwidth_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS minimum bandwidth rule', operations=[ @@ -486,7 +485,7 @@ ), policy.DocumentedRuleDefault( name='get_policy_minimum_packet_rate_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS minimum packet rate rule', operations=[ @@ -503,7 +502,7 @@ ), policy.DocumentedRuleDefault( name='create_policy_minimum_packet_rate_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Create a QoS minimum packet rate rule', operations=[ @@ -515,7 +514,7 @@ ), policy.DocumentedRuleDefault( name='update_policy_minimum_packet_rate_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS minimum packet rate rule', operations=[ @@ -528,7 +527,7 @@ ), policy.DocumentedRuleDefault( name='delete_policy_minimum_packet_rate_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS minimum packet rate rule', operations=[ @@ -541,7 +540,7 @@ ), policy.DocumentedRuleDefault( name='get_alias_bandwidth_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS bandwidth limit rule through alias', operations=[ @@ -558,7 +557,7 @@ ), policy.DocumentedRuleDefault( name='update_alias_bandwidth_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS bandwidth limit rule through alias', operations=[ @@ -575,7 +574,7 @@ ), policy.DocumentedRuleDefault( name='delete_alias_bandwidth_limit_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS bandwidth limit rule through alias', operations=[ @@ -592,7 +591,7 @@ ), policy.DocumentedRuleDefault( name='get_alias_dscp_marking_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS DSCP marking rule through alias', operations=[ @@ -609,7 +608,7 @@ ), policy.DocumentedRuleDefault( name='update_alias_dscp_marking_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS DSCP marking rule through alias', operations=[ @@ -626,7 +625,7 @@ ), policy.DocumentedRuleDefault( name='delete_alias_dscp_marking_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS DSCP marking rule through alias', operations=[ @@ -643,7 +642,7 @@ ), policy.DocumentedRuleDefault( name='get_alias_minimum_bandwidth_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a QoS minimum bandwidth rule through alias', operations=[ @@ -660,7 +659,7 @@ ), policy.DocumentedRuleDefault( name='update_alias_minimum_bandwidth_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Update a QoS minimum bandwidth rule through alias', operations=[ @@ -677,7 +676,7 @@ ), policy.DocumentedRuleDefault( name='delete_alias_minimum_bandwidth_rule', - check_str=base.ADMIN_OR_PARENT_OWNER_MANAGER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MANAGER, scope_types=['project'], description='Delete a QoS minimum bandwidth rule through alias', operations=[ diff --git a/neutron/conf/policies/quotas.py b/neutron/conf/policies/quotas.py index 3e7a7603d81..646a276a6ef 100644 --- a/neutron/conf/policies/quotas.py +++ b/neutron/conf/policies/quotas.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The quotas API now supports project scope and default roles. """ @@ -28,7 +27,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_quota', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Get a resource quota', operations=[ @@ -49,7 +48,7 @@ ), policy.DocumentedRuleDefault( name='update_quota', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update a resource quota', operations=[ @@ -66,7 +65,7 @@ ), policy.DocumentedRuleDefault( name='delete_quota', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete a resource quota', operations=[ diff --git a/neutron/conf/policies/rbac.py b/neutron/conf/policies/rbac.py index a3f513c3afb..37e833a7270 100644 --- a/neutron/conf/policies/rbac.py +++ b/neutron/conf/policies/rbac.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The RBAC API now supports system scope and default roles. """ @@ -29,7 +28,7 @@ _create_rbac_target_tenant = policy.DocumentedRuleDefault( name='create_rbac_policy:target_tenant', check_str=neutron_policy.policy_or( - base.ADMIN, + lib_rules.ADMIN, '(not field:rbac_policy:target_tenant=* and ' 'not field:rbac_policy:target_project=*)'), description='Specify ``target_tenant`` when creating an RBAC policy', @@ -52,7 +51,7 @@ _update_rbac_target_tenant = policy.DocumentedRuleDefault( name='update_rbac_policy:target_tenant', check_str=neutron_policy.policy_or( - base.ADMIN, + lib_rules.ADMIN, '(not field:rbac_policy:target_tenant=* and ' 'not field:rbac_policy:target_project=*)'), description='Update ``target_tenant`` attribute of an RBAC policy', @@ -87,7 +86,7 @@ policy.DocumentedRuleDefault( name='create_rbac_policy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create an RBAC policy', operations=[ @@ -106,7 +105,7 @@ policy.DocumentedRuleDefault( name='create_rbac_policy:target_project', check_str=neutron_policy.policy_or( - base.ADMIN, + lib_rules.ADMIN, 'not field:rbac_policy:target_project=*'), description='Specify ``target_project`` when creating an RBAC policy', operations=[ @@ -119,7 +118,7 @@ ), policy.DocumentedRuleDefault( name='update_rbac_policy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update an RBAC policy', operations=[ @@ -138,7 +137,7 @@ policy.DocumentedRuleDefault( name='update_rbac_policy:target_project', check_str=neutron_policy.policy_or( - base.ADMIN, + lib_rules.ADMIN, 'not field:rbac_policy:target_project=*'), description='Update ``target_project`` attribute of an RBAC policy', operations=[ @@ -151,7 +150,7 @@ ), policy.DocumentedRuleDefault( name='get_rbac_policy', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get an RBAC policy', operations=[ @@ -172,7 +171,7 @@ ), policy.DocumentedRuleDefault( name='delete_rbac_policy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete an RBAC policy', operations=[ diff --git a/neutron/conf/policies/router.py b/neutron/conf/policies/router.py index 318221f528e..ea9fb2e7878 100644 --- a/neutron/conf/policies/router.py +++ b/neutron/conf/policies/router.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = ( "The router API now supports system scope and default roles.") @@ -57,7 +56,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_router', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create a router', operations=ACTION_POST, @@ -69,7 +68,7 @@ ), policy.DocumentedRuleDefault( name='create_router:distributed', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``distributed`` attribute when creating a router', operations=ACTION_POST, @@ -81,7 +80,7 @@ ), policy.DocumentedRuleDefault( name='create_router:ha', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``ha`` attribute when creating a router', operations=ACTION_POST, @@ -93,7 +92,7 @@ ), policy.DocumentedRuleDefault( name='create_router:external_gateway_info', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description=('Specify ``external_gateway_info`` ' 'information when creating a router'), @@ -106,7 +105,7 @@ ), policy.DocumentedRuleDefault( name='create_router:external_gateway_info:network_id', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description=('Specify ``network_id`` in ``external_gateway_info`` ' 'information when creating a router'), @@ -119,7 +118,7 @@ ), policy.DocumentedRuleDefault( name='create_router:external_gateway_info:enable_snat', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Specify ``enable_snat`` in ``external_gateway_info`` ' 'information when creating a router'), @@ -132,7 +131,7 @@ ), policy.DocumentedRuleDefault( name='create_router:external_gateway_info:external_fixed_ips', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Specify ``external_fixed_ips`` in ' '``external_gateway_info`` information when creating a ' @@ -146,7 +145,7 @@ ), policy.DocumentedRuleDefault( name='create_router:enable_default_route_bfd', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Specify ``enable_default_route_bfd`` attribute when' ' creating a router'), @@ -154,7 +153,7 @@ ), policy.DocumentedRuleDefault( name='create_router:enable_default_route_ecmp', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Specify ``enable_default_route_ecmp`` attribute when' ' creating a router'), @@ -162,20 +161,20 @@ ), policy.DocumentedRuleDefault( name='create_router:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create the router tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_routers_tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_router', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get a router', operations=ACTION_GET, @@ -187,7 +186,7 @@ ), policy.DocumentedRuleDefault( name='get_router:distributed', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``distributed`` attribute of a router', operations=ACTION_GET, @@ -199,7 +198,7 @@ ), policy.DocumentedRuleDefault( name='get_router:ha', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``ha`` attribute of a router', operations=ACTION_GET, @@ -211,20 +210,20 @@ ), policy.DocumentedRuleDefault( name='get_router:tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get the router tags', operations=ACTION_GET_TAGS, deprecated_rule=policy.DeprecatedRule( name='get_routers_tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_router', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update a router', operations=ACTION_PUT, @@ -236,7 +235,7 @@ ), policy.DocumentedRuleDefault( name='update_router:distributed', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``distributed`` attribute of a router', operations=ACTION_PUT, @@ -248,7 +247,7 @@ ), policy.DocumentedRuleDefault( name='update_router:ha', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``ha`` attribute of a router', operations=ACTION_PUT, @@ -260,7 +259,7 @@ ), policy.DocumentedRuleDefault( name='update_router:external_gateway_info', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update ``external_gateway_info`` information of a router', operations=ACTION_PUT, @@ -272,7 +271,7 @@ ), policy.DocumentedRuleDefault( name='update_router:external_gateway_info:network_id', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description=('Update ``network_id`` attribute of ' '``external_gateway_info`` information of a router'), @@ -285,7 +284,7 @@ ), policy.DocumentedRuleDefault( name='update_router:external_gateway_info:enable_snat', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Update ``enable_snat`` attribute of ' '``external_gateway_info`` information of a router'), @@ -298,7 +297,7 @@ ), policy.DocumentedRuleDefault( name='update_router:external_gateway_info:external_fixed_ips', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Update ``external_fixed_ips`` attribute of ' '``external_gateway_info`` information of a router'), @@ -311,7 +310,7 @@ ), policy.DocumentedRuleDefault( name='update_router:enable_default_route_bfd', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Specify ``enable_default_route_bfd`` attribute when ' 'updating a router'), @@ -319,7 +318,7 @@ ), policy.DocumentedRuleDefault( name='update_router:enable_default_route_ecmp', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=('Specify ``enable_default_route_ecmp`` attribute when ' 'updating a router'), @@ -327,20 +326,20 @@ ), policy.DocumentedRuleDefault( name='update_router:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update the router tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_routers_tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_router', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete a router', operations=ACTION_DELETE, @@ -352,20 +351,20 @@ ), policy.DocumentedRuleDefault( name='delete_router:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete the router tags', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_routers_tags', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='add_router_interface', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Add an interface to a router', operations=[ @@ -382,7 +381,7 @@ ), policy.DocumentedRuleDefault( name='remove_router_interface', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Remove an interface from a router', operations=[ @@ -399,7 +398,7 @@ ), policy.DocumentedRuleDefault( name='add_extraroutes', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Add extra route to a router', operations=[ @@ -416,7 +415,7 @@ ), policy.DocumentedRuleDefault( name='remove_extraroutes', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Remove extra route from a router', operations=[ @@ -434,35 +433,35 @@ policy.DocumentedRuleDefault( name='add_external_gateways', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Add router external gateways', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='add_external_gateways:external_gateways', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Add router external gateways', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='add_external_gateways:external_gateways:network_id', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Add router external gateways with defined network ID', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='add_external_gateways:external_gateways:enable_snat', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Add router external gateways specifying SNAT flag', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='add_external_gateways:external_gateways:external_fixed_ips', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Add router external gateways specifying the fixed IPs', operations=ACTION_PUT, @@ -470,35 +469,35 @@ policy.DocumentedRuleDefault( name='update_external_gateways', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update router external gateways', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_external_gateways:external_gateways', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update router external gateways', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_external_gateways:external_gateways:network_id', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update router external gateways network ID', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_external_gateways:external_gateways:enable_snat', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update router external gateways SNAT flag', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_external_gateways:external_gateways:external_fixed_ips', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update router external gateways fixed IPs', operations=ACTION_PUT, @@ -506,14 +505,14 @@ policy.DocumentedRuleDefault( name='remove_external_gateways', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Remove router external gateways', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='remove_external_gateways:external_gateways', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Remove router external gateways', operations=ACTION_PUT, diff --git a/neutron/conf/policies/security_group.py b/neutron/conf/policies/security_group.py index dc89e5dd68c..05f5ee5a330 100644 --- a/neutron/conf/policies/security_group.py +++ b/neutron/conf/policies/security_group.py @@ -11,6 +11,7 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy @@ -80,7 +81,7 @@ # Does an empty string make more sense for create_security_group? policy.DocumentedRuleDefault( name='create_security_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create a security group', operations=[ @@ -97,20 +98,20 @@ ), policy.DocumentedRuleDefault( name='create_security_group:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create the security group tags', operations=SG_ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_security_groups_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_security_group', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_security_group' ), scope_types=['project'], @@ -134,7 +135,7 @@ policy.DocumentedRuleDefault( name='get_security_group:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_security_group' ), scope_types=['project'], @@ -143,7 +144,7 @@ deprecated_rule=policy.DeprecatedRule( name='get_security_groups_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_security_group' ), deprecated_reason="Name of the rule is changed.", @@ -151,7 +152,7 @@ ), policy.DocumentedRuleDefault( name='update_security_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update a security group', operations=[ @@ -168,19 +169,19 @@ ), policy.DocumentedRuleDefault( name='update_security_group:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update the security group tags', operations=SG_ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_security_groups_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_security_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete a security group', operations=[ @@ -197,13 +198,13 @@ ), policy.DocumentedRuleDefault( name='delete_security_group:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete the security group tags', operations=SG_ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_security_groups_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), diff --git a/neutron/conf/policies/segment.py b/neutron/conf/policies/segment.py index 0e9648c28c6..ca0a0d587d1 100644 --- a/neutron/conf/policies/segment.py +++ b/neutron/conf/policies/segment.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = ( "The segment API now supports project scope and default roles.") @@ -44,7 +43,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_segment', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a segment', operations=[ @@ -61,14 +60,14 @@ ), policy.DocumentedRuleDefault( name='create_segments_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create the segment tags', operations=ACTION_POST_TAGS, ), policy.DocumentedRuleDefault( name='get_segment', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get a segment', operations=[ @@ -89,14 +88,14 @@ ), policy.DocumentedRuleDefault( name='get_segments_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get the segment tags', operations=ACTION_GET_TAGS, ), policy.DocumentedRuleDefault( name='update_segment', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update a segment', operations=[ @@ -113,14 +112,14 @@ ), policy.DocumentedRuleDefault( name='update_segments_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update the segment tags', operations=ACTION_PUT_TAGS, ), policy.DocumentedRuleDefault( name='delete_segment', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete a segment', operations=[ @@ -137,7 +136,7 @@ ), policy.DocumentedRuleDefault( name='delete_segments_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete the segment tags', operations=ACTION_DELETE_TAGS, diff --git a/neutron/conf/policies/subnet.py b/neutron/conf/policies/subnet.py index 5e8913a3c2b..2ead89b7801 100644 --- a/neutron/conf/policies/subnet.py +++ b/neutron/conf/policies/subnet.py @@ -11,6 +11,7 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy @@ -75,7 +76,7 @@ ), policy.DocumentedRuleDefault( name='create_subnet:segment_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``segment_id`` attribute when creating a subnet' @@ -89,7 +90,7 @@ ), policy.DocumentedRuleDefault( name='create_subnet:service_types', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``service_types`` attribute when creating a subnet' @@ -104,7 +105,7 @@ policy.DocumentedRuleDefault( name='create_subnet:tags', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, ), scope_types=['project'], @@ -113,7 +114,7 @@ deprecated_rule=policy.DeprecatedRule( name='create_subnets_tags', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, ), deprecated_reason="Name of the rule is changed.", @@ -122,11 +123,11 @@ policy.DocumentedRuleDefault( name='get_subnet', check_str=neutron_policy.policy_or( - base.PROJECT_READER, + lib_rules.PROJECT_READER, 'rule:shared', 'rule:external_network', base.ADMIN_OR_NET_OWNER_READER, - base.SERVICE, + lib_rules.SERVICE, ), scope_types=['project'], description='Get a subnet', @@ -143,7 +144,7 @@ ), policy.DocumentedRuleDefault( name='get_subnet:segment_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``segment_id`` attribute of a subnet', operations=ACTION_GET, @@ -156,7 +157,7 @@ policy.DocumentedRuleDefault( name='get_subnet:tags', check_str=neutron_policy.policy_or( - base.PROJECT_READER, + lib_rules.PROJECT_READER, 'rule:shared', 'rule:external_network', base.ADMIN_OR_NET_OWNER_READER, @@ -167,7 +168,7 @@ deprecated_rule=policy.DeprecatedRule( name='get_subnets_tags', check_str=neutron_policy.policy_or( - base.PROJECT_READER, + lib_rules.PROJECT_READER, 'rule:shared', 'rule:external_network', base.ADMIN_OR_NET_OWNER_READER, @@ -178,7 +179,7 @@ policy.DocumentedRuleDefault( name='update_subnet', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER), scope_types=['project'], description='Update a subnet', @@ -191,7 +192,7 @@ ), policy.DocumentedRuleDefault( name='update_subnet:segment_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``segment_id`` attribute of a subnet', operations=ACTION_PUT, @@ -203,7 +204,7 @@ ), policy.DocumentedRuleDefault( name='update_subnet:service_types', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``service_types`` attribute of a subnet', operations=ACTION_PUT, @@ -216,7 +217,7 @@ policy.DocumentedRuleDefault( name='update_subnet:tags', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, ), scope_types=['project'], @@ -225,7 +226,7 @@ deprecated_rule=policy.DeprecatedRule( name='update_subnets_tags', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, ), deprecated_reason="Name of the rule is changed.", @@ -234,7 +235,7 @@ policy.DocumentedRuleDefault( name='delete_subnet', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, ), scope_types=['project'], @@ -249,7 +250,7 @@ policy.DocumentedRuleDefault( name='delete_subnet:tags', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, ), scope_types=['project'], @@ -258,7 +259,7 @@ deprecated_rule=policy.DeprecatedRule( name='delete_subnets_tags', check_str=neutron_policy.policy_or( - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, ), deprecated_reason="Name of the rule is changed.", diff --git a/neutron/conf/policies/subnetpool.py b/neutron/conf/policies/subnetpool.py index ff300904ed2..8f2e14091a7 100644 --- a/neutron/conf/policies/subnetpool.py +++ b/neutron/conf/policies/subnetpool.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = ( "The subnet pool API now supports system scope and default roles.") @@ -52,7 +51,7 @@ ), policy.DocumentedRuleDefault( name='create_subnetpool', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create a subnetpool', operations=[ @@ -69,7 +68,7 @@ ), policy.DocumentedRuleDefault( name='create_subnetpool:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a shared subnetpool', operations=[ @@ -86,7 +85,7 @@ ), policy.DocumentedRuleDefault( name='create_subnetpool:is_default', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``is_default`` attribute when creating a subnetpool' @@ -105,20 +104,20 @@ ), policy.DocumentedRuleDefault( name='create_subnetpool:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create the subnetpool tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_subnetpools_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_subnetpool', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_subnetpools' ), scope_types=['project'], @@ -144,7 +143,7 @@ policy.DocumentedRuleDefault( name='get_subnetpool:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_subnetpools' ), scope_types=['project'], @@ -153,7 +152,7 @@ deprecated_rule=policy.DeprecatedRule( name='get_subnetpools_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_subnetpools' ), deprecated_reason="Name of the rule is changed.", @@ -161,7 +160,7 @@ ), policy.DocumentedRuleDefault( name='update_subnetpool', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update a subnetpool', operations=[ @@ -178,7 +177,7 @@ ), policy.DocumentedRuleDefault( name='update_subnetpool:is_default', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``is_default`` attribute of a subnetpool', operations=[ @@ -195,19 +194,19 @@ ), policy.DocumentedRuleDefault( name='update_subnetpool:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update the subnetpool tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_subnetpools_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_subnetpool', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete a subnetpool', operations=[ @@ -224,19 +223,19 @@ ), policy.DocumentedRuleDefault( name='delete_subnetpool:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete the subnetpool tags', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_subnetpools_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='onboard_network_subnets', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Onboard existing subnet into a subnetpool', operations=[ @@ -253,7 +252,7 @@ ), policy.DocumentedRuleDefault( name='add_prefixes', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Add prefixes to a subnetpool', operations=[ @@ -270,7 +269,7 @@ ), policy.DocumentedRuleDefault( name='remove_prefixes', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Remove unallocated prefixes from a subnetpool', operations=[ diff --git a/neutron/conf/policies/trunk.py b/neutron/conf/policies/trunk.py index 010d96f5d10..e57a9f9339a 100644 --- a/neutron/conf/policies/trunk.py +++ b/neutron/conf/policies/trunk.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/trunks' RESOURCE_PATH = '/trunks/{id}' @@ -45,7 +44,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_trunk', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create a trunk', operations=[ @@ -62,19 +61,19 @@ ), policy.DocumentedRuleDefault( name='create_trunk:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create the trunk tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_trunks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_trunk', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get a trunk', operations=[ @@ -95,19 +94,19 @@ ), policy.DocumentedRuleDefault( name='get_trunk:tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get the trunk tags', operations=ACTION_GET_TAGS, deprecated_rule=policy.DeprecatedRule( name='get_trunks_tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_trunk', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update a trunk', operations=[ @@ -124,19 +123,19 @@ ), policy.DocumentedRuleDefault( name='update_trunk:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update the trunk tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_trunks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_trunk', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete a trunk', operations=[ @@ -153,19 +152,19 @@ ), policy.DocumentedRuleDefault( name='delete_trunk:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete a trunk', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_trunks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_subports', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='List subports attached to a trunk', operations=[ @@ -182,7 +181,7 @@ ), policy.DocumentedRuleDefault( name='add_subports', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Add subports to a trunk', operations=[ @@ -199,7 +198,7 @@ ), policy.DocumentedRuleDefault( name='remove_subports', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete subports from a trunk', operations=[ From 2232cd1a759dcec4855401cb0170d75767c7f1e4 Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Fri, 5 Jun 2026 14:27:09 -0400 Subject: [PATCH 10/32] epvn: Split Svd into generic and Evpn specific This is a followup patch to I78fec86595fb358880b306ec1fe014adad007d87 The patch moves Netlink specific constants from evpn to netlink module. It moves the EVPN specific naming convention out of the Svd class and creates a new EvpnSvd that is used by the evpn-ovn driver. It adds back the functional tests from the patch it depends on, now that the Svd is generic and doesn't use same device names, we can test its integration because the device names can be generated and will not collide in a parallel run. Depends-On: https://review.opendev.org/c/openstack/neutron/+/989626 Related-Bug: #2144617 Assited-By: Claude Opus 4.6 Co-Authored-By: Helen Chen Change-Id: I95fe3a93da6a8ec353c4f8de05ab6b281f5df6ec Signed-off-by: Jakub Libosvar --- neutron/agent/linux/nl_constants.py | 21 ++ neutron/agent/linux/svd.py | 101 +++++--- neutron/agent/ovn/extensions/evpn/__init__.py | 5 +- .../agent/ovn/extensions/evpn/constants.py | 5 - .../agent/ovn/extensions/evpn/exceptions.py | 24 -- neutron/agent/ovn/extensions/evpn/svd.py | 43 ++++ neutron/privileged/agent/linux/svd.py | 50 ++-- neutron/tests/functional/agent/linux/base.py | 11 + .../tests/functional/agent/linux/test_svd.py | 240 ++++++++++++++++++ .../agent/ovn/extensions/test_evpn.py | 20 +- .../unit/agent/linux/test_nl_dispatcher.py | 52 ++-- 11 files changed, 452 insertions(+), 120 deletions(-) create mode 100644 neutron/agent/linux/nl_constants.py create mode 100644 neutron/agent/ovn/extensions/evpn/svd.py create mode 100644 neutron/tests/functional/agent/linux/test_svd.py diff --git a/neutron/agent/linux/nl_constants.py b/neutron/agent/linux/nl_constants.py new file mode 100644 index 00000000000..d7d640f366a --- /dev/null +++ b/neutron/agent/linux/nl_constants.py @@ -0,0 +1,21 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +RTM_NEWLINK = 'RTM_NEWLINK' +RTM_DELLINK = 'RTM_DELLINK' + +IP_LINK_ADD = 'add' +IP_LINK_DEL = 'del' +IP_LINK_SET = 'set' diff --git a/neutron/agent/linux/svd.py b/neutron/agent/linux/svd.py index 8560a60f620..bfc7bbb90f6 100644 --- a/neutron/agent/linux/svd.py +++ b/neutron/agent/linux/svd.py @@ -17,22 +17,45 @@ from pyroute2.netlink import exceptions as netlink_exc -from neutron.agent.ovn.extensions.evpn import exceptions as evpn_exc +from neutron._i18n import _ from neutron.privileged.agent.linux import svd as privileged_svd +class SvdNoVxlanParent(Exception): + pass + + +class SvdDeviceAlreadyExists(Exception): + pass + + +class SvdDevsNotFound(Exception): + pass + + +class SvdSviNotFound(Exception): + pass + + +class SvdNotFound(Exception): + pass + + +class SvdNetlinkError(Exception): + pass + + class Svd: """A Single VXLAN Device: a VLAN-aware bridge + VXLAN device pair. Up to 4094 VNIs share the same SVD via VLAN/VNI mappings added with add_vni(). - When a VNI is mapped to a VLAN, a VLAN interface with - EVPN_VLAN_IFNAME_PATTERN is created + When a VNI is mapped to a VLAN, the caller-provided VLAN interface + name is created. """ - def __init__(self, br_evpn, vxlan_evpn, index=0): - self._index = index + def __init__(self, br_evpn, vxlan_evpn): self.br_evpn = br_evpn self.vxlan_evpn = vxlan_evpn @@ -42,54 +65,60 @@ def create(self, local_ip, mac, vxlan_parent, dstport): self.br_evpn, self.vxlan_evpn, local_ip, mac, vxlan_parent, dstport) except IndexError: - raise evpn_exc.SvdNoVxlanParent("Missing VxLAN underlay: %s" % - (vxlan_parent)) + raise SvdNoVxlanParent( + _("Missing VxLAN underlay: %(parent)s") % + {'parent': vxlan_parent}) except netlink_exc.NetlinkError as e: if e.code == errno.EEXIST: - raise evpn_exc.SvdDeviceAlreadyExists("SVD %s/%s device(s) " - "already exist(s)" % - (self.br_evpn, - self.vxlan_evpn)) - raise evpn_exc.SvdNetlinkError( - "Failed to add SVD %s/%s: %s" % - (self.br_evpn, self.vxlan_evpn, e)) + raise SvdDeviceAlreadyExists( + _("SVD %(br)s/%(vx)s device(s) already exist(s)") % + {'br': self.br_evpn, 'vx': self.vxlan_evpn}) + raise SvdNetlinkError( + _("Failed to add SVD %(br)s/%(vx)s: %(err)s") % + {'br': self.br_evpn, 'vx': self.vxlan_evpn, 'err': e}) def delete(self): try: privileged_svd.delete_svd(self.br_evpn, self.vxlan_evpn) except IndexError: - raise evpn_exc.SvdNotFound( - "SVD %s/%s not found" % - (self.br_evpn, self.vxlan_evpn)) + raise SvdNotFound( + _("SVD %(br)s/%(vx)s not found") % + {'br': self.br_evpn, 'vx': self.vxlan_evpn}) except netlink_exc.NetlinkError as e: - raise evpn_exc.SvdNetlinkError( - "Failed to delete SVD %s/%s: %s" % - (self.br_evpn, self.vxlan_evpn, e)) + raise SvdNetlinkError( + _("Failed to delete SVD %(br)s/%(vx)s: %(err)s") % + {'br': self.br_evpn, 'vx': self.vxlan_evpn, 'err': e}) - def add_vni(self, vni, vid, vrf_name, mac): + def add_vni(self, svi_name, vni, vid, vrf_name, mac): try: privileged_svd.add_vni( self.br_evpn, self.vxlan_evpn, - vni, vid, vrf_name, mac, self._index) + svi_name, vni, vid, vrf_name, mac) except IndexError: - raise evpn_exc.SvdDevsNotFound( - "SVD %s/%s or VRF %s not found" % - (self.br_evpn, self.vxlan_evpn, vrf_name)) + raise SvdDevsNotFound( + _("SVD %(br)s/%(vx)s or VRF %(vrf)s not found") % + {'br': self.br_evpn, 'vx': self.vxlan_evpn, + 'vrf': vrf_name}) except netlink_exc.NetlinkError as e: - raise evpn_exc.SvdNetlinkError( - "Failed to add VNI %d to SVD %s/%s: %s" % - (vni, self.br_evpn, self.vxlan_evpn, e)) + raise SvdNetlinkError( + _("Failed to add VNI %(vni)d to SVD %(br)s/%(vx)s:" + " %(err)s") % + {'vni': vni, 'br': self.br_evpn, + 'vx': self.vxlan_evpn, 'err': e}) - def del_vni(self, vni, vid): + def del_vni(self, svi_name, vni, vid): try: privileged_svd.del_vni( self.br_evpn, self.vxlan_evpn, - vni, vid, self._index) + svi_name, vni, vid) except IndexError: - raise evpn_exc.SvdSviNotFound( - "SVI for VNI %d not found on SVD %s/%s" % - (vni, self.br_evpn, self.vxlan_evpn)) + raise SvdSviNotFound( + _("SVI for VNI %(vni)d not found on SVD %(br)s/%(vx)s") % + {'vni': vni, 'br': self.br_evpn, + 'vx': self.vxlan_evpn}) except netlink_exc.NetlinkError as e: - raise evpn_exc.SvdNetlinkError( - "Failed to delete VNI %d from SVD %s/%s: %s" % - (vni, self.br_evpn, self.vxlan_evpn, e)) + raise SvdNetlinkError( + _("Failed to delete VNI %(vni)d from SVD %(br)s/%(vx)s:" + " %(err)s") % + {'vni': vni, 'br': self.br_evpn, + 'vx': self.vxlan_evpn, 'err': e}) diff --git a/neutron/agent/ovn/extensions/evpn/__init__.py b/neutron/agent/ovn/extensions/evpn/__init__.py index 5143fdaf1e6..381f2c5868d 100644 --- a/neutron/agent/ovn/extensions/evpn/__init__.py +++ b/neutron/agent/ovn/extensions/evpn/__init__.py @@ -16,6 +16,7 @@ from oslo_log import log from pyroute2.netlink import rtnl +from neutron.agent.linux import nl_constants as nl_const from neutron.agent.linux import nl_dispatcher from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import events as evpn_events @@ -39,9 +40,9 @@ def start(self): self.nl_dispatcher = nl_dispatcher.NetlinkDispatcher( rtnl.RTMGRP_LINK) self.nl_dispatcher.register_handler( - evpn_const.EVPN_RTM_NEWLINK, vrf_handler.handle_newlink) + nl_const.RTM_NEWLINK, vrf_handler.handle_newlink) self.nl_dispatcher.register_handler( - evpn_const.EVPN_RTM_DELLINK, vrf_handler.handle_dellink) + nl_const.RTM_DELLINK, vrf_handler.handle_dellink) self.nl_dispatcher.register_replay_callbacks( on_start=vrf_handler.replay_start, on_end=vrf_handler.replay_end) diff --git a/neutron/agent/ovn/extensions/evpn/constants.py b/neutron/agent/ovn/extensions/evpn/constants.py index bac44820f05..625af6b5df8 100644 --- a/neutron/agent/ovn/extensions/evpn/constants.py +++ b/neutron/agent/ovn/extensions/evpn/constants.py @@ -17,11 +17,6 @@ EVPN_VRF_PREFIX = 'vr' EVPN_VRF_NAME_LEN = 14 EVPN_LINK_KIND_VRF = 'vrf' -EVPN_RTM_NEWLINK = 'RTM_NEWLINK' -EVPN_RTM_DELLINK = 'RTM_DELLINK' -EVPN_IP_LINK_ADD = 'add' -EVPN_IP_LINK_DEL = 'del' -EVPN_IP_LINK_SET = 'set' EVPN_LB_NAME_PREFIX = 'brevpn-' diff --git a/neutron/agent/ovn/extensions/evpn/exceptions.py b/neutron/agent/ovn/extensions/evpn/exceptions.py index 89dd3b1fab6..00afb41ffe5 100644 --- a/neutron/agent/ovn/extensions/evpn/exceptions.py +++ b/neutron/agent/ovn/extensions/evpn/exceptions.py @@ -28,27 +28,3 @@ class FSMIllegalTransition(Exception): class FSMMissingTransitionCallback(Exception): pass - - -class SvdNoVxlanParent(Exception): - pass - - -class SvdDeviceAlreadyExists(Exception): - pass - - -class SvdDevsNotFound(Exception): - pass - - -class SvdSviNotFound(Exception): - pass - - -class SvdNotFound(Exception): - pass - - -class SvdNetlinkError(Exception): - pass diff --git a/neutron/agent/ovn/extensions/evpn/svd.py b/neutron/agent/ovn/extensions/evpn/svd.py new file mode 100644 index 00000000000..d5bdbe21fab --- /dev/null +++ b/neutron/agent/ovn/extensions/evpn/svd.py @@ -0,0 +1,43 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron.agent.linux import svd +from neutron.agent.ovn.extensions.evpn import constants as evpn_const + + +class EvpnSvd(svd.Svd): + """EVPN-aware SVD that manages SVI interface names internally. + + The base Svd requires callers to supply an explicit ``svi_name``. + EvpnSvd derives the name from ``EVPN_VLAN_IFNAME_PATTERN`` using + the SVD index and the VLAN ID, and keeps track of the VNI -> + svi_name mapping so that callers only deal with VNI/VID + identifiers. + """ + + def __init__(self, br_evpn, vxlan_evpn, index=0): + super().__init__(br_evpn, vxlan_evpn) + self._index = index + self._svi_names = {} + + def add_vni(self, vni, vid, vrf_name, mac): + svi_name = evpn_const.EVPN_VLAN_IFNAME_PATTERN % { + 'index': self._index, 'vid': vid} + super().add_vni(svi_name, vni, vid, vrf_name, mac) + self._svi_names[vni] = svi_name + + def del_vni(self, vni, vid): + svi_name = self._svi_names.pop(vni) + super().del_vni(svi_name, vni, vid) diff --git a/neutron/privileged/agent/linux/svd.py b/neutron/privileged/agent/linux/svd.py index 02724ce8213..ec55882093c 100644 --- a/neutron/privileged/agent/linux/svd.py +++ b/neutron/privileged/agent/linux/svd.py @@ -22,6 +22,7 @@ from pyroute2.netlink.rtnl import ifinfmsg from pyroute2.netlink.rtnl.ifinfmsg.plugins import vxlan +from neutron.agent.linux import nl_constants as nl_const from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron import privileged from neutron.privileged.agent.linux import ip_lib as priv_ip_lib @@ -70,6 +71,17 @@ def register_vxlan_vnifilter(): module={'vxlan': EvpnVxLAN}) +@privileged.default.entrypoint +def reset_vxlan_vnifilter_nla(): + """Force recompilation of the EvpnVxLAN NLA table in the daemon. + + Only needed in functional tests where an earlier test may have + compiled the parent vxlan NLA (without IFLA_VXLAN_VNIFILTER) + in the same privsep daemon process. + """ + EvpnVxLAN._nlmsg_base__compiled_nla = False + + class TunnelMsg(netlink.nlmsg): """Netlink message for RTM_NEWTUNNEL / RTM_DELTUNNEL. @@ -169,7 +181,7 @@ def create_svd(br_evpn, vxlan_evpn, local_ip, mac, vxlan_parent, dstport): # ip link add vxlan \ # dev dstport local \ # no learning external vnifilter - ipr.link(evpn_const.EVPN_IP_LINK_ADD, ifname=vxlan_evpn, kind='vxlan', + ipr.link(nl_const.IP_LINK_ADD, ifname=vxlan_evpn, kind='vxlan', vxlan_link=vxlan_parent_idx, vxlan_port=dstport, vxlan_local=local_ip, @@ -183,10 +195,10 @@ def create_svd(br_evpn, vxlan_evpn, local_ip, mac, vxlan_parent, dstport): # vlan_default_pvid 0 # ip link set address # ip link set up - ipr.link(evpn_const.EVPN_IP_LINK_ADD, ifname=br_evpn, kind='bridge', + ipr.link(nl_const.IP_LINK_ADD, ifname=br_evpn, kind='bridge', br_vlan_filtering=1, br_vlan_default_pvid=0) br_idx = ipr.link_lookup(ifname=br_evpn)[0] - ipr.link(evpn_const.EVPN_IP_LINK_SET, index=br_idx, address=mac, + ipr.link(nl_const.IP_LINK_SET, index=br_idx, address=mac, state='up') # Equivalent to: @@ -194,15 +206,15 @@ def create_svd(br_evpn, vxlan_evpn, local_ip, mac, vxlan_parent, dstport): # ip link set up # bridge link set dev vlan_tunnel on neigh_suppress on \ # learning off - ipr.link(evpn_const.EVPN_IP_LINK_SET, index=vxlan_idx, address=mac, + ipr.link(nl_const.IP_LINK_SET, index=vxlan_idx, address=mac, master=br_idx, state='up') - ipr.brport(evpn_const.EVPN_IP_LINK_SET, index=vxlan_idx, + ipr.brport(nl_const.IP_LINK_SET, index=vxlan_idx, vlan_tunnel=1, neigh_suppress=1, learning=0) # Equivalent to: # ip link set mtu 1500 addrgenmode none # ip link set addrgenmode none - ipr.link(evpn_const.EVPN_IP_LINK_SET, index=br_idx, + ipr.link(nl_const.IP_LINK_SET, index=br_idx, mtu=evpn_const.EVPN_BR_MTU) _set_addrgenmode_none(ipr, br_idx) _set_addrgenmode_none(ipr, vxlan_idx) @@ -218,14 +230,14 @@ def delete_svd(br_evpn, vxlan_evpn): with priv_ip_lib.get_iproute(None) as ipr: vxlan_idx = ipr.link_lookup(ifname=vxlan_evpn)[0] br_idx = ipr.link_lookup(ifname=br_evpn)[0] - ipr.link(evpn_const.EVPN_IP_LINK_DEL, index=vxlan_idx) - ipr.link(evpn_const.EVPN_IP_LINK_DEL, index=br_idx) + ipr.link(nl_const.IP_LINK_DEL, index=vxlan_idx) + ipr.link(nl_const.IP_LINK_DEL, index=br_idx) LOG.debug("Deleted SVD: bridge %s, vxlan %s", br_evpn, vxlan_evpn) @privileged.default.entrypoint -def add_vni(br_evpn, vxlan_evpn, vni, vid, vrf_name, mac, index): +def add_vni(br_evpn, vxlan_evpn, svi_name, vni, vid, vrf_name, mac): with priv_ip_lib.get_iproute(None) as ipr: br_idx = ipr.link_lookup(ifname=br_evpn)[0] vxlan_idx = ipr.link_lookup(ifname=vxlan_evpn)[0] @@ -236,10 +248,10 @@ def add_vni(br_evpn, vxlan_evpn, vni, vid, vrf_name, mac, index): # bridge vlan add dev vid # bridge vlan add dev vid \ # tunnel_info id - ipr.vlan_filter(evpn_const.EVPN_IP_LINK_ADD, index=br_idx, + ipr.vlan_filter(nl_const.IP_LINK_ADD, index=br_idx, vlan_info={'vid': vid}, vlan_flags='self') - ipr.vlan_filter(evpn_const.EVPN_IP_LINK_ADD, index=vxlan_idx, + ipr.vlan_filter(nl_const.IP_LINK_ADD, index=vxlan_idx, vlan_info={'vid': vid}, vlan_tunnel_info={'vid': vid, 'id': vni}) @@ -252,12 +264,10 @@ def add_vni(br_evpn, vxlan_evpn, vni, vid, vrf_name, mac, index): # ip link set master # ip link set addr addrgenmode none # ip link set up - svi_name = evpn_const.EVPN_VLAN_IFNAME_PATTERN % { - 'index': index, 'vid': vid} - ipr.link(evpn_const.EVPN_IP_LINK_ADD, ifname=svi_name, kind='vlan', + ipr.link(nl_const.IP_LINK_ADD, ifname=svi_name, kind='vlan', link=br_idx, vlan_id=vid) svi_idx = ipr.link_lookup(ifname=svi_name)[0] - ipr.link(evpn_const.EVPN_IP_LINK_SET, index=svi_idx, + ipr.link(nl_const.IP_LINK_SET, index=svi_idx, master=vrf_idx, address=mac, mtu=evpn_const.EVPN_BR_MTU, state='up') _set_addrgenmode_none(ipr, svi_idx) @@ -267,17 +277,15 @@ def add_vni(br_evpn, vxlan_evpn, vni, vid, vrf_name, mac, index): @privileged.default.entrypoint -def del_vni(br_evpn, vxlan_evpn, vni, vid, index): +def del_vni(br_evpn, vxlan_evpn, svi_name, vni, vid): with priv_ip_lib.get_iproute(None) as ipr: br_idx = ipr.link_lookup(ifname=br_evpn)[0] vxlan_idx = ipr.link_lookup(ifname=vxlan_evpn)[0] # Equivalent to: # ip link del - svi_name = evpn_const.EVPN_VLAN_IFNAME_PATTERN % { - 'index': index, 'vid': vid} svi_idx = ipr.link_lookup(ifname=svi_name)[0] - ipr.link(evpn_const.EVPN_IP_LINK_DEL, index=svi_idx) + ipr.link(nl_const.IP_LINK_DEL, index=svi_idx) # Equivalent to: # bridge vni del dev vni @@ -286,10 +294,10 @@ def del_vni(br_evpn, vxlan_evpn, vni, vid, index): # tunnel_info id # bridge vlan del dev vid self _bridge_del_vni(ipr, vxlan_idx, vni) - ipr.vlan_filter(evpn_const.EVPN_IP_LINK_DEL, index=vxlan_idx, + ipr.vlan_filter(nl_const.IP_LINK_DEL, index=vxlan_idx, vlan_info={'vid': vid}, vlan_tunnel_info={'vid': vid, 'id': vni}) - ipr.vlan_filter(evpn_const.EVPN_IP_LINK_DEL, index=br_idx, + ipr.vlan_filter(nl_const.IP_LINK_DEL, index=br_idx, vlan_info={'vid': vid}, vlan_flags='self') diff --git a/neutron/tests/functional/agent/linux/base.py b/neutron/tests/functional/agent/linux/base.py index 47cfa030c9b..361f118a9c5 100644 --- a/neutron/tests/functional/agent/linux/base.py +++ b/neutron/tests/functional/agent/linux/base.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron.privileged.agent.linux import svd as privileged_svd from neutron.tests.common.exclusive_resources import ip_address from neutron.tests.functional import base @@ -33,3 +34,13 @@ def get_test_net_address(self, block): """ return str(self.useFixture( ip_address.get_test_net_address_fixture(block)).address) + + +class BaseNetlinkTestCase(base.BaseSudoTestCase): + def setUp(self): + super().setUp() + self.register_vxlan_vnifilter() + + def register_vxlan_vnifilter(self): + privileged_svd.reset_vxlan_vnifilter_nla() + privileged_svd.register_vxlan_vnifilter() diff --git a/neutron/tests/functional/agent/linux/test_svd.py b/neutron/tests/functional/agent/linux/test_svd.py new file mode 100644 index 00000000000..1ba82a207f9 --- /dev/null +++ b/neutron/tests/functional/agent/linux/test_svd.py @@ -0,0 +1,240 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron.agent.linux import ip_lib +from neutron.agent.linux import svd as linux_svd +from neutron.agent.linux import utils as agent_utils +from neutron.common import utils +from neutron.privileged.agent.linux import ip_lib as privileged +from neutron.tests.functional.agent.linux import base + + +class TestSvdFunctional(base.BaseNetlinkTestCase): + + DSTPORT = 15000 + LOCAL_IP = '10.10.10.10' + MAC = 'aa:bb:cc:dd:ee:ff' + SVI_MAC = '00:11:22:33:44:55' + + @staticmethod + def _safe_delete(name): + try: + ip_lib.IPDevice(name).link.delete() + except Exception: + pass + + @staticmethod + def _set_link_up(name): + agent_utils.execute( + ['ip', 'link', 'set', name, 'up'], + run_as_root=True, privsep_exec=True) + + def setUp(self): + super().setUp() + self._br = utils.get_rand_name(15, 'brevpn-') + self._vx = utils.get_rand_name(15, 'vxevpn-') + self._svi_names = {} + # VNIs are a system-global resource (vnifilter mode), so parallel + # test workers must not share VNI values. Derive a unique base + # from the test method's position in the sorted method list. + vni_methods = sorted( + m for m in dir(self) if m.startswith('test_')) + method_idx = vni_methods.index(self._testMethodName) + self._base_vni = 5000 + method_idx * 10 + + self._parent = utils.get_rand_name(15, 'svdp-') + privileged.create_interface(self._parent, None, 'dummy') + self._set_link_up(self._parent) + ip_lib.IPDevice(self._parent).addr.add(self.LOCAL_IP + '/32') + self.addCleanup(self._safe_delete, self._parent) + + self._vrf = utils.get_rand_name(15, 'svdr-') + privileged.create_interface(self._vrf, None, 'vrf', vrf_table=9999) + self._set_link_up(self._vrf) + self.addCleanup(self._safe_delete, self._vrf) + + def _create_svd(self): + brvxlan = linux_svd.Svd(br_evpn=self._br, vxlan_evpn=self._vx) + brvxlan.create(local_ip=self.LOCAL_IP, mac=self.MAC, + vxlan_parent=self._parent, dstport=self.DSTPORT) + self.addCleanup(self._safe_delete, self._vx) + self.addCleanup(self._safe_delete, self._br) + return brvxlan + + def _svi_name(self, vid): + return self._svi_names.setdefault(vid, utils.get_rand_name(15, 'vl-')) + + def _vid(self, offset=0): + return 100 + offset + + def _vni(self, offset=0): + return self._base_vni + offset + + def _bridge_cmd(self, *args): + return agent_utils.execute( + ['bridge'] + list(args), + run_as_root=True, privsep_exec=True) + + def test_create_svd(self): + self._create_svd() + + self.assertTrue(ip_lib.device_exists(self._br)) + self.assertTrue(ip_lib.device_exists(self._vx)) + + br_output = agent_utils.execute( + ['ip', '-d', 'link', 'show', self._br], + run_as_root=True, privsep_exec=True) + self.assertIn('vlan_filtering 1', br_output) + self.assertIn('vlan_default_pvid 0', br_output) + self.assertIn('mtu 1500', br_output) + self.assertIn('addrgenmode none', br_output) + + vx_output = agent_utils.execute( + ['ip', '-d', 'link', 'show', self._vx], + run_as_root=True, privsep_exec=True) + self.assertIn('vnifilter', vx_output) + self.assertIn('external', vx_output) + self.assertIn('addrgenmode none', vx_output) + + def test_create_svd_parent_not_found(self): + brvxlan = linux_svd.Svd(br_evpn=self._br, vxlan_evpn=self._vx) + self.assertRaises(linux_svd.SvdNoVxlanParent, brvxlan.create, + local_ip=self.LOCAL_IP, mac=self.MAC, + vxlan_parent='no-such-dev', + dstport=self.DSTPORT) + self.assertFalse(ip_lib.device_exists(self._br)) + self.assertFalse(ip_lib.device_exists(self._vx)) + + def test_create_svd_device_exists(self): + self._create_svd() + brvxlan = linux_svd.Svd(br_evpn=self._br, vxlan_evpn=self._vx) + self.assertRaises(linux_svd.SvdDeviceAlreadyExists, brvxlan.create, + local_ip=self.LOCAL_IP, mac=self.MAC, + vxlan_parent=self._parent, + dstport=self.DSTPORT) + + def test_delete_svd(self): + svd = self._create_svd() + + svd.delete() + + self.assertFalse(ip_lib.device_exists(self._br)) + self.assertFalse(ip_lib.device_exists(self._vx)) + + def test_delete_svd_not_found(self): + brvxlan = linux_svd.Svd(br_evpn=self._br, vxlan_evpn=self._vx) + self.assertRaises(linux_svd.SvdNotFound, brvxlan.delete) + + def test_add_vni(self): + svd = self._create_svd() + + vni = self._vni() + vid = self._vid() + svi_name = self._svi_name(vid) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + self.assertTrue(ip_lib.device_exists(svi_name)) + + br_vlans = self._bridge_cmd('vlan', 'show', 'dev', self._br) + self.assertRegex(br_vlans, r'\b%s\b' % vid) + + vx_vlans = self._bridge_cmd('vlan', 'show', 'dev', self._vx) + self.assertRegex(vx_vlans, r'\b%s\b' % vid) + + vni_output = self._bridge_cmd('vni', 'show', 'dev', self._vx) + self.assertIn(str(vni), vni_output) + + def test_add_vni_vrf_not_found(self): + brvxlan = self._create_svd() + vni = self._vni() + vid = self._vid() + svi_name = self._svi_name(vid) + self.assertRaises(linux_svd.SvdDevsNotFound, brvxlan.add_vni, + svi_name, vni, vid, 'no-such-vrf', self.SVI_MAC) + self.assertFalse(ip_lib.device_exists(svi_name)) + vni_output = self._bridge_cmd('vni', 'show', 'dev', self._vx) + self.assertNotIn(str(vni), vni_output) + + def test_add_vni_netlink_error(self): + svd = self._create_svd() + vni = self._vni() + vid = self._vid() + svi_name = self._svi_name(vid) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + self.addCleanup(svd.del_vni, svi_name, vni, vid) + # Add the same VNI a second time to trigger NetlinkError + self.assertRaises(linux_svd.SvdNetlinkError, svd.add_vni, + svi_name, vni, vid, self._vrf, self.SVI_MAC) + + def test_del_vni(self): + svd = self._create_svd() + vni = self._vni() + vid = self._vid() + svi_name = self._svi_name(vid) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + + svd.del_vni(svi_name, vni, vid) + + self.assertFalse(ip_lib.device_exists(svi_name)) + + vni_output = self._bridge_cmd('vni', 'show', 'dev', self._vx) + self.assertNotIn(str(vni), vni_output) + + def test_del_vni_svi_not_found(self): + svd = self._create_svd() + self.assertRaises(linux_svd.SvdSviNotFound, svd.del_vni, + self._svi_name(self._vid()), self._vni(), + self._vid()) + + def test_add_multiple_vnis(self): + svd = self._create_svd() + + vni1 = self._vni() + vid1 = self._vid() + vni2 = self._vni(1) + vid2 = self._vid(1) + svi_name1 = self._svi_name(vid1) + svi_name2 = self._svi_name(vid2) + svd.add_vni(svi_name1, vni1, vid1, self._vrf, self.SVI_MAC) + svd.add_vni(svi_name2, vni2, vid2, self._vrf, self.SVI_MAC) + + self.assertTrue(ip_lib.device_exists(svi_name1)) + self.assertTrue(ip_lib.device_exists(svi_name2)) + + vni_output = self._bridge_cmd('vni', 'show', 'dev', self._vx) + self.assertIn(str(vni1), vni_output) + self.assertIn(str(vni2), vni_output) + + svd.del_vni(svi_name1, vni1, vid1) + self.assertFalse(ip_lib.device_exists(svi_name1)) + self.assertTrue(ip_lib.device_exists(svi_name2)) + + svd.del_vni(svi_name2, vni2, vid2) + + def test_svi_attached_to_vrf(self): + svd = self._create_svd() + + vni = self._vni() + vid = self._vid() + svi_name = self._svi_name(vid) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + + link_output = agent_utils.execute( + ['ip', '-d', 'link', 'show', svi_name], + run_as_root=True, privsep_exec=True) + self.assertIn(self.SVI_MAC, link_output) + self.assertIn('master %s' % self._vrf, link_output) + self.assertIn('state UP', link_output) + + svd.del_vni(svi_name, vni, vid) diff --git a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py index 9cfd4ecccb2..5c06416fe6f 100644 --- a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py @@ -13,11 +13,13 @@ # License for the specific language governing permissions and limitations # under the License. +import uuid + from pyroute2.netlink import rtnl from neutron.agent.linux import ip_lib +from neutron.agent.linux import nl_constants as nl_const from neutron.agent.linux import nl_dispatcher -from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import fsm from neutron.agent.ovn.extensions.evpn import netlink_monitor from neutron.common import utils @@ -34,20 +36,24 @@ def _safe_delete(name): except Exception: pass + @staticmethod + def _evpn_vrf_name(): + return 'vr%s' % uuid.uuid4().hex[:12] + def test_vrf_handler_lifecycle(self): vrf_handler = netlink_monitor.VrfHandler(fsm.EvpnFSM()) dispatcher = nl_dispatcher.NetlinkDispatcher(rtnl.RTMGRP_LINK) dispatcher.register_handler( - evpn_const.EVPN_RTM_NEWLINK, vrf_handler.handle_newlink) + nl_const.RTM_NEWLINK, vrf_handler.handle_newlink) dispatcher.register_handler( - evpn_const.EVPN_RTM_DELLINK, vrf_handler.handle_dellink) + nl_const.RTM_DELLINK, vrf_handler.handle_dellink) dispatcher.register_replay_callbacks( on_start=vrf_handler.replay_start, on_end=vrf_handler.replay_end) # Create a VRF before starting the dispatcher so replay discovers it. - preexisting_vrf = 'vr0a1b2c3d-fff' + preexisting_vrf = self._evpn_vrf_name() privileged.create_interface(preexisting_vrf, None, 'vrf', vrf_table=100) self.addCleanup(self._safe_delete, preexisting_vrf) @@ -58,7 +64,7 @@ def test_vrf_handler_lifecycle(self): timeout=10, sleep=0.1) # Create a VRF after start — live newlink detection. - live_vrf = 'vr1a2b3c3d-eee' + live_vrf = self._evpn_vrf_name() privileged.create_interface(live_vrf, None, 'vrf', vrf_table=200) self.addCleanup(self._safe_delete, live_vrf) utils.wait_until_true( @@ -81,7 +87,9 @@ def test_vrf_handler_lifecycle(self): utils.wait_until_true( lambda: ip_lib.device_exists('testdummy'), timeout=10, sleep=0.1) - self.assertEqual(baseline, vrf_handler._known_vrfs) + utils.wait_until_true( + lambda: vrf_handler._known_vrfs.issubset(baseline), + timeout=10, sleep=0.1) # VRF with non-EVPN name is ignored. non_evpn_vrf = 'myvrf-300' diff --git a/neutron/tests/unit/agent/linux/test_nl_dispatcher.py b/neutron/tests/unit/agent/linux/test_nl_dispatcher.py index e0ca53f0f0d..6e0be762a78 100644 --- a/neutron/tests/unit/agent/linux/test_nl_dispatcher.py +++ b/neutron/tests/unit/agent/linux/test_nl_dispatcher.py @@ -20,8 +20,8 @@ from pyroute2.netlink import rtnl from pyroute2.netlink.rtnl.ifinfmsg import ifinfmsg +from neutron.agent.linux import nl_constants as nl_const from neutron.agent.linux import nl_dispatcher -from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.tests import base @@ -51,34 +51,34 @@ def setUp(self): def test_register_handler(self): handler1 = mock.Mock() handler2 = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler1) - self.dispatcher.register_handler(evpn_const.EVPN_RTM_DELLINK, handler2) - self.assertIn(evpn_const.EVPN_RTM_NEWLINK, self.dispatcher._handlers) - self.assertIn(evpn_const.EVPN_RTM_DELLINK, self.dispatcher._handlers) + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler1) + self.dispatcher.register_handler(nl_const.RTM_DELLINK, handler2) + self.assertIn(nl_const.RTM_NEWLINK, self.dispatcher._handlers) + self.assertIn(nl_const.RTM_DELLINK, self.dispatcher._handlers) self.assertIs( - self.dispatcher._handlers[evpn_const.EVPN_RTM_NEWLINK], handler1) + self.dispatcher._handlers[nl_const.RTM_NEWLINK], handler1) self.assertIs( - self.dispatcher._handlers[evpn_const.EVPN_RTM_DELLINK], handler2) + self.dispatcher._handlers[nl_const.RTM_DELLINK], handler2) def test_dispatch_routes_to_matching_handler(self): handler = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler) - msg = _make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK) + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler) + msg = _make_nlmsg('eth0', nl_const.RTM_NEWLINK) self.dispatcher._dispatch(msg) handler.assert_called_once_with(msg) def test_dispatch_ignores_unregistered_event(self): handler = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler) + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler) msg = _make_nlmsg('eth0', 'RTM_NEWADDR') self.dispatcher._dispatch(msg) handler.assert_not_called() def test_replay_dispatches_dump_messages(self): handler = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler) - msgs = [_make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK), - _make_nlmsg('eth1', evpn_const.EVPN_RTM_NEWLINK)] + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler) + msgs = [_make_nlmsg('eth0', nl_const.RTM_NEWLINK), + _make_nlmsg('eth1', nl_const.RTM_NEWLINK)] self.mock_ipr.dump.return_value = msgs self.dispatcher._replay(self.mock_ipr) self.assertEqual(2, handler.call_count) @@ -96,10 +96,10 @@ def test_start_spawns_daemon_thread(self, mock_thread_cls): def test_replay_calls_start_before_dispatch_and_end_after(self): tracker = mock.Mock() self.dispatcher.register_handler( - evpn_const.EVPN_RTM_NEWLINK, tracker.dispatch) + nl_const.RTM_NEWLINK, tracker.dispatch) self.dispatcher.register_replay_callbacks( on_start=tracker.start, on_end=tracker.end) - msg = _make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK) + msg = _make_nlmsg('eth0', nl_const.RTM_NEWLINK) self.mock_ipr.dump.return_value = [msg] self.dispatcher._replay(self.mock_ipr) tracker.assert_has_calls( @@ -108,11 +108,11 @@ def test_replay_calls_start_before_dispatch_and_end_after(self): def test_replay_callbacks_called_once_per_replay(self): tracker = mock.Mock() self.dispatcher.register_handler( - evpn_const.EVPN_RTM_NEWLINK, tracker.dispatch) + nl_const.RTM_NEWLINK, tracker.dispatch) self.dispatcher.register_replay_callbacks( on_start=tracker.start, on_end=tracker.end) - msgs = [_make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK), - _make_nlmsg('eth1', evpn_const.EVPN_RTM_NEWLINK)] + msgs = [_make_nlmsg('eth0', nl_const.RTM_NEWLINK), + _make_nlmsg('eth1', nl_const.RTM_NEWLINK)] self.mock_ipr.dump.return_value = msgs self.dispatcher._replay(self.mock_ipr) tracker.start.assert_called_once() @@ -143,16 +143,16 @@ def _run_loop(self): def test_loop_dispatches_messages(self): handler = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler) - msg = _make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK) + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler) + msg = _make_nlmsg('eth0', nl_const.RTM_NEWLINK) self.mock_ipr.get.side_effect = [[msg], RuntimeError] self._run_loop() handler.assert_called_once_with(msg) def test_loop_opens_sock_and_replays_on_start(self): handler = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler) - msg = _make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK) + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler) + msg = _make_nlmsg('eth0', nl_const.RTM_NEWLINK) self.mock_ipr.dump.return_value = [msg] self.mock_ipr.get.side_effect = RuntimeError self._run_loop() @@ -162,8 +162,8 @@ def test_loop_opens_sock_and_replays_on_start(self): def test_enobufs_triggers_replay(self): handler = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler) - msg = _make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK) + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler) + msg = _make_nlmsg('eth0', nl_const.RTM_NEWLINK) self.mock_ipr.dump.return_value = [msg] self.mock_ipr.get.side_effect = [ OSError(errno.ENOBUFS, 'No buffer space'), @@ -186,8 +186,8 @@ def test_socket_error_reopens_with_backoff(self): def test_socket_error_retries_reset_on_success(self): handler = mock.Mock() - self.dispatcher.register_handler(evpn_const.EVPN_RTM_NEWLINK, handler) - msg = _make_nlmsg('eth0', evpn_const.EVPN_RTM_NEWLINK) + self.dispatcher.register_handler(nl_const.RTM_NEWLINK, handler) + msg = _make_nlmsg('eth0', nl_const.RTM_NEWLINK) self.mock_ipr.get.side_effect = [ OSError(errno.EBADF, 'Bad file descriptor'), [msg], From 022c7bd18c0170a5992ad0380507fb9ae91e135e Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Mon, 8 Jun 2026 16:35:51 +0200 Subject: [PATCH 11/32] Remove contrib module from neutron.tests This old module was just left overs from the dark old ages before Zuul v3 and jobs defined in its native way. Migration to zuul v3 was done many years ago and those scripts which were in neutron.tests.contrib are not used anymore. The only exception is `testing.filters` file which contains rootwrap filters to use in the CI jobs like e.g. functional tests. This file is now moved to the `tools/rootwrap/testing.filters` location which is "closer" to the scripts which actually are using it. Change-Id: I9199596a06c84d6f6b84ff43bc95d04a31074685 Signed-off-by: Slawek Kaplonski --- neutron/tests/contrib/README | 3 - neutron/tests/contrib/gate_hook.sh | 101 ------------------ .../tests/contrib/hooks/api_all_extensions | 88 --------------- neutron/tests/contrib/hooks/availability_zone | 14 --- neutron/tests/contrib/hooks/disable_dvr | 4 - neutron/tests/contrib/hooks/disable_dvr_tests | 1 - neutron/tests/contrib/hooks/dns | 1 - neutron/tests/contrib/hooks/dvr | 9 -- neutron/tests/contrib/hooks/log | 6 -- .../tests/contrib/hooks/network_segment_range | 1 - .../contrib/hooks/openvswitch_type_drivers | 18 ---- neutron/tests/contrib/hooks/osprofiler | 7 -- neutron/tests/contrib/hooks/qos | 2 - neutron/tests/contrib/hooks/quotas | 8 -- neutron/tests/contrib/hooks/segments | 1 - neutron/tests/contrib/hooks/trunk | 1 - neutron/tests/contrib/hooks/tunnel_types | 6 -- neutron/tests/contrib/hooks/ubuntu_image | 10 -- .../contrib/hooks/uplink_status_propagation | 1 - neutron/tests/contrib/hooks/vlan_provider | 9 -- tools/deploy_rootwrap.sh | 2 +- .../rootwrap}/testing.filters | 0 22 files changed, 1 insertion(+), 292 deletions(-) delete mode 100644 neutron/tests/contrib/README delete mode 100755 neutron/tests/contrib/gate_hook.sh delete mode 100644 neutron/tests/contrib/hooks/api_all_extensions delete mode 100644 neutron/tests/contrib/hooks/availability_zone delete mode 100644 neutron/tests/contrib/hooks/disable_dvr delete mode 100644 neutron/tests/contrib/hooks/disable_dvr_tests delete mode 100644 neutron/tests/contrib/hooks/dns delete mode 100644 neutron/tests/contrib/hooks/dvr delete mode 100644 neutron/tests/contrib/hooks/log delete mode 100644 neutron/tests/contrib/hooks/network_segment_range delete mode 100644 neutron/tests/contrib/hooks/openvswitch_type_drivers delete mode 100644 neutron/tests/contrib/hooks/osprofiler delete mode 100644 neutron/tests/contrib/hooks/qos delete mode 100644 neutron/tests/contrib/hooks/quotas delete mode 100644 neutron/tests/contrib/hooks/segments delete mode 100644 neutron/tests/contrib/hooks/trunk delete mode 100644 neutron/tests/contrib/hooks/tunnel_types delete mode 100644 neutron/tests/contrib/hooks/ubuntu_image delete mode 100644 neutron/tests/contrib/hooks/uplink_status_propagation delete mode 100644 neutron/tests/contrib/hooks/vlan_provider rename {neutron/tests/contrib => tools/rootwrap}/testing.filters (100%) diff --git a/neutron/tests/contrib/README b/neutron/tests/contrib/README deleted file mode 100644 index a73d75af992..00000000000 --- a/neutron/tests/contrib/README +++ /dev/null @@ -1,3 +0,0 @@ -The files in this directory are intended for use by the -Neutron infra jobs that run the various functional test -suites in the gate. diff --git a/neutron/tests/contrib/gate_hook.sh b/neutron/tests/contrib/gate_hook.sh deleted file mode 100755 index 0c7e34931fe..00000000000 --- a/neutron/tests/contrib/gate_hook.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -VENV=${1:-"api"} -FLAVOR=${2:-"all"} - -GATE_DEST=$BASE/new -NEUTRON_DIR=$GATE_DEST/neutron -GATE_HOOKS=$NEUTRON_DIR/neutron/tests/contrib/hooks -DEVSTACK_PATH=$GATE_DEST/devstack -LOCAL_CONF=$DEVSTACK_PATH/late-local.conf -RALLY_EXTRA_DIR=$NEUTRON_DIR/rally-jobs/extra -DSCONF=/tmp/devstack-tools/bin/dsconf - -# Install devstack-tools used to produce local.conf; we can't rely on -# test-requirements.txt because the gate hook is triggered before neutron is -# installed -sudo -H pip install virtualenv -virtualenv /tmp/devstack-tools -/tmp/devstack-tools/bin/pip install -U devstack-tools==0.4.0 - -# Inject config from hook into localrc -function load_rc_hook { - local hook="$1" - local tmpfile - local config - tmpfile=$(mktemp) - config=$(cat $GATE_HOOKS/$hook) - echo "[[local|localrc]]" > $tmpfile - $DSCONF setlc_raw $tmpfile "$config" - $DSCONF merge_lc $LOCAL_CONF $tmpfile - rm -f $tmpfile -} - - -# Inject config from hook into local.conf -function load_conf_hook { - local hook="$1" - $DSCONF merge_lc $LOCAL_CONF $GATE_HOOKS/$hook -} - - -# Tweak gate configuration for our rally scenarios -function load_rc_for_rally { - for file in $(ls $RALLY_EXTRA_DIR/*.setup); do - tmpfile=$(mktemp) - config=$(cat $file) - echo "[[local|localrc]]" > $tmpfile - $DSCONF setlc_raw $tmpfile "$config" - $DSCONF merge_lc $LOCAL_CONF $tmpfile - rm -f $tmpfile - done -} - - -case $VENV in -"api"|"api-pecan"|"full-pecan"|"dsvm-scenario-ovs") - # TODO(ihrachys) consider feeding result of ext-list into tempest.conf - load_rc_hook api_all_extensions - if [ "${FLAVOR}" = "dvrskip" ]; then - load_rc_hook disable_dvr_tests - fi - load_conf_hook quotas - load_rc_hook uplink_status_propagation - load_rc_hook dns - load_rc_hook qos - load_rc_hook segments - load_rc_hook trunk - load_rc_hook network_segment_range - load_conf_hook vlan_provider - load_conf_hook osprofiler - load_conf_hook availability_zone - load_conf_hook tunnel_types - load_rc_hook log # bug 1743463 - load_conf_hook openvswitch_type_drivers - if [[ "$VENV" =~ "dsvm-scenario" ]]; then - load_rc_hook ubuntu_image - fi - if [[ "$VENV" =~ "pecan" ]]; then - load_conf_hook pecan - fi - if [[ "$FLAVOR" = "dvrskip" ]]; then - load_conf_hook disable_dvr - fi - if [[ "$VENV" =~ "dsvm-scenario-ovs" ]]; then - load_conf_hook dvr - fi - ;; - -"rally") - load_rc_for_rally - ;; - -*) - echo "Unrecognized environment $VENV". - exit 1 -esac - -export DEVSTACK_LOCALCONF=$(cat $LOCAL_CONF) -$BASE/new/devstack-gate/devstack-vm-gate.sh diff --git a/neutron/tests/contrib/hooks/api_all_extensions b/neutron/tests/contrib/hooks/api_all_extensions deleted file mode 100644 index 5d0b2b97151..00000000000 --- a/neutron/tests/contrib/hooks/api_all_extensions +++ /dev/null @@ -1,88 +0,0 @@ -# Keep entries alphabetically -# NOTE: The first entry should not use '+=' and a comma. -NETWORK_API_EXTENSIONS="address-group" -NETWORK_API_EXTENSIONS+=",address-scope" -NETWORK_API_EXTENSIONS+=",agent" -NETWORK_API_EXTENSIONS+=",allowed-address-pairs" -NETWORK_API_EXTENSIONS+=",auto-allocated-topology" -NETWORK_API_EXTENSIONS+=",availability_zone" -NETWORK_API_EXTENSIONS+=",availability_zone_filter" -NETWORK_API_EXTENSIONS+=",binding" -NETWORK_API_EXTENSIONS+=",binding-extended" -NETWORK_API_EXTENSIONS+=",default-subnetpools" -NETWORK_API_EXTENSIONS+=",dhcp_agent_scheduler" -NETWORK_API_EXTENSIONS+=",dns-integration" -NETWORK_API_EXTENSIONS+=",dvr" -NETWORK_API_EXTENSIONS+=",empty-string-filtering" -NETWORK_API_EXTENSIONS+=",ext-gw-mode" -NETWORK_API_EXTENSIONS+=",external-gateway-multihoming" -NETWORK_API_EXTENSIONS+=",external-net" -NETWORK_API_EXTENSIONS+=",extra_dhcp_opt" -NETWORK_API_EXTENSIONS+=",extraroute" -NETWORK_API_EXTENSIONS+=",filter-validation" -NETWORK_API_EXTENSIONS+=",fip-port-details" -NETWORK_API_EXTENSIONS+=",flavors" -NETWORK_API_EXTENSIONS+=",floatingip-pools" -NETWORK_API_EXTENSIONS+=",ip-substring-filtering" -NETWORK_API_EXTENSIONS+=",l3-conntrack-helper" -NETWORK_API_EXTENSIONS+=",l3-flavors" -NETWORK_API_EXTENSIONS+=",l3-ha" -NETWORK_API_EXTENSIONS+=",l3_agent_scheduler" -NETWORK_API_EXTENSIONS+=",l3-port-ip-change-not-allowed" -NETWORK_API_EXTENSIONS+=",logging" -NETWORK_API_EXTENSIONS+=",metering" -NETWORK_API_EXTENSIONS+=",multi-provider" -NETWORK_API_EXTENSIONS+=",net-mtu" -NETWORK_API_EXTENSIONS+=",net-mtu-writable" -NETWORK_API_EXTENSIONS+=",network-ip-availability" -NETWORK_API_EXTENSIONS+=",network_availability_zone" -NETWORK_API_EXTENSIONS+=",network-segment-range" -NETWORK_API_EXTENSIONS+=",pagination" -NETWORK_API_EXTENSIONS+=",port-security" -NETWORK_API_EXTENSIONS+=",project-id" -NETWORK_API_EXTENSIONS+=",provider" -NETWORK_API_EXTENSIONS+=",qos" -NETWORK_API_EXTENSIONS+=",qos-fip" -NETWORK_API_EXTENSIONS+=",qos-gateway-ip" -NETWORK_API_EXTENSIONS+=",quotas" -NETWORK_API_EXTENSIONS+=",quota-check-limit" -NETWORK_API_EXTENSIONS+=",quota-check-limit-default" -NETWORK_API_EXTENSIONS+=",quota_details" -NETWORK_API_EXTENSIONS+=",rbac-policies" -NETWORK_API_EXTENSIONS+=",rbac-address-group" -NETWORK_API_EXTENSIONS+=",rbac-address-scope" -NETWORK_API_EXTENSIONS+=",rbac-security-groups" -NETWORK_API_EXTENSIONS+=",rbac-subnetpool" -NETWORK_API_EXTENSIONS+=",router" -NETWORK_API_EXTENSIONS+=",router-admin-state-down-before-update" -NETWORK_API_EXTENSIONS+=",router_availability_zone" -NETWORK_API_EXTENSIONS+=",router-enable_snat" -NETWORK_API_EXTENSIONS+=",security-group" -NETWORK_API_EXTENSIONS+=",security-groups-remote-address-group" -NETWORK_API_EXTENSIONS+=",security-groups-rules-belongs-to-default-sg" -NETWORK_API_EXTENSIONS+=",security-groups-shared-filtering" -NETWORK_API_EXTENSIONS+=",port-device-profile" -NETWORK_API_EXTENSIONS+=",port-hardware-offload-type" -NETWORK_API_EXTENSIONS+=",port-mac-address-regenerate" -NETWORK_API_EXTENSIONS+=",port-numa-affinity-policy" -NETWORK_API_EXTENSIONS+=",port-numa-affinity-policy-socket" -NETWORK_API_EXTENSIONS+=",port-security-groups-filtering" -NETWORK_API_EXTENSIONS+=",port-trusted-vif" -NETWORK_API_EXTENSIONS+=",segment" -NETWORK_API_EXTENSIONS+=",segments-peer-subnet-host-routes" -NETWORK_API_EXTENSIONS+=",service-type" -NETWORK_API_EXTENSIONS+=",sorting" -NETWORK_API_EXTENSIONS+=",standard-attr-description" -NETWORK_API_EXTENSIONS+=",standard-attr-revisions" -NETWORK_API_EXTENSIONS+=",standard-attr-segment" -NETWORK_API_EXTENSIONS+=",standard-attr-timestamp" -NETWORK_API_EXTENSIONS+=",standard-attr-tag" -NETWORK_API_EXTENSIONS+=",stateful-security-group" -NETWORK_API_EXTENSIONS+=",subnet_allocation" -NETWORK_API_EXTENSIONS+=",subnet-dns-publish-fixed-ip" -NETWORK_API_EXTENSIONS+=",subnet-external-network" -NETWORK_API_EXTENSIONS+=",tag-ports-during-bulk-creation" -NETWORK_API_EXTENSIONS+=",trunk" -NETWORK_API_EXTENSIONS+=",trunk-details" -NETWORK_API_EXTENSIONS+=",uplink-status-propagation" -NETWORK_API_EXTENSIONS+=",uplink-status-propagation-updatable" diff --git a/neutron/tests/contrib/hooks/availability_zone b/neutron/tests/contrib/hooks/availability_zone deleted file mode 100644 index 7c2ca0d19e9..00000000000 --- a/neutron/tests/contrib/hooks/availability_zone +++ /dev/null @@ -1,14 +0,0 @@ -[[test-config|$TEMPEST_CONFIG]] - -[neutron_plugin_options] -agent_availability_zone = nova - -[[post-config|/$NEUTRON_L3_CONF]] - -[agent] -availability_zone = nova - -[[post-config|/$NEUTRON_DHCP_CONF]] - -[agent] -availability_zone = nova diff --git a/neutron/tests/contrib/hooks/disable_dvr b/neutron/tests/contrib/hooks/disable_dvr deleted file mode 100644 index ac9cec3db52..00000000000 --- a/neutron/tests/contrib/hooks/disable_dvr +++ /dev/null @@ -1,4 +0,0 @@ -[[post-config|/$NEUTRON_CONF]] - -[DEFAULT] -enable_dvr=False diff --git a/neutron/tests/contrib/hooks/disable_dvr_tests b/neutron/tests/contrib/hooks/disable_dvr_tests deleted file mode 100644 index 4bde66ad9cf..00000000000 --- a/neutron/tests/contrib/hooks/disable_dvr_tests +++ /dev/null @@ -1 +0,0 @@ -DISABLE_NETWORK_API_EXTENSIONS="dvr" diff --git a/neutron/tests/contrib/hooks/dns b/neutron/tests/contrib/hooks/dns deleted file mode 100644 index 38dfb74be62..00000000000 --- a/neutron/tests/contrib/hooks/dns +++ /dev/null @@ -1 +0,0 @@ -enable_service neutron-dns diff --git a/neutron/tests/contrib/hooks/dvr b/neutron/tests/contrib/hooks/dvr deleted file mode 100644 index 2be1f0a2d2c..00000000000 --- a/neutron/tests/contrib/hooks/dvr +++ /dev/null @@ -1,9 +0,0 @@ -[[test-config|$TEMPEST_CONFIG]] - -[neutron_plugin_options] -l3_agent_mode = dvr_snat - -[[post-config|/$NEUTRON_L3_CONF]] - -[DEFAULT] -agent_mode = dvr_snat diff --git a/neutron/tests/contrib/hooks/log b/neutron/tests/contrib/hooks/log deleted file mode 100644 index 774b2a436a1..00000000000 --- a/neutron/tests/contrib/hooks/log +++ /dev/null @@ -1,6 +0,0 @@ -enable_service neutron-log - -[[post-config|/$NEUTRON_CORE_PLUGIN_CONF]] - -[network_log] -local_output_log_base = /tmp/test_log.log diff --git a/neutron/tests/contrib/hooks/network_segment_range b/neutron/tests/contrib/hooks/network_segment_range deleted file mode 100644 index 0c31dd2670c..00000000000 --- a/neutron/tests/contrib/hooks/network_segment_range +++ /dev/null @@ -1 +0,0 @@ -enable_service neutron-network-segment-range \ No newline at end of file diff --git a/neutron/tests/contrib/hooks/openvswitch_type_drivers b/neutron/tests/contrib/hooks/openvswitch_type_drivers deleted file mode 100644 index a7653e25be2..00000000000 --- a/neutron/tests/contrib/hooks/openvswitch_type_drivers +++ /dev/null @@ -1,18 +0,0 @@ -[[test-config|$TEMPEST_CONFIG]] - -[neutron_plugin_options] -available_type_drivers=flat,geneve,vlan,gre,local,vxlan - -[[post-config|/$NEUTRON_CORE_PLUGIN_CONF]] - -[ml2] -type_drivers=flat,geneve,vlan,gre,local,vxlan - -[ml2_type_vxlan] -vni_ranges = 1:1000 - -[ml2_type_gre] -tunnel_id_ranges = 1:1000 - -[ml2_type_geneve] -vni_ranges = 1:1000 diff --git a/neutron/tests/contrib/hooks/osprofiler b/neutron/tests/contrib/hooks/osprofiler deleted file mode 100644 index 117d0f29f85..00000000000 --- a/neutron/tests/contrib/hooks/osprofiler +++ /dev/null @@ -1,7 +0,0 @@ -[[post-config|/etc/neutron/api-paste.ini]] - -[composite:neutronapi_v2_0] -use = call:neutron.auth:pipeline_factory -noauth = cors request_id catch_errors osprofiler extensions neutronapiapp_v2_0 -keystone = cors request_id catch_errors osprofiler authtoken keystonecontext extensions neutronapiapp_v2_0 - diff --git a/neutron/tests/contrib/hooks/qos b/neutron/tests/contrib/hooks/qos deleted file mode 100644 index 7e2798f4ef5..00000000000 --- a/neutron/tests/contrib/hooks/qos +++ /dev/null @@ -1,2 +0,0 @@ -enable_plugin neutron https://opendev.org/openstack/neutron -enable_service neutron-qos diff --git a/neutron/tests/contrib/hooks/quotas b/neutron/tests/contrib/hooks/quotas deleted file mode 100644 index c2c0cac9721..00000000000 --- a/neutron/tests/contrib/hooks/quotas +++ /dev/null @@ -1,8 +0,0 @@ -[[post-config|$NEUTRON_CONF]] - -[quotas] -# x10 of default quotas (at the time of writing) -quota_router=100 -quota_floatingip=500 -quota_security_group=100 -quota_security_group_rule=1000 diff --git a/neutron/tests/contrib/hooks/segments b/neutron/tests/contrib/hooks/segments deleted file mode 100644 index 81e5f110531..00000000000 --- a/neutron/tests/contrib/hooks/segments +++ /dev/null @@ -1 +0,0 @@ -enable_service neutron-segments diff --git a/neutron/tests/contrib/hooks/trunk b/neutron/tests/contrib/hooks/trunk deleted file mode 100644 index 4b3e9bb12c5..00000000000 --- a/neutron/tests/contrib/hooks/trunk +++ /dev/null @@ -1 +0,0 @@ -enable_service neutron-trunk diff --git a/neutron/tests/contrib/hooks/tunnel_types b/neutron/tests/contrib/hooks/tunnel_types deleted file mode 100644 index 27b72d8eac5..00000000000 --- a/neutron/tests/contrib/hooks/tunnel_types +++ /dev/null @@ -1,6 +0,0 @@ -# ideally we would configure it in openvswitch_agent.ini but devstack doesn't -# load it for its l2 agent -[[post-config|/$NEUTRON_CORE_PLUGIN_CONF]] - -[AGENT] -tunnel_types=gre,vxlan diff --git a/neutron/tests/contrib/hooks/ubuntu_image b/neutron/tests/contrib/hooks/ubuntu_image deleted file mode 100644 index 62d6ea4bdc5..00000000000 --- a/neutron/tests/contrib/hooks/ubuntu_image +++ /dev/null @@ -1,10 +0,0 @@ -DOWNLOAD_DEFAULT_IMAGES=False -IMAGE_URLS="http://cloud-images.ubuntu.com/releases/16.04/release-20180424/ubuntu-16.04-server-cloudimg-amd64-disk1.img," -DEFAULT_INSTANCE_TYPE=ds512M -DEFAULT_INSTANCE_USER=ubuntu -BUILD_TIMEOUT=784 - -[[test-config|$TEMPEST_CONFIG]] - -[neutron_plugin_options] -image_is_advanced=True diff --git a/neutron/tests/contrib/hooks/uplink_status_propagation b/neutron/tests/contrib/hooks/uplink_status_propagation deleted file mode 100644 index f7320a0ce52..00000000000 --- a/neutron/tests/contrib/hooks/uplink_status_propagation +++ /dev/null @@ -1 +0,0 @@ -enable_service neutron-uplink-status-propagation diff --git a/neutron/tests/contrib/hooks/vlan_provider b/neutron/tests/contrib/hooks/vlan_provider deleted file mode 100644 index 0451ab64a21..00000000000 --- a/neutron/tests/contrib/hooks/vlan_provider +++ /dev/null @@ -1,9 +0,0 @@ -[[test-config|$TEMPEST_CONFIG]] - -[neutron_plugin_options] -provider_vlans=foo, - -[[post-config|/$NEUTRON_CORE_PLUGIN_CONF]] - -[ml2_type_vlan] -network_vlan_ranges = foo:1:10 diff --git a/tools/deploy_rootwrap.sh b/tools/deploy_rootwrap.sh index c7f41c3d807..105fb3f8ec1 100755 --- a/tools/deploy_rootwrap.sh +++ b/tools/deploy_rootwrap.sh @@ -63,6 +63,6 @@ if [[ "$OS_SUDO_TESTING" = "1" ]]; then sed -i 's/use_syslog=False/use_syslog=True/g' ${dst_conf} sed -i 's/syslog_log_level=ERROR/syslog_log_level=DEBUG/g' ${dst_conf} sed -i 's/daemon_timeout=600/daemon_timeout=7800/g' ${dst_conf} - cp -p ${neutron_path}/neutron/tests/contrib/testing.filters \ + cp -p ${neutron_path}/tools/rootwrap/testing.filters \ ${filters_path}/ fi diff --git a/neutron/tests/contrib/testing.filters b/tools/rootwrap/testing.filters similarity index 100% rename from neutron/tests/contrib/testing.filters rename to tools/rootwrap/testing.filters From 2064a3992f39b2886f9bae96b60d499fc67f6074 Mon Sep 17 00:00:00 2001 From: Fiorella Yanac Date: Mon, 8 Jun 2026 12:26:58 +0100 Subject: [PATCH 12/32] Enable PVLAN service plugin in DevStack Add DevStack configuration to load the PVLAN service plugin when neutron-pvlan is enabled. This is required for neutron-tempest-plugin OVN jobs that exercise PVLAN scenario tests. Assisted-By: Cursor-composer-2-fast Related-Bug: #2138746 Change-Id: Ic6e8bbccbd6695eadb007952241c275a694f19c0 Signed-off-by: Fiorella Yanac --- devstack/lib/pvlan | 3 +++ devstack/ovn-local.conf.sample | 1 + devstack/plugin.sh | 4 ++++ 3 files changed, 8 insertions(+) create mode 100644 devstack/lib/pvlan diff --git a/devstack/lib/pvlan b/devstack/lib/pvlan new file mode 100644 index 00000000000..2070e0624d8 --- /dev/null +++ b/devstack/lib/pvlan @@ -0,0 +1,3 @@ +function configure_pvlan { + neutron_service_plugin_class_add "pvlan" +} diff --git a/devstack/ovn-local.conf.sample b/devstack/ovn-local.conf.sample index e4b81debb21..1e71f52a40c 100644 --- a/devstack/ovn-local.conf.sample +++ b/devstack/ovn-local.conf.sample @@ -48,6 +48,7 @@ enable_service q-dns enable_service q-port-forwarding enable_service q-qos enable_service neutron-segments +enable_service neutron-pvlan enable_service q-log # Enable neutron tempest plugin tests diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 5a2d0287e87..0de51d858c0 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -19,6 +19,7 @@ source $LIBDIR/tag_ports_during_bulk_creation source $LIBDIR/octavia source $LIBDIR/loki source $LIBDIR/local_ip +source $LIBDIR/pvlan source $LIBDIR/port_trusted_vif source $LIBDIR/frr @@ -72,6 +73,9 @@ if [[ "$1" == "stack" ]]; then if is_service_enabled neutron-segments; then configure_segments_extension fi + if is_service_enabled neutron-pvlan; then + configure_pvlan + fi if is_service_enabled neutron-network-segment-range; then configure_network_segment_range fi From 25c8c8fb25b33e42ef34145f26722f8e1e7ac08f Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Thu, 21 May 2026 16:51:34 -0400 Subject: [PATCH 13/32] evpn: Implement OVN portion The patch implements resource creation for an EVPN router, its deletion and advertise-host option for the LSP associated with an EVPN router. Assisted-By: Claude Opus 4.6 Related-Bug: #2144617 Change-Id: I66c59707006b4351f637a14fee38f5fd3ebfd22d Signed-off-by: Jakub Libosvar --- neutron/agent/ovn/extensions/evpn/utils.py | 23 ++ neutron/common/ovn/utils.py | 6 + neutron/db/evpn_db.py | 20 +- neutron/services/evpn/commands.py | 180 +++++++++++ neutron/services/evpn/constants.py | 1 + neutron/services/evpn/plugin.py | 70 +++++ .../agent/ovn/extensions/bgp/test_commands.py | 6 +- .../agent/ovn/extensions/evpn/test_events.py | 6 +- neutron/tests/functional/base.py | 4 + .../tests/functional/services/bgp/__init__.py | 3 + .../functional/services/bgp/test_commands.py | 4 +- .../functional/services/evpn/__init__.py | 0 .../functional/services/evpn/test_commands.py | 283 ++++++++++++++++++ .../tests/unit/services/evpn/test_plugin.py | 31 ++ 14 files changed, 625 insertions(+), 12 deletions(-) create mode 100644 neutron/agent/ovn/extensions/evpn/utils.py create mode 100644 neutron/services/evpn/commands.py create mode 100644 neutron/tests/functional/services/evpn/__init__.py create mode 100644 neutron/tests/functional/services/evpn/test_commands.py diff --git a/neutron/agent/ovn/extensions/evpn/utils.py b/neutron/agent/ovn/extensions/evpn/utils.py new file mode 100644 index 00000000000..488de4ec42a --- /dev/null +++ b/neutron/agent/ovn/extensions/evpn/utils.py @@ -0,0 +1,23 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron_lib import constants as n_const + +from neutron.agent.ovn.extensions.evpn import constants as evpn_const + + +def evpn_vrf_name(uuid): + return ( + evpn_const.EVPN_VRF_PREFIX + str(uuid))[:n_const.DEVICE_NAME_MAX_LEN] diff --git a/neutron/common/ovn/utils.py b/neutron/common/ovn/utils.py index a67e574bb64..c6f394891b2 100644 --- a/neutron/common/ovn/utils.py +++ b/neutron/common/ovn/utils.py @@ -1584,3 +1584,9 @@ def get_mac_and_ips_from_port_binding(port_binding): if not netaddr.valid_mac(mac): raise ValueError(_("Invalid MAC address: %s"), mac) return mac, mac_list[1:] + + +def setkeys(row, column, key_values): + """Merge key-value pairs into an OVSDB row's map column via setkey.""" + for key, value in key_values.items(): + row.setkey(column, key, value) diff --git a/neutron/db/evpn_db.py b/neutron/db/evpn_db.py index 99b29064bbc..a2395f1dc93 100644 --- a/neutron/db/evpn_db.py +++ b/neutron/db/evpn_db.py @@ -186,9 +186,6 @@ def advertise_port(self, context, port_id, network_id, router_id): def get_vni_for_router(self, context, router_id): """Get the VNI allocated to a router, or None if not allocated. - This is a standalone read method that can be called outside of - callbacks. - :param context: Neutron request context :param router_id: UUID of the router :returns: VNI (integer) or None @@ -199,3 +196,20 @@ def get_vni_for_router(self, context, router_id): if not instance: return None return instance.mapping.vni_allocation.vni + + @db_api.retry_if_session_inactive() + @db_api.CONTEXT_READER + def get_vlan_for_router(self, context, router_id): + """Get the VLAN ID allocated to a router. + + :param context: Neutron request context + :param router_id: UUID of the router + :returns: VLAN ID (integer) + :raises EVPNVNINotFound: if no EVPN instance exists for the router + """ + instance = context.session.query( + evpn_models.EVPNL3Instance + ).filter_by(router_id=router_id).first() + if not instance: + raise evpn_exc.EVPNVNINotFound(router_id=router_id) + return instance.mapping.vlan_allocation.vlan_id diff --git a/neutron/services/evpn/commands.py b/neutron/services/evpn/commands.py new file mode 100644 index 00000000000..568a0d010c1 --- /dev/null +++ b/neutron/services/evpn/commands.py @@ -0,0 +1,180 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron_lib.utils import net as n_net +from oslo_config import cfg +from ovsdbapp.backend.ovs_idl import command +from ovsdbapp.backend.ovs_idl import idlutils +from ovsdbapp.schema.ovn_northbound import commands as ovn_nb_commands + +from neutron.agent.ovn.extensions.evpn import constants as evpn_agent_const +from neutron.agent.ovn.extensions.evpn import utils as evpn_agent_utils +from neutron.common.ovn import constants as ovn_const +from neutron.common.ovn import utils as ovn_utils +from neutron.services.bgp import constants as bgp_const +from neutron.services.evpn import constants as evpn_const + + +def _evpn_ls_name(vni): + return '%s%s' % (evpn_const.EVPN_LS_NAME_PREFIX, vni) + + +def _evpn_lrp_name(router_id, vni): + evpn_ls_name = _evpn_ls_name(vni) + return evpn_const.EVPN_LRP_NAME_PATTERN % { + 'lrp_uuid': router_id[:12], + 'evpn_ls_name': evpn_ls_name, + } + + +def _evpn_lsp_name(router_id, vni): + evpn_ls_name = _evpn_ls_name(vni) + return evpn_const.EVPN_LSP_NAME_PATTERN % { + 'evpn_ls_name': evpn_ls_name, + 'lrp_uuid': router_id[:12], + } + + +class CreateEVPNRouterCommand(command.BaseCommand): + """Create the full EVPN OVN topology for a router. + + Sets dynamic-routing options on the logical router, creates a dummy + logical switch for the VNI bridge domain, connects them with a + logical router port and logical switch port pair. + """ + # We support only one SVD at this time. + SVD_INDEX = 0 + + def __init__(self, api, router_id, vni, vlan): + super().__init__(api) + self.lrouter_name = ovn_utils.ovn_name(router_id) + self.vni = vni + self.vlan = vlan + self.router_id = router_id + + def run_idl(self, txn): + self._set_router_options() + + ls_name = _evpn_ls_name(self.vni) + lrp_name = _evpn_lrp_name(self.router_id, self.vni) + lsp_name = _evpn_lsp_name(self.router_id, self.vni) + mac = n_net.get_random_mac(cfg.CONF.base_mac.split(':')) + + self._create_dummy_ls(txn, ls_name) + self._create_lrp(txn, lrp_name, mac) + self._create_lsp(txn, ls_name, lsp_name, lrp_name) + + def _set_router_options(self): + lrouter = idlutils.row_by_value( + self.api.idl, 'Logical_Router', + 'name', self.lrouter_name) + + ovn_utils.setkeys(lrouter, 'options', { + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING: 'true', + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING_VRF_ID: str(self.vni), + ovn_const.LR_OPTIONS_DR_VRF_NAME: + evpn_agent_utils.evpn_vrf_name(self.router_id), + }) + + def _create_dummy_ls(self, txn, ls_name): + ovn_nb_commands.LsAddCommand( + self.api, ls_name, may_exist=True, + other_config={ + ovn_const.LS_OTHER_CFG_DR_VNI: + str(self.vni), + ovn_const.LS_OTHER_CFG_DR_BRIDGE_IFNAME: + evpn_agent_const.EVPN_VLAN_IFNAME_PATTERN % { + 'index': self.SVD_INDEX, + 'vid': self.vlan, + }, + ovn_const.LS_OTHER_CFG_DR_VXLAN_IFNAME: + "%s%d" % ( + evpn_agent_const.EVPN_VXLAN_IFNAME, self.SVD_INDEX), + }).run_idl(txn) + + def _create_lrp(self, txn, lrp_name, mac): + options = { + bgp_const.LRP_OPTIONS_DYNAMIC_ROUTING_MAINTAIN_VRF: 'true', + } + external_ids = { + evpn_const.EVPN_LRP_VNI_EXT_ID_KEY: str(self.vni), + evpn_const.EVPN_LRP_VLAN_EXT_ID_KEY: str(self.vlan), + } + try: + lrp = self.api.lookup('Logical_Router_Port', lrp_name) + except idlutils.RowNotFound: + ovn_nb_commands.LrpAddCommand( + self.api, self.lrouter_name, lrp_name, mac, + networks=[], + options=options, + external_ids=external_ids).run_idl(txn) + return + + for column_name, column_data in ( + ('options', options), ('external_ids', external_ids)): + ovn_utils.setkeys(lrp, column_name, column_data) + + def _create_lsp(self, txn, ls_name, lsp_name, lrp_name): + options = {'router-port': lrp_name} + addresses = [ovn_const.DEFAULT_ADDR_FOR_LSP_WITH_PEER] + try: + lsp = self.api.lookup('Logical_Switch_Port', lsp_name) + except idlutils.RowNotFound: + ovn_nb_commands.LspAddCommand( + self.api, ls_name, lsp_name, + type='router', + options=options, + addresses=addresses, + ).run_idl(txn) + return + + lsp.type = 'router' + ovn_utils.setkeys(lsp, 'options', options) + lsp.addresses = addresses + + +class AdvertiseHostCommand(command.BaseCommand): + """Set dynamic-routing-redistribute on a logical router port.""" + + def __init__(self, api, port_id): + super().__init__(api) + self.lrp_name = ovn_utils.ovn_lrouter_port_name(port_id) + + def run_idl(self, txn): + lrp = idlutils.row_by_value( + self.api.idl, 'Logical_Router_Port', + 'name', self.lrp_name) + + ovn_utils.setkeys(lrp, 'options', { + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING_REDISTRIBUTE: + 'connected-as-host', + }) + + +class DeleteEVPNRouterCommand(command.BaseCommand): + """Delete the EVPN OVN topology for a router. + + Deletes the dummy logical switch (cascades to its LSP). + The LR and its LRP are deleted by the OvnDriver. + """ + + def __init__(self, api, vni): + super().__init__(api) + self.vni = vni + + def run_idl(self, txn): + ls_name = _evpn_ls_name(self.vni) + ovn_nb_commands.LsDelCommand( + self.api, ls_name, if_exists=True).run_idl(txn) diff --git a/neutron/services/evpn/constants.py b/neutron/services/evpn/constants.py index 68d0c3b6fa1..f9830b815bc 100644 --- a/neutron/services/evpn/constants.py +++ b/neutron/services/evpn/constants.py @@ -14,6 +14,7 @@ # under the License. EVPN_LRP_VNI_EXT_ID_KEY = 'vni' +EVPN_LRP_VLAN_EXT_ID_KEY = 'vlan' EVPN_LS_NAME_PREFIX = 'evpn-ls-' EVPN_LRP_NAME_PATTERN = 'evpn-lrp-%(lrp_uuid)s-to-%(evpn_ls_name)s' EVPN_LSP_NAME_PATTERN = 'evpn-lsp-%(evpn_ls_name)s-to-%(lrp_uuid)s' diff --git a/neutron/services/evpn/plugin.py b/neutron/services/evpn/plugin.py index 49b620e5b91..746838f5e98 100644 --- a/neutron/services/evpn/plugin.py +++ b/neutron/services/evpn/plugin.py @@ -21,10 +21,12 @@ from neutron_lib import constants as n_const from neutron_lib.db import resource_extend from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory from neutron_lib.services import base as service_base from oslo_log import log as logging from neutron.db import evpn_db +from neutron.services.evpn import commands as evpn_ovn LOG = logging.getLogger(__name__) @@ -49,8 +51,17 @@ class EVPNPlugin(service_base.ServicePluginBase): def __init__(self): super().__init__() self._evpn_db = evpn_db.EVPNDbHelper() + self._ovn_mech_driver = None LOG.info("Starting EVPN service plugin") + @property + def _nb_idl(self): + if self._ovn_mech_driver is None: + plugin = directory.get_plugin() + self._ovn_mech_driver = ( + plugin.mechanism_manager.mech_drivers['ovn'].obj) + return self._ovn_mech_driver.nb_ovn + def get_plugin_description(self): return "EVPN service plugin" @@ -93,6 +104,27 @@ def _process_router_create(self, resource, event, trigger, payload): payload.context, router_id, requested_vni) LOG.info("Allocated EVPN VNI %s for router %s", vni, router_id) + @registry.receives(resources.ROUTER, [events.AFTER_CREATE]) + def _process_ovn_router_create(self, resource, event, trigger, payload): + """Create EVPN OVN topology after router creation. + + Sets dynamic-routing options on the logical router so OVN + treats it as an EVPN VRF. + """ + router = payload.states[0] + vni = router.get(evpn_apidef.EVPN_VNI) + if not vni: + return + + router_id = payload.resource_id + vlan = self._evpn_db.get_vlan_for_router(payload.context, router_id) + with self._nb_idl.transaction(check_error=True) as txn: + txn.add(evpn_ovn.CreateEVPNRouterCommand( + self._nb_idl, router_id, vni, vlan)) + + LOG.info("Set EVPN dynamic-routing options for router %s VNI %s", + router_id, vni) + @registry.receives(resources.ROUTER, [events.PRECOMMIT_DELETE]) def _process_router_delete(self, resource, event, trigger, payload): """Clean up EVPN VNI before router deletion. @@ -107,6 +139,25 @@ def _process_router_delete(self, resource, event, trigger, payload): self._evpn_db.deallocate_vni_for_router(context, router_id) LOG.info("Deallocated EVPN VNI for router %s", router_id) + @registry.receives(resources.ROUTER, [events.AFTER_DELETE]) + def _process_ovn_router_delete(self, resource, event, trigger, payload): + """Delete EVPN OVN topology after router deletion. + + Deletes the dummy logical switch for the VNI bridge domain. + The LR and its LRP are already deleted by the OvnDriver. + """ + router = payload.states[0] + vni = router.get(evpn_apidef.EVPN_VNI) + if not vni: + return + + with self._nb_idl.transaction(check_error=True) as txn: + txn.add(evpn_ovn.DeleteEVPNRouterCommand( + self._nb_idl, vni)) + + LOG.info("Deleted EVPN OVN topology for router %s VNI %s", + payload.resource_id, vni) + @registry.receives(resources.ROUTER_INTERFACE, [events.BEFORE_CREATE]) def _process_router_interface_create(self, resource, event, trigger, payload): @@ -133,6 +184,25 @@ def _process_router_interface_create(self, resource, event, trigger, LOG.info("EVPN advertise_host enabled for port %s on router %s and " "network %s", port_id, router_id, network_id) + @registry.receives(resources.ROUTER_INTERFACE, [events.AFTER_CREATE]) + def _process_ovn_router_interface_create(self, resource, event, trigger, + payload): + """Set advertise-host on the OVN logical router port. + + Sets dynamic-routing-redistribute=connected-as-host on the LRP + so OVN adds host routes from this subnet to the EVPN VRF. + """ + interface_info = payload.metadata.get('interface_info', {}) + if not interface_info.get(evpn_apidef.ADVERTISE_HOST): + return + + port_id = payload.metadata['port']['id'] + with self._nb_idl.transaction(check_error=True) as txn: + txn.add(evpn_ovn.AdvertiseHostCommand( + self._nb_idl, port_id)) + + LOG.info("Set EVPN advertise-host on LRP for port %s", port_id) + @registry.receives(resources.ROUTER_INTERFACE, [events.BEFORE_DELETE]) def _process_router_interface_delete(self, resource, event, trigger, payload): diff --git a/neutron/tests/functional/agent/ovn/extensions/bgp/test_commands.py b/neutron/tests/functional/agent/ovn/extensions/bgp/test_commands.py index da6c9138380..0a6df2714fa 100644 --- a/neutron/tests/functional/agent/ovn/extensions/bgp/test_commands.py +++ b/neutron/tests/functional/agent/ovn/extensions/bgp/test_commands.py @@ -13,17 +13,15 @@ # License for the specific language governing permissions and limitations # under the License. -from oslo_utils import uuidutils - from neutron.agent.ovn.extensions.bgp import commands from neutron.services.bgp import constants from neutron.tests.common import net_helpers from neutron.tests.functional.agent.ovn.extensions import bgp as test_bgp +from neutron.tests.functional import base as func_base from neutron.tests.functional.services import bgp -def _get_unique_name(prefix="test"): - return f"{prefix}_{uuidutils.generate_uuid()[:8]}" +_get_unique_name = func_base.get_unique_name class SetChassisBgpBridgesCommandTestCase(bgp.BaseBgpSbIdlTestCase): diff --git a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py index ab167650476..47156a71aab 100644 --- a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py +++ b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py @@ -17,10 +17,10 @@ import testtools -from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import events as evpn_events from neutron.agent.ovn.extensions.evpn import exceptions as evpn_exc from neutron.agent.ovn.extensions.evpn import fsm as evpn_fsm +from neutron.agent.ovn.extensions.evpn import utils as evpn_utils from neutron.common.ovn import constants as ovn_const from neutron.common import utils as common_utils from neutron.services.bgp import ovn as bgp_ovn @@ -56,7 +56,7 @@ def _create_evpn_lrp(self, vni, mac): lrp_name = f'lrp-to-evpn-{vni}' lsp_name = f'lsp-to-evpn-{vni}' lr = self.nb_api.lr_add(lr_name).execute(check_error=True) - vrf = evpn_const.EVPN_VRF_PREFIX + str(lr.uuid)[:12] + vrf = evpn_utils.evpn_vrf_name(lr.uuid) with self.nb_api.transaction(check_error=True) as txn: txn.add(self.nb_api.db_set( 'Logical_Router', lr_name, @@ -90,7 +90,7 @@ def _create_lrp_without_evpn_match(self, vni, mac, options = {'dynamic-routing': 'true', 'chassis': 'fake-chassis'} if set_vrf: options[ovn_const.LR_OPTIONS_DR_VRF_NAME] = ( - evpn_const.EVPN_VRF_PREFIX + str(lr.uuid)[:12]) + evpn_utils.evpn_vrf_name(lr.uuid)) external_ids = {} if set_vni: external_ids[svc_const.EVPN_LRP_VNI_EXT_ID_KEY] = str(vni) diff --git a/neutron/tests/functional/base.py b/neutron/tests/functional/base.py index f4b55a504c4..66b16da3959 100644 --- a/neutron/tests/functional/base.py +++ b/neutron/tests/functional/base.py @@ -117,6 +117,10 @@ def _collect_ovn_process_logs(self): self._copy_file(src_filename, dst_filename) +def get_unique_name(prefix="test"): + return f"{prefix}_{uuidutils.generate_uuid()}" + + def config_decorator(method_to_decorate, config_tuples): def wrapper(*args, **kwargs): method_to_decorate(*args, **kwargs) diff --git a/neutron/tests/functional/services/bgp/__init__.py b/neutron/tests/functional/services/bgp/__init__.py index 88beab2c4f1..2bbf9c5f6ce 100644 --- a/neutron/tests/functional/services/bgp/__init__.py +++ b/neutron/tests/functional/services/bgp/__init__.py @@ -30,6 +30,9 @@ from neutron.tests.functional.services.bgp import fixtures as bgp_fixtures +get_unique_name = n_base.get_unique_name + + class OvsTestIdl(connection.OvsdbIdl): tables = ['Open_vSwitch', 'Bridge', 'Port', 'Interface'] diff --git a/neutron/tests/functional/services/bgp/test_commands.py b/neutron/tests/functional/services/bgp/test_commands.py index 9d081fae4f2..9643a406192 100644 --- a/neutron/tests/functional/services/bgp/test_commands.py +++ b/neutron/tests/functional/services/bgp/test_commands.py @@ -30,11 +30,11 @@ from neutron.services.bgp import constants from neutron.services.bgp import exceptions from neutron.services.bgp import helpers +from neutron.tests.functional import base as func_base from neutron.tests.functional.services import bgp -def _get_unique_name(prefix="test"): - return f"{prefix}_{uuidutils.generate_uuid()[:8]}" +_get_unique_name = func_base.get_unique_name def _create_fake_chassis(): diff --git a/neutron/tests/functional/services/evpn/__init__.py b/neutron/tests/functional/services/evpn/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/tests/functional/services/evpn/test_commands.py b/neutron/tests/functional/services/evpn/test_commands.py new file mode 100644 index 00000000000..1182ad73346 --- /dev/null +++ b/neutron/tests/functional/services/evpn/test_commands.py @@ -0,0 +1,283 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron_lib import constants as n_const +from oslo_utils import uuidutils +from ovsdbapp.backend.ovs_idl import idlutils + +from neutron.agent.ovn.extensions.evpn import constants as evpn_agent_const +from neutron.common.ovn import constants as ovn_const +from neutron.common.ovn import utils as ovn_utils +from neutron.services.bgp import constants as bgp_const +from neutron.services.evpn import commands as evpn_ovn +from neutron.services.evpn import constants as evpn_const +from neutron.tests.functional import base as func_base +from neutron.tests.functional.services import bgp + + +class CreateEVPNRouterCommandTestCase(bgp.BaseBgpNbIdlTestCase): + def setUp(self): + super().setUp() + self.router_id = uuidutils.generate_uuid() + self.lr_name = ovn_utils.ovn_name(self.router_id) + self.vni = 5000 + self.vlan = 100 + self.nb_api.lr_add(self.lr_name).execute(check_error=True) + + def _execute(self, router_id=None, vni=None, vlan=None): + evpn_ovn.CreateEVPNRouterCommand( + self.nb_api, router_id or self.router_id, + vni or self.vni, vlan or self.vlan, + ).execute(check_error=True) + + def test_sets_dynamic_routing_options_on_router(self): + self._execute() + + lr = self.nb_api.lr_get(self.lr_name).execute(check_error=True) + self.assertEqual('true', lr.options.get( + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING)) + self.assertEqual(str(self.vni), lr.options.get( + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING_VRF_ID)) + self.assertEqual( + ('vr%s' % self.router_id)[:n_const.DEVICE_NAME_MAX_LEN], + lr.options.get( + ovn_const.LR_OPTIONS_DR_VRF_NAME)) + + def test_preserves_existing_router_options(self): + self.nb_api.db_set( + 'Logical_Router', self.lr_name, + options={'existing-key': 'existing-value'}, + ).execute(check_error=True) + + self._execute() + + lr = self.nb_api.lr_get(self.lr_name).execute(check_error=True) + self.assertEqual('existing-value', + lr.options.get('existing-key')) + self.assertEqual('true', lr.options.get( + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING)) + + def test_creates_dummy_logical_switch(self): + self._execute() + + ls_name = evpn_ovn._evpn_ls_name(self.vni) + ls = self.nb_api.ls_get(ls_name).execute(check_error=True) + self.assertEqual(ls_name, ls.name) + self.assertEqual(str(self.vni), ls.other_config.get( + ovn_const.LS_OTHER_CFG_DR_VNI)) + self.assertEqual( + 'vl-%d-%s' % ( + evpn_ovn.CreateEVPNRouterCommand.SVD_INDEX, self.vlan), + ls.other_config.get(ovn_const.LS_OTHER_CFG_DR_BRIDGE_IFNAME)) + self.assertEqual( + "%s%d" % (evpn_agent_const.EVPN_VXLAN_IFNAME, + evpn_ovn.CreateEVPNRouterCommand.SVD_INDEX), + ls.other_config.get( + ovn_const.LS_OTHER_CFG_DR_VXLAN_IFNAME)) + + def test_creates_logical_router_port(self): + self._execute() + + lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) + lrp = self.nb_api.lrp_get(lrp_name).execute(check_error=True) + self.assertEqual(lrp_name, lrp.name) + self.assertEqual('true', lrp.options.get( + bgp_const.LRP_OPTIONS_DYNAMIC_ROUTING_MAINTAIN_VRF)) + self.assertEqual(str(self.vni), lrp.external_ids.get( + evpn_const.EVPN_LRP_VNI_EXT_ID_KEY)) + + def test_creates_logical_switch_port(self): + self._execute() + + ls_name = evpn_ovn._evpn_ls_name(self.vni) + lsp_name = evpn_ovn._evpn_lsp_name(self.router_id, self.vni) + lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) + + lsp = self.nb_api.lsp_get(lsp_name).execute(check_error=True) + self.assertEqual('router', lsp.type) + self.assertEqual(lrp_name, lsp.options.get('router-port')) + self.assertEqual([ovn_const.DEFAULT_ADDR_FOR_LSP_WITH_PEER], + lsp.addresses) + + ls = self.nb_api.ls_get(ls_name).execute(check_error=True) + lsp_uuids = {p.uuid for p in ls.ports} + self.assertIn(lsp.uuid, lsp_uuids) + + def test_idempotent(self): + self._execute() + self._execute() + + ls_name = evpn_ovn._evpn_ls_name(self.vni) + lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) + lsp_name = evpn_ovn._evpn_lsp_name(self.router_id, self.vni) + + self.nb_api.ls_get(ls_name).execute(check_error=True) + self.nb_api.lrp_get(lrp_name).execute(check_error=True) + self.nb_api.lsp_get(lsp_name).execute(check_error=True) + + lr = self.nb_api.lr_get(self.lr_name).execute(check_error=True) + self.assertEqual('true', lr.options.get( + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING)) + + def test_updates_existing_lrp_options_and_external_ids(self): + self._execute() + + lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) + self.nb_api.db_set( + 'Logical_Router_Port', lrp_name, + options={'dynamic-routing-maintain-vrf': 'false'}, + external_ids={'vni': '9999'}, + ).execute(check_error=True) + + self._execute() + + lrp = self.nb_api.lrp_get(lrp_name).execute(check_error=True) + self.assertEqual('true', lrp.options.get( + bgp_const.LRP_OPTIONS_DYNAMIC_ROUTING_MAINTAIN_VRF)) + self.assertEqual(str(self.vni), lrp.external_ids.get('vni')) + + def test_updates_existing_lsp_attributes(self): + self._execute() + + lsp_name = evpn_ovn._evpn_lsp_name(self.router_id, self.vni) + self.nb_api.db_set( + 'Logical_Switch_Port', lsp_name, + type='patch', + options={'router-port': 'wrong-port'}, + ).execute(check_error=True) + + self._execute() + + lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) + lsp = self.nb_api.lsp_get(lsp_name).execute(check_error=True) + self.assertEqual('router', lsp.type) + self.assertEqual(lrp_name, lsp.options.get('router-port')) + self.assertEqual([ovn_const.DEFAULT_ADDR_FOR_LSP_WITH_PEER], + lsp.addresses) + + +class DeleteEVPNRouterCommandTestCase(bgp.BaseBgpNbIdlTestCase): + def setUp(self): + super().setUp() + self.router_id = uuidutils.generate_uuid() + self.lr_name = ovn_utils.ovn_name(self.router_id) + self.vni = 6000 + self.vlan = 200 + self.nb_api.lr_add(self.lr_name).execute(check_error=True) + + evpn_ovn.CreateEVPNRouterCommand( + self.nb_api, self.router_id, self.vni, self.vlan, + ).execute(check_error=True) + + def _execute(self, vni=None): + evpn_ovn.DeleteEVPNRouterCommand( + self.nb_api, vni or self.vni, + ).execute(check_error=True) + + def test_deletes_dummy_logical_switch(self): + ls_name = evpn_ovn._evpn_ls_name(self.vni) + self.nb_api.ls_get(ls_name).execute(check_error=True) + + self._execute() + + self.assertRaises( + idlutils.RowNotFound, + self.nb_api.ls_get(ls_name).execute, check_error=True) + + def test_deletes_logical_switch_port_via_cascade(self): + lsp_name = evpn_ovn._evpn_lsp_name(self.router_id, self.vni) + self.nb_api.lsp_get(lsp_name).execute(check_error=True) + + self._execute() + + self.assertRaises( + idlutils.RowNotFound, + self.nb_api.lsp_get(lsp_name).execute, check_error=True) + + def test_does_not_delete_router_or_lrp(self): + lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) + + self._execute() + + self.nb_api.lr_get(self.lr_name).execute(check_error=True) + self.nb_api.lrp_get(lrp_name).execute(check_error=True) + + def test_idempotent(self): + self._execute() + self._execute() + + def test_nonexistent_vni(self): + self._execute(vni=9999) + + +class AdvertiseHostCommandTestCase(bgp.BaseBgpNbIdlTestCase): + def setUp(self): + super().setUp() + self.lr_name = func_base.get_unique_name("lr") + self.nb_api.lr_add(self.lr_name).execute(check_error=True) + + def _create_lrp(self, port_id, **kwargs): + lrp_name = ovn_utils.ovn_lrouter_port_name(port_id) + self.nb_api.lrp_add( + self.lr_name, lrp_name, + mac='00:00:00:00:00:01', + networks=['192.168.1.1/24'], + **kwargs, + ).execute(check_error=True) + return lrp_name + + def test_sets_redistribute_option(self): + port_id = uuidutils.generate_uuid() + lrp_name = self._create_lrp(port_id) + + evpn_ovn.AdvertiseHostCommand( + self.nb_api, port_id).execute(check_error=True) + + lrp = self.nb_api.lrp_get(lrp_name).execute(check_error=True) + self.assertEqual( + 'connected-as-host', + lrp.options.get( + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING_REDISTRIBUTE)) + + def test_preserves_existing_options(self): + port_id = uuidutils.generate_uuid() + lrp_name = self._create_lrp( + port_id, options={'existing-key': 'existing-value'}) + + evpn_ovn.AdvertiseHostCommand( + self.nb_api, port_id).execute(check_error=True) + + lrp = self.nb_api.lrp_get(lrp_name).execute(check_error=True) + self.assertEqual('existing-value', + lrp.options.get('existing-key')) + self.assertEqual( + 'connected-as-host', + lrp.options.get( + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING_REDISTRIBUTE)) + + def test_idempotent(self): + port_id = uuidutils.generate_uuid() + lrp_name = self._create_lrp(port_id) + + evpn_ovn.AdvertiseHostCommand( + self.nb_api, port_id).execute(check_error=True) + evpn_ovn.AdvertiseHostCommand( + self.nb_api, port_id).execute(check_error=True) + + lrp = self.nb_api.lrp_get(lrp_name).execute(check_error=True) + self.assertEqual( + 'connected-as-host', + lrp.options.get( + bgp_const.LR_OPTIONS_DYNAMIC_ROUTING_REDISTRIBUTE)) diff --git a/neutron/tests/unit/services/evpn/test_plugin.py b/neutron/tests/unit/services/evpn/test_plugin.py index 36e0cdf65de..27dc2db97d0 100644 --- a/neutron/tests/unit/services/evpn/test_plugin.py +++ b/neutron/tests/unit/services/evpn/test_plugin.py @@ -22,6 +22,7 @@ from neutron_lib.plugins import directory from neutron.db.models import evpn as evpn_models +from neutron.services.evpn import commands as evpn_ovn from neutron.services.evpn import plugin as evpn_plugin from neutron.tests.common import test_db_base_plugin_v2 from neutron.tests.unit.extensions import test_l3 @@ -37,10 +38,15 @@ def setUp(self): 'evpn': 'neutron.services.evpn.plugin.EVPNPlugin', } ext_mgr = test_l3.L3TestExtensionManager() + self.mock_nb_idl = mock.patch.object( + evpn_plugin.EVPNPlugin, '_nb_idl', + new_callable=mock.PropertyMock).start() super().setUp(plugin=plugin, service_plugins=service_plugins, ext_mgr=ext_mgr) self.evpn_plugin = directory.get_plugin(plugin_constants.EVPN) self.ctx = context.get_admin_context() + self.nb_idl = self.mock_nb_idl.return_value + self.txn = self.nb_idl.transaction.return_value.__enter__.return_value def test_get_plugin_type(self): self.assertEqual( @@ -75,16 +81,23 @@ def test_extend_router_dict_no_allocation_key(self): def test_router_create_no_vni(self): with self.router(as_admin=True) as router: self.assertIsNone(router['router'][evpn_apidef.EVPN_VNI]) + self.txn.add.assert_not_called() def test_router_create_with_vni(self): with self.router(as_admin=True, arg_list=('evpn_vni',), evpn_vni=5000) as router: self.assertEqual(5000, router['router'][evpn_apidef.EVPN_VNI]) + self.txn.add.assert_called_once() + cmd = self.txn.add.call_args[0][0] + self.assertIsInstance(cmd, evpn_ovn.CreateEVPNRouterCommand) + self.assertEqual(5000, cmd.vni) + self.assertIsNotNone(cmd.vlan) def test_router_delete_deallocates_vni(self): with self.router(as_admin=True, arg_list=('evpn_vni',), evpn_vni=5000) as router: router_id = router['router']['id'] + self.txn.reset_mock() self._delete('routers', router_id) with db_api.CONTEXT_READER.using(self.ctx): @@ -93,11 +106,23 @@ def test_router_delete_deallocates_vni(self): ).filter_by(router_id=router_id).one_or_none() self.assertIsNone(instance) + self.txn.add.assert_called_once() + cmd = self.txn.add.call_args[0][0] + self.assertIsInstance(cmd, evpn_ovn.DeleteEVPNRouterCommand) + self.assertEqual(5000, cmd.vni) + + def test_router_delete_without_vni(self): + with self.router(as_admin=True) as router: + self.txn.reset_mock() + self._delete('routers', router['router']['id']) + self.txn.add.assert_not_called() + def test_router_interface_create_without_advertise_host(self): with self.router(as_admin=True, arg_list=('evpn_vni',), evpn_vni=5000) as router, \ self.network() as net, \ self.subnet(network=net) as subnet: + self.txn.reset_mock() self._router_interface_action( 'add', router['router']['id'], subnet['subnet']['id'], None, @@ -112,12 +137,14 @@ def test_router_interface_create_without_advertise_host(self): network_id=net['network']['id'] ).one_or_none() self.assertIsNone(evpn_net) + self.txn.add.assert_not_called() def test_router_interface_create_with_advertise_host(self): with self.router(as_admin=True, arg_list=('evpn_vni',), evpn_vni=5000) as router, \ self.network() as net, \ self.subnet(network=net) as subnet: + self.txn.reset_mock() self._router_interface_action( 'add', router['router']['id'], subnet['subnet']['id'], None, @@ -136,6 +163,10 @@ def test_router_interface_create_with_advertise_host(self): self.assertEqual( router['router']['id'], evpn_net.router_id) + self.txn.add.assert_called_once() + cmd = self.txn.add.call_args[0][0] + self.assertIsInstance(cmd, evpn_ovn.AdvertiseHostCommand) + def test_router_interface_delete_cleans_evpn_network(self): with self.router(as_admin=True, arg_list=('evpn_vni',), evpn_vni=5000) as router, \ From b54f6643a4bd9fe04de3ce310ce79d3a7f8d5e65 Mon Sep 17 00:00:00 2001 From: Winicius Silva Date: Mon, 8 Jun 2026 12:42:08 +0100 Subject: [PATCH 14/32] doc: Add missing zero quota information in subnetpools Change-Id: I55d86d7a86eb4e65bfef884ff8822fb241c6bce6 Signed-off-by: Winicius Silva --- doc/source/admin/config-subnet-pools.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/source/admin/config-subnet-pools.rst b/doc/source/admin/config-subnet-pools.rst index ffa3cb8f60a..9f651273bd7 100644 --- a/doc/source/admin/config-subnet-pools.rst +++ b/doc/source/admin/config-subnet-pools.rst @@ -67,7 +67,9 @@ like a router, network, or a port, it uses one from your total quota. With subnets, the resource is the IP address space. Some subnets take more of it than others. For example, 203.0.113.0/24 uses 256 addresses in one subnet but 198.51.100.224/28 uses only 16. If address space is -limited, the quota system can encourage efficient use of the space. +limited, the quota system can encourage efficient use of the space. If +the quota value is set as zero, it means that no quota will be +enforced when allocating a subnet from a subnet pool. With IPv4, the default_quota can be set to the number of absolute addresses any given project is allowed to consume from the pool. For From 6d43aab58ff35d7b31e1cc613577635e7e66c888 Mon Sep 17 00:00:00 2001 From: Helen Chen Date: Tue, 2 Jun 2026 09:37:30 -0400 Subject: [PATCH 15/32] Integrated SVD into OVN Agent EVPN Extension OVN Agent EVPN Extension now creates an SVD at start up. The SVD consists of a Linux bridge with a name based on the pattern evpn.constants.EVPN_LB_NAME_PREFIX and a vxlan interface with a name based on the pattern evpn.constants.EVPN_VXLAN_IFNAME. Since the current plan is to create only one SVD per OVN Agent, the index for the Linux bridge and vxlan interface is the default 0. When an EVPN instance's finite state machine advances to the evpn.fsm.Evpn.ADVERTISING state, a vlan:vni mapping is added to the SVD and a vlan interface with its name based on the pattern evpn.constants.EVPN_VLAN_IFNAME_PATTERN is also created. Similarly, when the EVPN instance's finite state machine advances away from evpn.fsm.Evpn.ADVERTISING, the vlan:vni mapping is removed from the SVD and the vlan interface is deleted. Related-Bug: #2144617 Assisted-By: Claude Opus 4.6 Change-Id: Ia9aeb47a6b06b003b2ff3c65c525603a1b760bb9 Signed-off-by: Helen Chen --- neutron/agent/linux/svd.py | 8 +- neutron/agent/ovn/extensions/evpn/__init__.py | 50 +++++++- neutron/agent/ovn/extensions/evpn/fsm.py | 59 ++++++---- neutron/agent/ovn/extensions/evpn/svd.py | 4 +- .../agent/ovn/ovn_neutron_agent/config.py | 14 ++- neutron/privileged/agent/linux/svd.py | 10 +- .../tests/functional/agent/linux/test_svd.py | 30 +++-- .../agent/ovn/extensions/evpn/test_events.py | 7 +- .../agent/ovn/extensions/test_evpn.py | 111 +++++++++++++++++- .../agent/ovn/extensions/evpn/test_fsm.py | 93 ++++++++++++--- .../unit/agent/ovn/extensions/test_evpn.py | 44 ++++++- 11 files changed, 358 insertions(+), 72 deletions(-) diff --git a/neutron/agent/linux/svd.py b/neutron/agent/linux/svd.py index bfc7bbb90f6..148f45d6569 100644 --- a/neutron/agent/linux/svd.py +++ b/neutron/agent/linux/svd.py @@ -59,11 +59,11 @@ def __init__(self, br_evpn, vxlan_evpn): self.br_evpn = br_evpn self.vxlan_evpn = vxlan_evpn - def create(self, local_ip, mac, vxlan_parent, dstport): + def create(self, local_ip, mac, vxlan_parent, dstport, br_mtu): try: privileged_svd.create_svd( self.br_evpn, self.vxlan_evpn, - local_ip, mac, vxlan_parent, dstport) + local_ip, mac, vxlan_parent, dstport, br_mtu) except IndexError: raise SvdNoVxlanParent( _("Missing VxLAN underlay: %(parent)s") % @@ -89,11 +89,11 @@ def delete(self): _("Failed to delete SVD %(br)s/%(vx)s: %(err)s") % {'br': self.br_evpn, 'vx': self.vxlan_evpn, 'err': e}) - def add_vni(self, svi_name, vni, vid, vrf_name, mac): + def add_vni(self, svi_name, vni, vid, vrf_name, mac, br_mtu): try: privileged_svd.add_vni( self.br_evpn, self.vxlan_evpn, - svi_name, vni, vid, vrf_name, mac) + svi_name, vni, vid, vrf_name, mac, br_mtu) except IndexError: raise SvdDevsNotFound( _("SVD %(br)s/%(vx)s or VRF %(vrf)s not found") % diff --git a/neutron/agent/ovn/extensions/evpn/__init__.py b/neutron/agent/ovn/extensions/evpn/__init__.py index 381f2c5868d..849bc73744b 100644 --- a/neutron/agent/ovn/extensions/evpn/__init__.py +++ b/neutron/agent/ovn/extensions/evpn/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2026 Red Hat, Inc. +# Copyright 2026 Red Hat, LLC # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,18 +13,35 @@ # License for the specific language governing permissions and limitations # under the License. +import dataclasses + +from neutron_lib.utils import net as net_lib +from oslo_config import cfg from oslo_log import log from pyroute2.netlink import rtnl from neutron.agent.linux import nl_constants as nl_const from neutron.agent.linux import nl_dispatcher +from neutron.agent.linux import svd as linux_svd from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import events as evpn_events from neutron.agent.ovn.extensions.evpn import fsm from neutron.agent.ovn.extensions.evpn import netlink_monitor +from neutron.agent.ovn.extensions.evpn import svd from neutron.agent.ovn.extensions import extension_manager as ovn_ext_mgr +from neutron.privileged.agent.linux import svd as privileged_svd LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +@dataclasses.dataclass(frozen=True) +class EvpnConfig: + local_ip: str + dstport: int + vxlan_parent: str + mac: str + br_mtu: int class EVPNAgentExtension(ovn_ext_mgr.OVNAgentExtension): @@ -33,9 +50,38 @@ def __init__(self): self._evpn_fsm = None self.nl_dispatcher = None + def _get_evpn_config(self): + ext_ids = self.agent_api.ovs_idl.db_get( + 'Open_vSwitch', '.', 'external_ids').execute() + local_ip = ext_ids['ovn-evpn-local-ip'] + vxlan_port = ext_ids['ovn-evpn-vxlan-ports'] + vxlan_parent = 'vxlan_sys_%s' % vxlan_port + dstport = CONF.ovn_evpn.child_vxlan_port + mac = net_lib.get_random_mac(CONF.base_mac.split(':')) + self.cfg = EvpnConfig(local_ip=local_ip, dstport=dstport, + vxlan_parent=vxlan_parent, mac=mac, + br_mtu=evpn_const.EVPN_BR_MTU) + LOG.debug("EVPN config: local_ip %s vxlan_parent %s " + "child vxlan port %d SVD MAC %s", + self.cfg.local_ip, self.cfg.vxlan_parent, + self.cfg.dstport, self.cfg.mac) + def start(self): super().start() - self._evpn_fsm = fsm.EvpnFSM() + self._get_evpn_config() + + privileged_svd.register_vxlan_vnifilter() + br_evpn = '%s%d' % (evpn_const.EVPN_LB_NAME_PREFIX, 0) + vxlan_evpn = '%s%d' % (evpn_const.EVPN_VXLAN_IFNAME, 0) + self.svd = svd.EvpnSvd(br_evpn=br_evpn, vxlan_evpn=vxlan_evpn) + try: + self.svd.create(local_ip=self.cfg.local_ip, + mac=self.cfg.mac, + vxlan_parent=self.cfg.vxlan_parent, + dstport=self.cfg.dstport, br_mtu=self.cfg.br_mtu) + except linux_svd.SvdDeviceAlreadyExists: + LOG.warning("SVD already exists, reusing") + self._evpn_fsm = fsm.EvpnFSM(self.svd, self.cfg) vrf_handler = netlink_monitor.VrfHandler(self._evpn_fsm) self.nl_dispatcher = nl_dispatcher.NetlinkDispatcher( rtnl.RTMGRP_LINK) diff --git a/neutron/agent/ovn/extensions/evpn/fsm.py b/neutron/agent/ovn/extensions/evpn/fsm.py index 8740163031b..9b32351ca93 100644 --- a/neutron/agent/ovn/extensions/evpn/fsm.py +++ b/neutron/agent/ovn/extensions/evpn/fsm.py @@ -1,4 +1,4 @@ -# Copyright 2026 Red Hat, Inc. +# Copyright 2026 Red Hat, LLC # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -27,8 +27,8 @@ class Evpn: """Per EVPN instance tracking FSM state.""" INIT = 'init' - WAITING_FOR_VRF_UP = 'waiting_for_vrf_up' - WAITING_FOR_MAC_VNI = 'waiting_for_mac_vni' + WAITING_FOR_ROUTER = 'waiting_for_router' + WAITING_FOR_BRIDGE = 'waiting_for_bridge' ADVERTISING = 'advertising' DESTROY = 'destroy' @@ -37,6 +37,7 @@ def __init__(self, vrf): self.vrf_up = False self.mac = None self.vni = None + self.vid = None self.state = self.INIT @@ -59,51 +60,63 @@ class EvpnFSM: # (Current state, Event):(New state, transition callback) TRANSITIONS = { (Evpn.INIT, FSM_EVENT_PORT_BINDING_CREATE): - (Evpn.WAITING_FOR_VRF_UP, "_set_mac_vni"), + (Evpn.WAITING_FOR_ROUTER, "_set_evpn_bridge"), (Evpn.INIT, FSM_EVENT_VRF_CREATE): - (Evpn.WAITING_FOR_MAC_VNI, "_set_vrf_up"), - (Evpn.WAITING_FOR_VRF_UP, FSM_EVENT_VRF_CREATE): - (Evpn.ADVERTISING, "_set_vrf_up_and_advertise"), - (Evpn.WAITING_FOR_MAC_VNI, FSM_EVENT_PORT_BINDING_CREATE): - (Evpn.ADVERTISING, "_set_mac_vni_and_advertise"), + (Evpn.WAITING_FOR_BRIDGE, "_set_evpn_router"), + (Evpn.WAITING_FOR_ROUTER, FSM_EVENT_VRF_CREATE): + (Evpn.ADVERTISING, "_set_evpn_router_and_advertise"), + (Evpn.WAITING_FOR_BRIDGE, FSM_EVENT_PORT_BINDING_CREATE): + (Evpn.ADVERTISING, "_set_evpn_bridge_and_advertise"), (Evpn.ADVERTISING, FSM_EVENT_PORT_BINDING_DELETE): - (Evpn.WAITING_FOR_MAC_VNI, "_unset_mac_vni"), + (Evpn.WAITING_FOR_BRIDGE, "_unset_evpn_bridge_and_unadvertise"), (Evpn.ADVERTISING, FSM_EVENT_VRF_DELETE): - (Evpn.WAITING_FOR_VRF_UP, "_unset_vrf_up"), - (Evpn.WAITING_FOR_MAC_VNI, FSM_EVENT_VRF_DELETE): + (Evpn.WAITING_FOR_ROUTER, "_unset_evpn_router_and_unadvertise"), + (Evpn.WAITING_FOR_BRIDGE, FSM_EVENT_VRF_DELETE): (Evpn.DESTROY, "_destroy"), - (Evpn.WAITING_FOR_VRF_UP, FSM_EVENT_PORT_BINDING_DELETE): + (Evpn.WAITING_FOR_ROUTER, FSM_EVENT_PORT_BINDING_DELETE): (Evpn.DESTROY, "_destroy"), } - def __init__(self): + def __init__(self, svd, config): self.instances = {} # vrf -> Evpn + self._svd = svd + self._cfg = config - def _set_mac_vni(self, evpn, mac, vni): + def _set_evpn_bridge(self, evpn, mac, vni, vid): evpn.mac = mac evpn.vni = vni + evpn.vid = vid - def _unset_mac_vni(self, evpn): + def _unset_evpn_bridge_and_unadvertise(self, evpn): + self._unadvertise(evpn) evpn.mac = None evpn.vni = None + evpn.vid = None - def _set_vrf_up(self, evpn): + def _set_evpn_router(self, evpn): evpn.vrf_up = True - def _unset_vrf_up(self, evpn): + def _unset_evpn_router_and_unadvertise(self, evpn): + self._unadvertise(evpn) evpn.vrf_up = False def _advertise(self, evpn): + self._svd.add_vni(evpn.vni, evpn.vid, evpn.vrf, evpn.mac, + self._cfg.br_mtu) LOG.debug("EVPN: VNI %d Create VLAN and update FRR " "configuration to start advertising and learning", evpn.vni) - pass - def _set_vrf_up_and_advertise(self, evpn): - self._set_vrf_up(evpn) + def _unadvertise(self, evpn): + LOG.debug("EVPN: VNI %d Remove VLAN and update FRR " + "configuration to stop advertising and learning", evpn.vni) + self._svd.del_vni(evpn.vni, evpn.vid) + + def _set_evpn_router_and_advertise(self, evpn): + self._set_evpn_router(evpn) self._advertise(evpn) - def _set_mac_vni_and_advertise(self, evpn, mac, vni): - self._set_mac_vni(evpn, mac, vni) + def _set_evpn_bridge_and_advertise(self, evpn, mac, vni, vid): + self._set_evpn_bridge(evpn, mac, vni, vid) self._advertise(evpn) def _destroy(self, evpn): diff --git a/neutron/agent/ovn/extensions/evpn/svd.py b/neutron/agent/ovn/extensions/evpn/svd.py index d5bdbe21fab..f8b860f4b66 100644 --- a/neutron/agent/ovn/extensions/evpn/svd.py +++ b/neutron/agent/ovn/extensions/evpn/svd.py @@ -32,10 +32,10 @@ def __init__(self, br_evpn, vxlan_evpn, index=0): self._index = index self._svi_names = {} - def add_vni(self, vni, vid, vrf_name, mac): + def add_vni(self, vni, vid, vrf_name, mac, br_mtu): svi_name = evpn_const.EVPN_VLAN_IFNAME_PATTERN % { 'index': self._index, 'vid': vid} - super().add_vni(svi_name, vni, vid, vrf_name, mac) + super().add_vni(svi_name, vni, vid, vrf_name, mac, br_mtu) self._svi_names[vni] = svi_name def del_vni(self, vni, vid): diff --git a/neutron/conf/agent/ovn/ovn_neutron_agent/config.py b/neutron/conf/agent/ovn/ovn_neutron_agent/config.py index 6c0462878d2..5db1acfa925 100644 --- a/neutron/conf/agent/ovn/ovn_neutron_agent/config.py +++ b/neutron/conf/agent/ovn/ovn_neutron_agent/config.py @@ -33,6 +33,16 @@ help=_('Timeout in seconds for the OVSDB connection transaction')) ] +OVN_EVPN_OPTS = [ + cfg.IntOpt( + 'bgp_as', + help=_('BGP Autonomous System number for EVPN')), + cfg.IntOpt( + 'child_vxlan_port', + default=49152, + help=_('UDP port for the child VxLAN device used by EVPN')), +] + def list_ovn_neutron_agent_opts(): return [ @@ -46,7 +56,8 @@ def list_ovn_neutron_agent_opts(): ovsdb_api.API_OPTS, ) ), - (meta_conf.RATE_LIMITING_GROUP, meta_conf.METADATA_RATE_LIMITING_OPTS) + (meta_conf.RATE_LIMITING_GROUP, meta_conf.METADATA_RATE_LIMITING_OPTS), + ('ovn_evpn', OVN_EVPN_OPTS), ] @@ -54,6 +65,7 @@ def register_opts(): cfg.CONF.register_opts(ovn_conf.ovn_opts, group='ovn') cfg.CONF.register_opts(OVS_OPTS, group='ovs') cfg.CONF.register_opts(ovsdb_api.API_OPTS, group='ovs') + cfg.CONF.register_opts(OVN_EVPN_OPTS, group='ovn_evpn') def get_root_helper(conf): diff --git a/neutron/privileged/agent/linux/svd.py b/neutron/privileged/agent/linux/svd.py index ec55882093c..8b68029b9ac 100644 --- a/neutron/privileged/agent/linux/svd.py +++ b/neutron/privileged/agent/linux/svd.py @@ -23,7 +23,6 @@ from pyroute2.netlink.rtnl.ifinfmsg.plugins import vxlan from neutron.agent.linux import nl_constants as nl_const -from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron import privileged from neutron.privileged.agent.linux import ip_lib as priv_ip_lib @@ -169,7 +168,8 @@ def _set_addrgenmode_none(ipr, idx): @privileged.default.entrypoint -def create_svd(br_evpn, vxlan_evpn, local_ip, mac, vxlan_parent, dstport): +def create_svd(br_evpn, vxlan_evpn, local_ip, mac, vxlan_parent, dstport, + br_mtu): """Create a shared Single VxLAN Device (SVD) A shared SVD consist of a vlan-aware Linux bridge and a vlan-aware VxLAN @@ -215,7 +215,7 @@ def create_svd(br_evpn, vxlan_evpn, local_ip, mac, vxlan_parent, dstport): # ip link set mtu 1500 addrgenmode none # ip link set addrgenmode none ipr.link(nl_const.IP_LINK_SET, index=br_idx, - mtu=evpn_const.EVPN_BR_MTU) + mtu=br_mtu) _set_addrgenmode_none(ipr, br_idx) _set_addrgenmode_none(ipr, vxlan_idx) @@ -237,7 +237,7 @@ def delete_svd(br_evpn, vxlan_evpn): @privileged.default.entrypoint -def add_vni(br_evpn, vxlan_evpn, svi_name, vni, vid, vrf_name, mac): +def add_vni(br_evpn, vxlan_evpn, svi_name, vni, vid, vrf_name, mac, br_mtu): with priv_ip_lib.get_iproute(None) as ipr: br_idx = ipr.link_lookup(ifname=br_evpn)[0] vxlan_idx = ipr.link_lookup(ifname=vxlan_evpn)[0] @@ -269,7 +269,7 @@ def add_vni(br_evpn, vxlan_evpn, svi_name, vni, vid, vrf_name, mac): svi_idx = ipr.link_lookup(ifname=svi_name)[0] ipr.link(nl_const.IP_LINK_SET, index=svi_idx, master=vrf_idx, address=mac, - mtu=evpn_const.EVPN_BR_MTU, state='up') + mtu=br_mtu, state='up') _set_addrgenmode_none(ipr, svi_idx) LOG.debug("SVD %s/%s: added VLAN %d -> VNI %d, SVI %s", diff --git a/neutron/tests/functional/agent/linux/test_svd.py b/neutron/tests/functional/agent/linux/test_svd.py index 1ba82a207f9..4ae6f315b05 100644 --- a/neutron/tests/functional/agent/linux/test_svd.py +++ b/neutron/tests/functional/agent/linux/test_svd.py @@ -27,6 +27,7 @@ class TestSvdFunctional(base.BaseNetlinkTestCase): LOCAL_IP = '10.10.10.10' MAC = 'aa:bb:cc:dd:ee:ff' SVI_MAC = '00:11:22:33:44:55' + BR_MTU = 1500 @staticmethod def _safe_delete(name): @@ -68,7 +69,8 @@ def setUp(self): def _create_svd(self): brvxlan = linux_svd.Svd(br_evpn=self._br, vxlan_evpn=self._vx) brvxlan.create(local_ip=self.LOCAL_IP, mac=self.MAC, - vxlan_parent=self._parent, dstport=self.DSTPORT) + vxlan_parent=self._parent, dstport=self.DSTPORT, + br_mtu=self.BR_MTU) self.addCleanup(self._safe_delete, self._vx) self.addCleanup(self._safe_delete, self._br) return brvxlan @@ -113,7 +115,8 @@ def test_create_svd_parent_not_found(self): self.assertRaises(linux_svd.SvdNoVxlanParent, brvxlan.create, local_ip=self.LOCAL_IP, mac=self.MAC, vxlan_parent='no-such-dev', - dstport=self.DSTPORT) + dstport=self.DSTPORT, + br_mtu=self.BR_MTU) self.assertFalse(ip_lib.device_exists(self._br)) self.assertFalse(ip_lib.device_exists(self._vx)) @@ -123,7 +126,8 @@ def test_create_svd_device_exists(self): self.assertRaises(linux_svd.SvdDeviceAlreadyExists, brvxlan.create, local_ip=self.LOCAL_IP, mac=self.MAC, vxlan_parent=self._parent, - dstport=self.DSTPORT) + dstport=self.DSTPORT, + br_mtu=self.BR_MTU) def test_delete_svd(self): svd = self._create_svd() @@ -143,7 +147,7 @@ def test_add_vni(self): vni = self._vni() vid = self._vid() svi_name = self._svi_name(vid) - svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC, self.BR_MTU) self.assertTrue(ip_lib.device_exists(svi_name)) br_vlans = self._bridge_cmd('vlan', 'show', 'dev', self._br) @@ -161,7 +165,8 @@ def test_add_vni_vrf_not_found(self): vid = self._vid() svi_name = self._svi_name(vid) self.assertRaises(linux_svd.SvdDevsNotFound, brvxlan.add_vni, - svi_name, vni, vid, 'no-such-vrf', self.SVI_MAC) + svi_name, vni, vid, 'no-such-vrf', self.SVI_MAC, + self.BR_MTU) self.assertFalse(ip_lib.device_exists(svi_name)) vni_output = self._bridge_cmd('vni', 'show', 'dev', self._vx) self.assertNotIn(str(vni), vni_output) @@ -171,18 +176,19 @@ def test_add_vni_netlink_error(self): vni = self._vni() vid = self._vid() svi_name = self._svi_name(vid) - svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC, self.BR_MTU) self.addCleanup(svd.del_vni, svi_name, vni, vid) # Add the same VNI a second time to trigger NetlinkError self.assertRaises(linux_svd.SvdNetlinkError, svd.add_vni, - svi_name, vni, vid, self._vrf, self.SVI_MAC) + svi_name, vni, vid, self._vrf, self.SVI_MAC, + self.BR_MTU) def test_del_vni(self): svd = self._create_svd() vni = self._vni() vid = self._vid() svi_name = self._svi_name(vid) - svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC, self.BR_MTU) svd.del_vni(svi_name, vni, vid) @@ -206,8 +212,10 @@ def test_add_multiple_vnis(self): vid2 = self._vid(1) svi_name1 = self._svi_name(vid1) svi_name2 = self._svi_name(vid2) - svd.add_vni(svi_name1, vni1, vid1, self._vrf, self.SVI_MAC) - svd.add_vni(svi_name2, vni2, vid2, self._vrf, self.SVI_MAC) + svd.add_vni(svi_name1, vni1, vid1, self._vrf, + self.SVI_MAC, self.BR_MTU) + svd.add_vni(svi_name2, vni2, vid2, self._vrf, + self.SVI_MAC, self.BR_MTU) self.assertTrue(ip_lib.device_exists(svi_name1)) self.assertTrue(ip_lib.device_exists(svi_name2)) @@ -228,7 +236,7 @@ def test_svi_attached_to_vrf(self): vni = self._vni() vid = self._vid() svi_name = self._svi_name(vid) - svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC) + svd.add_vni(svi_name, vni, vid, self._vrf, self.SVI_MAC, self.BR_MTU) link_output = agent_utils.execute( ['ip', '-d', 'link', 'show', svi_name], diff --git a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py index ab167650476..47b7c89131e 100644 --- a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py +++ b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import unittest from unittest import mock import testtools @@ -41,7 +42,7 @@ def setUp(self): finally: bgp_ovn.OvnSbIdl.tables = bgp_ovn.OVN_SB_TABLES self.mock_evpn_ext = mock.Mock() - self.real_fsm = evpn_fsm.EvpnFSM() + self.real_fsm = evpn_fsm.EvpnFSM(mock.Mock(), mock.Mock()) self.mock_evpn_ext._evpn_fsm = mock.Mock(wraps=self.real_fsm) self.sb_api.idl.notify_handler.watch_event( evpn_events.PortBindingLrpEvpnCreateEvent( @@ -121,6 +122,8 @@ def _wait_for_advance(self, timeout=5): class PortBindingLrpEvpnCreateEventTestCase(BaseEvpnEventsTestCase): + @unittest.skip('This has a circular dependency with 991528 and ' + 'will be fixed in that patch') def test_create_event_advances_fsm(self): vni = 10000 mac = 'aa:bb:cc:dd:ee:ff' @@ -128,7 +131,7 @@ def test_create_event_advances_fsm(self): self._wait_for_advance() self.assertIn(vrf, self.real_fsm.instances) instance = self.real_fsm.instances[vrf] - self.assertEqual(evpn_fsm.Evpn.WAITING_FOR_VRF_UP, instance.state) + self.assertEqual(evpn_fsm.Evpn.WAITING_FOR_ROUTER, instance.state) self.assertEqual(mac, instance.mac) self.assertEqual(vni, instance.vni) diff --git a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py index 5c06416fe6f..b6f98b109f7 100644 --- a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py @@ -1,4 +1,4 @@ -# Copyright 2026 Red Hat, Inc. +# Copyright 2026 Red Hat, LLC # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -20,10 +20,15 @@ from neutron.agent.linux import ip_lib from neutron.agent.linux import nl_constants as nl_const from neutron.agent.linux import nl_dispatcher +from neutron.agent.linux import utils as agent_utils +from neutron.agent.ovn.extensions import evpn +from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import fsm from neutron.agent.ovn.extensions.evpn import netlink_monitor +from neutron.agent.ovn.extensions.evpn import svd from neutron.common import utils from neutron.privileged.agent.linux import ip_lib as privileged +from neutron.tests.functional.agent.linux import base from neutron.tests.functional import base as functional_base @@ -41,7 +46,8 @@ def _evpn_vrf_name(): return 'vr%s' % uuid.uuid4().hex[:12] def test_vrf_handler_lifecycle(self): - vrf_handler = netlink_monitor.VrfHandler(fsm.EvpnFSM()) + vrf_handler = netlink_monitor.VrfHandler( + fsm.EvpnFSM(svd=None, config=None)) dispatcher = nl_dispatcher.NetlinkDispatcher(rtnl.RTMGRP_LINK) dispatcher.register_handler( @@ -99,3 +105,104 @@ def test_vrf_handler_lifecycle(self): lambda: ip_lib.device_exists(non_evpn_vrf), timeout=10, sleep=0.1) self.assertEqual(baseline, vrf_handler._known_vrfs) + + +class TestFsmSvdIntegration(base.BaseNetlinkTestCase): + + DSTPORT = 15000 + LOCAL_IP = '10.10.10.10' + SVD_MAC = 'aa:bb:cc:dd:ee:ff' + SVI_MAC = '00:11:22:33:44:55' + + @staticmethod + def _safe_delete(name): + try: + ip_lib.IPDevice(name).link.delete() + except Exception: + pass + + @staticmethod + def _set_link_up(name): + agent_utils.execute( + ['ip', 'link', 'set', name, 'up'], + run_as_root=True, privsep_exec=True) + + def setUp(self): + super().setUp() + self._parent = utils.get_rand_device_name(prefix='evpnp-') + privileged.create_interface(self._parent, None, 'dummy') + self._set_link_up(self._parent) + ip_lib.IPDevice(self._parent).addr.add(self.LOCAL_IP + '/32') + self.addCleanup(self._safe_delete, self._parent) + self.cfg = evpn.EvpnConfig(local_ip=self.LOCAL_IP, + dstport=self.DSTPORT, + vxlan_parent=self._parent, + mac=self.SVD_MAC, + br_mtu=evpn_const.EVPN_BR_MTU) + + self._vrf = utils.get_rand_device_name(prefix='evpnvrf-') + privileged.create_interface(self._vrf, None, 'vrf', vrf_table=9999) + self._set_link_up(self._vrf) + self.addCleanup(self._safe_delete, self._vrf) + + self._br = utils.get_rand_device_name(prefix='evpnbr-') + self._vx = utils.get_rand_device_name(prefix='evpnvx-') + self.svd = svd.EvpnSvd(br_evpn=self._br, vxlan_evpn=self._vx) + self.svd.create(local_ip=self.LOCAL_IP, mac=self.SVD_MAC, + vxlan_parent=self._parent, dstport=self.DSTPORT, + br_mtu=evpn_const.EVPN_BR_MTU) + self.addCleanup(self._safe_delete, self._vx) + self.addCleanup(self._safe_delete, self._br) + + self._evpn_fsm = fsm.EvpnFSM(self.svd, config=self.cfg) + + def _advance_to_advertising(self, vni, vid): + self._evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, self._vrf) + self._evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, + self._vrf, mac=self.SVI_MAC, vni=vni, vid=vid) + + def test_fsm_advertise_creates_svi(self): + index = 0 + vni = 1000 + vid = 10 + svi_name = evpn_const.EVPN_VLAN_IFNAME_PATTERN % { + 'index': index, 'vid': vid} + self._advance_to_advertising(vni, vid) + + evpn = self._evpn_fsm.instances[self._vrf] + self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) + self.assertTrue(ip_lib.device_exists(svi_name)) + + def test_fsm_port_binding_delete_deletes_svi(self): + index = 0 + vni = 2000 + vid = 20 + svi_name = evpn_const.EVPN_VLAN_IFNAME_PATTERN % { + 'index': index, 'vid': vid} + self._advance_to_advertising(vni, vid) + self.assertTrue(ip_lib.device_exists(svi_name)) + + self._evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_DELETE, self._vrf) + + evpn = self._evpn_fsm.instances[self._vrf] + self.assertEqual(fsm.Evpn.WAITING_FOR_BRIDGE, evpn.state) + self.assertFalse(ip_lib.device_exists(svi_name)) + + def test_fsm_vrf_delete_deletes_svi(self): + index = 0 + vni = 3000 + vid = 30 + svi_name = evpn_const.EVPN_VLAN_IFNAME_PATTERN % { + 'index': index, 'vid': vid} + self._advance_to_advertising(vni, vid) + self.assertTrue(ip_lib.device_exists(svi_name)) + + self._evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_VRF_DELETE, self._vrf) + + evpn = self._evpn_fsm.instances[self._vrf] + self.assertEqual(fsm.Evpn.WAITING_FOR_ROUTER, evpn.state) + self.assertFalse(ip_lib.device_exists(svi_name)) diff --git a/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py b/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py index b87e62eca79..2d36f4cf431 100644 --- a/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py +++ b/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py @@ -1,4 +1,4 @@ -# Copyright 2026 Red Hat, Inc. +# Copyright 2026 Red Hat, LLC # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -20,48 +20,56 @@ from neutron.agent.ovn.extensions.evpn import netlink_monitor from neutron.tests import base +BR_MTU = 1500 + class TestEvpnFSM(base.BaseTestCase): def setUp(self): super().setUp() - self.evpn_fsm = fsm.EvpnFSM() + self.mock_svd = mock.Mock() + self.mock_config = mock.Mock() + self.evpn_fsm = fsm.EvpnFSM(self.mock_svd, self.mock_config) + self.mock_config.br_mtu = BR_MTU - @mock.patch.object(fsm.EvpnFSM, '_advertise') - def test_vrf_then_port_binding_create(self, mock_advertise): + def test_vrf_then_port_binding_create(self): vrf = 'vr0a1b2c3d-fff' self.evpn_fsm.advance( fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, vrf) self.evpn_fsm.advance( fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, - vrf, mac='aa:bb:cc:dd:ee:ff', vni=10) + vrf, mac='aa:bb:cc:dd:ee:ff', vni=10, vid=1) evpn = self.evpn_fsm.instances[vrf] self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) self.assertEqual('aa:bb:cc:dd:ee:ff', evpn.mac) self.assertEqual(10, evpn.vni) + self.assertEqual(1, evpn.vid) self.assertTrue(evpn.vrf_up) - mock_advertise.assert_called_once_with(evpn) + self.mock_svd.add_vni.assert_called_once_with( + 10, 1, vrf, 'aa:bb:cc:dd:ee:ff', BR_MTU) - @mock.patch.object(fsm.EvpnFSM, '_advertise') - def test_port_binding_then_vrf_create(self, mock_advertise): + def test_port_binding_then_vrf_create(self): vrf = 'vr0a1b2c3d-fff' self.evpn_fsm.advance( fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, - vrf, mac='aa:bb:cc:dd:ee:ff', vni=10) + vrf, mac='aa:bb:cc:dd:ee:ff', vni=10, vid=1) self.evpn_fsm.advance( fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, vrf) evpn = self.evpn_fsm.instances[vrf] self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) self.assertEqual('aa:bb:cc:dd:ee:ff', evpn.mac) self.assertEqual(10, evpn.vni) + self.assertEqual(1, evpn.vid) self.assertTrue(evpn.vrf_up) - mock_advertise.assert_called_once_with(evpn) + self.mock_svd.add_vni.assert_called_once_with( + 10, 1, vrf, 'aa:bb:cc:dd:ee:ff', BR_MTU) - def test_vrf_then_port_binding_delete(self): + def test_advertise_then_port_binding_delete(self): vrf = 'vr0a1b2c3d-fff' evpn = fsm.Evpn(vrf) evpn.mac = 'aa:bb:cc:dd:ee:ff' evpn.vni = 10 + evpn.vid = 1 evpn.vrf_up = True evpn.state = fsm.Evpn.ADVERTISING self.evpn_fsm.instances[vrf] = evpn @@ -71,12 +79,14 @@ def test_vrf_then_port_binding_delete(self): self.evpn_fsm.advance( fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_DELETE, vrf) self.assertNotIn(vrf, self.evpn_fsm.instances) + self.mock_svd.del_vni.assert_called_once_with(10, 1) - def test_port_binding_then_vrf_delete(self): + def test_advertise_then_vrf_delete(self): vrf = 'vr0a1b2c3d-fff' evpn = fsm.Evpn(vrf) evpn.mac = 'aa:bb:cc:dd:ee:ff' evpn.vni = 10 + evpn.vid = 1 evpn.vrf_up = True evpn.state = fsm.Evpn.ADVERTISING self.evpn_fsm.instances[vrf] = evpn @@ -86,6 +96,7 @@ def test_port_binding_then_vrf_delete(self): self.evpn_fsm.advance( fsm.EvpnFSM.FSM_EVENT_VRF_DELETE, vrf) self.assertNotIn(vrf, self.evpn_fsm.instances) + self.mock_svd.del_vni.assert_called_once_with(10, 1) def test_simultaneous_vrf_and_port_binding_create(self): """Netlink and SB IDL threads both create for the same VRF.""" @@ -101,7 +112,7 @@ def idl_thread(): barrier.wait() self.evpn_fsm.advance( fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, - vrf, mac='aa:bb:cc:dd:ee:ff', vni=10) + vrf, mac='aa:bb:cc:dd:ee:ff', vni=10, vid=1) threads = [threading.Thread(target=netlink_thread), threading.Thread(target=idl_thread)] @@ -114,6 +125,7 @@ def idl_thread(): self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) self.assertEqual('aa:bb:cc:dd:ee:ff', evpn.mac) self.assertEqual(10, evpn.vni) + self.assertEqual(1, evpn.vid) self.assertTrue(evpn.vrf_up) def test_simultaneous_vrf_and_port_binding_delete(self): @@ -122,6 +134,7 @@ def test_simultaneous_vrf_and_port_binding_delete(self): evpn = fsm.Evpn(vrf) evpn.mac = 'aa:bb:cc:dd:ee:ff' evpn.vni = 10 + evpn.vid = 1 evpn.vrf_up = True evpn.state = fsm.Evpn.ADVERTISING self.evpn_fsm.instances[vrf] = evpn @@ -147,11 +160,58 @@ def idl_thread(): self.assertNotIn(vrf, self.evpn_fsm.instances) + def test_advertise_then_vrf_delete_then_vrf_create(self): + vrf = 'vr0a1b2c3d-fff' + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, vrf) + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, + vrf, mac='aa:bb:cc:dd:ee:ff', vni=10, vid=1) + evpn = self.evpn_fsm.instances[vrf] + self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) + + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_VRF_DELETE, vrf) + self.assertEqual(fsm.Evpn.WAITING_FOR_ROUTER, evpn.state) + self.mock_svd.del_vni.assert_called_once_with(10, 1) + + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, vrf) + self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) + self.assertEqual(2, self.mock_svd.add_vni.call_count) + + def test_advertise_then_port_binding_delete_then_port_binding_create(self): + vrf = 'vr0a1b2c3d-fff' + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, + vrf, mac='aa:bb:cc:dd:ee:ff', vni=10, vid=1) + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, vrf) + evpn = self.evpn_fsm.instances[vrf] + self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) + + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_DELETE, vrf) + self.assertEqual(fsm.Evpn.WAITING_FOR_BRIDGE, evpn.state) + self.assertIsNone(evpn.mac) + self.assertIsNone(evpn.vni) + self.assertIsNone(evpn.vid) + self.mock_svd.del_vni.assert_called_once_with(10, 1) + + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, + vrf, mac='11:22:33:44:55:66', vni=20, vid=2) + self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) + self.assertEqual('11:22:33:44:55:66', evpn.mac) + self.assertEqual(20, evpn.vni) + self.assertEqual(2, evpn.vid) + self.assertEqual(2, self.mock_svd.add_vni.call_count) + def test_replay_end_deletes_stale_fsm_instance(self): vrf1, vrf2 = 'vr0a1b2c3d-eee', 'vr1a2b3c3d-fff' evpn = fsm.Evpn(vrf1) evpn.vrf_up = True - evpn.state = fsm.Evpn.WAITING_FOR_MAC_VNI + evpn.state = fsm.Evpn.WAITING_FOR_BRIDGE self.evpn_fsm.instances[vrf1] = evpn handler = netlink_monitor.VrfHandler(self.evpn_fsm) handler._known_vrfs = {vrf1, vrf2} @@ -165,6 +225,7 @@ def test_replay_end_transitions_advertising_to_waiting(self): evpn = fsm.Evpn(vrf) evpn.mac = 'aa:bb:cc:dd:ee:ff' evpn.vni = 10 + evpn.vid = 1 evpn.vrf_up = True evpn.state = fsm.Evpn.ADVERTISING self.evpn_fsm.instances[vrf] = evpn @@ -172,14 +233,14 @@ def test_replay_end_transitions_advertising_to_waiting(self): handler._known_vrfs = {vrf} handler._replay_vrfs = set() handler.replay_end() - self.assertEqual(fsm.Evpn.WAITING_FOR_VRF_UP, evpn.state) + self.assertEqual(fsm.Evpn.WAITING_FOR_ROUTER, evpn.state) self.assertIn(vrf, self.evpn_fsm.instances) def test_replay_end_no_stale_vrfs(self): vrf = 'vr0a1b2c3d-fff' evpn = fsm.Evpn(vrf) evpn.vrf_up = True - evpn.state = fsm.Evpn.WAITING_FOR_MAC_VNI + evpn.state = fsm.Evpn.WAITING_FOR_BRIDGE self.evpn_fsm.instances[vrf] = evpn handler = netlink_monitor.VrfHandler(self.evpn_fsm) handler._known_vrfs = {vrf} diff --git a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py index d83a08440cd..ffb562fd8ca 100644 --- a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py @@ -1,4 +1,4 @@ -# Copyright 2026 Red Hat, Inc. +# Copyright 2026 Red Hat, LLC # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,12 +13,19 @@ # License for the specific language governing permissions and limitations # under the License. +from unittest import mock + +from oslo_config import cfg from pyroute2.iproute import ipmock from pyroute2.netlink.rtnl import ifinfmsg +from neutron.agent.ovn.extensions import evpn as evpn_ext from neutron.agent.ovn.extensions.evpn import exceptions as evpn_exc from neutron.agent.ovn.extensions.evpn import fsm from neutron.agent.ovn.extensions.evpn import netlink_monitor +from neutron.agent.ovn.extensions.evpn import svd +from neutron.conf.agent.ovn.ovn_neutron_agent import config as agent_config +from neutron.privileged.agent.linux import svd as privileged_svd from neutron.tests import base @@ -36,11 +43,40 @@ def _make_vrf_msg(ifname, kind='vrf'): return _make_nlmsg(ifname, kind=kind) +class TestEVPNAgentExtension(base.BaseTestCase): + + LOCAL_IP = '10.10.10.10' + VXLAN_PORT = '4789' + DSTPORT = 49152 + MAC = 'fa:16:3e:aa:bb:cc' + + def setUp(self): + super().setUp() + cfg.CONF.register_opts(agent_config.OVN_EVPN_OPTS, group='ovn_evpn') + cfg.CONF.set_override('child_vxlan_port', self.DSTPORT, + group='ovn_evpn') + self.ext = evpn_ext.EVPNAgentExtension() + self.ext.agent_api = mock.Mock() + self.ext.agent_api.ovs_idl.db_get.return_value.execute.return_value = { + 'ovn-evpn-local-ip': self.LOCAL_IP, + 'ovn-evpn-vxlan-ports': self.VXLAN_PORT, + } + mock.patch.object(privileged_svd, + 'register_vxlan_vnifilter').start() + self.mock_svd_cls = mock.patch.object(svd, 'EvpnSvd').start() + self.mock_nl = mock.patch('neutron.agent.ovn.extensions' + '.evpn.nl_dispatcher' + '.NetlinkDispatcher').start() + mock.patch.object(evpn_ext.net_lib, 'get_random_mac', + return_value=self.MAC).start() + self.addCleanup(mock.patch.stopall) + + class TestVrfHandler(base.BaseTestCase): def setUp(self): super().setUp() - self._evpn_fsm = fsm.EvpnFSM() + self._evpn_fsm = fsm.EvpnFSM(mock.Mock(), mock.Mock()) self.handler = netlink_monitor.VrfHandler(self._evpn_fsm) def test_handle_newlink_evpn_vrf(self): @@ -61,7 +97,7 @@ def test_handle_dellink_evpn_vrf(self): self.handler._known_vrfs.add(vrf) evpn = fsm.Evpn(vrf) evpn.vrf_up = True - evpn.state = fsm.Evpn.WAITING_FOR_MAC_VNI + evpn.state = fsm.Evpn.WAITING_FOR_BRIDGE self._evpn_fsm.instances[vrf] = evpn msg = _make_vrf_msg(vrf) self.handler.handle_dellink(msg) @@ -73,7 +109,7 @@ def test_handle_dellink_unknown_vrf(self): self.handler._known_vrfs.add(vrf) evpn = fsm.Evpn(vrf) evpn.vrf_up = True - evpn.state = fsm.Evpn.WAITING_FOR_MAC_VNI + evpn.state = fsm.Evpn.WAITING_FOR_BRIDGE self._evpn_fsm.instances[vrf] = evpn msg = _make_vrf_msg('vr0a1b2c3d-eee') self.handler.handle_dellink(msg) From d8eb719ea9b2b0a4970dc4c23bf37baae37b9818 Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Wed, 3 Jun 2026 22:02:27 -0500 Subject: [PATCH 16/32] evpn: Pass VLAN ID in port binding events to FSM Add EVPN_LRP_VLAN_EXT_ID_KEY constant and pass vid to fsm.advance() for port binding create events to support SVD vni:vlan mapping. Change-Id: Ibd06716a98e1e4addb00a616d3d8e46ad22d1aca Signed-off-by: Terry Wilson --- neutron/agent/ovn/extensions/evpn/events.py | 11 +++++++-- neutron/services/evpn/constants.py | 1 + .../agent/ovn/extensions/evpn/test_events.py | 23 +++++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/neutron/agent/ovn/extensions/evpn/events.py b/neutron/agent/ovn/extensions/evpn/events.py index 301ff9a48a9..ab9aa4665c2 100644 --- a/neutron/agent/ovn/extensions/evpn/events.py +++ b/neutron/agent/ovn/extensions/evpn/events.py @@ -36,7 +36,8 @@ class EVPNPortBindingEvent(EVPNAgentEvent): def match_fn(self, event, row, old): return (ovn_const.LR_OPTIONS_DR_VRF_NAME in row.options and - svc_const.EVPN_LRP_VNI_EXT_ID_KEY in row.external_ids) + svc_const.EVPN_LRP_VNI_EXT_ID_KEY in row.external_ids and + svc_const.EVPN_LRP_VLAN_EXT_ID_KEY in row.external_ids) class PortBindingLrpEvpnCreateEvent(EVPNPortBindingEvent): @@ -51,14 +52,20 @@ def match_fn(self, event, row, old): except ValueError: LOG.error("Invalid VNI in Port_Binding %s", row.logical_port) return False + try: + int(row.external_ids[svc_const.EVPN_LRP_VLAN_EXT_ID_KEY]) + except ValueError: + LOG.error("Invalid VLAN in Port_Binding %s", row.logical_port) + return False return True def run(self, event, row, old): vrf = row.options[ovn_const.LR_OPTIONS_DR_VRF_NAME] vni = int(row.external_ids[svc_const.EVPN_LRP_VNI_EXT_ID_KEY]) + vid = int(row.external_ids[svc_const.EVPN_LRP_VLAN_EXT_ID_KEY]) try: self.fsm.advance(evpn_fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, - vrf, mac=row.mac[0], vni=vni) + vrf, mac=row.mac[0], vni=vni, vid=vid) except evpn_exc.FSMIllegalTransition: LOG.error("Unexpected FSM transition for VRF %s on %s", vrf, row.logical_port) diff --git a/neutron/services/evpn/constants.py b/neutron/services/evpn/constants.py index 68d0c3b6fa1..f9830b815bc 100644 --- a/neutron/services/evpn/constants.py +++ b/neutron/services/evpn/constants.py @@ -14,6 +14,7 @@ # under the License. EVPN_LRP_VNI_EXT_ID_KEY = 'vni' +EVPN_LRP_VLAN_EXT_ID_KEY = 'vlan' EVPN_LS_NAME_PREFIX = 'evpn-ls-' EVPN_LRP_NAME_PATTERN = 'evpn-lrp-%(lrp_uuid)s-to-%(evpn_ls_name)s' EVPN_LSP_NAME_PATTERN = 'evpn-lsp-%(evpn_ls_name)s-to-%(lrp_uuid)s' diff --git a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py index 47b7c89131e..b122f9b0b77 100644 --- a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py +++ b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py @@ -13,7 +13,6 @@ # License for the specific language governing permissions and limitations # under the License. -import unittest from unittest import mock import testtools @@ -51,7 +50,7 @@ def setUp(self): evpn_events.PortBindingLrpEvpnDeleteEvent( self.mock_evpn_ext._evpn_fsm)) - def _create_evpn_lrp(self, vni, mac): + def _create_evpn_lrp(self, vni, mac, vlan=100): lr_name = f'lr-evpn-{vni}' ls_name = f'ls-evpn-{vni}' lrp_name = f'lrp-to-evpn-{vni}' @@ -71,6 +70,7 @@ def _create_evpn_lrp(self, vni, mac): lr_name, lrp_name, mac, [], external_ids={ svc_const.EVPN_LRP_VNI_EXT_ID_KEY: str(vni), + svc_const.EVPN_LRP_VLAN_EXT_ID_KEY: str(vlan), }, options={ 'dynamic-routing-maintain-vrf': 'true', @@ -82,7 +82,8 @@ def _create_evpn_lrp(self, vni, mac): return vrf def _create_lrp_without_evpn_match(self, vni, mac, - set_vrf=True, set_vni=True): + set_vrf=True, set_vni=True, + set_vlan=True): lr_name = f'lr-no-evpn-{vni}' ls_name = f'ls-no-evpn-{vni}' lrp_name = f'lrp-no-evpn-{vni}' @@ -95,6 +96,8 @@ def _create_lrp_without_evpn_match(self, vni, mac, external_ids = {} if set_vni: external_ids[svc_const.EVPN_LRP_VNI_EXT_ID_KEY] = str(vni) + if set_vlan: + external_ids[svc_const.EVPN_LRP_VLAN_EXT_ID_KEY] = '100' with self.nb_api.transaction(check_error=True) as txn: txn.add(self.nb_api.db_set( 'Logical_Router', lr_name, options=options)) @@ -122,18 +125,18 @@ def _wait_for_advance(self, timeout=5): class PortBindingLrpEvpnCreateEventTestCase(BaseEvpnEventsTestCase): - @unittest.skip('This has a circular dependency with 991528 and ' - 'will be fixed in that patch') def test_create_event_advances_fsm(self): vni = 10000 + vlan = 100 mac = 'aa:bb:cc:dd:ee:ff' - vrf = self._create_evpn_lrp(vni, mac) + vrf = self._create_evpn_lrp(vni, mac, vlan=vlan) self._wait_for_advance() self.assertIn(vrf, self.real_fsm.instances) instance = self.real_fsm.instances[vrf] self.assertEqual(evpn_fsm.Evpn.WAITING_FOR_ROUTER, instance.state) self.assertEqual(mac, instance.mac) self.assertEqual(vni, instance.vni) + self.assertEqual(vlan, instance.vid) def test_create_event_not_triggered_without_vrf_option(self): self._create_lrp_without_evpn_match(10001, 'aa:bb:cc:dd:ee:ff', @@ -147,10 +150,16 @@ def test_create_event_not_triggered_missing_vni(self): with testtools.ExpectedException(AssertionError): self._wait_for_advance(timeout=2) + def test_create_event_not_triggered_missing_vlan(self): + self._create_lrp_without_evpn_match(10003, 'aa:bb:cc:dd:ee:ff', + set_vlan=False) + with testtools.ExpectedException(AssertionError): + self._wait_for_advance(timeout=2) + def test_create_event_illegal_fsm_transition(self): self.mock_evpn_ext._evpn_fsm.advance.side_effect = \ evpn_exc.FSMIllegalTransition("forced bad state") - self._create_evpn_lrp(10003, 'aa:bb:cc:dd:ee:ff') + self._create_evpn_lrp(10004, 'aa:bb:cc:dd:ee:ff') self._wait_for_advance() From 4bb1a29ddc50679ef94340c91dfd3d05bb8e51f4 Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Thu, 30 Apr 2026 11:04:47 -0400 Subject: [PATCH 17/32] FRR driver for a EVPN router Provides common interface via EVPNRouterDriver class. This is so we can implement other evpn drivers in the future. E.g. frr gRPC driver. This patch Implements `FrrVtyshDriver` which uses python subprocess to call into vtysh on the system to configure a evpn driver. The configuration file is generated based on templates in templates.py. For now, there is no way for the operator to add their own custom frr configuration. Fortunately, FrrCommandBuilder can be extended with a new "loader" to allow overwrite files in future. Follow up patches: - Hook up FrrVtyshDriver to the EVPN state machine Assisted-By: Claude Opus 4.6 Change-Id: I746e30c2b2ab36ad706bff6f411790d27d85be1b Signed-off-by: Miro Tomaska Related-Bug: #2144617 --- devstack/etc/frr_with_evpn/daemons | 42 ++ devstack/etc/frr_with_evpn/frr.conf | 4 + devstack/lib/frr | 15 +- neutron/agent/linux/evpn_router/__init__.py | 0 .../agent/linux/evpn_router/frr/__init__.py | 0 .../agent/linux/evpn_router/frr/exceptions.py | 43 ++ .../agent/linux/evpn_router/frr/frr_driver.py | 276 +++++++++++ .../agent/linux/evpn_router/frr/templates.py | 154 ++++++ neutron/agent/linux/evpn_router/interface.py | 52 ++ .../agent/linux/evpn_router/__init__.py | 0 .../agent/linux/evpn_router/frr/__init__.py | 0 .../linux/evpn_router/frr/test_frr_driver.py | 445 ++++++++++++++++++ .../unit/agent/linux/evpn_router/__init__.py | 0 .../agent/linux/evpn_router/frr/__init__.py | 0 .../linux/evpn_router/frr/test_frr_driver.py | 281 +++++++++++ .../tasks/main.yaml | 1 + tools/configure_for_func_testing.sh | 7 + zuul.d/base.yaml | 1 + 18 files changed, 1319 insertions(+), 2 deletions(-) create mode 100644 devstack/etc/frr_with_evpn/daemons create mode 100644 devstack/etc/frr_with_evpn/frr.conf create mode 100644 neutron/agent/linux/evpn_router/__init__.py create mode 100644 neutron/agent/linux/evpn_router/frr/__init__.py create mode 100644 neutron/agent/linux/evpn_router/frr/exceptions.py create mode 100644 neutron/agent/linux/evpn_router/frr/frr_driver.py create mode 100644 neutron/agent/linux/evpn_router/frr/templates.py create mode 100644 neutron/agent/linux/evpn_router/interface.py create mode 100644 neutron/tests/functional/agent/linux/evpn_router/__init__.py create mode 100644 neutron/tests/functional/agent/linux/evpn_router/frr/__init__.py create mode 100644 neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py create mode 100644 neutron/tests/unit/agent/linux/evpn_router/__init__.py create mode 100644 neutron/tests/unit/agent/linux/evpn_router/frr/__init__.py create mode 100644 neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py diff --git a/devstack/etc/frr_with_evpn/daemons b/devstack/etc/frr_with_evpn/daemons new file mode 100644 index 00000000000..776677baeba --- /dev/null +++ b/devstack/etc/frr_with_evpn/daemons @@ -0,0 +1,42 @@ +bgpd=yes +ospfd=no +ospf6d=no +ripd=no +ripngd=no +isisd=no +pimd=no +ldpd=no +nhrpd=no +eigrpd=no +babeld=no +sharpd=no +pbrd=no +bfdd=no +fabricd=no +vrrpd=no +pathd=no + +# +# If this option is set the /etc/init.d/frr script automatically loads +# the config via "vtysh -b" when the servers are started. +# Check /etc/pam.d/frr if you intend to use "vtysh"! +# +vtysh_enable=yes +zebra_options=" -A 127.0.0.1 -s 90000000" +bgpd_options=" -A 127.0.0.1" +ospfd_options=" -A 127.0.0.1" +ospf6d_options=" -A ::1" +ripd_options=" -A 127.0.0.1" +ripngd_options=" -A ::1" +isisd_options=" -A 127.0.0.1" +pimd_options=" -A 127.0.0.1" +ldpd_options=" -A 127.0.0.1" +nhrpd_options=" -A 127.0.0.1" +eigrpd_options=" -A 127.0.0.1" +babeld_options=" -A 127.0.0.1" +sharpd_options=" -A 127.0.0.1" +pbrd_options=" -A 127.0.0.1" +staticd_options="-A 127.0.0.1" +bfdd_options=" -A 127.0.0.1" +fabricd_options="-A 127.0.0.1" +vrrpd_options=" -A 127.0.0.1" diff --git a/devstack/etc/frr_with_evpn/frr.conf b/devstack/etc/frr_with_evpn/frr.conf new file mode 100644 index 00000000000..f50af963fec --- /dev/null +++ b/devstack/etc/frr_with_evpn/frr.conf @@ -0,0 +1,4 @@ +frr defaults traditional +hostname devstack +log file /var/log/frr/frr.log informational +log timestamp precision 3 diff --git a/devstack/lib/frr b/devstack/lib/frr index af24d39d94c..41ef57f3ad9 100755 --- a/devstack/lib/frr +++ b/devstack/lib/frr @@ -20,6 +20,15 @@ function install_frr { install_package frr } +function install_frr_vrf_modules { + if is_ubuntu; then + install_package linux-modules-extra-$(uname -r) + elif is_fedora; then + install_package kernel-modules-extra-$(uname -r) + fi + sudo modprobe vrf +} + function configure_frr { echo_summary "Configuring FRR" @@ -27,7 +36,10 @@ function configure_frr { sudo install -d -o $STACK_USER $FRR_CONF_DIR # Configure frr daemons - if [[ "$FRR_USE_BFD" == "True" ]]; then + if [[ "$FRR_USE_EVPN" == "True" ]]; then + sudo install -o root -g root -m 644 $FRR_ETC_SRC_DIR/frr_with_evpn/* $FRR_CONF_DIR/ + install_frr_vrf_modules + elif [[ "$FRR_USE_BFD" == "True" ]]; then sudo install -o root -g root -m 644 $FRR_ETC_SRC_DIR/frr_with_bfd/* $FRR_CONF_DIR/ else sudo install -o root -g root -m 644 $FRR_ETC_SRC_DIR/frr/* $FRR_CONF_DIR/ @@ -61,4 +73,3 @@ function cleanup_frr { # Clean the FRRt configuration dir sudo rm -rf $FRR_CONF_DIR } - diff --git a/neutron/agent/linux/evpn_router/__init__.py b/neutron/agent/linux/evpn_router/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/agent/linux/evpn_router/frr/__init__.py b/neutron/agent/linux/evpn_router/frr/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/agent/linux/evpn_router/frr/exceptions.py b/neutron/agent/linux/evpn_router/frr/exceptions.py new file mode 100644 index 00000000000..0ee8494fdbb --- /dev/null +++ b/neutron/agent/linux/evpn_router/frr/exceptions.py @@ -0,0 +1,43 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class FrrDriverError(Exception): + """Base exception for FRR EVPN driver failures.""" + + def __init__(self, message, step=None, cause=None): + super().__init__(message) + self.step = step + self.cause = cause + + def __str__(self): + return "%s - step: %s - cause: %s" % ( + super().__str__(), self.step, self.cause) + + +class FrrTemplateRenderError(FrrDriverError): + """Template render/load error for FRR EVPN configuration.""" + + +class FrrVrfError(FrrDriverError): + """VRF operation error while preparing EVPN router state.""" + + +class FrrDryrunError(FrrDriverError): + """VTYSH dry-run validation error for FRR configuration.""" + + +class FrrApplyError(FrrDriverError): + """VTYSH apply error while configuring FRR.""" diff --git a/neutron/agent/linux/evpn_router/frr/frr_driver.py b/neutron/agent/linux/evpn_router/frr/frr_driver.py new file mode 100644 index 00000000000..9d906b73f53 --- /dev/null +++ b/neutron/agent/linux/evpn_router/frr/frr_driver.py @@ -0,0 +1,276 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import tempfile +import typing + +import jinja2 +from neutron_lib import exceptions +from oslo_log import log as logging +from oslo_serialization import jsonutils + +from neutron.agent.linux.evpn_router.frr import exceptions as frr_exceptions +from neutron.agent.linux.evpn_router.frr import templates as frr_tmpl +from neutron.agent.linux.evpn_router import interface +from neutron.agent.linux import utils as linux_utils + + +LOG = logging.getLogger(__name__) + + +class FrrCommandBuilder: + + def __init__(self): + self._env = jinja2.Environment( + loader=jinja2.DictLoader(frr_tmpl.TMPL_MAP), + autoescape=True, + trim_blocks=True, + lstrip_blocks=True, + ) + + def _render_template( + self, template_name: frr_tmpl.TmplName, + context: dict[str, typing.Any]) -> str: + try: + template = self._env.get_template(str(template_name)) + return template.render(context) + except Exception as err: + raise frr_exceptions.FrrTemplateRenderError( + "Failed to render FRR template context:\n%s" % context, + step=str(template_name), + cause=err, + ) from err + + def _build_base_bgp_context( + self, config: interface.EVPNRouterConfig, + peer_interface: str) -> dict[str, str]: + bgp_router_context = { + 'asn': config.asn, + 'bgp_router_id': config.bgp_router_id, + 'peer_interface': peer_interface, + } + bgp_af_context = { + 'peer_interface': peer_interface, + } + return { + frr_tmpl.TmplName.BGP_ROUTER_CONFIG: self._render_template( + frr_tmpl.TmplName.BGP_ROUTER_CONFIG, + bgp_router_context, + ), + frr_tmpl.TmplName.BGP_AF_IPV4_UNICAST: self._render_template( + frr_tmpl.TmplName.BGP_AF_IPV4_UNICAST, + bgp_af_context, + ), + frr_tmpl.TmplName.BGP_AF_IPV6_UNICAST: self._render_template( + frr_tmpl.TmplName.BGP_AF_IPV6_UNICAST, + bgp_af_context, + ), + frr_tmpl.TmplName.BGP_AF_L2VPN_EVPN: self._render_template( + frr_tmpl.TmplName.BGP_AF_L2VPN_EVPN, + bgp_af_context, + ), + } + + def _build_evpn_context( + self, config: interface.EVPNRouterConfig) -> dict[str, typing.Any]: + evpn_router_context = { + 'asn': config.asn, + 'bgp_router_id': config.bgp_router_id, + 'vrf_name': config.vrf_name, + } + return { + 'vrf_name': config.vrf_name, + 'vni': config.vni, + frr_tmpl.TmplName.EVPN_ROUTER_CONFIG: self._render_template( + frr_tmpl.TmplName.EVPN_ROUTER_CONFIG, + evpn_router_context, + ), + frr_tmpl.TmplName.EVPN_AF_IPV4_UNICAST: self._render_template( + frr_tmpl.TmplName.EVPN_AF_IPV4_UNICAST, + {}, + ), + frr_tmpl.TmplName.EVPN_AF_IPV6_UNICAST: self._render_template( + frr_tmpl.TmplName.EVPN_AF_IPV6_UNICAST, + {}, + ), + frr_tmpl.TmplName.EVPN_AF_L2VPN_EVPN: self._render_template( + frr_tmpl.TmplName.EVPN_AF_L2VPN_EVPN, + {}, + ), + } + + def _build_delete_evpn_context( + self, config: interface.EVPNRouterConfig) -> dict[str, typing.Any]: + return { + 'vrf_name': config.vrf_name, + 'vni': config.vni, + 'asn': config.asn, + } + + def _build_delete_bgp_context( + self, asn: int) -> dict[str, typing.Any]: + return { + 'asn': asn, + } + + def add_bgp_router_cmds(self, config: interface.EVPNRouterConfig, + peer_interface: str) -> str: + context = self._build_base_bgp_context( + config=config, peer_interface=peer_interface) + return self._render_template(frr_tmpl.TmplName.ADD_BGP_ROUTER, context) + + def add_evpn_router_cmds( + self, config: interface.EVPNRouterConfig) -> str: + context = self._build_evpn_context(config=config) + return self._render_template( + frr_tmpl.TmplName.ADD_EVPN_ROUTER, context) + + def delete_evpn_router_cmds( + self, config: interface.EVPNRouterConfig) -> str: + context = self._build_delete_evpn_context(config=config) + return self._render_template( + frr_tmpl.TmplName.DEL_EVPN_ROUTER, context) + + def delete_bgp_router_cmds( + self, config: interface.EVPNRouterConfig) -> str: + context = self._build_delete_bgp_context(config.asn) + return self._render_template(frr_tmpl.TmplName.DEL_BGP_ROUTER, context) + + +class FrrVtyshExecutor: + + @property + def _vtysh_base_cmd(self) -> list[str]: + return ['vtysh'] + + def _execute_vtysh(self, vtysh_args: list[str]) -> str: + """Execute any vtysh command args and return stdout.""" + cmd = self._vtysh_base_cmd + vtysh_args + return typing.cast(str, linux_utils.execute(cmd, run_as_root=True)) + + def execute_cli_cmd(self, cmd_string: str) -> str: + """Execute single vtysh CLI command (e.g. show).""" + try: + return self._execute_vtysh(['-c', cmd_string]) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrApplyError( + "Failed to execute vtysh command:\n%s" % cmd_string, + step='execute_cli', + cause=err, + ) from err + + def execute_cmds(self, cmd_string: str) -> None: + with tempfile.NamedTemporaryFile( + mode='w+', delete=True, suffix=".cmd") as f: + f.write(cmd_string) + f.flush() + temp_path = f.name + try: + self._execute_vtysh(['--dryrun', '-f', temp_path]) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrDryrunError( + "FRR syntatic validity failed for " + "command:\n%s" % cmd_string, + step='dryrun', + cause=err, + ) from err + try: + self._execute_vtysh(['-f', temp_path]) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrApplyError( + "Failed to apply FRR configuration:\n%s\n" + "via vtysh" % cmd_string, + step='apply', + cause=err, + ) from err + + +class FrrVtyshDriver(interface.EVPNRouterDriver): + + def __init__(self, vrf_handler: interface.EVPNRouterVrfHandler, + peer_interface: str, + executor: FrrVtyshExecutor | None = None): + self.vrf_handler = vrf_handler + self.peer_interface = peer_interface + self.cmd_builder = FrrCommandBuilder() + self.executor = executor or FrrVtyshExecutor() + + def _bgp_router_exist(self, asn: int, bgp_router_id: str) -> bool: + try: + std_out = self.executor.execute_cli_cmd('show bgp summary json') + except frr_exceptions.FrrApplyError as err: + raise frr_exceptions.FrrApplyError( + "Failed to fetch BGP summary for router existence check", + step='check_bgp_exists', + cause=err, + ) from err + + try: + data = jsonutils.loads(std_out) + except ValueError as err: + raise frr_exceptions.FrrApplyError( + "Failed to parse BGP summary JSON for router existence check", + step='check_bgp_exists', + cause=err, + ) from err + + if not isinstance(data, dict): + raise frr_exceptions.FrrApplyError( + "Unexpected BGP summary format for router existence check", + step='check_bgp_exists', + ) + l2vpn_evpn = data.get('l2VpnEvpn') + if not isinstance(l2vpn_evpn, dict): + return False + + existing_router_id = l2vpn_evpn.get('routerId') + existing_asn = l2vpn_evpn.get('as') + if not isinstance(existing_asn, (int, str)): + raise frr_exceptions.FrrApplyError( + "Unexpected BGP ASN type while checking router existence", + step='check_bgp_exists', + ) + try: + existing_asn = int(existing_asn) + except (TypeError, ValueError): + raise frr_exceptions.FrrApplyError( + "Unable to parse BGP ASN while checking router existence", + step='check_bgp_exists', + ) + return ( + existing_router_id == bgp_router_id and + existing_asn == asn + ) + + def _create_bgp_router(self, config: interface.EVPNRouterConfig): + if self._bgp_router_exist(config.asn, config.bgp_router_id): + return + add_bgp_router_cmd = self.cmd_builder.add_bgp_router_cmds( + config, + self.peer_interface) + self.executor.execute_cmds(add_bgp_router_cmd) + + def create_evpn_router(self, config: interface.EVPNRouterConfig) -> None: + LOG.debug("Creating EVPN router: %s", config) + self.vrf_handler.ensure_vrf_exists(config.vrf_name) + self._create_bgp_router(config) + add_evpn_router_cmd = self.cmd_builder.add_evpn_router_cmds(config) + self.executor.execute_cmds(add_evpn_router_cmd) + + def delete_evpn_router(self, config: interface.EVPNRouterConfig) -> None: + LOG.debug("Deleting EVPN router: %s", config) + self.vrf_handler.ensure_vrf_deleted(config.vrf_name) + self.executor.execute_cmds( + self.cmd_builder.delete_evpn_router_cmds(config)) diff --git a/neutron/agent/linux/evpn_router/frr/templates.py b/neutron/agent/linux/evpn_router/frr/templates.py new file mode 100644 index 00000000000..7d620d169c7 --- /dev/null +++ b/neutron/agent/linux/evpn_router/frr/templates.py @@ -0,0 +1,154 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import enum + + +ADD_BGP_ROUTER = """\ +{{ bgp_router_config }} +! +{% if bgp_af_ipv4_unicast %} +{{ bgp_af_ipv4_unicast }} +{% endif %} +! +{% if bgp_af_ipv6_unicast %} +{{ bgp_af_ipv6_unicast }} +{% endif %} +! +{% if bgp_af_l2vpn_evpn %} +{{ bgp_af_l2vpn_evpn }} +{% endif %} +exit +""" + +ADD_EVPN_ROUTER = """\ +vrf {{ vrf_name }} + vni {{ vni }} +exit-vrf +! +{{ evpn_router_config }} +! +{% if evpn_af_ipv4_unicast %} +{{ evpn_af_ipv4_unicast }} +{% endif %} +! +{% if evpn_af_ipv6_unicast %} +{{ evpn_af_ipv6_unicast }} +{% endif %} +! +{% if evpn_af_l2vpn_evpn %} +{{ evpn_af_l2vpn_evpn }} +{% endif %} +exit +""" + +DEL_BGP_ROUTER = """\ +no router bgp {{ asn }} +""" + +DEL_EVPN_ROUTER = """\ +vrf {{ vrf_name }} + no vni {{ vni }} +exit-vrf +! +no router bgp {{ asn }} vrf {{ vrf_name }} +! +no vrf {{ vrf_name }} +""" + +BGP_ROUTER_CONFIG = """\ +router bgp {{ asn }} + bgp router-id {{ bgp_router_id }} + no bgp ebgp-requires-policy + neighbor {{ peer_interface }} interface remote-as internal +""" + +BGP_AF_IPV4_UNICAST = """\ + address-family ipv4 unicast + neighbor {{ peer_interface }} activate + redistribute connected + exit-address-family +""" + +BGP_AF_IPV6_UNICAST = """\ + address-family ipv6 unicast + neighbor {{ peer_interface }} activate + redistribute connected + exit-address-family +""" + +BGP_AF_L2VPN_EVPN = """\ + address-family l2vpn evpn + neighbor {{ peer_interface }} activate + advertise-all-vni + advertise-svi-ip + exit-address-family +""" + +EVPN_ROUTER_CONFIG = """\ +router bgp {{ asn }} vrf {{ vrf_name }} + bgp router-id {{ bgp_router_id }} + no bgp ebgp-requires-policy +""" + +EVPN_AF_IPV4_UNICAST = """\ + address-family ipv4 unicast + redistribute kernel + exit-address-family +""" + +EVPN_AF_IPV6_UNICAST = """\ + address-family ipv6 unicast + redistribute kernel + exit-address-family +""" + +EVPN_AF_L2VPN_EVPN = """\ + address-family l2vpn evpn + advertise ipv4 unicast + advertise ipv6 unicast + exit-address-family +""" + + +class TmplName(enum.StrEnum): + ADD_BGP_ROUTER = 'add_bgp_router' + ADD_EVPN_ROUTER = 'add_evpn_router' + DEL_BGP_ROUTER = 'del_bgp_router' + DEL_EVPN_ROUTER = 'del_evpn_router' + BGP_ROUTER_CONFIG = 'bgp_router_config' + BGP_AF_IPV4_UNICAST = 'bgp_af_ipv4_unicast' + BGP_AF_IPV6_UNICAST = 'bgp_af_ipv6_unicast' + BGP_AF_L2VPN_EVPN = 'bgp_af_l2vpn_evpn' + EVPN_ROUTER_CONFIG = 'evpn_router_config' + EVPN_AF_IPV4_UNICAST = 'evpn_af_ipv4_unicast' + EVPN_AF_IPV6_UNICAST = 'evpn_af_ipv6_unicast' + EVPN_AF_L2VPN_EVPN = 'evpn_af_l2vpn_evpn' + + +TMPL_MAP: dict[str, str] = { + TmplName.ADD_BGP_ROUTER: ADD_BGP_ROUTER, + TmplName.ADD_EVPN_ROUTER: ADD_EVPN_ROUTER, + TmplName.DEL_BGP_ROUTER: DEL_BGP_ROUTER, + TmplName.DEL_EVPN_ROUTER: DEL_EVPN_ROUTER, + TmplName.BGP_ROUTER_CONFIG: BGP_ROUTER_CONFIG, + TmplName.BGP_AF_IPV4_UNICAST: BGP_AF_IPV4_UNICAST, + TmplName.BGP_AF_IPV6_UNICAST: BGP_AF_IPV6_UNICAST, + TmplName.BGP_AF_L2VPN_EVPN: BGP_AF_L2VPN_EVPN, + TmplName.EVPN_ROUTER_CONFIG: EVPN_ROUTER_CONFIG, + TmplName.EVPN_AF_IPV4_UNICAST: EVPN_AF_IPV4_UNICAST, + TmplName.EVPN_AF_IPV6_UNICAST: EVPN_AF_IPV6_UNICAST, + TmplName.EVPN_AF_L2VPN_EVPN: EVPN_AF_L2VPN_EVPN, +} diff --git a/neutron/agent/linux/evpn_router/interface.py b/neutron/agent/linux/evpn_router/interface.py new file mode 100644 index 00000000000..163cd00e81c --- /dev/null +++ b/neutron/agent/linux/evpn_router/interface.py @@ -0,0 +1,52 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc +from dataclasses import dataclass + + +class EVPNRouterVrfHandler(abc.ABC): + """EVPN Router VRF handler.""" + + @abc.abstractmethod + def ensure_vrf_exists(self, vrf_name) -> None: + pass + + @abc.abstractmethod + def ensure_vrf_deleted(self, vrf_name) -> None: + pass + + +@dataclass +class EVPNRouterConfig: + """EVPN router configuration parameters.""" + asn: int + bgp_router_id: str + vrf_name: str + vni: int + + +class EVPNRouterDriver(abc.ABC): + """Generic interface for an EVPN router driver.""" + + @abc.abstractmethod + def create_evpn_router(self, config: EVPNRouterConfig) -> None: + """Creates the EVPN VRF in the routing fabric.""" + pass + + @abc.abstractmethod + def delete_evpn_router(self, config: EVPNRouterConfig) -> None: + """Deletes the EVPN VRF from the routing fabric.""" + pass diff --git a/neutron/tests/functional/agent/linux/evpn_router/__init__.py b/neutron/tests/functional/agent/linux/evpn_router/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/tests/functional/agent/linux/evpn_router/frr/__init__.py b/neutron/tests/functional/agent/linux/evpn_router/frr/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py b/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py new file mode 100644 index 00000000000..13288d1cf4a --- /dev/null +++ b/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py @@ -0,0 +1,445 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from pyroute2.netlink import rtnl + +from neutron.agent.linux.evpn_router.frr import exceptions as frr_exceptions +from neutron.agent.linux.evpn_router.frr import frr_driver +from neutron.agent.linux.evpn_router import interface +from neutron.agent.linux import ip_lib +from neutron.agent.linux import utils as linux_utils +from neutron.common import utils as common_utils +from neutron.tests.common import net_helpers +from neutron.tests.functional import base +from neutron_lib import exceptions + + +class FrrVtyshExecutorNamespaced(frr_driver.FrrVtyshExecutor): + """Namespaced vtysh executor for testing. + + Do not add any logic here — this subclass exists only to run + vtysh in a network namespace so that functional tests exercise + the production FrrVtyshExecutor code paths unchanged. + """ + + def __init__(self, namespace): + self._namespace = namespace + + @property + def _vtysh_base_cmd(self) -> list[str]: + return ['vtysh', '-N', self._namespace] + + +class NamespacedVRFHandler(interface.EVPNRouterVrfHandler): + """VRF handler that creates VRFs and required linux interfaces for + a FRR service. + """ + # TODO(mtomaska): Replace subprocess ip commands with ip_lib (pyroute2). + # ip_lib already supports: device exists, create/delete interface, + # set up/down, set master, add IP address, create VXLAN. + # Missing from ip_lib: addrgenmode, bridge_slave neigh_suppress/learning, + # VXLAN nolearning. + # For now, to avoid mixing two different approaches, all operations + # use subprocess ip commands. + + def __init__(self, namespace, vtep_ip=None, dstport=4789): + self._namespace = namespace + self._vtep_ip = vtep_ip + self._dstport = dstport + + def _ns_exec(self, cmd, **kwargs): + return linux_utils.execute( + ['ip', 'netns', 'exec', self._namespace] + cmd, + run_as_root=True, **kwargs) + + def _vni(self, vrf_name): + return int(vrf_name.split('-')[-1]) + + def _bridge_name(self, vni): + return 'br-%d' % vni + + def _vxlan_name(self, vni): + return 'vxlan-%d' % vni + + def _device_exists(self, dev_name): + _out, std_err = self._ns_exec( + ['ip', 'link', 'show', dev_name], + check_exit_code=False, + return_stderr=True, + log_fail_as_error=False) + return not str(std_err).strip() + + def _delete_device(self, dev_name, step): + try: + _out, std_err = self._ns_exec( + ['ip', 'link', 'del', dev_name], + check_exit_code=False, + return_stderr=True, + log_fail_as_error=False) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to delete %s" % dev_name, + step=step, + cause=err, + ) from err + std_err = str(std_err).strip() + ok = std_err in ('', 'Cannot find device "%s"' % dev_name) + if not ok: + raise frr_exceptions.FrrVrfError( + "Failed to delete %s: %s" % (dev_name, std_err), + step=step, + ) + + def _ensure_vrf_created(self, vrf_name): + if self._device_exists(vrf_name): + return + + # NOTE: For simplicity, the routing table ID is the same as the VNI + table_id = self._vni(vrf_name) + try: + self._ns_exec( + ['ip', 'link', 'add', vrf_name, + 'type', 'vrf', 'table', str(table_id)]) + self._ns_exec( + ['ip', 'link', 'set', vrf_name, 'up']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to create VRF: %s" % vrf_name, + step='ensure_vrf_exists', + cause=err, + ) from err + + def _ensure_bridge_created(self, vrf_name): + vni = self._vni(vrf_name) + br_name = self._bridge_name(vni) + if self._device_exists(br_name): + return + + try: + self._ns_exec( + ['ip', 'link', 'add', br_name, 'type', 'bridge']) + self._ns_exec( + ['ip', 'link', 'set', br_name, + 'master', vrf_name, 'addrgenmode', 'none']) + self._ns_exec( + ['ip', 'link', 'set', br_name, 'up']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to create bridge: %s" % br_name, + step='ensure_bridge_exists', + cause=err, + ) from err + + def _ensure_vxlan_created(self, vrf_name): + vni = self._vni(vrf_name) + vxlan_name = self._vxlan_name(vni) + br_name = self._bridge_name(vni) + if self._device_exists(vxlan_name): + return + + try: + self._ns_exec( + ['ip', 'link', 'add', vxlan_name, + 'type', 'vxlan', 'local', self._vtep_ip, + 'dstport', str(self._dstport), + 'id', str(vni), 'nolearning']) + self._ns_exec( + ['ip', 'link', 'set', vxlan_name, + 'master', br_name, 'addrgenmode', 'none']) + self._ns_exec( + ['ip', 'link', 'set', vxlan_name, + 'type', 'bridge_slave', + 'neigh_suppress', 'on', 'learning', 'off']) + self._ns_exec( + ['ip', 'link', 'set', vxlan_name, 'up']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to create VXLAN: %s" % vxlan_name, + step='ensure_vxlan_exists', + cause=err, + ) from err + + def _set_vtep_ip_on_lo(self): + out = self._ns_exec( + ['ip', 'addr', 'show', 'dev', 'lo']) + if self._vtep_ip in str(out): + return + try: + self._ns_exec( + ['ip', 'addr', 'add', '%s/32' % self._vtep_ip, 'dev', 'lo']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to set VTEP IP on lo", + step='set_vtep_ip_on_lo', + cause=err, + ) from err + + def ensure_vrf_exists(self, vrf_name): + self._ensure_vrf_created(vrf_name) + if self._vtep_ip: + self._set_vtep_ip_on_lo() + self._ensure_bridge_created(vrf_name) + self._ensure_vxlan_created(vrf_name) + + def ensure_vrf_deleted(self, vrf_name): + if self._vtep_ip: + vni = self._vni(vrf_name) + self._delete_device( + self._vxlan_name(vni), step='ensure_vxlan_deleted') + self._delete_device( + self._bridge_name(vni), step='ensure_bridge_deleted') + self._delete_device(vrf_name, step='ensure_vrf_deleted') + + +def make_evpn_config(vni, bgp_router_id='10.0.0.1', vrf_name_prefix='vrf-'): + return interface.EVPNRouterConfig( + asn=65000, + bgp_router_id=bgp_router_id, + vrf_name=vrf_name_prefix + str(vni), + vni=vni, + ) + + +def add_blackhole_routes(namespace, cidrs, table_id): + """Add blackhole routes to simulate ovn-controller route advertisment.""" + for cidr in cidrs: + ip_lib.add_ip_route(namespace, cidr, table=table_id, + type=rtnl.rt_type['blackhole'], scope=0) + + +def assert_routes(namespace, table_id, present=None, absent=None, + ip_version=4, timeout=5): + def _check(): + routes = ip_lib.list_ip_routes(namespace, ip_version, table=table_id) + found = {r['cidr'] for r in routes} + if present and not present.issubset(found): + return False + if absent and absent.intersection(found): + return False + return True + + details = [] + if present: + details.append("expected present: %s" % present) + if absent: + details.append("expected absent: %s" % absent) + common_utils.wait_until_true( + _check, timeout=timeout, sleep=1, + exception=RuntimeError( + "Routes did not converge in VRF table %s (%s)" + % (table_id, ', '.join(details)))) + + +class TestFrrVtyshDriverConfiguration(base.BaseSudoTestCase): + + def setUp(self): + super().setUp() + self.namespace = self.useFixture(net_helpers.NamespaceFixture()).name + self.useFixture(net_helpers.FrrFixture(namespace=self.namespace)) + + vrf_handler = NamespacedVRFHandler(self.namespace) + executor = FrrVtyshExecutorNamespaced(self.namespace) + self.driver = frr_driver.FrrVtyshDriver( + vrf_handler=vrf_handler, + peer_interface='lo', + executor=executor) + + def _vrf_exists(self, vrf_name): + return ip_lib.IPDevice(vrf_name, namespace=self.namespace).exists() + + def _get_running_config(self): + return self.driver.executor.execute_cli_cmd( + 'show running-config') + + def test_create_evpn_router(self): + config = make_evpn_config(vni=100) + self.driver.create_evpn_router(config) + + running_config = self._get_running_config() + self.assertIn('router bgp 65000', running_config) + self.assertIn('bgp router-id 10.0.0.1', running_config) + self.assertIn('router bgp 65000 vrf vrf-100', running_config) + self.assertIn('vni 100', running_config) + + def test_delete_evpn_router(self): + config = make_evpn_config(vni=100) + self.driver.create_evpn_router(config) + self.driver.delete_evpn_router(config) + + running_config = self._get_running_config() + self.assertNotIn('router bgp 65000 vrf vrf-100', running_config) + self.assertNotIn('vni 100', running_config) + + def test_create_three_delete_one_two_remain(self): + configs = [make_evpn_config(vni) for vni in (100, 200, 300)] + for config in configs: + self.driver.create_evpn_router(config) + + self.driver.delete_evpn_router(configs[1]) + + self.assertTrue(self._vrf_exists('vrf-100')) + self.assertFalse(self._vrf_exists('vrf-200')) + self.assertTrue(self._vrf_exists('vrf-300')) + + running_config = self._get_running_config() + self.assertIn('router bgp 65000 vrf vrf-100', running_config) + self.assertNotIn('router bgp 65000 vrf vrf-200', running_config) + self.assertIn('router bgp 65000 vrf vrf-300', running_config) + + def test_create_multiple_then_delete_all(self): + configs = [make_evpn_config(vni) for vni in (100, 200, 300)] + for config in configs: + self.driver.create_evpn_router(config) + for config in configs: + self.driver.delete_evpn_router(config) + + running_config = self._get_running_config() + self.assertNotIn('router bgp 65000 vrf vrf-100', running_config) + self.assertNotIn('router bgp 65000 vrf vrf-200', running_config) + self.assertNotIn('router bgp 65000 vrf vrf-300', running_config) + + def test_create_evpn_router_idempotent(self): + config = make_evpn_config(vni=100) + self.driver.create_evpn_router(config) + self.driver.create_evpn_router(config) + + running_config = self._get_running_config() + self.assertIn('router bgp 65000', running_config) + self.assertIn('router bgp 65000 vrf vrf-100', running_config) + self.assertIn('vni 100', running_config) + + def test_delete_noexisting_router_raises(self): + config = make_evpn_config(vni=101) + + self.assertRaises( + frr_exceptions.FrrApplyError, + self.driver.delete_evpn_router, config) + + +class TestFrrVtyshDriverOperation(base.BaseSudoTestCase): + """Functional tests for FRR EVPN router operation between two peers. + + Topology:: + + Namespace A Bridge NS Namespace B + +----------------+ +-----------+ +----------------+ + | FRR (bgpd) | | | | FRR (bgpd) | + | | | | | | + | port_a -------+-----+- bridge --+-----+- port_b | + | (link-local) |veth | |veth | (link-local) | + +----------------+ +-----------+ +----------------+ + """ + + def setUp(self): + super().setUp() + + self.vtep_ip_a = '10.0.0.1' + self.vtep_ip_b = '10.0.0.2' + + self.ns_a = self.useFixture( + net_helpers.NamespaceFixture('frr-a-')).name + self.useFixture(net_helpers.FrrFixture(namespace=self.ns_a)) + + self.ns_b = self.useFixture( + net_helpers.NamespaceFixture('frr-b-')).name + self.useFixture(net_helpers.FrrFixture(namespace=self.ns_b)) + + bridge_fixture = self.useFixture(net_helpers.LinuxBridgeFixture()) + bridge = bridge_fixture.bridge + + self.port_a = self.useFixture( + net_helpers.LinuxBridgePortFixture( + bridge=bridge, namespace=self.ns_a)).port + + self.port_b = self.useFixture( + net_helpers.LinuxBridgePortFixture(bridge, self.ns_b)).port + + vrf_handler_a = NamespacedVRFHandler(namespace=self.ns_a, + vtep_ip=self.vtep_ip_a) + executor_a = FrrVtyshExecutorNamespaced(self.ns_a) + self.driver_a = frr_driver.FrrVtyshDriver( + vrf_handler=vrf_handler_a, + peer_interface=self.port_a.name, + executor=executor_a) + + vrf_handler_b = NamespacedVRFHandler(namespace=self.ns_b, + vtep_ip=self.vtep_ip_b) + executor_b = FrrVtyshExecutorNamespaced(self.ns_b) + self.driver_b = frr_driver.FrrVtyshDriver( + vrf_handler=vrf_handler_b, + peer_interface=self.port_b.name, + executor=executor_b) + + # NOTE: Interfaces used for BGP instances must be reachable, + # otherwise nothing will work. + self._assert_ports_reachable() + + def _assert_ports_reachable(self): + lladdr_b = ip_lib.get_ipv6_lladdr( + self.port_b.link.address).split('/')[0] + lladdr_a = ip_lib.get_ipv6_lladdr( + self.port_a.link.address).split('/')[0] + net_helpers.assert_ping( + self.ns_a, lladdr_b, device=self.port_a.name) + net_helpers.assert_ping( + self.ns_b, lladdr_a, device=self.port_b.name) + + def test_routes_get_advertised(self): + vni = 10 + advertised_routes_v4 = {'11.1.1.1/32', '12.1.1.0/32'} + advertised_routes_v6 = {'fd00::1/128', 'fd00:1::/64'} + conf_a = make_evpn_config(vni=vni, bgp_router_id=self.vtep_ip_a) + conf_b = make_evpn_config(vni=vni, bgp_router_id=self.vtep_ip_b) + + self.driver_a.create_evpn_router(conf_a) + self.driver_b.create_evpn_router(conf_b) + + add_blackhole_routes( + self.ns_a, advertised_routes_v4, table_id=vni) + add_blackhole_routes( + self.ns_a, advertised_routes_v6, table_id=vni) + + assert_routes(self.ns_b, table_id=vni, present=advertised_routes_v4) + assert_routes(self.ns_b, table_id=vni, present=advertised_routes_v6, + ip_version=6) + + def test_multiple_routers_then_delete_one(self): + vni_1 = 10 + vni_2 = 20 + route_1 = {'11.1.1.1/32'} + route_2 = {'12.1.1.1/32'} + + conf_a1 = make_evpn_config(vni=vni_1, bgp_router_id=self.vtep_ip_a) + conf_a2 = make_evpn_config(vni=vni_2, bgp_router_id=self.vtep_ip_a) + conf_b1 = make_evpn_config(vni=vni_1, bgp_router_id=self.vtep_ip_b) + conf_b2 = make_evpn_config(vni=vni_2, bgp_router_id=self.vtep_ip_b) + + self.driver_a.create_evpn_router(conf_a1) + self.driver_a.create_evpn_router(conf_a2) + self.driver_b.create_evpn_router(conf_b1) + self.driver_b.create_evpn_router(conf_b2) + + add_blackhole_routes( + self.ns_a, route_1, table_id=vni_1) + add_blackhole_routes( + self.ns_a, route_2, table_id=vni_2) + + assert_routes(self.ns_b, table_id=vni_1, present=route_1) + assert_routes(self.ns_b, table_id=vni_2, present=route_2) + + self.driver_a.delete_evpn_router(conf_a1) + + assert_routes(self.ns_b, table_id=vni_1, absent=route_1) + assert_routes(self.ns_b, table_id=vni_2, present=route_2) diff --git a/neutron/tests/unit/agent/linux/evpn_router/__init__.py b/neutron/tests/unit/agent/linux/evpn_router/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/tests/unit/agent/linux/evpn_router/frr/__init__.py b/neutron/tests/unit/agent/linux/evpn_router/frr/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py new file mode 100644 index 00000000000..e97bf0ac2b0 --- /dev/null +++ b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py @@ -0,0 +1,281 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from neutron.agent.linux.evpn_router.frr import exceptions as frr_exceptions +from neutron.agent.linux.evpn_router.frr import frr_driver +from neutron.agent.linux.evpn_router import interface +from neutron.tests import base +from neutron_lib import exceptions + + +def _build_test_evpn_router_config(vni): + return interface.EVPNRouterConfig( + asn=65000, + bgp_router_id='10.0.0.1', + vrf_name=f'vrf-{vni}', + vni=vni, + ) + + +class TestFrrCommandBuilder(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.builder = frr_driver.FrrCommandBuilder() + + def test_add_bgp_router_cmds(self): + config = _build_test_evpn_router_config(100) + peer_iface = 'eth1' + result = self.builder.add_bgp_router_cmds( + config, peer_iface) + + self.assertIn( + 'router bgp %d' % config.asn, result) + self.assertIn( + 'bgp router-id %s' % config.bgp_router_id, result) + self.assertIn( + 'neighbor %s interface remote-as internal' + % peer_iface, result) + self.assertIn('address-family ipv4 unicast', result) + self.assertIn('address-family ipv6 unicast', result) + self.assertIn('address-family l2vpn evpn', result) + self.assertIn('advertise-all-vni', result) + + def test_add_evpn_router_cmds(self): + config = _build_test_evpn_router_config(100) + result = self.builder.add_evpn_router_cmds(config) + + self.assertIn('vrf %s' % config.vrf_name, result) + self.assertIn('vni %d' % config.vni, result) + self.assertIn('address-family ipv4 unicast', result) + self.assertIn('address-family ipv6 unicast', result) + self.assertIn('address-family l2vpn evpn', result) + self.assertIn('redistribute kernel', result) + + def test_delete_evpn_router_cmds(self): + config = _build_test_evpn_router_config(100) + result = self.builder.delete_evpn_router_cmds(config) + + self.assertIn('no vni %d' % config.vni, result) + self.assertIn( + 'no router bgp %d vrf %s' + % (config.asn, config.vrf_name), result) + self.assertIn('no vrf %s' % config.vrf_name, result) + + def test_delete_bgp_router_cmds(self): + config = _build_test_evpn_router_config(100) + result = self.builder.delete_bgp_router_cmds(config) + + self.assertIn('no router bgp %d' % config.asn, result) + + +class TestFrrVtyshExecutor(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.execute = mock.patch.object( + frr_driver.linux_utils, 'execute').start() + self.executor = frr_driver.FrrVtyshExecutor() + + def test_execute_cli_cmd(self): + self.execute.return_value = "BGP summary output" + mock_cmd = 'show me something' + + out = self.executor.execute_cli_cmd(mock_cmd) + + self.assertEqual("BGP summary output", out) + self.execute.assert_called_once_with( + ['vtysh', '-c', mock_cmd], + run_as_root=True, + ) + + def test_execute_cli_cmd_raises_on_failure(self): + self.execute.side_effect = exceptions.ProcessExecutionError( + 'vtysh failure', returncode=1) + + self.assertRaises( + frr_exceptions.FrrApplyError, + self.executor.execute_cli_cmd, + "show bgp summary", + ) + + def test_execute_cmds_calls_dryrun_then_apply(self): + mock_cmds = "some config" + self.executor.execute_cmds(mock_cmds) + + calls = self.execute.call_args_list + self.assertEqual(2, len(calls)) + dryrun_cmd = calls[0][0][0] + apply_cmd = calls[1][0][0] + self.assertEqual('vtysh', dryrun_cmd[0]) + self.assertIn('--dryrun', dryrun_cmd) + self.assertIn('-f', dryrun_cmd) + self.assertEqual('vtysh', apply_cmd[0]) + self.assertIn('-f', apply_cmd) + self.assertNotIn('--dryrun', apply_cmd) + + def test_execute_cmds_raises_dryrun_error(self): + mock_cmds = "bad config" + self.execute.side_effect = exceptions.ProcessExecutionError( + 'syntax error', returncode=1) + + self.assertRaises( + frr_exceptions.FrrDryrunError, + self.executor.execute_cmds, + mock_cmds, + ) + self.execute.assert_called_once() + dryrun_cmd = self.execute.call_args[0][0] + self.assertIn('--dryrun', dryrun_cmd) + + def test_execute_cmds_raises_apply_error(self): + mock_cmds = "config syntactically correct, but apply failed" + + def _dryrun_ok_apply_fail(cmd, **_kwargs): + if '--dryrun' in cmd: + return '' + raise exceptions.ProcessExecutionError( + 'apply failed', returncode=1) + + self.execute.side_effect = _dryrun_ok_apply_fail + + self.assertRaises( + frr_exceptions.FrrApplyError, + self.executor.execute_cmds, + mock_cmds, + ) + + +class TestFrrVtyshDriver(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.vrf_handler = mock.Mock(spec=interface.EVPNRouterVrfHandler) + self.driver = frr_driver.FrrVtyshDriver( + self.vrf_handler, 'peer_iface') + self.cmd_builder = mock.Mock( + spec=frr_driver.FrrCommandBuilder) + self.executor = mock.Mock( + spec=frr_driver.FrrVtyshExecutor) + self.driver.cmd_builder = self.cmd_builder + self.driver.executor = self.executor + self.executor.execute_cli_cmd.return_value = '{}' + mock.patch.object(frr_driver, 'LOG').start() + + def test_create_evpn_router(self): + config = _build_test_evpn_router_config(100) + self.driver.create_evpn_router(config) + + self.vrf_handler.ensure_vrf_exists.assert_called_once_with( + config.vrf_name) + self.executor.execute_cli_cmd.assert_called_once_with( + 'show bgp summary json') + self.cmd_builder.add_bgp_router_cmds.assert_called_once_with( + config, self.driver.peer_interface) + self.cmd_builder.add_evpn_router_cmds.assert_called_once_with( + config) + self.executor.execute_cmds.assert_any_call( + self.cmd_builder.add_bgp_router_cmds.return_value) + self.executor.execute_cmds.assert_any_call( + self.cmd_builder.add_evpn_router_cmds.return_value) + + def test_create_evpn_router_skips_bgp_when_exists(self): + config = _build_test_evpn_router_config(100) + self.executor.execute_cli_cmd.return_value = ( + '{"l2VpnEvpn": ' + '{"routerId": "%s", "as": %d}}' + % (config.bgp_router_id, config.asn) + ) + + self.driver.create_evpn_router(config) + + self.cmd_builder.add_bgp_router_cmds.assert_not_called() + self.cmd_builder.add_evpn_router_cmds.assert_called_once_with( + config) + self.executor.execute_cmds.assert_called_once_with( + self.cmd_builder.add_evpn_router_cmds.return_value) + + def test_delete_evpn_router(self): + config = _build_test_evpn_router_config(100) + self.driver.delete_evpn_router(config) + + self.vrf_handler.ensure_vrf_deleted.assert_called_once_with( + config.vrf_name) + self.cmd_builder.delete_evpn_router_cmds\ + .assert_called_once_with(config) + self.executor.execute_cmds.assert_called_once_with( + self.cmd_builder.delete_evpn_router_cmds.return_value) + + def test_create_raises_on_vrf_failure(self): + self.vrf_handler.ensure_vrf_exists.side_effect = ( + frr_exceptions.FrrVrfError( + 'vrf failed', step='ensure_vrf_exists')) + + self.assertRaises( + frr_exceptions.FrrVrfError, + self.driver.create_evpn_router, + _build_test_evpn_router_config(100), + ) + self.executor.execute_cli_cmd.assert_not_called() + self.executor.execute_cmds.assert_not_called() + + def test_create_raises_on_bgp_check_failure(self): + self.executor.execute_cli_cmd.side_effect = ( + frr_exceptions.FrrApplyError( + 'vtysh failed', step='execute_cli')) + + self.assertRaises( + frr_exceptions.FrrApplyError, + self.driver.create_evpn_router, + _build_test_evpn_router_config(100), + ) + self.cmd_builder.add_bgp_router_cmds.assert_not_called() + self.executor.execute_cmds.assert_not_called() + + def test_create_raises_on_invalid_bgp_json(self): + self.executor.execute_cli_cmd.return_value = 'not-json' + + self.assertRaises( + frr_exceptions.FrrApplyError, + self.driver.create_evpn_router, + _build_test_evpn_router_config(100), + ) + self.cmd_builder.add_bgp_router_cmds.assert_not_called() + + def test_create_raises_on_template_failure(self): + self.cmd_builder.add_bgp_router_cmds.side_effect = ( + frr_exceptions.FrrTemplateRenderError( + 'render failed', step='template_render')) + + self.assertRaises( + frr_exceptions.FrrTemplateRenderError, + self.driver.create_evpn_router, + _build_test_evpn_router_config(100), + ) + self.executor.execute_cmds.assert_not_called() + + def test_delete_raises_on_vrf_failure(self): + self.vrf_handler.ensure_vrf_deleted.side_effect = ( + frr_exceptions.FrrVrfError( + 'vrf failed', step='ensure_vrf_deleted')) + + self.assertRaises( + frr_exceptions.FrrVrfError, + self.driver.delete_evpn_router, + _build_test_evpn_router_config(100), + ) + self.executor.execute_cmds.assert_not_called() diff --git a/roles/configure_functional_tests/tasks/main.yaml b/roles/configure_functional_tests/tasks/main.yaml index feb44c37244..884e17c843c 100644 --- a/roles/configure_functional_tests/tasks/main.yaml +++ b/roles/configure_functional_tests/tasks/main.yaml @@ -22,6 +22,7 @@ Q_BUILD_OVS_FROM_GIT={{ Q_BUILD_OVS_FROM_GIT }} MEMORY_TRACKER={{ MEMORY_TRACKER }} INSTALL_OVN={{ INSTALL_OVN }} + ENABLE_FRR={{ ENABLE_FRR | default(False) }} # This is DB USER used in mysql db DATABASE_USER=openstack_citest MYSQL_GATHER_PERFORMANCE={{ MYSQL_GATHER_PERFORMANCE | default(true) }} diff --git a/tools/configure_for_func_testing.sh b/tools/configure_for_func_testing.sh index 56dd336fd7d..49cc4578944 100755 --- a/tools/configure_for_func_testing.sh +++ b/tools/configure_for_func_testing.sh @@ -25,6 +25,7 @@ DATABASE_USER=${DATABASE_USER:-openstack_citest} DATABASE_NAME=${DATABASE_NAME:-openstack_citest} MEMORY_TRACKER=${MEMORY_TRACKER:-False} MYSQL_REDUCE_MEMORY=${MYSQL_REDUCE_MEMORY:-True} +ENABLE_FRR=${ENABLE_FRR:-False} if [[ "$IS_GATE" != "True" ]] && [[ "$#" -lt 1 ]]; then @@ -105,6 +106,7 @@ function _init { GetDistro source $DEVSTACK_PATH/tools/fixup_stuff.sh + source $DEST/neutron/devstack/lib/frr } function _install_base_deps { @@ -249,6 +251,11 @@ function _install_post_devstack { _install_database _install_rootwrap_sudoers + if [[ "$ENABLE_FRR" == "True" ]]; then + install_frr + install_frr_vrf_modules + fi + if is_ubuntu; then install_package isc-dhcp-client install_package nmap diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index aa702808aa2..0187f86b78c 100644 --- a/zuul.d/base.yaml +++ b/zuul.d/base.yaml @@ -51,6 +51,7 @@ Q_BUILD_OVS_FROM_GIT: True MEMORY_TRACKER: True INSTALL_OVN: True + ENABLE_FRR: True devstack_services: # Ignore any default set by devstack. Emit a "disable_all_services". base: false From 012c9e65b6b0ae93171383a567ebf07db41cb910 Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Wed, 3 Jun 2026 15:03:37 -0400 Subject: [PATCH 18/32] Frr should write running config to memory to persist reboots This patch adds `write memory` for each configuration it applies. Adds restart method and exposes start+stop methods in FrrFixture Adds tests which confirm that running config is used on reboots or explicit stop and starts. Related-Bug: #2144617 Change-Id: I5973492fc538946462d0fec0b1b93db73a032854 Signed-off-by: Miro Tomaska --- .../agent/linux/evpn_router/frr/frr_driver.py | 1 + neutron/tests/common/net_helpers.py | 34 +++++++---- .../linux/evpn_router/frr/test_frr_driver.py | 56 ++++++++++++++++++- .../linux/evpn_router/frr/test_frr_driver.py | 4 +- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/neutron/agent/linux/evpn_router/frr/frr_driver.py b/neutron/agent/linux/evpn_router/frr/frr_driver.py index 9d906b73f53..5fcb1959468 100644 --- a/neutron/agent/linux/evpn_router/frr/frr_driver.py +++ b/neutron/agent/linux/evpn_router/frr/frr_driver.py @@ -195,6 +195,7 @@ def execute_cmds(self, cmd_string: str) -> None: step='apply', cause=err, ) from err + self.execute_cli_cmd('write memory') class FrrVtyshDriver(interface.EVPNRouterDriver): diff --git a/neutron/tests/common/net_helpers.py b/neutron/tests/common/net_helpers.py index 77a6b8d0296..c668ca7cf5e 100644 --- a/neutron/tests/common/net_helpers.py +++ b/neutron/tests/common/net_helpers.py @@ -1151,10 +1151,9 @@ def __init__(self, namespace): self._state_dir = os.path.join(self.FRR_STATE_DIR_BASE, namespace) def _setUp(self): - self.addCleanup(self._stop_frr) - self.addCleanup(self._remove_config) + self.addCleanup(self._cleanup_frr) self._create_config() - self._start_frr() + self.start_frr() @staticmethod def _write_file(path, content): @@ -1185,23 +1184,34 @@ def _create_config(self): ['chown', '-R', 'frr:frr', self._conf_dir], run_as_root=True) - def _start_frr(self): + def start_frr(self): utils.execute( [self.FRRINIT, 'start', self.namespace], run_as_root=True) - def _stop_frr(self): + def stop_frr(self): + utils.execute( + [self.FRRINIT, 'stop', self.namespace], + run_as_root=True) + + def restart_frr(self): + utils.execute( + [self.FRRINIT, 'restart', self.namespace], + run_as_root=True) + + def _cleanup_frr(self): + # NOTE: frrinit.sh returns 0 when stopping an already-stopped + # service, so this is safe even if a test stopped FRR earlier. + # However, stop must be called before config directories are + # removed. try: - utils.execute( - [self.FRRINIT, 'stop', self.namespace], - run_as_root=True) + self.stop_frr() except RuntimeError: LOG.error("Failed to stop FRR in namespace %s", self.namespace) - def _remove_config(self): - for dir in (self._conf_dir, self._state_dir): + for pathspace_dir in (self._conf_dir, self._state_dir): try: utils.execute( - ['rm', '-rf', dir], run_as_root=True) + ['rm', '-rf', pathspace_dir], run_as_root=True) except RuntimeError: - LOG.error("Failed to remove %s", dir) + LOG.error("Failed to remove %s", pathspace_dir) diff --git a/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py b/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py index 13288d1cf4a..b4a0f913c71 100644 --- a/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py +++ b/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py @@ -247,7 +247,8 @@ class TestFrrVtyshDriverConfiguration(base.BaseSudoTestCase): def setUp(self): super().setUp() self.namespace = self.useFixture(net_helpers.NamespaceFixture()).name - self.useFixture(net_helpers.FrrFixture(namespace=self.namespace)) + self.frr_fixture = self.useFixture( + net_helpers.FrrFixture(namespace=self.namespace)) vrf_handler = NamespacedVRFHandler(self.namespace) executor = FrrVtyshExecutorNamespaced(self.namespace) @@ -320,6 +321,18 @@ def test_create_evpn_router_idempotent(self): self.assertIn('router bgp 65000 vrf vrf-100', running_config) self.assertIn('vni 100', running_config) + def test_running_config_persist_on_reboot(self): + config = make_evpn_config(vni=100) + self.driver.create_evpn_router(config) + + self.frr_fixture.restart_frr() + + running_config = self._get_running_config() + self.assertIn('router bgp 65000', running_config) + self.assertIn('bgp router-id 10.0.0.1', running_config) + self.assertIn('router bgp 65000 vrf vrf-100', running_config) + self.assertIn('vni 100', running_config) + def test_delete_noexisting_router_raises(self): config = make_evpn_config(vni=101) @@ -350,7 +363,8 @@ def setUp(self): self.ns_a = self.useFixture( net_helpers.NamespaceFixture('frr-a-')).name - self.useFixture(net_helpers.FrrFixture(namespace=self.ns_a)) + self.frr_fixture_a = self.useFixture( + net_helpers.FrrFixture(namespace=self.ns_a)) self.ns_b = self.useFixture( net_helpers.NamespaceFixture('frr-b-')).name @@ -443,3 +457,41 @@ def test_multiple_routers_then_delete_one(self): assert_routes(self.ns_b, table_id=vni_1, absent=route_1) assert_routes(self.ns_b, table_id=vni_2, present=route_2) + + def test_routes_persist_after_restart(self): + vni = 10 + advertised_routes = {'11.1.1.1/32', '12.1.1.0/32'} + conf_a = make_evpn_config(vni=vni, bgp_router_id=self.vtep_ip_a) + conf_b = make_evpn_config(vni=vni, bgp_router_id=self.vtep_ip_b) + + self.driver_a.create_evpn_router(conf_a) + self.driver_b.create_evpn_router(conf_b) + + add_blackhole_routes( + self.ns_a, advertised_routes, table_id=vni) + assert_routes(self.ns_b, table_id=vni, present=advertised_routes) + + self.frr_fixture_a.restart_frr() + + assert_routes(self.ns_b, table_id=vni, present=advertised_routes) + + def test_routes_withdrawn_on_stop_and_restored_on_start(self): + vni = 123 + advertised_routes = {'10.0.1.1/32', '12.2.1.1/32'} + conf_a = make_evpn_config(vni=vni, bgp_router_id=self.vtep_ip_a) + conf_b = make_evpn_config(vni=vni, bgp_router_id=self.vtep_ip_b) + + self.driver_a.create_evpn_router(conf_a) + self.driver_b.create_evpn_router(conf_b) + + add_blackhole_routes( + self.ns_a, advertised_routes, table_id=vni) + assert_routes(self.ns_b, table_id=vni, present=advertised_routes) + + self.frr_fixture_a.stop_frr() + + assert_routes(self.ns_b, table_id=vni, absent=advertised_routes) + + self.frr_fixture_a.start_frr() + + assert_routes(self.ns_b, table_id=vni, present=advertised_routes) diff --git a/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py index e97bf0ac2b0..0b427e51efe 100644 --- a/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py +++ b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py @@ -118,15 +118,17 @@ def test_execute_cmds_calls_dryrun_then_apply(self): self.executor.execute_cmds(mock_cmds) calls = self.execute.call_args_list - self.assertEqual(2, len(calls)) + self.assertEqual(3, len(calls)) dryrun_cmd = calls[0][0][0] apply_cmd = calls[1][0][0] + write_mem_cmd = calls[2][0][0] self.assertEqual('vtysh', dryrun_cmd[0]) self.assertIn('--dryrun', dryrun_cmd) self.assertIn('-f', dryrun_cmd) self.assertEqual('vtysh', apply_cmd[0]) self.assertIn('-f', apply_cmd) self.assertNotIn('--dryrun', apply_cmd) + self.assertEqual(['vtysh', '-c', 'write memory'], write_mem_cmd) def test_execute_cmds_raises_dryrun_error(self): mock_cmds = "bad config" From 06aaa4cc2d5c37bca9dde4079caf4dc2eb980e07 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 8 Jun 2026 12:43:43 +0200 Subject: [PATCH 19/32] quota: Fix quota details API error with unloaded service plugins When a service plugin package (e.g. neutron-fwaas, neutron-vpnaas, networking-sfc) is installed but its service plugin is not configured in ``service_plugins``, the quota details API endpoint (GET /v2.0/quotas/{project_id}/details) returns a 500 Server Error. The installed package registers quota resources (e.g. firewall_group, firewall_policy, firewall_rule) at import time via ``resource_helper.build_resource_info(register_quota=True)``. When the quota details endpoint iterates over all registered resources to count usage, it calls ``_count_resource()`` which looks for a plugin that provides ``get__count`` or ``get_``. Since the service plugin is not loaded, no plugin supports counting those resources, and a ``NotImplementedError`` is raised. Catch the ``NotImplementedError`` in ``DbQuotaDriver.get_detailed_project_quotas()`` and skip the resource instead of letting the exception propagate as a 500 error. Also guard the project-specific limit update loop against skipped resources. Closes-Bug: #2155846 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I923e90279edf3de3fa85c83fd46e1b5dec0468de --- neutron/db/quota/driver.py | 10 +++++++-- neutron/tests/unit/db/quota/test_driver.py | 22 +++++++++++++++++++ ...oaded-service-plugin-f4c2a0766eb045b0.yaml | 10 +++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/fix-quota-details-unloaded-service-plugin-f4c2a0766eb045b0.yaml diff --git a/neutron/db/quota/driver.py b/neutron/db/quota/driver.py index 694a40735fa..fc6dffc52c4 100644 --- a/neutron/db/quota/driver.py +++ b/neutron/db/quota/driver.py @@ -97,7 +97,12 @@ def get_detailed_project_quotas(self, context, resources, project_id): # pass it regardless to keep the quota driver API intact plugins = directory.get_plugins() plugin = plugins.get(key, plugins[constants.CORE]) - used = resource.count(context, plugin, project_id) + try: + used = resource.count(context, plugin, project_id) + except NotImplementedError: + LOG.info('Skipping quota resource %s: no plugin loaded ' + 'that supports counting it.', key) + continue project_quota_ext[key] = { 'limit': resource.default, @@ -108,7 +113,8 @@ def get_detailed_project_quotas(self, context, resources, project_id): quota_objs = quota_obj.Quota.get_objects(context, project_id=project_id) for item in quota_objs: - project_quota_ext[item['resource']]['limit'] = item['limit'] + if item['resource'] in project_quota_ext: + project_quota_ext[item['resource']]['limit'] = item['limit'] return project_quota_ext @staticmethod diff --git a/neutron/tests/unit/db/quota/test_driver.py b/neutron/tests/unit/db/quota/test_driver.py index 42d8681147d..35cc05ad5d6 100644 --- a/neutron/tests/unit/db/quota/test_driver.py +++ b/neutron/tests/unit/db/quota/test_driver.py @@ -310,6 +310,28 @@ def test_get_detailed_project_quotas_multiple_resource(self): self.assertEqual(7, detailed_quota[self.resource_2]['reserved']) self.assertEqual(3, detailed_quota[self.resource_2]['used']) + def test_get_detailed_project_quotas_skips_uncount_resource(self): + def _raise_not_implemented(context, resource, project_id): + raise NotImplementedError( + 'No plugins that support counting %s found.' % resource) + + resources = { + self.resource_1: + TestTrackedResource(self.resource_1, test_quota.MehModel), + self.resource_2: + TestCountableResource(self.resource_2, + _raise_not_implemented)} + self.plugin.update_quota_limit(self.context, self.project_1, + self.resource_1, 6) + self.plugin.update_quota_limit(self.context, self.project_1, + self.resource_2, 9) + detailed_quota = self.plugin.get_detailed_project_quotas( + self.context, resources, self.project_1) + + self.assertIn(self.resource_1, detailed_quota) + self.assertNotIn(self.resource_2, detailed_quota) + self.assertEqual(6, detailed_quota[self.resource_1]['limit']) + def test_quota_limit_check(self): resources = self._create_resources() self.plugin.update_quota_limit(self.context, self.project_1, diff --git a/releasenotes/notes/fix-quota-details-unloaded-service-plugin-f4c2a0766eb045b0.yaml b/releasenotes/notes/fix-quota-details-unloaded-service-plugin-f4c2a0766eb045b0.yaml new file mode 100644 index 00000000000..636cd66722d --- /dev/null +++ b/releasenotes/notes/fix-quota-details-unloaded-service-plugin-f4c2a0766eb045b0.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + Fixed a 500 Internal Server Error when querying the quota details API + (``GET /v2.0/quotas/{project_id}/details``) while a service plugin + package (e.g. ``neutron-fwaas``, ``neutron-vpnaas``, + ``networking-sfc``) is installed but its service plugin is not + configured in ``service_plugins``. The quota engine now gracefully + skips resources whose service plugin is not loaded instead of raising + a ``NotImplementedError``. From 8a4a4b13f55f0729f7fd0e7fc1ec19639d5bf97a Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 9 Jun 2026 12:28:37 +0200 Subject: [PATCH 20/32] ovs: skip OF operations for ports with invalid ofport When a VIF port is picked up by the OVS agent ``rpc_loop`` before OVS has assigned it a valid ofport (the underlying TAP device may not yet exist), ``port_alive()`` and ``port_dead()`` pass the invalid value (``[]`` or ``-1``) through to ``uninstall_flows()`` / ``drop_port()``. The resulting OpenFlow FlowMod with ``in_port=None`` causes os-ken's ``send_msg`` to hang until the ``of_request_timeout`` (300 s) fires, blocking the ``rpc_loop`` for the entire duration and preventing all subsequent port processing. Guard ``port_alive()``, ``port_dead()`` and ``treat_vif_port()`` so they return early when the ofport is unassigned or invalid. ``treat_vif_port()`` returns False so the port is not marked as bound; the OVSDB monitor will detect the ofport change on a later iteration and re-trigger processing. Closes-Bug: #2155883 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Ic137f8a2862794c3c7ac670e643ca532873e474b --- .../openvswitch/agent/ovs_neutron_agent.py | 31 ++++++----- .../agent/test_ovs_neutron_agent.py | 55 ++++++++++++++++++- ...f-ops-invalid-ofport-a1b2c3d4e5f6a7b8.yaml | 13 +++++ 3 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 releasenotes/notes/ovs-agent-skip-of-ops-invalid-ofport-a1b2c3d4e5f6a7b8.yaml diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py index c5b89e86eab..46f34530eb5 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py @@ -1490,7 +1490,15 @@ def port_unbound(self, vif_id, net_uuid=None): if not lvm.vif_ports: self.reclaim_local_vlan(net_uuid, lvm.segmentation_id) + @staticmethod + def _has_valid_ofport(port): + return port.ofport and port.ofport != ovs_lib.INVALID_OFPORT + def port_alive(self, port, log_errors=True): + if not self._has_valid_ofport(port): + LOG.warning("port_alive skipped for port %s: invalid ofport %s", + port.port_name, port.ofport) + return cur_tag = self.int_br.db_get_val("Port", port.port_name, "tag", log_errors=log_errors) # Port normal vlan tag is set correctly, remove the drop flows @@ -1506,6 +1514,10 @@ def port_dead(self, port, log_errors=True): :param port: an ovs_lib.VifPort object. ''' + if not self._has_valid_ofport(port): + LOG.warning("port_dead skipped for port %s: invalid ofport %s", + port.port_name, port.ofport) + return # Don't kill a port if it's already dead cur_tag = self.int_br.db_get_val("Port", port.port_name, "tag", log_errors=log_errors) @@ -1967,19 +1979,12 @@ def treat_vif_port(self, vif_port, port_id, network_id, network_type, physical_network, segmentation_id, admin_state_up, fixed_ips, device_owner, provisioning_needed): port_needs_binding = True - if not vif_port.ofport: - # Log an error if the VIF port has no ofport, which indicates - # that the port might not be able to transmit traffic. - LOG.error("VIF port: %s has no ofport and might not " - "be able to transmit.", vif_port.vif_id) - elif vif_port.ofport == ovs_lib.INVALID_OFPORT: - # When the ofport is set to INVALID_OFPORT, it indicates that - # the port is in a transitional state and has not yet been fully - # configured. - LOG.info("VIF port: %s is in a transitional state and has not " - "yet been assigned a valid ofport. This is expected " - "during port initialization. (ofport=%s)", - vif_port.vif_id, vif_port.ofport) + if not self._has_valid_ofport(vif_port): + LOG.warning("VIF port: %s has no valid ofport (ofport=%s), " + "skipping OF operations; the port will be " + "retried on the next iteration.", + vif_port.vif_id, vif_port.ofport) + return False if admin_state_up: port_needs_binding = self.port_bound( vif_port, network_id, network_type, diff --git a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py index ffb7ee8a932..894ca0317e2 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py +++ b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py @@ -412,6 +412,59 @@ def test_port_dead_with_port_already_dead(self): def test_port_dead_with_valid_tag(self): self._test_port_dead(cur_tag=1) + def test_port_dead_invalid_ofport_unassigned(self): + port = mock.Mock() + port.ofport = ovs_lib.UNASSIGNED_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_dead(port) + int_br.set_db_attribute.assert_not_called() + int_br.drop_port.assert_not_called() + + def test_port_dead_invalid_ofport_negative(self): + port = mock.Mock() + port.ofport = ovs_lib.INVALID_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_dead(port) + int_br.set_db_attribute.assert_not_called() + int_br.drop_port.assert_not_called() + + def test_port_alive_invalid_ofport_unassigned(self): + port = mock.Mock() + port.ofport = ovs_lib.UNASSIGNED_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_alive(port) + int_br.db_get_val.assert_not_called() + int_br.uninstall_flows.assert_not_called() + + def test_port_alive_invalid_ofport_negative(self): + port = mock.Mock() + port.ofport = ovs_lib.INVALID_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_alive(port) + int_br.db_get_val.assert_not_called() + int_br.uninstall_flows.assert_not_called() + + def test_treat_vif_port_invalid_ofport_returns_false(self): + for ofport in (ovs_lib.UNASSIGNED_OFPORT, ovs_lib.INVALID_OFPORT, 0): + vif_port = mock.Mock() + vif_port.ofport = ofport + vif_port.vif_id = 'test-port-id' + with mock.patch.object( + self.agent, 'port_bound' + ) as port_bound, mock.patch.object( + self.agent, 'port_alive' + ) as port_alive: + result = self.agent.treat_vif_port( + vif_port, 'port-id', 'net-id', 'vxlan', + None, 100, True, [], 'compute:nova', False) + self.assertFalse(result) + port_bound.assert_not_called() + port_alive.assert_not_called() + def mock_scan_ports(self, vif_port_set=None, registered_ports=None, updated_ports=None, port_tags_dict=None, sync=False): if port_tags_dict is None: # Because empty dicts evaluate as False. @@ -1153,7 +1206,7 @@ def test_treat_vif_port_shut_down_port(self): "iface-id": "407a79e0-e0be-4b7d-92a6-513b2161011b", "vif_mac": "fa:16:3e:68:46:7b", "port_name": "qr-407a79e0-e0", - "ofport": -1, + "ofport": 10, "bridge_name": "br-int"}) with mock.patch.object( self.agent.plugin_rpc, 'update_device_down' diff --git a/releasenotes/notes/ovs-agent-skip-of-ops-invalid-ofport-a1b2c3d4e5f6a7b8.yaml b/releasenotes/notes/ovs-agent-skip-of-ops-invalid-ofport-a1b2c3d4e5f6a7b8.yaml new file mode 100644 index 00000000000..49ea45a9ce7 --- /dev/null +++ b/releasenotes/notes/ovs-agent-skip-of-ops-invalid-ofport-a1b2c3d4e5f6a7b8.yaml @@ -0,0 +1,13 @@ +--- +fixes: + - | + Fixed a race condition in the OVS agent where ``port_alive`` and + ``port_dead`` could issue OpenFlow operations with an invalid + ``in_port`` value when a VIF port had an unassigned or error ofport + (e.g. ``[]`` or ``-1``). The malformed OpenFlow message caused the + os-ken ``send_msg`` call to hang until the 300-second + ``of_request_timeout`` expired, which blocked the agent's + ``rpc_loop`` and prevented all subsequent port processing for the + remainder of the timeout period. The functions now validate the + ofport before performing any OpenFlow operations and log a warning + so the port is retried on the next iteration. From 8ccf3f9e7990ea417adfb6776e99809437757e07 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 9 Jun 2026 14:07:27 +0200 Subject: [PATCH 21/32] Fix ``update_router:enable_default_route_*`` policies The operation for these actions is PUT, not POST. Closes-Bug: #2156054 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Iec4b8ddf3717ddc781acfb46ada81839f853bdea --- neutron/conf/policies/router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/neutron/conf/policies/router.py b/neutron/conf/policies/router.py index 318221f528e..fec04e433af 100644 --- a/neutron/conf/policies/router.py +++ b/neutron/conf/policies/router.py @@ -315,7 +315,7 @@ scope_types=['project'], description=('Specify ``enable_default_route_bfd`` attribute when ' 'updating a router'), - operations=ACTION_POST, + operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_router:enable_default_route_ecmp', @@ -323,7 +323,7 @@ scope_types=['project'], description=('Specify ``enable_default_route_ecmp`` attribute when ' 'updating a router'), - operations=ACTION_POST, + operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_router:tags', From 10358060a8b2078ab3fb4f98485c2120f0b2b34c Mon Sep 17 00:00:00 2001 From: Eduardo Olivares Date: Tue, 9 Jun 2026 14:40:13 +0200 Subject: [PATCH 22/32] BGP job: move to experimental pipeline The neutron-ovn-bgp-tempest-multinode CI job intermittently fails because leaf nodes become unreachable after FRR starts ("Dead loop on virtual device" routing loops). This is a known issue tracked in LP#2152328 that has not been fully resolved yet. Move the job from the check pipeline to the experimental pipeline to reduce the duration of the check pipeline while the leaf stability issue is being investigated. The job is non-voting and can be triggered manually with "check experimental" in Gerrit. Related-Bug: #2152328 Change-Id: Ibf38f6ca7c57bed78f30c8a39f8f24f8a83374e6 Signed-off-by: Eduardo Olivares --- zuul.d/job-templates.yaml | 1 + zuul.d/project.yaml | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/zuul.d/job-templates.yaml b/zuul.d/job-templates.yaml index a48e0144864..3b1f4277175 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -119,6 +119,7 @@ - neutron-tempest-plugin-ovn-with-ovn-metadata-agent - neutron-ovn-grenade-multinode-ovn-metadata-agent - ironic-tempest-ovn-uefi-ipmi-pxe + - neutron-ovn-bgp-tempest-multinode experimental: jobs: *neutron-periodic-jobs diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index dc36066aac5..e5200629622 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -66,14 +66,6 @@ - ^zuul.d/rally.yaml - ^zuul.d/tempest-multinode.yaml - ^zuul.d/tempest-singlenode.yaml - - neutron-ovn-bgp-tempest-multinode: - # neutron-ovn-bgp-tempest-multinode inherits irrelevant-files from - # its parent job neutron-tempest-plugin-ovn, defined in - # neutron-tempest-plugin, which are identical to this file's - # ovn-irrelevant-files - voting: false - attempts: 1 - gate: jobs: - neutron-functional From f379dea40d3c969185a9c283648538e43589bd04 Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Tue, 9 Jun 2026 10:33:56 -0400 Subject: [PATCH 23/32] evpn: Set HA chassis group on EVPN LRP The LRP that connects to the dummy EVPN LS needs to be bound. Since we support only centralized routing with EVPN, the LRP can be bound to any of the GW chassis. The patch creates an HA Chassis Group per EVPN router and uses it to bind to port to the gateway chassis. Related-Bug: #2144617 Assisted-By: Claude Opus 4.6 Change-Id: Ifc9600fc7497f9773f672b6d8735e55b18368634 Signed-off-by: Jakub Libosvar --- neutron/services/evpn/commands.py | 32 ++++++++- neutron/services/evpn/constants.py | 1 + neutron/services/evpn/plugin.py | 15 +++- .../functional/services/evpn/test_commands.py | 71 +++++++++++++++++-- .../tests/unit/services/evpn/test_plugin.py | 4 ++ 5 files changed, 112 insertions(+), 11 deletions(-) diff --git a/neutron/services/evpn/commands.py b/neutron/services/evpn/commands.py index 568a0d010c1..a1b98b2fa47 100644 --- a/neutron/services/evpn/commands.py +++ b/neutron/services/evpn/commands.py @@ -47,6 +47,10 @@ def _evpn_lsp_name(router_id, vni): } +def _evpn_hcg_name(router_id): + return '%s%s' % (evpn_const.EVPN_HCG_NAME_PREFIX, router_id) + + class CreateEVPNRouterCommand(command.BaseCommand): """Create the full EVPN OVN topology for a router. @@ -57,12 +61,13 @@ class CreateEVPNRouterCommand(command.BaseCommand): # We support only one SVD at this time. SVD_INDEX = 0 - def __init__(self, api, router_id, vni, vlan): + def __init__(self, api, router_id, vni, vlan, gw_chassis): super().__init__(api) self.lrouter_name = ovn_utils.ovn_name(router_id) self.vni = vni self.vlan = vlan self.router_id = router_id + self.gw_chassis = gw_chassis def run_idl(self, txn): self._set_router_options() @@ -112,6 +117,10 @@ def _create_lrp(self, txn, lrp_name, mac): evpn_const.EVPN_LRP_VNI_EXT_ID_KEY: str(self.vni), evpn_const.EVPN_LRP_VLAN_EXT_ID_KEY: str(self.vlan), } + + hcg_name = _evpn_hcg_name(self.router_id) + hcg = self._create_ha_chassis_group(txn, hcg_name) + try: lrp = self.api.lookup('Logical_Router_Port', lrp_name) except idlutils.RowNotFound: @@ -119,12 +128,31 @@ def _create_lrp(self, txn, lrp_name, mac): self.api, self.lrouter_name, lrp_name, mac, networks=[], options=options, - external_ids=external_ids).run_idl(txn) + external_ids=external_ids, + ha_chassis_group=hcg.uuid).run_idl(txn) return for column_name, column_data in ( ('options', options), ('external_ids', external_ids)): ovn_utils.setkeys(lrp, column_name, column_data) + lrp.ha_chassis_group = hcg.uuid + + def _create_ha_chassis_group(self, txn, hcg_name): + hcg_external_ids = { + ovn_const.OVN_ROUTER_ID_EXT_ID_KEY: self.router_id, + } + hcg_cmd = ovn_nb_commands.HAChassisGroupAddCommand( + self.api, hcg_name, may_exist=True, + external_ids=hcg_external_ids) + hcg_cmd.run_idl(txn) + hcg = self.api.lookup('HA_Chassis_Group', hcg_name) + + chassis_priority = ovn_utils.get_chassis_priority(self.gw_chassis) + for chassis_name, priority in chassis_priority.items(): + ovn_nb_commands.HAChassisGroupAddChassisCommand( + self.api, hcg.uuid, chassis_name, priority).run_idl(txn) + + return hcg def _create_lsp(self, txn, ls_name, lsp_name, lrp_name): options = {'router-port': lrp_name} diff --git a/neutron/services/evpn/constants.py b/neutron/services/evpn/constants.py index f9830b815bc..37158bed905 100644 --- a/neutron/services/evpn/constants.py +++ b/neutron/services/evpn/constants.py @@ -18,3 +18,4 @@ EVPN_LS_NAME_PREFIX = 'evpn-ls-' EVPN_LRP_NAME_PATTERN = 'evpn-lrp-%(lrp_uuid)s-to-%(evpn_ls_name)s' EVPN_LSP_NAME_PATTERN = 'evpn-lsp-%(evpn_ls_name)s-to-%(lrp_uuid)s' +EVPN_HCG_NAME_PREFIX = 'evpn-hcg-' diff --git a/neutron/services/evpn/plugin.py b/neutron/services/evpn/plugin.py index 746838f5e98..336b52c302c 100644 --- a/neutron/services/evpn/plugin.py +++ b/neutron/services/evpn/plugin.py @@ -55,12 +55,20 @@ def __init__(self): LOG.info("Starting EVPN service plugin") @property - def _nb_idl(self): + def _mech_driver(self): if self._ovn_mech_driver is None: plugin = directory.get_plugin() self._ovn_mech_driver = ( plugin.mechanism_manager.mech_drivers['ovn'].obj) - return self._ovn_mech_driver.nb_ovn + return self._ovn_mech_driver + + @property + def _nb_idl(self): + return self._mech_driver.nb_ovn + + @property + def _sb_idl(self): + return self._mech_driver.sb_ovn def get_plugin_description(self): return "EVPN service plugin" @@ -118,9 +126,10 @@ def _process_ovn_router_create(self, resource, event, trigger, payload): router_id = payload.resource_id vlan = self._evpn_db.get_vlan_for_router(payload.context, router_id) + gw_chassis = self._sb_idl.get_gateway_chassis_from_cms_options() with self._nb_idl.transaction(check_error=True) as txn: txn.add(evpn_ovn.CreateEVPNRouterCommand( - self._nb_idl, router_id, vni, vlan)) + self._nb_idl, router_id, vni, vlan, gw_chassis)) LOG.info("Set EVPN dynamic-routing options for router %s VNI %s", router_id, vni) diff --git a/neutron/tests/functional/services/evpn/test_commands.py b/neutron/tests/functional/services/evpn/test_commands.py index 1182ad73346..855a94cd1ea 100644 --- a/neutron/tests/functional/services/evpn/test_commands.py +++ b/neutron/tests/functional/services/evpn/test_commands.py @@ -24,10 +24,11 @@ from neutron.services.evpn import commands as evpn_ovn from neutron.services.evpn import constants as evpn_const from neutron.tests.functional import base as func_base -from neutron.tests.functional.services import bgp +from neutron.tests.functional.services import bgp as bgp_base -class CreateEVPNRouterCommandTestCase(bgp.BaseBgpNbIdlTestCase): +class CreateEVPNRouterCommandTestCase(bgp_base.BaseBgpTestCase): + def setUp(self): super().setUp() self.router_id = uuidutils.generate_uuid() @@ -36,10 +37,27 @@ def setUp(self): self.vlan = 100 self.nb_api.lr_add(self.lr_name).execute(check_error=True) - def _execute(self, router_id=None, vni=None, vlan=None): + def _add_gw_chassis(self, name, ip='10.0.0.1'): + self.add_fake_chassis(name, ip) + self.sb_api.db_set( + 'Chassis', name, + ('other_config', {ovn_const.OVN_CMS_OPTIONS: + ovn_const.CMS_OPT_CHASSIS_AS_GW}), + ).execute(check_error=True) + + def _get_gw_chassis(self): + return [ch.name for ch in + self.sb_api.chassis_list().execute(check_error=True) + if ovn_utils.is_gateway_chassis(ch)] + + def _execute(self, router_id=None, vni=None, vlan=None, + gw_chassis=None): + if gw_chassis is None: + gw_chassis = self._get_gw_chassis() evpn_ovn.CreateEVPNRouterCommand( self.nb_api, router_id or self.router_id, vni or self.vni, vlan or self.vlan, + gw_chassis, ).execute(check_error=True) def test_sets_dynamic_routing_options_on_router(self): @@ -98,6 +116,44 @@ def test_creates_logical_router_port(self): self.assertEqual(str(self.vni), lrp.external_ids.get( evpn_const.EVPN_LRP_VNI_EXT_ID_KEY)) + def test_creates_ha_chassis_group(self): + self._add_gw_chassis('chassis-1') + + self._execute() + + hcg_name = evpn_ovn._evpn_hcg_name(self.router_id) + hcg = self.nb_api.ha_chassis_group_get( + hcg_name).execute(check_error=True) + self.assertEqual(hcg_name, hcg.name) + self.assertEqual(self.router_id, hcg.external_ids.get( + ovn_const.OVN_ROUTER_ID_EXT_ID_KEY)) + + def test_ha_chassis_group_contains_only_gw_chassis(self): + self._add_gw_chassis('gw-chassis-1', '10.0.0.1') + self._add_gw_chassis('gw-chassis-2', '10.0.0.2') + self.add_fake_chassis('compute-chassis', '10.0.0.3') + + self._execute() + + hcg_name = evpn_ovn._evpn_hcg_name(self.router_id) + hcg = self.nb_api.ha_chassis_group_get( + hcg_name).execute(check_error=True) + ha_chassis_names = {hc.chassis_name for hc in hcg.ha_chassis} + self.assertEqual({'gw-chassis-1', 'gw-chassis-2'}, ha_chassis_names) + + def test_ha_chassis_group_assigned_to_lrp(self): + self._add_gw_chassis('chassis-1') + + self._execute() + + hcg_name = evpn_ovn._evpn_hcg_name(self.router_id) + hcg = self.nb_api.ha_chassis_group_get( + hcg_name).execute(check_error=True) + lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) + lrp = self.nb_api.lrp_get(lrp_name).execute(check_error=True) + self.assertEqual(1, len(lrp.ha_chassis_group)) + self.assertEqual(hcg.uuid, lrp.ha_chassis_group[0].uuid) + def test_creates_logical_switch_port(self): self._execute() @@ -122,10 +178,13 @@ def test_idempotent(self): ls_name = evpn_ovn._evpn_ls_name(self.vni) lrp_name = evpn_ovn._evpn_lrp_name(self.router_id, self.vni) lsp_name = evpn_ovn._evpn_lsp_name(self.router_id, self.vni) + hcg_name = evpn_ovn._evpn_hcg_name(self.router_id) self.nb_api.ls_get(ls_name).execute(check_error=True) self.nb_api.lrp_get(lrp_name).execute(check_error=True) self.nb_api.lsp_get(lsp_name).execute(check_error=True) + self.nb_api.ha_chassis_group_get( + hcg_name).execute(check_error=True) lr = self.nb_api.lr_get(self.lr_name).execute(check_error=True) self.assertEqual('true', lr.options.get( @@ -168,7 +227,7 @@ def test_updates_existing_lsp_attributes(self): lsp.addresses) -class DeleteEVPNRouterCommandTestCase(bgp.BaseBgpNbIdlTestCase): +class DeleteEVPNRouterCommandTestCase(bgp_base.BaseBgpNbIdlTestCase): def setUp(self): super().setUp() self.router_id = uuidutils.generate_uuid() @@ -178,7 +237,7 @@ def setUp(self): self.nb_api.lr_add(self.lr_name).execute(check_error=True) evpn_ovn.CreateEVPNRouterCommand( - self.nb_api, self.router_id, self.vni, self.vlan, + self.nb_api, self.router_id, self.vni, self.vlan, [], ).execute(check_error=True) def _execute(self, vni=None): @@ -222,7 +281,7 @@ def test_nonexistent_vni(self): self._execute(vni=9999) -class AdvertiseHostCommandTestCase(bgp.BaseBgpNbIdlTestCase): +class AdvertiseHostCommandTestCase(bgp_base.BaseBgpNbIdlTestCase): def setUp(self): super().setUp() self.lr_name = func_base.get_unique_name("lr") diff --git a/neutron/tests/unit/services/evpn/test_plugin.py b/neutron/tests/unit/services/evpn/test_plugin.py index 27dc2db97d0..9ccbbacc6a7 100644 --- a/neutron/tests/unit/services/evpn/test_plugin.py +++ b/neutron/tests/unit/services/evpn/test_plugin.py @@ -41,11 +41,15 @@ def setUp(self): self.mock_nb_idl = mock.patch.object( evpn_plugin.EVPNPlugin, '_nb_idl', new_callable=mock.PropertyMock).start() + self.mock_sb_idl = mock.patch.object( + evpn_plugin.EVPNPlugin, '_sb_idl', + new_callable=mock.PropertyMock).start() super().setUp(plugin=plugin, service_plugins=service_plugins, ext_mgr=ext_mgr) self.evpn_plugin = directory.get_plugin(plugin_constants.EVPN) self.ctx = context.get_admin_context() self.nb_idl = self.mock_nb_idl.return_value + self.sb_idl = self.mock_sb_idl.return_value self.txn = self.nb_idl.transaction.return_value.__enter__.return_value def test_get_plugin_type(self): From 445966139fbeb880976a0f4d3840bb50b3f98095 Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Tue, 9 Jun 2026 08:34:37 -0400 Subject: [PATCH 24/32] evpn: Remove EVPN_VRF_NAME_LEN constant The constant wrongly sets name of vrf device names to 14 characters. There is an existing constant DEVICE_NAME_MAX_LEN that already defines the right maximum name length. Assisted-By: Claude Opus 4.6 Change-Id: Idc1144951133b07749ad83de8cc05e1dd74bf59e Signed-off-by: Jakub Libosvar --- .../agent/ovn/extensions/evpn/constants.py | 2 +- .../ovn/extensions/evpn/netlink_monitor.py | 10 ++++---- .../agent/ovn/extensions/test_evpn.py | 12 ++++------ .../unit/agent/ovn/extensions/test_evpn.py | 24 +++++++++++-------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/neutron/agent/ovn/extensions/evpn/constants.py b/neutron/agent/ovn/extensions/evpn/constants.py index 625af6b5df8..3b7bd82ba97 100644 --- a/neutron/agent/ovn/extensions/evpn/constants.py +++ b/neutron/agent/ovn/extensions/evpn/constants.py @@ -15,7 +15,7 @@ EVPN_EXT_NAME = 'EVPN agent extension' EVPN_VRF_PREFIX = 'vr' -EVPN_VRF_NAME_LEN = 14 + EVPN_LINK_KIND_VRF = 'vrf' EVPN_LB_NAME_PREFIX = 'brevpn-' diff --git a/neutron/agent/ovn/extensions/evpn/netlink_monitor.py b/neutron/agent/ovn/extensions/evpn/netlink_monitor.py index cc7f0ea8af1..a19bf35ef5b 100644 --- a/neutron/agent/ovn/extensions/evpn/netlink_monitor.py +++ b/neutron/agent/ovn/extensions/evpn/netlink_monitor.py @@ -17,16 +17,18 @@ from oslo_log import log +from neutron_lib import constants as n_const + from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import exceptions as evpn_exc from neutron.agent.ovn.extensions.evpn import fsm LOG = log.getLogger(__name__) -# EVPN VRF name has EVPN_VRF_NAME_LEN characters: -# EVPN_VRF_PREFIX followed by -# the first 12 characters of the logical router's UUID -EVPN_VRF_RE = re.compile(evpn_const.EVPN_VRF_PREFIX + r'[0-9a-f\-]{12}$') +_EVPN_VRF_UUID_LEN = ( + n_const.DEVICE_NAME_MAX_LEN - len(evpn_const.EVPN_VRF_PREFIX)) +EVPN_VRF_RE = re.compile( + evpn_const.EVPN_VRF_PREFIX + r'[0-9a-f\-]{%d}$' % _EVPN_VRF_UUID_LEN) class VrfHandler: diff --git a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py index b6f98b109f7..9aecad112f3 100644 --- a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py @@ -13,8 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -import uuid - +from oslo_utils import uuidutils from pyroute2.netlink import rtnl from neutron.agent.linux import ip_lib @@ -26,6 +25,7 @@ from neutron.agent.ovn.extensions.evpn import fsm from neutron.agent.ovn.extensions.evpn import netlink_monitor from neutron.agent.ovn.extensions.evpn import svd +from neutron.agent.ovn.extensions.evpn import utils as evpn_utils from neutron.common import utils from neutron.privileged.agent.linux import ip_lib as privileged from neutron.tests.functional.agent.linux import base @@ -41,10 +41,6 @@ def _safe_delete(name): except Exception: pass - @staticmethod - def _evpn_vrf_name(): - return 'vr%s' % uuid.uuid4().hex[:12] - def test_vrf_handler_lifecycle(self): vrf_handler = netlink_monitor.VrfHandler( fsm.EvpnFSM(svd=None, config=None)) @@ -59,7 +55,7 @@ def test_vrf_handler_lifecycle(self): on_end=vrf_handler.replay_end) # Create a VRF before starting the dispatcher so replay discovers it. - preexisting_vrf = self._evpn_vrf_name() + preexisting_vrf = evpn_utils.evpn_vrf_name(uuidutils.generate_uuid()) privileged.create_interface(preexisting_vrf, None, 'vrf', vrf_table=100) self.addCleanup(self._safe_delete, preexisting_vrf) @@ -70,7 +66,7 @@ def test_vrf_handler_lifecycle(self): timeout=10, sleep=0.1) # Create a VRF after start — live newlink detection. - live_vrf = self._evpn_vrf_name() + live_vrf = evpn_utils.evpn_vrf_name(uuidutils.generate_uuid()) privileged.create_interface(live_vrf, None, 'vrf', vrf_table=200) self.addCleanup(self._safe_delete, live_vrf) utils.wait_until_true( diff --git a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py index ffb562fd8ca..2ab7a4a3dfa 100644 --- a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py @@ -80,20 +80,20 @@ def setUp(self): self.handler = netlink_monitor.VrfHandler(self._evpn_fsm) def test_handle_newlink_evpn_vrf(self): - vrf = 'vr0a1b2c3d-fff' + vrf = 'vr0a1b2c3d-ffff' msg = _make_vrf_msg(vrf) self.handler.handle_newlink(msg) self.assertIn(vrf, self.handler._known_vrfs) def test_handle_newlink_deduplicates(self): - vrf = 'vr0a1b2c3d-fff' + vrf = 'vr0a1b2c3d-ffff' msg = _make_vrf_msg(vrf) self.handler.handle_newlink(msg) self.handler.handle_newlink(msg) self.assertEqual({vrf}, self.handler._known_vrfs) def test_handle_dellink_evpn_vrf(self): - vrf = 'vr0a1b2c3d-fff' + vrf = 'vr0a1b2c3d-ffff' self.handler._known_vrfs.add(vrf) evpn = fsm.Evpn(vrf) evpn.vrf_up = True @@ -105,18 +105,18 @@ def test_handle_dellink_evpn_vrf(self): self.assertNotIn(vrf, self._evpn_fsm.instances) def test_handle_dellink_unknown_vrf(self): - vrf = 'vr0a1b2c3d-fff' + vrf = 'vr0a1b2c3d-ffff' self.handler._known_vrfs.add(vrf) evpn = fsm.Evpn(vrf) evpn.vrf_up = True evpn.state = fsm.Evpn.WAITING_FOR_BRIDGE self._evpn_fsm.instances[vrf] = evpn - msg = _make_vrf_msg('vr0a1b2c3d-eee') + msg = _make_vrf_msg('vr0a1b2c3d-eeee') self.handler.handle_dellink(msg) self.assertEqual({vrf}, self.handler._known_vrfs) def test_ignores_non_vrf_kind(self): - msg = _make_vrf_msg('vr0a1b2c3d-fff', kind='bridge') + msg = _make_vrf_msg('vr0a1b2c3d-ffff', kind='bridge') self.handler.handle_newlink(msg) self.assertEqual(set(), self.handler._known_vrfs) @@ -131,7 +131,7 @@ def test_ignores_wrong_length(self): self.assertEqual(set(), self.handler._known_vrfs) def test_ignores_no_linkinfo(self): - no_linkinfo_msg = _make_nlmsg('vr0a1b2c3d-fff') + no_linkinfo_msg = _make_nlmsg('vr0a1b2c3d-ffff') self.handler.handle_newlink(no_linkinfo_msg) self.assertEqual(set(), self.handler._known_vrfs) @@ -151,7 +151,9 @@ def test_parse_evpn_vrf_raises_unknown_vrf_for_wrong_length(self): self.handler._parse_evpn_vrf, msg) def test_multiple_vrfs(self): - vrf1, vrf2, vrf3 = 'vr0a1b2c3d-ddd', 'vr0a1b2c3d-eee', 'vr0a1b2c3d-fff' + vrf1 = 'vr0a1b2c3d-dddd' + vrf2 = 'vr0a1b2c3d-eeee' + vrf3 = 'vr0a1b2c3d-ffff' self.handler.handle_newlink(_make_vrf_msg(vrf1)) self.handler.handle_newlink(_make_vrf_msg(vrf2)) self.handler.handle_newlink(_make_vrf_msg(vrf3)) @@ -160,7 +162,9 @@ def test_multiple_vrfs(self): self.assertEqual({vrf1, vrf3}, self.handler._known_vrfs) def test_replay_removes_stale_vrfs(self): - vrf1, vrf2, vrf3 = 'vr0a1b2c3d-ddd', 'vr0a1b2c3d-eee', 'vr0a1b2c3d-fff' + vrf1 = 'vr0a1b2c3d-dddd' + vrf2 = 'vr0a1b2c3d-eeee' + vrf3 = 'vr0a1b2c3d-ffff' self.handler.handle_newlink(_make_vrf_msg(vrf1)) self.handler.handle_newlink(_make_vrf_msg(vrf2)) self.handler.handle_newlink(_make_vrf_msg(vrf3)) @@ -171,7 +175,7 @@ def test_replay_removes_stale_vrfs(self): self.assertEqual({vrf1, vrf3}, self.handler._known_vrfs) def test_replay_adds_new_vrfs(self): - vrf1, vrf2 = 'vr0a1b2c3d-ddd', 'vr0a1b2c3d-eee' + vrf1, vrf2 = 'vr0a1b2c3d-dddd', 'vr0a1b2c3d-eeee' self.handler._known_vrfs = {vrf1} self.handler.replay_start() self.handler.handle_newlink(_make_vrf_msg(vrf1)) From 1fc95d96bd321f880ded09bd0ee5146812359ce9 Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Wed, 3 Jun 2026 22:04:36 +0000 Subject: [PATCH 25/32] Add RandomRangeAllocator for random VNI selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RandomRangeAllocator as a subclass of RangeAllocator that selects a uniformly random unoccupied value rather than the minimum. The algorithm scans the set of allocated values using a LAG window function to identify contiguous gaps, computes the total number of free slots, then maps a Python-generated random float (rand_val) to a position within the free set via cumulative gap arithmetic. rand_val is supplied as a bound parameter rather than using SQL random() for two reasons: non-materialized CTEs can re-evaluate random() independently for each referencing row, producing inconsistent values between the SELECT column and WHERE clause; and SQL random() differs across databases — SQLite returns a 64-bit integer, PostgreSQL and MySQL/ MariaDB return a float, and MySQL spells it rand() — making a single portable expression impractical. The query is O(K) in allocated values and guaranteed to find a free slot in a single round-trip if one exists, regardless of range density. Related-Bug: #2144617 Change-Id: I68567ccf5da19110435c6d3afe9dd00c6f9de95f Signed-off-by: Terry Wilson --- neutron/db/rangeallocator.py | 138 ++++++++++++++++-- .../functional/db/test_rangeallocator.py | 90 +++++++++++- 2 files changed, 210 insertions(+), 18 deletions(-) diff --git a/neutron/db/rangeallocator.py b/neutron/db/rangeallocator.py index 625244cca16..654193fd806 100644 --- a/neutron/db/rangeallocator.py +++ b/neutron/db/rangeallocator.py @@ -13,6 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. +import random as _random + from oslo_utils import uuidutils import sqlalchemy as sa @@ -42,6 +44,10 @@ class RangeAllocator: that each retry opens a fresh transaction with a new UUID. SQL statements are built once at construction time. + + Subclasses may override _gap_source() to change the allocation strategy, + and _make_params() to supply any additional bind parameters required by + their _gap_source() implementation. """ def __init__(self, table, value_col_name, scope_col_name, @@ -65,7 +71,7 @@ def __init__(self, table, value_col_name, scope_col_name, self._scope_p = sa.bindparam('scope_val', type_=scope_param_type) self._id_p = sa.bindparam('allocation_id', type_=sa.String(36)) - source = self._gap_subquery() + source = self._gap_source() self._stmt = ( table.insert() .from_select( @@ -75,8 +81,14 @@ def __init__(self, table, value_col_name, scope_col_name, ) ) - def _gap_subquery(self): - """Subquery returning the minimum unoccupied value in the range.""" + def _gap_source(self): + """Subquery returning the next_val to allocate. + + Returns a subquery with a single next_val column containing the + integer to claim, or NULL if none is available. The default + implementation claims the smallest unoccupied value in the range. + Subclasses override this to change the allocation strategy. + """ range_start = sa.select(self._min_val.label('candidate')) after_existing = sa.select( @@ -101,23 +113,32 @@ def _gap_subquery(self): ) ).subquery() + @staticmethod + def _make_params(min_val, max_val, scope_val, allocation_id): + """Return the bind parameter dict for execute(). + + Subclasses that introduce additional bind parameters in _gap_source() + should override this to include them. + """ + return { + 'min_val': min_val, + 'max_val': max_val, + 'scope_val': scope_val, + 'allocation_id': allocation_id, + } + def allocate(self, context, min_val, max_val, scope_val): """Claim the next available value in [min_val, max_val] for scope_val. Returns (allocation_id, allocated_value) where allocation_id is a Python-generated UUID suitable for use as a foreign key. - Raises self._exception_class(min_val, max_val) if the range is - exhausted. Lets DBDuplicateEntry propagate for retry handling by + Raises self._exception_class(min_val, max_val) if no value is + available. Lets DBDuplicateEntry propagate for retry handling by the caller. """ allocation_id = uuidutils.generate_uuid() - params = { - 'min_val': min_val, - 'max_val': max_val, - 'scope_val': scope_val, - 'allocation_id': allocation_id, - } + params = self._make_params(min_val, max_val, scope_val, allocation_id) context.session.execute(self._stmt, params) @@ -130,3 +151,98 @@ def allocate(self, context, min_val, max_val, scope_val): raise self._exception_class(min_val, max_val) return allocation_id, getattr(row, self._value_col_name) + + +class RandomRangeAllocator(RangeAllocator): + """RangeAllocator that claims a randomly chosen unoccupied value. + + Scans taken values, computes gaps, and maps a Python-generated random + proportion to a position in the free set. Guaranteed to find a free + value if one exists. O(K) in taken values. + + rand_val is provided as a Python-generated float rather than using + SQL random() to avoid CTE re-evaluation issues on non-materialized + CTEs, which could produce different values in the SELECT column and + WHERE clause and return incorrect results. + """ + + def _gap_source(self): + """Subquery returning a randomly selected unoccupied value.""" + rand_val = sa.bindparam('rand_val', type_=sa.Float) + + taken = sa.select( + self._value_col.label('val'), + sa.func.coalesce( + sa.func.lag(self._value_col).over(order_by=self._value_col), + self._min_val - 1, + ).label('prev_val'), + ).where( + sa.and_( + self._scope_col == self._scope_p, + self._value_col >= self._min_val, + self._value_col <= self._max_val, + ) + ).cte('taken') + + inner_gaps = sa.select( + (taken.c.prev_val + 1).label('gap_start'), + (taken.c.val - taken.c.prev_val - 1).label('gap_size'), + ).where(taken.c.val - taken.c.prev_val > 1) + + max_allocated = ( + sa.select(sa.func.max(self._value_col).label('val')) + .where( + sa.and_( + self._scope_col == self._scope_p, + self._value_col >= self._min_val, + self._value_col <= self._max_val, + ) + ) + .cte('max_allocated') + ) + trailing_gap = ( + sa.select( + sa.func.coalesce( + max_allocated.c.val + 1, self._min_val).label('gap_start'), + (self._max_val - sa.func.coalesce( + max_allocated.c.val, self._min_val - 1)).label('gap_size'), + ) + .select_from(max_allocated) + ) + + gaps = sa.union_all(inner_gaps, trailing_gap).cte('gaps') + + free_count = sa.select( + sa.func.sum(gaps.c.gap_size).label('n') + ).cte('free_count') + + n = free_count.c.n + target = sa.select( + sa.cast(sa.func.floor(rand_val * n), sa.Integer).label('idx') + ).where(n > 0).cte('target') + + cumul = sa.select( + gaps.c.gap_start, + gaps.c.gap_size, + (sa.func.sum(gaps.c.gap_size).over(order_by=gaps.c.gap_start) - + gaps.c.gap_size).label('cum_before'), + ).cte('cumul') + + idx = sa.select(target.c.idx).scalar_subquery() + return ( + sa.select( + (cumul.c.gap_start + idx - + cumul.c.cum_before).label('next_val') + ) + .where(idx.between(cumul.c.cum_before, + cumul.c.cum_before + cumul.c.gap_size - 1)) + .limit(1) + .subquery() + ) + + @staticmethod + def _make_params(min_val, max_val, scope_val, allocation_id): + params = super()._make_params( + min_val, max_val, scope_val, allocation_id) + params['rand_val'] = _random.random() # noqa: S311 + return params diff --git a/neutron/tests/functional/db/test_rangeallocator.py b/neutron/tests/functional/db/test_rangeallocator.py index 1106e4063a3..20e8b42b224 100644 --- a/neutron/tests/functional/db/test_rangeallocator.py +++ b/neutron/tests/functional/db/test_rangeallocator.py @@ -31,7 +31,7 @@ _OTHER_PHYSNET = 'other-physnet' -class TestRangeAllocator(testlib_api.SqlTestCase): +class TestRangeAllocatorBase(testlib_api.SqlTestCase): """Tests for RangeAllocator against a real SQL engine. Runs against SQLite by default (RETURNING path). @@ -78,6 +78,8 @@ def _all_vnis(self, physnet=_PHYSNET): ).fetchall() return [r.vni for r in rows] + +class TestRangeAllocator(TestRangeAllocatorBase): def test_allocate_from_empty_gets_min(self): alloc_id, vni = self._allocate(min_vni=10, max_vni=100) self.assertEqual(10, vni) @@ -180,12 +182,13 @@ def test_custom_allocator_generic(self): self.assertIsNotNone(alloc_id) -class TestRangeAllocatorMySQL(testlib_api.MySQLTestCaseMixin, - TestRangeAllocator): - """Re-runs the full suite against MySQL (LAST_INSERT_ID path). - - Skipped automatically if MySQL is unavailable. - """ +class TestAllocatorMySQL(testlib_api.MySQLTestCaseMixin): + def setUp(self): + super().setUp() + with db_api.CONTEXT_WRITER.using(self.ctx): + dialect = self.ctx.session.get_bind().dialect.name + self.assertIn(dialect, ('mysql', 'mariadb'), + "expected MySQL/MariaDB but got: %s" % dialect) def test_engine_is_mysql(self): # @@version_comment is a MySQL/MariaDB system variable that does @@ -196,6 +199,13 @@ def test_engine_is_mysql(self): sa.text('SELECT @@version_comment')).fetchone() self.assertIsNotNone(row) + +class TestRangeAllocatorMySQL(TestAllocatorMySQL, TestRangeAllocator): + """Re-runs the full suite against MySQL (LAST_INSERT_ID path). + + Skipped automatically if MySQL is unavailable. + """ + def test_allocate_concurrent_no_duplicates(self): """Two threads allocating simultaneously must get distinct VNIs. @@ -224,3 +234,69 @@ def allocate(): self.assertEqual(2, len(results)) self.assertEqual(2, len(set(results)), "concurrent allocations must " "produce distinct VNIs, got %s" % results) + + +class TestRandomRangeAllocator(TestRangeAllocatorBase): + """Tests for RangeAllocator with strategy=RANDOM. + + Runs against SQLite by default. + TestRandomRangeAllocatorMySQL runs the same suite against MySQL. + """ + + def test_random_result_within_range(self): + _, vni = self._allocate(min_vni=5, max_vni=10) + self.assertGreaterEqual(vni, 5) + self.assertLessEqual(vni, 10) + + def test_random_multiple_allocations_distinct(self): + _, vni1 = self._allocate(min_vni=1, max_vni=5) + _, vni2 = self._allocate(min_vni=1, max_vni=5) + _, vni3 = self._allocate(min_vni=1, max_vni=5) + self.assertEqual(3, len({vni1, vni2, vni3})) + + def test_random_finds_last_available(self): + """Gap scan must find the sole remaining value in one query.""" + for vni in [1, 2, 4, 5]: + self._insert(vni) + _, vni = self._allocate(min_vni=1, max_vni=5) + self.assertEqual(3, vni) + + def test_random_range_exhausted_raises(self): + for vni in [1, 2, 3]: + self._insert(vni) + self.assertRaises( + evpn_exc.EVPNNoVniAvailable, + self._allocate, min_vni=1, max_vni=3) + + def test_random_scope_isolated(self): + for vni in range(1, 5): + self._insert(vni, physnet=_PHYSNET) + _, vni = self._allocate(min_vni=1, max_vni=5, physnet=_OTHER_PHYSNET) + self.assertGreaterEqual(vni, 1) + self.assertLessEqual(vni, 5) + + def test_random_allocation_id_usable_as_fk(self): + alloc_id, vni = self._allocate() + self.assertIsNotNone(alloc_id) + with db_api.CONTEXT_READER.using(self.ctx): + row = self.ctx.session.execute( + sa.select(self.table).where(self.table.c.id == alloc_id) + ).fetchone() + self.assertIsNotNone(row) + self.assertEqual(vni, row.vni) + + def test_random_all_values_allocatable(self): + """Gap scan guarantees every value reachable regardless of density.""" + allocated = set() + for _ in range(20): + _, vni = self._allocate(min_vni=1, max_vni=20) + allocated.add(vni) + self.assertEqual(set(range(1, 21)), allocated) + + +class TestRandomRangeAllocatorMySQL(TestAllocatorMySQL, + TestRandomRangeAllocator): + """Re-runs random strategy suite against MySQL. + + Skipped automatically if MySQL is unavailable. + """ From ae7e7b5118eee278d0b9be60ef64a5df2964b09c Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Sun, 7 Jun 2026 20:11:11 +0000 Subject: [PATCH 26/32] Switch from setting LSP.tag to LSP.tag_request Setting LSP.tag has been documented as something only northd is supposed to do. The CMS is supposed to set tag_request. A recent patch to OVN will clear tags that do not have a tag_request. A previous patch to unbreak the gate sets both tag and tag_request in a case that was breaking the funcitonal tests, but this patch changes another place that was setting tag directly and adapts the Add/Set commands for LSPs to convert any passed 'tag' to a 'tag_request'. Only test code read tag after setting it, and in that case we now wait for northd to set the tag before reading. Related-Bug: #2155789 Change-Id: I3bf0c45a233e692f5b6905d72d943556dd9e61e1 Signed-off-by: Terry Wilson --- .../ml2/drivers/ovn/mech_driver/ovsdb/api.py | 8 ++++++++ .../ml2/drivers/ovn/mech_driver/ovsdb/commands.py | 15 ++++++++++++--- .../drivers/ovn/mech_driver/ovsdb/ovn_client.py | 4 ++-- .../drivers/ovn/mech_driver/test_mech_driver.py | 1 + .../trunk/drivers/ovn/test_trunk_driver.py | 4 ++-- .../drivers/ovn/mech_driver/test_mech_driver.py | 5 +---- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py index c0d526e51cd..47b9934123e 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py @@ -26,6 +26,10 @@ def create_lswitch_port(self, lport_name, lswitch_name, may_exist=True, **columns): """Create a command to add an OVN logical switch port + NOTE: Setting a tag sets the LSP tag_request column and northd + will eventually set the tag column. tag cannot be set and immediately + read. + :param lport_name: The name of the lport :type lport_name: string :param lswitch_name: The name of the lswitch the lport is created on @@ -44,6 +48,10 @@ def set_lswitch_port(self, lport_name, external_ids_update=None, if_exists=True, **columns): """Create a command to set OVN logical switch port fields + NOTE: Setting a tag sets the LSP tag_request column and northd + will eventually set the tag column. tag cannot be set and immediately + read. + :param lport_name: The name of the lport :type lport_name: string :param external_ids_update: Dictionary of keys to be updated diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py index 026473538aa..8fe3ea65ea2 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py @@ -197,6 +197,16 @@ def run_idl(self, txn): hcg.delete() +def _tag_column_to_tag_request(columns): + # Setting tag directly is verboten, if it is set convert it to a + # tag_request if there isn't one, otherwise ignore it + tag = columns.pop('tag', None) + if tag is not None and 'tag_request' not in columns: + LOG.debug("Converting tag %s to a tag_request", tag) + columns['tag_request'] = tag + return columns + + class AddLSwitchPortCommand(command.BaseCommand): def __init__(self, api, lport, lswitch, may_exist, network_id=None, **columns): @@ -205,7 +215,7 @@ def __init__(self, api, lport, lswitch, may_exist, network_id=None, self.lswitch = lswitch self.may_exist = may_exist self.network_uuid = uuid.UUID(str(network_id)) if network_id else None - self.columns = columns + self.columns = _tag_column_to_tag_request(columns) def run_idl(self, txn): try: @@ -235,7 +245,6 @@ def run_idl(self, txn): port = txn.insert(self.api._tables['Logical_Switch_Port']) port.name = self.lport - port.tag = self.columns.pop('tag', []) or [] dhcpv4_options = self.columns.pop('dhcpv4_options', []) if isinstance(dhcpv4_options, list): port.dhcpv4_options = dhcpv4_options @@ -269,7 +278,7 @@ def __init__(self, api, lport, external_ids_update, if_exists, **columns): super().__init__(api) self.lport = lport self.external_ids_update = external_ids_update - self.columns = columns + self.columns = _tag_column_to_tag_request(columns) self.if_exists = if_exists def run_idl(self, txn): diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 17a013c3f29..3ffd90bf748 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -2185,7 +2185,6 @@ def create_provnet_port(self, context, network_id, segment, txn=None, addresses=[ovn_const.UNKNOWN_ADDR], external_ids={ovn_const.OVN_PHYSNET_EXT_ID_KEY: physnet}, type=ovn_const.LSP_TYPE_LOCALNET, - tag=tag, tag_request=tag, options=options) self._transaction([cmd], txn=txn) @@ -2395,7 +2394,8 @@ def update_network(self, context, network, original_network=None): tag = [] if tag is None else tag lport_name = utils.ovn_provnet_port_name(segment['id']) txn.add(self._nb_idl.set_lswitch_port(lport_name=lport_name, - tag=tag, if_exists=True)) + tag_request=tag, + if_exists=True)) self._qos_driver.update_network(context, txn, network, original_network) diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index f29060560b9..24b15b0a643 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -1136,6 +1136,7 @@ def test_network_segments_localnet_ports(self): ovn_localnetport = self._find_port_row_by_name( utils.ovn_provnet_port_name(seg_2['id'])) self.assertEqual(ovn_localnetport.options['network_name'], 'physnet2') + n_utils.wait_until_true(lambda: ovn_localnetport.tag, timeout=5) self.assertEqual(ovn_localnetport.tag, [222]) # Delete segments and ensure that localnet diff --git a/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py b/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py index 66666070fe6..ba5dba979ae 100644 --- a/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py +++ b/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py @@ -96,14 +96,14 @@ def _get_ovn_trunk_info(self): ovn_trunk_info = [] for row in self.nb_api.tables[ 'Logical_Switch_Port'].rows.values(): - if row.parent_name and row.tag: + if row.parent_name and row.tag_request: device_owner = row.external_ids[ ovn_const.OVN_DEVICE_OWNER_EXT_ID_KEY] revision_number = row.external_ids[ ovn_const.OVN_REV_NUM_EXT_ID_KEY] ovn_trunk_info.append({'port_id': row.name, 'parent_port_id': row.parent_name, - 'tag': row.tag, + 'tag': row.tag_request, 'device_owner': device_owner, 'revision_number': revision_number, }) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index e05d85e5fe2..8f03a7db04a 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -1033,7 +1033,6 @@ def test_create_network_create_localnet_port_physical_network_type(self): ovn_const.LSP_OPTIONS_MCAST_FLOOD: ovs_conf.get_igmp_flood(), ovn_const.LSP_OPTIONS_LOCALNET_LEARN_FDB: 'false'}, - tag=2, tag_request=2, type='localnet') @@ -3700,7 +3699,7 @@ def test_update_network_segmentation_id(self): # Assert the tag was changed in the OVN database expected_call = mock.call( lport_name=ovn_utils.ovn_provnet_port_name(segment['id']), - tag=new_vlan_tag, if_exists=True) + tag_request=new_vlan_tag, if_exists=True) self.nb_ovn.set_lswitch_port.assert_has_calls([expected_call]) @mock.patch.object(wsgi_utils, 'get_api_worker_id', return_value=1) @@ -4027,7 +4026,6 @@ def test_create_segment_create_localnet_port(self): ovn_const.LSP_OPTIONS_MCAST_FLOOD: ovs_conf.get_igmp_flood(), ovn_const.LSP_OPTIONS_LOCALNET_LEARN_FDB: 'false'}, - tag=200, tag_request=200, type='localnet') ovn_nb_api.create_lswitch_port.reset_mock() @@ -4048,7 +4046,6 @@ def test_create_segment_create_localnet_port(self): ovn_const.LSP_OPTIONS_MCAST_FLOOD: ovs_conf.get_igmp_flood(), ovn_const.LSP_OPTIONS_LOCALNET_LEARN_FDB: 'false'}, - tag=300, tag_request=300, type='localnet') segments = segments_db.get_network_segments( From 83f1d8305651a0ab23629c88d70bfa237055158c Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Tue, 9 Jun 2026 17:33:16 -0500 Subject: [PATCH 27/32] Ensure MaintenanceWorker lock set before connect The Idl.set_lock() call sets the lock name on the Idl class and sends a request to ovsdb-server requesting a lock. It does not wait for a reply. This creates a window where we run without yet receiving the lock, and tasks might fire and fail and have to be retried. If set_lock() is called prior to connection, the reply that contains the initial database dump wil aslo have the reply to the set_lock request, so we eliminate that window. Related-Bug: #2155155 Change-Id: Iaefe1e2cc86ddd55c9053fde66353b2a333e1e98 Signed-off-by: Terry Wilson --- neutron/common/ovn/constants.py | 1 + .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py | 1 + .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py | 3 --- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index 2f2407bcf62..a9991bce281 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -291,6 +291,7 @@ DB_CONSISTENCY_CHECK_INTERVAL = 300 # 5 minutes MAINTENANCE_TASK_RETRY_LIMIT = 100 # times MAINTENANCE_ONE_RUN_TASK_SPACING = 5 # seconds +MAINTENANCE_NB_IDL_LOCK_NAME = "ovn_db_inconsistencies_periodics" # The addresses field to set in the logical switch port which has a # peer router port (connecting to the logical router). diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py index 6d94c12aa47..5e5a862e486 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py @@ -245,6 +245,7 @@ def from_worker(cls, worker_class, driver=None): args = (cls.connection_string, cls.schema_helper) if worker_class == worker.MaintenanceWorker: idl_ = ovsdb_monitor.BaseOvnIdl.from_server(*args) + idl_.set_lock(ovn_const.MAINTENANCE_NB_IDL_LOCK_NAME) else: idl_ = ovsdb_monitor.OvnNbIdl.from_server(*args, driver=driver) conn = connection.Connection(idl_, timeout=cfg.get_ovn_ovsdb_timeout()) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index 792a9e73f61..e8b87223216 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -51,8 +51,6 @@ INCONSISTENCY_TYPE_CREATE_UPDATE = 'create/update' INCONSISTENCY_TYPE_DELETE = 'delete' -# TODO(bpetermann): move MAINTENANCE_NB_IDL_LOCK_NAME to neutron-lib -MAINTENANCE_NB_IDL_LOCK_NAME = "ovn_db_inconsistencies_periodics" def has_lock_periodic(*args, periodic_run_limit=0, **kwargs): @@ -229,7 +227,6 @@ def __init__(self, ovn_client): self._nb_idl = self._ovn_client._nb_idl self._sb_idl = self._ovn_client._sb_idl self._idl = self._nb_idl.idl - self._idl.set_lock(MAINTENANCE_NB_IDL_LOCK_NAME) super().__init__(ovn_client) self._resources_func_map = { From b252d4aa3cf5bb4caf85c0310f0a62a0d9af699a Mon Sep 17 00:00:00 2001 From: Yatin Karel Date: Wed, 10 Jun 2026 10:29:45 +0530 Subject: [PATCH 28/32] [functional tests] Handle frrinit.sh path for CentOS frrinit.sh is located at /usr/libexec/frr/frrinit.sh on CentOS, so also need to consider that. Closes-Bug: #2156315 Signed-off-by: Yatin Karel Change-Id: Ib20016775b49d36e9aafa5dc01459a39ba1363f0 --- neutron/tests/common/net_helpers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/neutron/tests/common/net_helpers.py b/neutron/tests/common/net_helpers.py index c668ca7cf5e..b566002e18b 100644 --- a/neutron/tests/common/net_helpers.py +++ b/neutron/tests/common/net_helpers.py @@ -1109,7 +1109,10 @@ class FrrFixture(fixtures.Fixture): FRR_CONF_DIR_BASE = '/etc/frr' FRR_STATE_DIR_BASE = '/var/run/frr' - FRRINIT = '/usr/lib/frr/frrinit.sh' + FRRINIT_PATHS = ['/usr/lib/frr/frrinit.sh', + '/usr/libexec/frr/frrinit.sh'] + FRRINIT = next((p for p in FRRINIT_PATHS if os.path.isfile(p)), + FRRINIT_PATHS[0]) DAEMONS_CONF = ( 'zebra=yes\n' From 7f986bb19940921c3b8ca48be0eebad0a20004a9 Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Sun, 7 Jun 2026 21:00:32 -0400 Subject: [PATCH 29/32] Integrate FrrDriver into OVN Agent EVPN Extension This patch creates new FrrvtyshDriver adapter (FsmFrrVtyshDriver) which is adopted to operated with EVPN finite state machine(fsm), where vrf is handled by the fsm. It also adopts to fact that FSM only needs static bgp-router-id and asn for each instance. This patch also creates config.py for evpn extension in order to keep configurations for the evpn extension separate from the OVN agent configs. ovn-evpn-local-ip from Open_vswitch table is used as the bgp router-id that will be configured by the frr driver. Related-Bug: #2144617 Assiste-By: Claude Opus 4.6 Change-Id: I037110ce0bc9b2569147cab7c44a02063b108e5c Signed-off-by: Miro Tomaska --- .../agent/linux/evpn_router/frr/frr_driver.py | 4 +- neutron/agent/ovn/extensions/evpn/__init__.py | 6 +- neutron/agent/ovn/extensions/evpn/fsm.py | 11 +-- .../ovn/extensions/evpn/fsm_frr_driver.py | 71 +++++++++++++++++++ neutron/conf/agent/ovn/evpn/__init__.py | 0 neutron/conf/agent/ovn/evpn/config.py | 37 ++++++++++ .../agent/ovn/ovn_neutron_agent/config.py | 15 +--- .../agent/ovn/extensions/evpn/test_events.py | 3 +- .../agent/ovn/extensions/test_evpn.py | 7 +- .../linux/evpn_router/frr/test_frr_driver.py | 6 +- .../agent/ovn/extensions/evpn/test_fsm.py | 33 ++++++++- .../unit/agent/ovn/extensions/test_evpn.py | 6 +- 12 files changed, 169 insertions(+), 30 deletions(-) create mode 100644 neutron/agent/ovn/extensions/evpn/fsm_frr_driver.py create mode 100644 neutron/conf/agent/ovn/evpn/__init__.py create mode 100644 neutron/conf/agent/ovn/evpn/config.py diff --git a/neutron/agent/linux/evpn_router/frr/frr_driver.py b/neutron/agent/linux/evpn_router/frr/frr_driver.py index 5fcb1959468..a19e6cb275a 100644 --- a/neutron/agent/linux/evpn_router/frr/frr_driver.py +++ b/neutron/agent/linux/evpn_router/frr/frr_driver.py @@ -200,8 +200,8 @@ def execute_cmds(self, cmd_string: str) -> None: class FrrVtyshDriver(interface.EVPNRouterDriver): - def __init__(self, vrf_handler: interface.EVPNRouterVrfHandler, - peer_interface: str, + def __init__(self, peer_interface: str, + vrf_handler: interface.EVPNRouterVrfHandler, executor: FrrVtyshExecutor | None = None): self.vrf_handler = vrf_handler self.peer_interface = peer_interface diff --git a/neutron/agent/ovn/extensions/evpn/__init__.py b/neutron/agent/ovn/extensions/evpn/__init__.py index 849bc73744b..aa6bd216f4a 100644 --- a/neutron/agent/ovn/extensions/evpn/__init__.py +++ b/neutron/agent/ovn/extensions/evpn/__init__.py @@ -26,6 +26,7 @@ from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import events as evpn_events from neutron.agent.ovn.extensions.evpn import fsm +from neutron.agent.ovn.extensions.evpn import fsm_frr_driver from neutron.agent.ovn.extensions.evpn import netlink_monitor from neutron.agent.ovn.extensions.evpn import svd from neutron.agent.ovn.extensions import extension_manager as ovn_ext_mgr @@ -81,7 +82,10 @@ def start(self): dstport=self.cfg.dstport, br_mtu=self.cfg.br_mtu) except linux_svd.SvdDeviceAlreadyExists: LOG.warning("SVD already exists, reusing") - self._evpn_fsm = fsm.EvpnFSM(self.svd, self.cfg) + driver = fsm_frr_driver.FsmFrrVtyshDriver( + peer_interface=CONF.ovn_evpn.bgp_local_interface, + bgp_router_id=self.cfg.local_ip) + self._evpn_fsm = fsm.EvpnFSM(self.svd, self.cfg, driver) vrf_handler = netlink_monitor.VrfHandler(self._evpn_fsm) self.nl_dispatcher = nl_dispatcher.NetlinkDispatcher( rtnl.RTMGRP_LINK) diff --git a/neutron/agent/ovn/extensions/evpn/fsm.py b/neutron/agent/ovn/extensions/evpn/fsm.py index 9b32351ca93..1d340baf641 100644 --- a/neutron/agent/ovn/extensions/evpn/fsm.py +++ b/neutron/agent/ovn/extensions/evpn/fsm.py @@ -77,10 +77,11 @@ class EvpnFSM: (Evpn.DESTROY, "_destroy"), } - def __init__(self, svd, config): + def __init__(self, svd, config, frr_driver): self.instances = {} # vrf -> Evpn self._svd = svd self._cfg = config + self._driver = frr_driver def _set_evpn_bridge(self, evpn, mac, vni, vid): evpn.mac = mac @@ -103,13 +104,13 @@ def _unset_evpn_router_and_unadvertise(self, evpn): def _advertise(self, evpn): self._svd.add_vni(evpn.vni, evpn.vid, evpn.vrf, evpn.mac, self._cfg.br_mtu) - LOG.debug("EVPN: VNI %d Create VLAN and update FRR " - "configuration to start advertising and learning", evpn.vni) + self._driver.create_router(evpn.vrf, evpn.vni) + LOG.debug("EVPN: advertised %s", evpn) def _unadvertise(self, evpn): - LOG.debug("EVPN: VNI %d Remove VLAN and update FRR " - "configuration to stop advertising and learning", evpn.vni) self._svd.del_vni(evpn.vni, evpn.vid) + self._driver.delete_router(evpn.vrf, evpn.vni) + LOG.debug("EVPN: unadvertised %s", evpn) def _set_evpn_router_and_advertise(self, evpn): self._set_evpn_router(evpn) diff --git a/neutron/agent/ovn/extensions/evpn/fsm_frr_driver.py b/neutron/agent/ovn/extensions/evpn/fsm_frr_driver.py new file mode 100644 index 00000000000..578851fae9e --- /dev/null +++ b/neutron/agent/ovn/extensions/evpn/fsm_frr_driver.py @@ -0,0 +1,71 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg +from oslo_log import log + +from neutron.agent.linux.evpn_router.frr import frr_driver +from neutron.agent.linux.evpn_router import interface as evpn_interface + +LOG = log.getLogger(__name__) + + +class FsmFrrVrfHandler(evpn_interface.EVPNRouterVrfHandler): + """No-op VRF handler for the FSM-driven EVPN lifecycle. + + The FSM relies on netlink RTM_NEWLINK / RTM_DELLINK events (via + VrfHandler) to track VRF creation and deletion. Because VRF + lifecycle is already managed by those netlink-driven state + transitions, FrrVtyshDriver must not create or delete VRFs + itself — this handler satisfies the interface with no-ops. + """ + + def ensure_vrf_exists(self, vrf_name) -> None: + LOG.debug("%s.%s: skipping for %s", + type(self).__name__, "ensure_vrf_exists", vrf_name) + + def ensure_vrf_deleted(self, vrf_name) -> None: + LOG.debug("%s.%s: skipping for %s", + type(self).__name__, "ensure_vrf_deleted", vrf_name) + + +class FsmFrrVtyshDriver(frr_driver.FrrVtyshDriver): + """FRR driver adapter for the EVPN FSM + + This class is set to operate on a single ASN and BGP router ID. + """ + + def __init__(self, peer_interface: str, bgp_router_id: str): + self._asn = cfg.CONF.ovn_evpn.bgp_as + self._bgp_router_id = bgp_router_id + super().__init__(peer_interface, FsmFrrVrfHandler()) + + def create_router(self, vrf_name, vni) -> None: + config = evpn_interface.EVPNRouterConfig( + asn=self._asn, + bgp_router_id=self._bgp_router_id, + vrf_name=vrf_name, + vni=vni, + ) + return super().create_evpn_router(config) + + def delete_router(self, vrf_name, vni) -> None: + config = evpn_interface.EVPNRouterConfig( + asn=self._asn, + bgp_router_id=self._bgp_router_id, + vrf_name=vrf_name, + vni=vni, + ) + return super().delete_evpn_router(config) diff --git a/neutron/conf/agent/ovn/evpn/__init__.py b/neutron/conf/agent/ovn/evpn/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/conf/agent/ovn/evpn/config.py b/neutron/conf/agent/ovn/evpn/config.py new file mode 100644 index 00000000000..b9ce6d78eb3 --- /dev/null +++ b/neutron/conf/agent/ovn/evpn/config.py @@ -0,0 +1,37 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg + +from neutron._i18n import _ + + +EVPN_OPTS = [ + cfg.IntOpt( + 'bgp_as', + help=_('BGP Autonomous System number for EVPN')), + cfg.StrOpt( + 'bgp_local_interface', + help=_('The local interface name (e.g. eth2) on which to establish ' + 'BGP peer session')), + cfg.IntOpt( + 'child_vxlan_port', + default=49152, + help=_('UDP port for the child VxLAN device used by EVPN')), +] + + +def register_opts(): + cfg.CONF.register_opts(EVPN_OPTS, group='ovn_evpn') diff --git a/neutron/conf/agent/ovn/ovn_neutron_agent/config.py b/neutron/conf/agent/ovn/ovn_neutron_agent/config.py index 5db1acfa925..6d90c031684 100644 --- a/neutron/conf/agent/ovn/ovn_neutron_agent/config.py +++ b/neutron/conf/agent/ovn/ovn_neutron_agent/config.py @@ -18,6 +18,7 @@ from neutron.conf.agent import agent_extensions_manager as ext_manager_conf from neutron.conf.agent.metadata import config as meta_conf +from neutron.conf.agent.ovn.evpn import config as evpn_conf from neutron.conf.agent import ovsdb_api from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf from oslo_config import cfg @@ -33,16 +34,6 @@ help=_('Timeout in seconds for the OVSDB connection transaction')) ] -OVN_EVPN_OPTS = [ - cfg.IntOpt( - 'bgp_as', - help=_('BGP Autonomous System number for EVPN')), - cfg.IntOpt( - 'child_vxlan_port', - default=49152, - help=_('UDP port for the child VxLAN device used by EVPN')), -] - def list_ovn_neutron_agent_opts(): return [ @@ -57,7 +48,7 @@ def list_ovn_neutron_agent_opts(): ) ), (meta_conf.RATE_LIMITING_GROUP, meta_conf.METADATA_RATE_LIMITING_OPTS), - ('ovn_evpn', OVN_EVPN_OPTS), + ('ovn_evpn', evpn_conf.EVPN_OPTS), ] @@ -65,7 +56,7 @@ def register_opts(): cfg.CONF.register_opts(ovn_conf.ovn_opts, group='ovn') cfg.CONF.register_opts(OVS_OPTS, group='ovs') cfg.CONF.register_opts(ovsdb_api.API_OPTS, group='ovs') - cfg.CONF.register_opts(OVN_EVPN_OPTS, group='ovn_evpn') + evpn_conf.register_opts() def get_root_helper(conf): diff --git a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py index b64269c0353..bfd6fdd61e7 100644 --- a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py +++ b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py @@ -41,7 +41,8 @@ def setUp(self): finally: bgp_ovn.OvnSbIdl.tables = bgp_ovn.OVN_SB_TABLES self.mock_evpn_ext = mock.Mock() - self.real_fsm = evpn_fsm.EvpnFSM(mock.Mock(), mock.Mock()) + self.real_fsm = evpn_fsm.EvpnFSM(mock.Mock(), mock.Mock(), + mock.Mock()) self.mock_evpn_ext._evpn_fsm = mock.Mock(wraps=self.real_fsm) self.sb_api.idl.notify_handler.watch_event( evpn_events.PortBindingLrpEvpnCreateEvent( diff --git a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py index 9aecad112f3..f146ff13343 100644 --- a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py @@ -13,6 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. +from unittest import mock + from oslo_utils import uuidutils from pyroute2.netlink import rtnl @@ -43,7 +45,7 @@ def _safe_delete(name): def test_vrf_handler_lifecycle(self): vrf_handler = netlink_monitor.VrfHandler( - fsm.EvpnFSM(svd=None, config=None)) + fsm.EvpnFSM(svd=None, config=None, frr_driver=None)) dispatcher = nl_dispatcher.NetlinkDispatcher(rtnl.RTMGRP_LINK) dispatcher.register_handler( @@ -150,7 +152,8 @@ def setUp(self): self.addCleanup(self._safe_delete, self._vx) self.addCleanup(self._safe_delete, self._br) - self._evpn_fsm = fsm.EvpnFSM(self.svd, config=self.cfg) + self._evpn_fsm = fsm.EvpnFSM(self.svd, config=self.cfg, + frr_driver=mock.Mock()) def _advance_to_advertising(self, vni, vid): self._evpn_fsm.advance( diff --git a/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py index 0b427e51efe..c1120b101d7 100644 --- a/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py +++ b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py @@ -40,8 +40,7 @@ def setUp(self): def test_add_bgp_router_cmds(self): config = _build_test_evpn_router_config(100) peer_iface = 'eth1' - result = self.builder.add_bgp_router_cmds( - config, peer_iface) + result = self.builder.add_bgp_router_cmds(config, peer_iface) self.assertIn( 'router bgp %d' % config.asn, result) @@ -168,7 +167,8 @@ def setUp(self): super().setUp() self.vrf_handler = mock.Mock(spec=interface.EVPNRouterVrfHandler) self.driver = frr_driver.FrrVtyshDriver( - self.vrf_handler, 'peer_iface') + peer_interface='peer_iface', + vrf_handler=self.vrf_handler) self.cmd_builder = mock.Mock( spec=frr_driver.FrrCommandBuilder) self.executor = mock.Mock( diff --git a/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py b/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py index 2d36f4cf431..8aa41ff2407 100644 --- a/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py +++ b/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py @@ -29,7 +29,9 @@ def setUp(self): super().setUp() self.mock_svd = mock.Mock() self.mock_config = mock.Mock() - self.evpn_fsm = fsm.EvpnFSM(self.mock_svd, self.mock_config) + self.mock_driver = mock.Mock() + self.evpn_fsm = fsm.EvpnFSM(self.mock_svd, + self.mock_config, self.mock_driver) self.mock_config.br_mtu = BR_MTU def test_vrf_then_port_binding_create(self): @@ -47,6 +49,7 @@ def test_vrf_then_port_binding_create(self): self.assertTrue(evpn.vrf_up) self.mock_svd.add_vni.assert_called_once_with( 10, 1, vrf, 'aa:bb:cc:dd:ee:ff', BR_MTU) + self.mock_driver.create_router.assert_called_once_with(vrf, 10) def test_port_binding_then_vrf_create(self): vrf = 'vr0a1b2c3d-fff' @@ -63,6 +66,7 @@ def test_port_binding_then_vrf_create(self): self.assertTrue(evpn.vrf_up) self.mock_svd.add_vni.assert_called_once_with( 10, 1, vrf, 'aa:bb:cc:dd:ee:ff', BR_MTU) + self.mock_driver.create_router.assert_called_once_with(vrf, 10) def test_advertise_then_port_binding_delete(self): vrf = 'vr0a1b2c3d-fff' @@ -80,6 +84,7 @@ def test_advertise_then_port_binding_delete(self): fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_DELETE, vrf) self.assertNotIn(vrf, self.evpn_fsm.instances) self.mock_svd.del_vni.assert_called_once_with(10, 1) + self.mock_driver.delete_router.assert_called_once_with(vrf, 10) def test_advertise_then_vrf_delete(self): vrf = 'vr0a1b2c3d-fff' @@ -97,6 +102,7 @@ def test_advertise_then_vrf_delete(self): fsm.EvpnFSM.FSM_EVENT_VRF_DELETE, vrf) self.assertNotIn(vrf, self.evpn_fsm.instances) self.mock_svd.del_vni.assert_called_once_with(10, 1) + self.mock_driver.delete_router.assert_called_once_with(vrf, 10) def test_simultaneous_vrf_and_port_binding_create(self): """Netlink and SB IDL threads both create for the same VRF.""" @@ -179,6 +185,8 @@ def test_advertise_then_vrf_delete_then_vrf_create(self): fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, vrf) self.assertEqual(fsm.Evpn.ADVERTISING, evpn.state) self.assertEqual(2, self.mock_svd.add_vni.call_count) + self.assertEqual(2, self.mock_driver.create_router.call_count) + self.mock_driver.delete_router.assert_called_once_with(vrf, 10) def test_advertise_then_port_binding_delete_then_port_binding_create(self): vrf = 'vr0a1b2c3d-fff' @@ -206,6 +214,9 @@ def test_advertise_then_port_binding_delete_then_port_binding_create(self): self.assertEqual(20, evpn.vni) self.assertEqual(2, evpn.vid) self.assertEqual(2, self.mock_svd.add_vni.call_count) + self.assertEqual(2, self.mock_driver.create_router.call_count) + self.mock_driver.create_router.assert_called_with(vrf, 20) + self.mock_driver.delete_router.assert_called_once_with(vrf, 10) def test_replay_end_deletes_stale_fsm_instance(self): vrf1, vrf2 = 'vr0a1b2c3d-eee', 'vr1a2b3c3d-fff' @@ -235,6 +246,7 @@ def test_replay_end_transitions_advertising_to_waiting(self): handler.replay_end() self.assertEqual(fsm.Evpn.WAITING_FOR_ROUTER, evpn.state) self.assertIn(vrf, self.evpn_fsm.instances) + self.mock_driver.delete_router.assert_called_once_with(vrf, 10) def test_replay_end_no_stale_vrfs(self): vrf = 'vr0a1b2c3d-fff' @@ -248,3 +260,22 @@ def test_replay_end_no_stale_vrfs(self): handler.replay_end() self.assertIn(vrf, self.evpn_fsm.instances) self.assertEqual({vrf}, handler._known_vrfs) + + def test_driver_not_called_before_advertising(self): + vrf = 'vr0a1b2c3d-fff' + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_VRF_CREATE, vrf) + evpn = self.evpn_fsm.instances[vrf] + self.assertEqual(fsm.Evpn.WAITING_FOR_BRIDGE, evpn.state) + self.mock_driver.create_router.assert_not_called() + self.mock_driver.delete_router.assert_not_called() + + def test_driver_not_called_port_binding_before_vrf(self): + vrf = 'vr0a1b2c3d-fff' + self.evpn_fsm.advance( + fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, + vrf, mac='aa:bb:cc:dd:ee:ff', vni=10, vid=1) + evpn = self.evpn_fsm.instances[vrf] + self.assertEqual(fsm.Evpn.WAITING_FOR_ROUTER, evpn.state) + self.mock_driver.create_router.assert_not_called() + self.mock_driver.delete_router.assert_not_called() diff --git a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py index 2ab7a4a3dfa..e288f2e8442 100644 --- a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py @@ -24,7 +24,7 @@ from neutron.agent.ovn.extensions.evpn import fsm from neutron.agent.ovn.extensions.evpn import netlink_monitor from neutron.agent.ovn.extensions.evpn import svd -from neutron.conf.agent.ovn.ovn_neutron_agent import config as agent_config +from neutron.conf.agent.ovn.evpn import config as evpn_conf from neutron.privileged.agent.linux import svd as privileged_svd from neutron.tests import base @@ -52,7 +52,7 @@ class TestEVPNAgentExtension(base.BaseTestCase): def setUp(self): super().setUp() - cfg.CONF.register_opts(agent_config.OVN_EVPN_OPTS, group='ovn_evpn') + evpn_conf.register_opts() cfg.CONF.set_override('child_vxlan_port', self.DSTPORT, group='ovn_evpn') self.ext = evpn_ext.EVPNAgentExtension() @@ -76,7 +76,7 @@ class TestVrfHandler(base.BaseTestCase): def setUp(self): super().setUp() - self._evpn_fsm = fsm.EvpnFSM(mock.Mock(), mock.Mock()) + self._evpn_fsm = fsm.EvpnFSM(mock.Mock(), mock.Mock(), mock.Mock()) self.handler = netlink_monitor.VrfHandler(self._evpn_fsm) def test_handle_newlink_evpn_vrf(self): From 88362898583a0530ad4106c89c591359e26088f4 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 10 Jun 2026 09:00:21 +0200 Subject: [PATCH 30/32] doc: Document runtime ``uwsgi`` Python module in WSGI guide Explain that the ``uwsgi`` module is injected at runtime by the uWSGI server and is used by ``neutron.common.wsgi_utils`` for options such as ``start-time`` and ``uwsgi.worker_id()``. Assisted-By: Composer 2.5 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Ia28d1febaac93b9339deda8d9cf1721b131ffa63 --- doc/source/admin/config-wsgi.rst | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/doc/source/admin/config-wsgi.rst b/doc/source/admin/config-wsgi.rst index 4a16d3b5d32..223b0bab637 100644 --- a/doc/source/admin/config-wsgi.rst +++ b/doc/source/admin/config-wsgi.rst @@ -58,6 +58,19 @@ Start neutron-api: .. end +uWSGI Python API +~~~~~~~~~~~~~~~~ + +When Neutron API workers run under uWSGI with the ``python3`` plugin, the +server injects a Python ``uwsgi`` module into each worker process at runtime. +This module is not a Neutron dependency and cannot be installed separately +with ``pip``; it exists only inside uWSGI-managed workers. + +Neutron uses this module (via ``neutron.common.wsgi_utils``) to read uWSGI +configuration options such as ``start-time`` and to obtain the worker ID +(``uwsgi.worker_id()``). For the full list of available functions, see the +`uWSGI API documentation `_. + Start Neutron RPC server ------------------------ @@ -167,5 +180,7 @@ in processing agents heartbeats. .. note:: ML2/OVN uses the ``[uwsgi]start-time = %t`` parameter to create the OVN hash ring registers during the initialization process. This value is populated - by the uWSGi process with the start time. For more information, check + by the uWSGi process with the start time and read via the runtime ``uwsgi`` + Python module (see the uWSGI Python API subsection above). For more + information, check `Configuring uWSGI `_. From 7ebcce0b8dcd4cd482da92672f5ae7c51530a429 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 10 Jun 2026 11:03:20 +0200 Subject: [PATCH 31/32] ovs: defer ports with invalid ofport in ``_process_port`` When a port is inserted into OVS, the kernel may transiently assign ``ofport=-1`` (``INVALID_OFPORT``) before settling on a valid value. Previously, ``_process_port`` only deferred ports with ``UNASSIGNED_OFPORT`` (``[]``), so a port arriving with ``ofport=-1`` was immediately added to the processing pipeline. Patch [1] then correctly skipped OF operations in ``treat_vif_port`` for such ports but never re-queued them, which meant the ``network-vif-plugged`` event was never sent to Nova, causing a 300 s VIF-plug timeout. Extend the ``_process_port`` check to also defer ``INVALID_OFPORT`` ports via the existing ``ports_not_ready_yet`` mechanism. On the next ``rpc_loop`` iteration the port attributes are re-read from OVS; if the ofport is now valid the port is processed normally, otherwise it is deferred again. [1]https://review.opendev.org/c/openstack/neutron/+/992423 Closes-Bug: #2155883 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I9fbc8f75f8084461901a004dfcb07e42f76db62b --- .../ml2/drivers/openvswitch/agent/ovs_neutron_agent.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py index 46f34530eb5..6b255169d55 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py @@ -1871,9 +1871,11 @@ def _process_port(port, ports, ancillary_ports): iface_id = self.int_br.portid_from_external_ids( port['external_ids']) if iface_id: - if port['ofport'] == ovs_lib.UNASSIGNED_OFPORT: - LOG.debug("Port %s not ready yet on the bridge", - iface_id) + if port['ofport'] in (ovs_lib.UNASSIGNED_OFPORT, + ovs_lib.INVALID_OFPORT): + LOG.debug("Port %s not ready yet on the bridge " + "(ofport=%s)", + iface_id, port['ofport']) ports_not_ready_yet.add(port['name']) return # check if port belongs to ancillary bridge From 13b62f46b698d5f2da3c80967b8efbf9d2ae20f4 Mon Sep 17 00:00:00 2001 From: Elvira Garcia Date: Tue, 9 Jun 2026 18:26:36 +0200 Subject: [PATCH 32/32] Add PVLAN extension policy rules The PVLAN service plugin did not have the needed policies for the management of the new network and port attributes. It is needed to have the policies in order to have consistent permissions across the different roles and users in a deployment. Assisted-by: Claude Opus 4.6 Related-Bug: 2138746 Change-Id: If4583d20f43e960fd858bc867ab6a59b3b74b397 Signed-off-by: Elvira Garcia --- neutron/conf/policies/__init__.py | 2 + neutron/conf/policies/pvlan.py | 118 +++++ .../tests/unit/conf/policies/test_pvlan.py | 416 ++++++++++++++++++ 3 files changed, 536 insertions(+) create mode 100644 neutron/conf/policies/pvlan.py create mode 100644 neutron/tests/unit/conf/policies/test_pvlan.py diff --git a/neutron/conf/policies/__init__.py b/neutron/conf/policies/__init__.py index 25508e610ca..b4ca60d9031 100644 --- a/neutron/conf/policies/__init__.py +++ b/neutron/conf/policies/__init__.py @@ -37,6 +37,7 @@ from neutron.conf.policies import network_segment_range from neutron.conf.policies import port from neutron.conf.policies import port_bindings +from neutron.conf.policies import pvlan from neutron.conf.policies import qos from neutron.conf.policies import quotas from neutron.conf.policies import rbac @@ -74,6 +75,7 @@ def list_rules(): network_segment_range.list_rules(), port_bindings.list_rules(), port.list_rules(), + pvlan.list_rules(), qos.list_rules(), quotas.list_rules(), rbac.list_rules(), diff --git a/neutron/conf/policies/pvlan.py b/neutron/conf/policies/pvlan.py new file mode 100644 index 00000000000..8c7fe3021ed --- /dev/null +++ b/neutron/conf/policies/pvlan.py @@ -0,0 +1,118 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron_lib.policy import rules as lib_rules +from oslo_policy import policy + + +PORT_COLLECTION_PATH = '/ports' +PORT_RESOURCE_PATH = '/ports/{id}' + +NETWORK_COLLECTION_PATH = '/networks' +NETWORK_RESOURCE_PATH = '/networks/{id}' + +PORT_ACTION_POST: list[policy.Operation] = [ + {'method': 'POST', 'path': PORT_COLLECTION_PATH}, +] +PORT_ACTION_PUT: list[policy.Operation] = [ + {'method': 'PUT', 'path': PORT_RESOURCE_PATH}, +] +PORT_ACTION_GET: list[policy.Operation] = [ + {'method': 'GET', 'path': PORT_COLLECTION_PATH}, + {'method': 'GET', 'path': PORT_RESOURCE_PATH}, +] + +NETWORK_ACTION_POST: list[policy.Operation] = [ + {'method': 'POST', 'path': NETWORK_COLLECTION_PATH}, +] +NETWORK_ACTION_PUT: list[policy.Operation] = [ + {'method': 'PUT', 'path': NETWORK_RESOURCE_PATH}, +] +NETWORK_ACTION_GET: list[policy.Operation] = [ + {'method': 'GET', 'path': NETWORK_COLLECTION_PATH}, + {'method': 'GET', 'path': NETWORK_RESOURCE_PATH}, +] + +rules = [ + # Port PVLAN attributes + policy.DocumentedRuleDefault( + name='create_port:pvlan_type', + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, + scope_types=['project'], + description='Specify ``pvlan_type`` attribute when creating a port', + operations=PORT_ACTION_POST, + ), + policy.DocumentedRuleDefault( + name='create_port:pvlan_community', + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, + scope_types=['project'], + description=( + 'Specify ``pvlan_community`` attribute when creating a port' + ), + operations=PORT_ACTION_POST, + ), + policy.DocumentedRuleDefault( + name='update_port:pvlan_type', + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, + scope_types=['project'], + description='Update ``pvlan_type`` attribute of a port', + operations=PORT_ACTION_PUT, + ), + policy.DocumentedRuleDefault( + name='update_port:pvlan_community', + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, + scope_types=['project'], + description='Update ``pvlan_community`` attribute of a port', + operations=PORT_ACTION_PUT, + ), + policy.DocumentedRuleDefault( + name='get_port:pvlan_type', + check_str=lib_rules.ADMIN_OR_PROJECT_READER, + scope_types=['project'], + description='Get ``pvlan_type`` attribute of a port', + operations=PORT_ACTION_GET, + ), + policy.DocumentedRuleDefault( + name='get_port:pvlan_community', + check_str=lib_rules.ADMIN_OR_PROJECT_READER, + scope_types=['project'], + description='Get ``pvlan_community`` attribute of a port', + operations=PORT_ACTION_GET, + ), + + # Network PVLAN attribute + policy.DocumentedRuleDefault( + name='create_network:pvlan', + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, + scope_types=['project'], + description='Specify ``pvlan`` attribute when creating a network', + operations=NETWORK_ACTION_POST, + ), + policy.DocumentedRuleDefault( + name='update_network:pvlan', + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, + scope_types=['project'], + description='Update ``pvlan`` attribute of a network', + operations=NETWORK_ACTION_PUT, + ), + policy.DocumentedRuleDefault( + name='get_network:pvlan', + check_str=lib_rules.ADMIN_OR_PROJECT_READER, + scope_types=['project'], + description='Get ``pvlan`` attribute of a network', + operations=NETWORK_ACTION_GET, + ), +] + + +def list_rules(): + return rules diff --git a/neutron/tests/unit/conf/policies/test_pvlan.py b/neutron/tests/unit/conf/policies/test_pvlan.py new file mode 100644 index 00000000000..1f0f8fe51a2 --- /dev/null +++ b/neutron/tests/unit/conf/policies/test_pvlan.py @@ -0,0 +1,416 @@ +# Copyright (c) 2026 Red Hat, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo_policy import policy as base_policy + +from neutron import policy +from neutron.tests.unit.conf.policies import test_base as base + + +class PvlanAPITestCase(base.PolicyBaseTestCase): + + def setUp(self): + super().setUp() + self.target = {'project_id': self.project_id} + self.alt_target = {'project_id': self.alt_project_id} + + +class SystemAdminTests(PvlanAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.system_admin_ctx + + # Port attributes -- pvlan_type and pvlan_community share the same + # check_str, so they are tested together here. + + def test_create_port_pvlan(self): + for attr in ('pvlan_type', 'pvlan_community'): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'create_port:%s' % attr, self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'create_port:%s' % attr, self.alt_target) + + def test_update_port_pvlan(self): + for attr in ('pvlan_type', 'pvlan_community'): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_port:%s' % attr, self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_port:%s' % attr, self.alt_target) + + def test_get_port_pvlan(self): + for attr in ('pvlan_type', 'pvlan_community'): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'get_port:%s' % attr, self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'get_port:%s' % attr, self.alt_target) + + # Network attributes + + def test_create_network_pvlan(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'create_network:pvlan', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'create_network:pvlan', self.alt_target) + + def test_update_network_pvlan(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_network:pvlan', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_network:pvlan', self.alt_target) + + def test_get_network_pvlan(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'get_network:pvlan', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'get_network:pvlan', self.alt_target) + + +class SystemMemberTests(SystemAdminTests): + + def setUp(self): + super().setUp() + self.context = self.system_member_ctx + + +class SystemReaderTests(SystemMemberTests): + + def setUp(self): + super().setUp() + self.context = self.system_reader_ctx + + +class AdminTests(PvlanAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.project_admin_ctx + + # Port attributes + + def test_create_port_pvlan_type(self): + self.assertTrue( + policy.enforce( + self.context, 'create_port:pvlan_type', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'create_port:pvlan_type', self.alt_target)) + + def test_create_port_pvlan_community(self): + self.assertTrue( + policy.enforce( + self.context, 'create_port:pvlan_community', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'create_port:pvlan_community', self.alt_target)) + + def test_update_port_pvlan_type(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:pvlan_type', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'update_port:pvlan_type', self.alt_target)) + + def test_update_port_pvlan_community(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:pvlan_community', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'update_port:pvlan_community', self.alt_target)) + + def test_get_port_pvlan_type(self): + self.assertTrue( + policy.enforce( + self.context, 'get_port:pvlan_type', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'get_port:pvlan_type', self.alt_target)) + + def test_get_port_pvlan_community(self): + self.assertTrue( + policy.enforce( + self.context, 'get_port:pvlan_community', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'get_port:pvlan_community', self.alt_target)) + + # Network attributes + + def test_create_network_pvlan(self): + self.assertTrue( + policy.enforce( + self.context, 'create_network:pvlan', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'create_network:pvlan', self.alt_target)) + + def test_update_network_pvlan(self): + self.assertTrue( + policy.enforce( + self.context, 'update_network:pvlan', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'update_network:pvlan', self.alt_target)) + + def test_get_network_pvlan(self): + self.assertTrue( + policy.enforce( + self.context, 'get_network:pvlan', self.target)) + self.assertTrue( + policy.enforce( + self.context, 'get_network:pvlan', self.alt_target)) + + +class ProjectManagerTests(PvlanAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.project_manager_ctx + + # Port attributes + + def test_create_port_pvlan_type(self): + self.assertTrue( + policy.enforce( + self.context, 'create_port:pvlan_type', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:pvlan_type', self.alt_target) + + def test_create_port_pvlan_community(self): + self.assertTrue( + policy.enforce( + self.context, 'create_port:pvlan_community', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:pvlan_community', self.alt_target) + + def test_update_port_pvlan_type(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:pvlan_type', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:pvlan_type', self.alt_target) + + def test_update_port_pvlan_community(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:pvlan_community', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:pvlan_community', self.alt_target) + + def test_get_port_pvlan_type(self): + self.assertTrue( + policy.enforce( + self.context, 'get_port:pvlan_type', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'get_port:pvlan_type', self.alt_target) + + def test_get_port_pvlan_community(self): + self.assertTrue( + policy.enforce( + self.context, 'get_port:pvlan_community', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'get_port:pvlan_community', self.alt_target) + + # Network attributes + + def test_create_network_pvlan(self): + self.assertTrue( + policy.enforce( + self.context, 'create_network:pvlan', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_network:pvlan', self.alt_target) + + def test_update_network_pvlan(self): + self.assertTrue( + policy.enforce( + self.context, 'update_network:pvlan', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_network:pvlan', self.alt_target) + + def test_get_network_pvlan(self): + self.assertTrue( + policy.enforce( + self.context, 'get_network:pvlan', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'get_network:pvlan', self.alt_target) + + +class ProjectMemberTests(ProjectManagerTests): + + def setUp(self): + super().setUp() + self.context = self.project_member_ctx + + +class ProjectReaderTests(ProjectMemberTests): + + def setUp(self): + super().setUp() + self.context = self.project_reader_ctx + + def test_create_port_pvlan_type(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:pvlan_type', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:pvlan_type', self.alt_target) + + def test_create_port_pvlan_community(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:pvlan_community', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:pvlan_community', self.alt_target) + + def test_update_port_pvlan_type(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:pvlan_type', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:pvlan_type', self.alt_target) + + def test_update_port_pvlan_community(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:pvlan_community', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:pvlan_community', self.alt_target) + + def test_create_network_pvlan(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_network:pvlan', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_network:pvlan', self.alt_target) + + def test_update_network_pvlan(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_network:pvlan', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_network:pvlan', self.alt_target) + + +class ServiceRoleTests(PvlanAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.service_ctx + + # Port attributes -- pvlan_type and pvlan_community share the same + # check_str, so they are tested together here. + + def test_create_port_pvlan(self): + for attr in ('pvlan_type', 'pvlan_community'): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:%s' % attr, self.target) + + def test_update_port_pvlan(self): + for attr in ('pvlan_type', 'pvlan_community'): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:%s' % attr, self.target) + + def test_get_port_pvlan(self): + for attr in ('pvlan_type', 'pvlan_community'): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'get_port:%s' % attr, self.target) + + # Network attributes + + def test_create_network_pvlan(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_network:pvlan', self.target) + + def test_update_network_pvlan(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_network:pvlan', self.target) + + def test_get_network_pvlan(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'get_network:pvlan', self.target)