From 86416d28490d3cfcd1c116b4c765a7d483cfd58e Mon Sep 17 00:00:00 2001 From: Jaroslav Pulchart Date: Wed, 10 Sep 2025 14:35:41 +0200 Subject: [PATCH 01/63] ip_conntrack: include allowed_address_pairs in CT_MARK_INVALID cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent restarts, the "Clean conntrack entries with mark == CT_MARK_INVALID" routine only considers IPs from fixed_ips. Deployments that rely on allowed_address_pairs (both single IPs and CIDRs) are skipped, leaving stale invalid-marked entries in conntrack and causing drops (e.g., UDP DNS). This change extends the cleanup candidate list with the port’s allowed_address_pairs and switches to passing the full CIDR to conntrack, so both host (/32) and network prefixes (e.g., /20) are handled natively. Examples: conntrack -D -f ipv4 -m 0x1 -s 10.15.194.184/32 -w 1 conntrack -D -f ipv4 -m 0x1 -s 10.16.192.0/20 -w 1 Closes-Bug: #2122495 Change-Id: I6ed507df845d068e13955758be9b2325e206cb6c Signed-off-by: Jaroslav Pulchart Signed-off-by: lajoskatona --- neutron/agent/linux/ip_conntrack.py | 12 ++++++++++-- .../unit/agent/linux/test_iptables_firewall.py | 16 ++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/neutron/agent/linux/ip_conntrack.py b/neutron/agent/linux/ip_conntrack.py index e73b6b984d8..ec087926cd3 100644 --- a/neutron/agent/linux/ip_conntrack.py +++ b/neutron/agent/linux/ip_conntrack.py @@ -141,12 +141,20 @@ def _get_conntrack_cmds(self, device_info_list, rule, remote_ip=None): {'dev': device_info['device'], 'zm': self._device_zone_map}) continue - ips = device_info.get('fixed_ips', []) + ips = ( + device_info.get('fixed_ips', []) + + [ + address_pair['ip_address'] + for address_pair in device_info.get( + 'allowed_address_pairs', [] + ) + ] + ) for ip in ips: net = netaddr.IPNetwork(ip) if str(net.version) not in ethertype: continue - ip_cmd = [str(net.ip), '-w', zone_id] + ip_cmd = [str(net), '-w', zone_id] if remote_ip and str( netaddr.IPNetwork(remote_ip).version) in ethertype: if rule.get('direction') == 'ingress': diff --git a/neutron/tests/unit/agent/linux/test_iptables_firewall.py b/neutron/tests/unit/agent/linux/test_iptables_firewall.py index 523467e9a01..0c6888dfa31 100644 --- a/neutron/tests/unit/agent/linux/test_iptables_firewall.py +++ b/neutron/tests/unit/agent/linux/test_iptables_firewall.py @@ -1480,15 +1480,15 @@ def _test_remove_conntrack_entries(self, ethertype, protocol, direction, if ethertype == 'IPv4': cmd.extend(['-f', 'ipv4']) if direction == 'ingress': - cmd.extend(['-d', '10.0.0.1']) + cmd.extend(['-d', '10.0.0.1/32']) else: - cmd.extend(['-s', '10.0.0.1']) + cmd.extend(['-s', '10.0.0.1/32']) else: cmd.extend(['-f', 'ipv6']) if direction == 'ingress': - cmd.extend(['-d', 'fe80::1']) + cmd.extend(['-d', 'fe80::1/128']) else: - cmd.extend(['-s', 'fe80::1']) + cmd.extend(['-s', 'fe80::1/128']) cmd.extend(['-w', ct_zone]) calls = [ @@ -1572,7 +1572,7 @@ def _test_remove_conntrack_entries_for_port_sec_group_change(self, while not self.firewall.ipconntrack._queue.empty(): self.firewall.ipconntrack._process_queue() calls = self._get_expected_conntrack_calls( - [('ipv4', '10.0.0.1'), ('ipv6', 'fe80::1')], ct_zone) + [('ipv4', '10.0.0.1/32'), ('ipv6', 'fe80::1/128')], ct_zone) self.utils_exec.assert_has_calls(calls) def test_remove_conntrack_entries_for_sg_member_changed_ipv4(self): @@ -1635,8 +1635,8 @@ def _test_remove_conntrack_entries_sg_member_changed(self, ethertype, # check conntrack deletion from '10.0.0.1' to '10.0.0.2' or # from 'fe80::1' to 'fe80::2' - ips = {"ipv4": ['10.0.0.1', '10.0.0.2'], - "ipv6": ['fe80::1', 'fe80::2']} + ips = {"ipv4": ['10.0.0.1/32', '10.0.0.2'], + "ipv6": ['fe80::1/128', 'fe80::2']} calls = [] # process conntrack updates in the queue while not self.firewall.ipconntrack._queue.empty(): @@ -1929,7 +1929,7 @@ def _test_delete_conntrack_from_delete_port(self, ct_zone): while not self.firewall.ipconntrack._queue.empty(): self.firewall.ipconntrack._process_queue() calls = self._get_expected_conntrack_calls( - [('ipv4', '10.0.0.1'), ('ipv6', 'fe80::1')], ct_zone) + [('ipv4', '10.0.0.1/32'), ('ipv6', 'fe80::1/128')], ct_zone) self.utils_exec.assert_has_calls(calls) def test_remove_unknown_port(self): From 0880e8724e4d9bc8bfcbe68ed24b01211cbb412c Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Fri, 24 Apr 2026 16:07:26 -0400 Subject: [PATCH 02/63] Remove NEUTRON_DEPLOY_MOD_WSGI from zuul jobs Devstack no longer supports it after eventlet deprecation so we can safely remove it. TrivialFix Change-Id: Ie69c99933d6a75771f24b144006e2e52b4aeb6f5 Signed-off-by: Brian Haley --- zuul.d/base.yaml | 1 - zuul.d/grenade.yaml | 3 --- zuul.d/rally.yaml | 2 -- zuul.d/tempest-multinode.yaml | 4 ---- zuul.d/tempest-singlenode.yaml | 7 ------- 5 files changed, 17 deletions(-) diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index 798693282c9..5be7a4af83e 100644 --- a/zuul.d/base.yaml +++ b/zuul.d/base.yaml @@ -56,7 +56,6 @@ devstack_localrc: INSTALL_TESTONLY_PACKAGES: true DATABASE_PASSWORD: stackdb - NEUTRON_DEPLOY_MOD_WSGI: true tox_envlist: dsvm-functional-gate tox_environment: PYTHONPATH: /opt/stack/data/venv/lib/python3.12/site-packages diff --git a/zuul.d/grenade.yaml b/zuul.d/grenade.yaml index 7f17c3728e1..43aeb4d9bdc 100644 --- a/zuul.d/grenade.yaml +++ b/zuul.d/grenade.yaml @@ -48,7 +48,6 @@ Q_AGENT: openvswitch Q_ML2_TENANT_NETWORK_TYPE: vxlan Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch - NEUTRON_DEPLOY_MOD_WSGI: True devstack_services: etcd: false br-ex-tcpdump: true @@ -129,7 +128,6 @@ Q_AGENT: openvswitch Q_ML2_TENANT_NETWORK_TYPE: vxlan Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch - NEUTRON_DEPLOY_MOD_WSGI: True devstack_services: etcd: false br-ex-tcpdump: true @@ -308,7 +306,6 @@ OVN_L3_CREATE_PUBLIC_NETWORK: true OVN_DBS_LOG_LEVEL: dbg OVN_IGMP_SNOOPING_ENABLE: True - NEUTRON_DEPLOY_MOD_WSGI: True devstack_plugins: neutron: https://opendev.org/openstack/neutron neutron-tempest-plugin: https://opendev.org/openstack/neutron-tempest-plugin diff --git a/zuul.d/rally.yaml b/zuul.d/rally.yaml index ca187198def..7d65e837aa4 100644 --- a/zuul.d/rally.yaml +++ b/zuul.d/rally.yaml @@ -10,7 +10,6 @@ Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch Q_AGENT: openvswitch KEYSTONE_ADMIN_ENDPOINT: true - NEUTRON_DEPLOY_MOD_WSGI: true rally_task: rally-jobs/task-neutron.yaml devstack_plugins: osprofiler: https://opendev.org/openstack/osprofiler @@ -185,7 +184,6 @@ OVN_BUILD_FROM_SOURCE: True OVN_BRANCH: "branch-24.03" OVS_BRANCH: "branch-3.3" - NEUTRON_DEPLOY_MOD_WSGI: true devstack_local_conf: post-config: "${RALLY_CONF_DIR}/${RALLY_CONF_FILE}": diff --git a/zuul.d/tempest-multinode.yaml b/zuul.d/tempest-multinode.yaml index cdfe04cf445..dcb1407e647 100644 --- a/zuul.d/tempest-multinode.yaml +++ b/zuul.d/tempest-multinode.yaml @@ -91,7 +91,6 @@ Q_ML2_TENANT_NETWORK_TYPE: vxlan Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch Q_AGENT: openvswitch - NEUTRON_DEPLOY_MOD_WSGI: true devstack_services: br-ex-tcpdump: true br-int-flows: true @@ -219,7 +218,6 @@ Q_ML2_TENANT_NETWORK_TYPE: vxlan Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch Q_AGENT: openvswitch - NEUTRON_DEPLOY_MOD_WSGI: true devstack_plugins: neutron: https://opendev.org/openstack/neutron.git devstack_services: @@ -309,7 +307,6 @@ Q_ML2_TENANT_NETWORK_TYPE: vxlan Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch Q_AGENT: openvswitch - NEUTRON_DEPLOY_MOD_WSGI: true devstack_services: br-ex-tcpdump: true br-int-flows: true @@ -454,7 +451,6 @@ BUILD_TIMEOUT: 784 ENABLE_TLS: True OVN_IGMP_SNOOPING_ENABLE: True - NEUTRON_DEPLOY_MOD_WSGI: true devstack_plugins: neutron: https://opendev.org/openstack/neutron neutron-tempest-plugin: https://opendev.org/openstack/neutron-tempest-plugin diff --git a/zuul.d/tempest-singlenode.yaml b/zuul.d/tempest-singlenode.yaml index ba44a1b1b49..6b93e23d940 100644 --- a/zuul.d/tempest-singlenode.yaml +++ b/zuul.d/tempest-singlenode.yaml @@ -16,7 +16,6 @@ CIRROS_VERSION: 0.6.3 DEFAULT_IMAGE_NAME: cirros-0.6.3-x86_64-uec DEFAULT_IMAGE_FILE_NAME: cirros-0.6.3-x86_64-uec.tar.gz - NEUTRON_DEPLOY_MOD_WSGI: true devstack_plugins: neutron: https://opendev.org/openstack/neutron.git devstack_services: @@ -180,7 +179,6 @@ CIRROS_VERSION: 0.6.3 DEFAULT_IMAGE_NAME: cirros-0.6.3-x86_64-uec DEFAULT_IMAGE_FILE_NAME: cirros-0.6.3-x86_64-uec.tar.gz - NEUTRON_DEPLOY_MOD_WSGI: true Q_ML2_PLUGIN_MECHANISM_DRIVERS: ovn,logger devstack_plugins: neutron: https://opendev.org/openstack/neutron.git @@ -303,7 +301,6 @@ CIRROS_VERSION: 0.6.3 DEFAULT_IMAGE_NAME: cirros-0.6.3-x86_64-uec DEFAULT_IMAGE_FILE_NAME: cirros-0.6.3-x86_64-uec.tar.gz - NEUTRON_DEPLOY_MOD_WSGI: true Q_ML2_PLUGIN_MECHANISM_DRIVERS: ovn,logger devstack_plugins: neutron: https://opendev.org/openstack/neutron.git @@ -388,7 +385,6 @@ CIRROS_VERSION: 0.6.3 DEFAULT_IMAGE_NAME: cirros-0.6.3-x86_64-uec DEFAULT_IMAGE_FILE_NAME: cirros-0.6.3-x86_64-uec.tar.gz - NEUTRON_DEPLOY_MOD_WSGI: true Q_ML2_PLUGIN_MECHANISM_DRIVERS: ovn,logger devstack_plugins: neutron: https://opendev.org/openstack/neutron.git @@ -496,7 +492,6 @@ # sources, new job, inheriting from this one, should be created and # that option should be overwritten there. OVN_BUILD_FROM_SOURCE: False - NEUTRON_DEPLOY_MOD_WSGI: true devstack_plugins: neutron: https://opendev.org/openstack/neutron zuul_copy_output: @@ -754,7 +749,6 @@ CIRROS_VERSION: 0.6.3 DEFAULT_IMAGE_NAME: cirros-0.6.3-x86_64-uec DEFAULT_IMAGE_FILE_NAME: cirros-0.6.3-x86_64-uec.tar.gz - NEUTRON_DEPLOY_MOD_WSGI: true Q_ML2_PLUGIN_MECHANISM_DRIVERS: ovn devstack_plugins: neutron: https://opendev.org/openstack/neutron.git @@ -824,7 +818,6 @@ CIRROS_VERSION: 0.6.3 DEFAULT_IMAGE_NAME: cirros-0.6.3-x86_64-uec DEFAULT_IMAGE_FILE_NAME: cirros-0.6.3-x86_64-uec.tar.gz - NEUTRON_DEPLOY_MOD_WSGI: true Q_ML2_PLUGIN_MECHANISM_DRIVERS: ovn,logger devstack_plugins: neutron: https://opendev.org/openstack/neutron.git From bf41c5493f6e0cabad770e22eb2321c1560afa88 Mon Sep 17 00:00:00 2001 From: Dai Dang Van Date: Fri, 30 May 2025 17:38:42 +0700 Subject: [PATCH 03/63] 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 144300867d9426362b1dc73926fb5022984a60fd Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Wed, 29 Apr 2026 20:18:18 -0400 Subject: [PATCH 04/63] Remove setting of enforce_scope in unit tests Remove it from two tests that were setting it, otherwise it is unused in Neutron as we are already doing policy scope enforcement. It is safe to remove as oslo.policy is finally removing the option in [0]. [0] https://review.opendev.org/c/openstack/oslo.policy/+/986475 Change-Id: If042296d49d731e1412858d672b2727163fde901 Signed-off-by: Brian Haley --- neutron/tests/unit/test_policy.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/neutron/tests/unit/test_policy.py b/neutron/tests/unit/test_policy.py index e1ba5865423..3172ae7e98c 100644 --- a/neutron/tests/unit/test_policy.py +++ b/neutron/tests/unit/test_policy.py @@ -189,8 +189,6 @@ def test_check_non_existent_action(self): def test_check_invalid_scope(self): cfg.CONF.set_override( 'enforce_new_defaults', True, group='oslo_policy') - cfg.CONF.set_override( - 'enforce_scope', True, group='oslo_policy') action = "get_example:only_project_user_allowed" target = {'project_id': 'some-project'} system_admin_ctx = context.Context( @@ -222,8 +220,6 @@ def test_enforce_http_false(self): def test_enforce_invalid_scope(self): cfg.CONF.set_override( 'enforce_new_defaults', True, group='oslo_policy') - cfg.CONF.set_override( - 'enforce_scope', True, group='oslo_policy') action = "get_example:only_project_user_allowed" target = {'project_id': 'some-project'} system_admin_ctx = context.Context( From 7bbadcb63a2e62840c758a620888ef1236383821 Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Mon, 1 Jun 2026 22:35:37 -0400 Subject: [PATCH 05/63] Fix issues in _check_router_interface_not_in_use() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There are two distinct issues here: - Uncommitted reads (TOCTOU): The method creates its own get_admin_context() instead of accepting the caller’s. Any changes in the caller’s transaction that have not been committed yet are invisible to these queries — a floating IP associated to the subnet’s port by the caller would not be seen. Change to use passed context object. - Unguarded in_([]) with empty list: When the router has no floating IPs, fip_ids = [], and PortForwarding.get_objects(context, floatingip_id=[]) generates WHERE floatingip_id IN () — invalid SQL on some DB engines. Change to initialize pf_objs to [] if there are no floating IPs. TrivialFix Assisted-by: Claude Sonnet 4.6 Signed-off-by: Brian Haley Change-Id: I1e68bc22b8c2d334039326b9b3aff44296abcb2d --- neutron/db/l3_db.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/neutron/db/l3_db.py b/neutron/db/l3_db.py index a215e70dc1d..d1a3a8235ed 100644 --- a/neutron/db/l3_db.py +++ b/neutron/db/l3_db.py @@ -1105,11 +1105,12 @@ def _add_router_port(self, context, port, router, device_owner): context, port['id'], {'port': {'device_id': router.id, 'device_owner': device_owner}}) - def _check_router_interface_not_in_use(self, router_id, subnet): - context = n_ctx.get_admin_context() + def _check_router_interface_not_in_use(self, context, router_id, subnet): + admin_ctx = context.elevated() subnet_cidr = netaddr.IPNetwork(subnet['cidr']) - fip_objs = l3_obj.FloatingIP.get_objects(context, router_id=router_id) + fip_objs = l3_obj.FloatingIP.get_objects(admin_ctx, + router_id=router_id) pf_plugin = directory.get_plugin(plugin_constants.PORTFORWARDING) subnet_port_ids = [ port.id for port in @@ -1118,7 +1119,7 @@ def _check_router_interface_not_in_use(self, router_id, subnet): if pf_plugin: fip_ids = [fip_obj.id for fip_obj in fip_objs] pf_objs = port_forwarding.PortForwarding.get_objects( - context, floatingip_id=fip_ids) + admin_ctx, floatingip_id=fip_ids) if fip_ids else [] for pf_obj in pf_objs: if (pf_obj.internal_ip_address and pf_obj.internal_ip_address in subnet_cidr): @@ -1148,7 +1149,7 @@ def _confirm_router_interface_not_in_use(self, context, router_id, raise e.errors[0].error raise l3_exc.RouterInUse(router_id=router_id, reason=e) - self._check_router_interface_not_in_use(router_id, subnet) + self._check_router_interface_not_in_use(context, router_id, subnet) def _remove_interface_by_port(self, context, router_id, port_id, subnet_id, owner): From d10035dc0c04c662fb1ef73ec1f30f9754e87f2d Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 3 Jun 2026 11:43:44 +0200 Subject: [PATCH 06/63] 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 07/63] 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 08/63] 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 09/63] 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 10/63] 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 7a70217d78bd7f90b8c9de4d9341715842d2ee12 Mon Sep 17 00:00:00 2001 From: Felix Moebius Date: Thu, 7 May 2026 11:59:35 +0200 Subject: [PATCH 11/63] [OVN] Fix race condition during floating ip deletion The delete path for floating ips currently only deletes the nat entry on the ovn side if it actually finds a nat entry for the floating ip. When quickly associating and then disassociating a floating ip through different neutron api instances, the nat entry may not yet have propagated from one api instance to the other through the northbound if load on the ovsdb is sufficiently high. Since the ovn revision entry is deleted anyways, the maintenance task has no chance of fixing it later on, leaving an orphaned nat entry in ovn which causes connectivity issues when the ip address gets reused. Keep the ovn revision entry in case we don't find a matching nat entry to give the maintenance task a chance to retry the deletion. Related-Bug: #1987530 Signed-off-by: Felix Moebius Change-Id: I6c598cbbca1e449cf67314d022f101f2f73cf1bc --- .../ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 5 ++++- .../ml2/drivers/ovn/mech_driver/test_mech_driver.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 c203a83f576..4d64808296f 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 @@ -1248,7 +1248,10 @@ def delete_floatingip(self, context, fip_id): with excutils.save_and_reraise_exception(): LOG.error('Unable to delete floating ip in gateway ' 'router. Error: %s', e) - db_rev.delete_revision(context, fip_id, ovn_const.TYPE_FLOATINGIPS) + db_rev.delete_revision(context, fip_id, ovn_const.TYPE_FLOATINGIPS) + else: + LOG.warning('Unable to delete floating ip in gateway router. ' + 'Floating ip %s not found.', fip_id) def disassociate_floatingip(self, context, floatingip, router_id): lrouter = utils.ovn_name(router_id) 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 c47984af255..b957161b945 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 @@ -2831,6 +2831,17 @@ def test__update_dnat_entry_if_needed_down_dvr(self): def test__update_dnat_entry_if_needed_down_no_dvr(self): self._test__update_dnat_entry_if_needed(up=False, dvr=False) + @mock.patch.object(ovn_revision_numbers_db, 'delete_revision') + @mock.patch.object(ovn_client.OVNClient, '_delete_floatingip') + def test_delete_floatingip_not_exist_in_ovn(self, mock_del_fip, + mock_del_rev): + fip_id = uuidutils.generate_uuid() + self.nb_ovn.get_floatingip.return_value = None + self.mech_driver._ovn_client.delete_floatingip(self.context, fip_id) + # No matching nat entry found in ovn so revision row must be retained + mock_del_rev.assert_not_called() + mock_del_fip.assert_not_called() + @mock.patch('neutron.objects.router.Router.get_object') @mock.patch('neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.' 'ovn_client.OVNClient._get_router_ports') From b50aa8525d9f91b70f7847a86f74b1a7173041ef Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Thu, 4 Jun 2026 23:01:08 +0900 Subject: [PATCH 12/63] 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 342bca3a315901b8549e0e65932277b93851c62a Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Sat, 6 Jun 2026 22:53:48 -0500 Subject: [PATCH 13/63] Add a neutron dependency change detection tool When dependencies change in neutron, it occasionally breaks the gate. This is a quick-and-dirty tool that will parse neutron, requiremnts, and ovn/ovs branches and print out dependency changes that happend in a date or neutron commit hash range. Example run for finding a recent gate failure: /tools/dep_version_diff.py --start 2026-06-04 --branch-commits Dependency changes (neutron direct deps) start : 2026-06-04 -> requirements bdc4e18f (2026-06-03) end : HEAD -> requirements e4a4f7d3 (2026-06-06) neutron deps read from: 8e0d77da Changed: webob 1.8.9 -> 1.8.10 2026-06-06 [bot] Updated from generate-constraints (requirements@e4a4f7d3) OVS/OVN binary branches: OVN_BRANCH branch-26.03 (unchanged, branch (moving)) 6 commit(s) on branch-26.03: cbb71611b northd: Clear stale LSP tags on tag_request removal. e43a84b21 tests: Add macro for running UDP "echo" service. 3888f8944 northd: Ignore LRP.status write-only column in northd. 7aa8875ca ovn-nbctl: Display tier in "acl-list" for multi-tier ACLs. 2a0ca98b7 ovn-nbctl: Display peer info in "show" for router ports. 9f04b8c50 tests: Fix flaky "Loadbalancer add-route option" system test. OVS_BRANCH branch-3.7 (unchanged, branch (moving)) 6 commit(s) on branch-3.7: ea7f21658 packets: Add support for unicast ND NS compose. e9082e2a6 ofproto-dpif-xlate: Track the last action through normal pipeline. eb0555761 ofproto-dpif-xlate: Use datapath actions for reversibility check. 5b2f54ea0 tests: ovsdb: Fix negotiation error check with OpenSSL 4.0. 70a73ab72 dpdk: Use DPDK 25.11.2 release for OVS 3.7. 04b05b31a ofproto-dpif: Fix bundle floodable flag when disabling STP/RSTP. It can also take zuul job names as filters, e.g. --job neutron-functional-with-pyroute2-master won't show pyroute2 requirements changes. Assisted-By: Claude Opus 4.6 Change-Id: I7403071949b1bfbdfa78aa0fd59739c1a42a8a6e Signed-off-by: Terry Wilson --- tools/dep_version_diff.py | 889 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 889 insertions(+) create mode 100755 tools/dep_version_diff.py diff --git a/tools/dep_version_diff.py b/tools/dep_version_diff.py new file mode 100755 index 00000000000..a9fbccc9723 --- /dev/null +++ b/tools/dep_version_diff.py @@ -0,0 +1,889 @@ +#!/usr/bin/env python3 +""" +dep_version_diff.py: Show dependency version changes that affect a neutron CI +job between two points in time or neutron commits. + +The tool understands two constraint-update pipelines: + - OpenStack projects: manual per-release patch to requirements repo + - External packages: generate-constraints bot sweeps PyPI periodically + +Usage examples: + # Changes over a date range (requirements repo timeline): + ./tools/dep_version_diff.py --start 2026-05-25 --end 2026-06-06 + + # Between two neutron commits (derives dates, finds requirements state): + ./tools/dep_version_diff.py --neutron-start abc1234 --neutron-end def5678 + + # Filter to packages relevant to a specific job: + ./tools/dep_version_diff.py --start 2026-05-25 --job neutron-functional + ./tools/dep_version_diff.py --start 2026-05-25 \\ + --job neutron-functional-with-neutron-lib-master + + # Show all constraint changes (not just neutron deps): + ./tools/dep_version_diff.py --start 2026-05-25 --all + + # Use a shared workspace for companion repos (clones missing repos on first + # run, reuses existing clones on subsequent runs): + ./tools/dep_version_diff.py --start 2026-06-05 --branch-commits \\ + --path /tmp/neutron-dep-workspace/ +""" + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +DEFAULT_NEUTRON_REPO = Path(__file__).resolve().parents[1] +_GIT_BIN = shutil.which('git') or 'git' + +REPO_URLS = { + 'requirements': 'https://opendev.org/openstack/requirements', + 'ovn': 'https://github.com/ovn-org/ovn', + 'ovs': 'https://github.com/openvswitch/ovs', +} + + +def find_repo(name, neutron_repo): + """Search common locations for a companion repo. + + Search order: + 1. Sibling of the neutron repo — covers both ~/src/ and /opt/stack/ + with a single rule since devstack clones everything into the same dir. + 2. ~/src/ — explicit fallback when neutron lives elsewhere. + 3. /opt/stack/ — explicit devstack fallback. + """ + candidates = [ + neutron_repo.parent / name, + Path.home() / 'src' / name, + Path('/opt/stack') / name, + ] + for p in candidates: + if (p / '.git').exists(): + return p + return None + + +def ensure_repo(name, explicit_path, neutron_repo, clone_to=None): + """Return a usable repo Path, discovering or cloning if needed. + + Args: + name: short name used for discovery and cloning (e.g. 'ovn') + explicit_path: value from the --xxx-repo CLI flag (may be None) + neutron_repo: used as the anchor for sibling discovery + clone_to: if set (--path), use/clone repos here; skips discovery + + When --path is given it acts as a self-contained workspace: + - if the repo already exists there, reuse it + - otherwise clone it from upstream + Auto-discovery (sibling / ~/src / /opt/stack) is only used when + --path is not given. + """ + # Explicit path always wins if it points at a real git repo + if explicit_path and (explicit_path / '.git').exists(): + return explicit_path + + # --path overrides discovery: use the workspace exclusively + if clone_to and name in REPO_URLS: + target = Path(clone_to) / name + if not (target / '.git').exists(): + print(f' cloning {name} from {REPO_URLS[name]} → {target}', + file=sys.stderr) + subprocess.run( # noqa: S603 + [_GIT_BIN, 'clone', REPO_URLS[name], str(target)], + check=True) + return target + + # Auto-discover in common locations + found = find_repo(name, neutron_repo) + if found: + return found + + return explicit_path # may be None — callers handle that + + +# --------------------------------------------------------------------------- +# Git helpers +# --------------------------------------------------------------------------- + +def _git(repo, *args): + r = subprocess.run( # noqa: S603 + [_GIT_BIN, '-C', str(repo)] + list(args), + capture_output=True, text=True, check=False) + return r.stdout.strip() if r.returncode == 0 else None + + +def resolve_ref(repo, ref_or_date): + """Resolve a date (YYYY-MM-DD) or git ref to a full commit hash.""" + if re.match(r'^\d{4}-\d{2}-\d{2}', ref_or_date): + # Date: find last commit on HEAD on or before midnight of that day. + for branch in ('HEAD', 'origin/master', 'origin/main'): + commit = _git(repo, 'rev-list', '-1', + f'--before={ref_or_date}T23:59:59', branch) + if commit: + return commit + return None + return _git(repo, 'rev-parse', '--verify', ref_or_date) + + +def commit_date(repo, ref): + return _git(repo, 'log', '-1', '--format=%cd', '--date=short', ref) + + +def file_at(repo, ref, path): + return _git(repo, 'show', f'{ref}:{path}') + + +def req_ref_for_date(req_repo, date_str): + """Find the requirements repo commit on or before the given date.""" + for branch in ('HEAD', 'origin/master', 'origin/main'): + commit = _git(req_repo, 'rev-list', '-1', + f'--before={date_str}T23:59:59', branch) + if commit: + return commit + return None + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + +def _norm(name): + """PEP 503 normalization: collapse [-_.] to '-' and lowercase.""" + return re.sub(r'[-_.]+', '-', name).lower() + + +def parse_upper_constraints(content): + """Return {normalized_name: version} from upper-constraints.txt content.""" + result = {} + for line in (content or '').splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + m = re.match(r'^([A-Za-z0-9_.\-]+)===(.+)$', line) + if m: + result[_norm(m.group(1))] = m.group(2) + return result + + +def parse_req_names(content): + """Return set of normalized package names from a requirements file.""" + names = set() + for line in (content or '').splitlines(): + line = line.strip() + if not line or line.startswith('#') or line.startswith('-'): + continue + m = re.match(r'^([A-Za-z0-9_.\-]+)', line) + if m: + names.add(_norm(m.group(1))) + return names + + +def neutron_dep_names(neutron_repo, ref): + """All direct dep names pulled in for the functional job at ref.""" + dep_files = [ + 'requirements.txt', + 'test-requirements.txt', + 'neutron/tests/functional/requirements.txt', + ] + names = set() + for f in dep_files: + names |= parse_req_names(file_at(neutron_repo, ref, f)) + return names + + +def from_source_packages(neutron_repo, ref, job_name): + """ + Return packages installed from source (not pinned by constraints) for + the given job. Walks the zuul.d/base.yaml job hierarchy collecting + required-projects, then maps repo names to package names. + """ + content = file_at(neutron_repo, ref, 'zuul.d/base.yaml') + if not content: + return set() + + # Simple regex-based YAML extraction to avoid requiring PyYAML. + # Pull every "name: " / "parent: " / "required-projects:" + # block without a full YAML parse. + jobs = {} # name -> {parent, required_projects} + current_job = None + in_req_projects = False + + for line in content.splitlines(): + # Detect start of a job block + m = re.match(r'^\s{4}name:\s+(\S+)', line) + if m: + current_job = m.group(1) + jobs.setdefault(current_job, {'parent': None, 'projects': []}) + in_req_projects = False + continue + + if current_job is None: + continue + + m = re.match(r'^\s{4}parent:\s+(\S+)', line) + if m: + jobs[current_job]['parent'] = m.group(1) + continue + + if re.match(r'^\s{4}required-projects:', line): + in_req_projects = True + continue + + # Once we hit another 4-space key, we're out of required-projects + if in_req_projects and re.match(r'^\s{4}\w', line): + in_req_projects = False + + if in_req_projects: + # Match "- openstack/foo" or "- name: openstack/foo" + m = (re.match(r'^\s+[-]\s+name:\s+(\S+)', line) or + re.match(r'^\s+[-]\s+(\S+)', line)) + if m: + jobs[current_job]['projects'].append(m.group(1)) + + # Walk the hierarchy to collect all required-projects for the target job + def collect(name, visited=None): + if visited is None: + visited = set() + if name in visited or name not in jobs: + return [] + visited.add(name) + projs = list(jobs[name]['projects']) + parent = jobs[name]['parent'] + if parent: + projs += collect(parent, visited) + return projs + + source_pkgs = set() + for repo_path in collect(job_name): + # e.g. "openstack/neutron-lib" -> "neutron-lib" + # "github.com/svinota/pyroute2" -> "pyroute2" + pkg = repo_path.rstrip('/').split('/')[-1] + source_pkgs.add(_norm(pkg)) + return source_pkgs + + +# --------------------------------------------------------------------------- +# OVS/OVN branch tracking +# --------------------------------------------------------------------------- + +def classify_ref(value): + """Return a stability label for an OVS/OVN branch value.""" + if re.match(r'^[0-9a-f]{7,40}$', value): + return 'commit (fixed)' + if re.match(r'^v?\d+\.\d+\.\d+', value): + return 'tag (fixed)' + return 'branch (moving)' + + +def _parse_zuul_job_branch_vars(content): + """ + Parse zuul.d yaml content and return + {job_name: {'parent': str|None, 'OVS_BRANCH': str, 'OVN_BRANCH': str}}. + Uses a line-by-line state machine to avoid requiring PyYAML. + """ + jobs = {} + current = None + in_vars = False + + for line in content.splitlines(): + # Job name — always at exactly 4 spaces + m = re.match(r'^ name:\s+(\S+)', line) + if m: + current = m.group(1) + jobs.setdefault(current, {'parent': None}) + in_vars = False + continue + + if current is None: + continue + + m = re.match(r'^ parent:\s+(\S+)', line) + if m: + jobs[current]['parent'] = m.group(1) + continue + + if re.match(r'^ vars:\s*$', line): + in_vars = True + continue + + # Another 4-space key ends the vars block + if in_vars and re.match(r'^ \w', line): + in_vars = False + + if in_vars: + m = re.match(r'^\s+(OVS_BRANCH|OVN_BRANCH):\s+"?([^"#\n]+?)"?\s*$', + line) + if m: + jobs[current][m.group(1)] = m.group(2).strip() + + return jobs + + +def get_ovs_ovn_branches(neutron_repo, ref, job_name=None): + """ + Return {'OVS_BRANCH': value, 'OVN_BRANCH': value} for the job at ref. + + Priority: + 1. Job vars in zuul.d files (child overrides parent) + 2. Defaults in tools/configure_for_func_testing.sh + """ + branches = {} + + if job_name: + # Collect all jobs from every zuul.d file + tree = _git(neutron_repo, 'ls-tree', '--name-only', ref, 'zuul.d/') + all_jobs = {} + for fname in (tree or '').splitlines(): + if fname.endswith(('.yaml', '.yml')): + content = file_at(neutron_repo, ref, fname) or '' + all_jobs.update(_parse_zuul_job_branch_vars(content)) + + # Walk the parent chain; child values win (already set → skip) + visited = set() + queue = [job_name] + while queue: + name = queue.pop(0) + if name in visited or name not in all_jobs: + continue + visited.add(name) + job = all_jobs[name] + for key in ('OVS_BRANCH', 'OVN_BRANCH'): + if key not in branches and key in job: + branches[key] = job[key] + if job.get('parent'): + queue.append(job['parent']) + + # Fall back to configure script defaults for any missing values + script = file_at(neutron_repo, ref, + 'tools/configure_for_func_testing.sh') or '' + for line in script.splitlines(): + m = re.match(r'(OVS_BRANCH|OVN_BRANCH)=\$\{\1:-([^}]+)\}', line) + if m and m.group(1) not in branches: + branches[m.group(1)] = m.group(2) + + return branches + + +def _merge_commit_info(repo, sha): + """ + Return (date, subject, sha8) for a commit, using the merge commit's date + and SHA (both on the first-parent chain and findable with plain git log) + but the patch commit's subject (cleaner than "Merge \"...\""). + Falls back to the commit itself if it has no second parent. + """ + date = _git(repo, 'log', '-1', '--format=%cd', '--date=short', sha) + # Try to get the subject from the second parent (the patch commit) + patch_subject = _git(repo, 'log', '-1', '--format=%s', f'{sha}^2') + subject = patch_subject if patch_subject else \ + _git(repo, 'log', '-1', '--format=%s', sha) + return date, subject, sha[:8] + + +def get_branch_commits(repo, branch, since_date, until_date): + """ + Return list of (sha8, subject) for commits on a branch between two dates. + Tries origin/ first, then as a local ref. + Returns None if the repo doesn't exist or the branch can't be found. + Returns [] if the branch exists but has no commits in range. + """ + if not Path(repo).exists(): + return None + for ref in (f'origin/{branch}', branch): + out = _git(repo, 'log', '--format=%h\t%s', + f'--since={since_date}T00:00:00', + f'--until={until_date}T23:59:59', + ref, '--') + if out is not None: + result = [] + for line in out.splitlines(): + if '\t' in line: + sha, subj = line.split('\t', 1) + result.append((sha, subj)) + return result + return None + + +def build_ovs_ovn_change_map(neutron_repo, start_ref, end_ref, job_name): + """ + Return {var_name: (date, subject, sha8, repo_name)} for OVS_BRANCH / + OVN_BRANCH changes between start_ref and end_ref. + """ + watch = ['tools/configure_for_func_testing.sh'] + # Also watch the zuul.d files that are likely to carry job-level vars + tree = _git(neutron_repo, 'ls-tree', '--name-only', end_ref, 'zuul.d/') + for fname in (tree or '').splitlines(): + if fname.endswith(('.yaml', '.yml')): + watch.append(fname) + + shas = _git(neutron_repo, 'log', '--first-parent', '--format=%H', + f'{start_ref}..{end_ref}', '--', *watch) + if not shas: + return {} + + repo_name = Path(neutron_repo).name + change_map = {} + + for sha in shas.splitlines(): + parent = _git(neutron_repo, 'rev-parse', f'{sha}^') + if not parent: + continue + before = get_ovs_ovn_branches(neutron_repo, parent, job_name) + after = get_ovs_ovn_branches(neutron_repo, sha, job_name) + for key in ('OVS_BRANCH', 'OVN_BRANCH'): + if before.get(key) != after.get(key) and key not in change_map: + date, subject, sha8 = _merge_commit_info(neutron_repo, sha) + change_map[key] = (date, subject, sha8, repo_name) + + return change_map + + +# --------------------------------------------------------------------------- +# Change attribution +# --------------------------------------------------------------------------- + +def build_neutron_dep_change_map(neutron_repo, start_ref, end_ref): + """ + Walk neutron commits in start_ref..end_ref that touched dep files and + return {pkg: (date, subject, sha8, 'neutron')} for each package whose + presence in the dep set changed. Walking newest-first means the first + hit is authoritative. + """ + dep_files = [ + 'requirements.txt', + 'test-requirements.txt', + 'neutron/tests/functional/requirements.txt', + ] + shas = _git(neutron_repo, 'log', '--first-parent', '--format=%H', + f'{start_ref}..{end_ref}', '--', *dep_files) + if not shas: + return {} + + repo_name = Path(neutron_repo).name + change_map = {} + + for sha in shas.splitlines(): + parent = _git(neutron_repo, 'rev-parse', f'{sha}^') + if not parent: + continue + before = set() + after = set() + for f in dep_files: + before |= parse_req_names(file_at(neutron_repo, parent, f)) + after |= parse_req_names(file_at(neutron_repo, sha, f)) + for pkg in (before ^ after): + if pkg not in change_map: + date, subject, sha8 = _merge_commit_info(neutron_repo, sha) + change_map[pkg] = (date, subject, sha8, repo_name) + + return change_map + + +def build_change_map(req_repo, start_ref, end_ref): + """ + Walk commits in start_ref..end_ref that touched upper-constraints.txt + and return {pkg: (date, subject, sha8, repo_name)} recording the most + recent commit that changed each package's pinned version. Walking + newest-first means the first hit for a package is the authoritative one. + """ + shas = _git(req_repo, 'log', '--no-merges', '--format=%H', + f'{start_ref}..{end_ref}', '--', 'upper-constraints.txt') + if not shas: + return {} + + repo_name = Path(req_repo).name + + change_map = {} + for sha in shas.splitlines(): + date = _git(req_repo, 'log', '-1', '--format=%cd', '--date=short', sha) + subject = _git(req_repo, 'log', '-1', '--format=%s', sha) + parent = _git(req_repo, 'rev-parse', f'{sha}^') + if not parent: + continue + before = parse_upper_constraints( + file_at(req_repo, parent, 'upper-constraints.txt')) + after = parse_upper_constraints( + file_at(req_repo, sha, 'upper-constraints.txt')) + for pkg in set(before) | set(after): + if before.get(pkg) != after.get(pkg) and pkg not in change_map: + change_map[pkg] = (date, subject, sha[:8], repo_name) + + return change_map + + +def classify_source(subject): + """Return a short source tag based on the commit subject.""" + if 'generate-constraints' in subject: + return 'bot' + if re.match(r'update constraint for .+ to new release', subject, + re.IGNORECASE): + return 'release' + return 'manual' + + +# --------------------------------------------------------------------------- +# Core diff logic +# --------------------------------------------------------------------------- + +def diff_constraints(start, end, relevant=None, exclude=None): + """ + Diff two {name: version} dicts. + + Args: + start, end: constraint dicts + relevant: if not None, only consider these package names + exclude: package names to skip (from-source packages) + + Returns: + (changed, added, removed) lists of (name, old_ver, new_ver) tuples + where old_ver/new_ver is None for added/removed entries. + """ + exclude = exclude or set() + all_pkgs = set(start) | set(end) + if relevant is not None: + all_pkgs &= relevant + + changed, added, removed = [], [], [] + for pkg in sorted(all_pkgs - exclude): + old = start.get(pkg) + new = end.get(pkg) + if old == new: + continue + if old and new: + changed.append((pkg, old, new)) + elif new: + added.append((pkg, None, new)) + else: + removed.append((pkg, old, None)) + + return changed, added, removed + + +def fmt_attribution(pkg, change_map): + """Format the date/source/sha attribution for a changed package.""" + if pkg not in change_map: + return '' + date, subject, sha8, repo_name = change_map[pkg] + tag = classify_source(subject) + subj = subject if len(subject) <= 52 else subject[:49] + '...' + return f' {date} [{tag}] {subj} ({repo_name}@{sha8})' + + +def print_rows(rows, label, change_map): + """Print a section of (pkg, old_ver, new_ver) rows with attribution.""" + print(f'{label}:') + name_w = max(len(pkg) for pkg, _, _ in rows) + ver_w = max(len(f'{o} -> {n}') if o and n else + len(f'(new) {n}') if n else len(f'{o} (dropped)') + for _, o, n in rows) + for pkg, old, new in rows: + ver_str = f'{old} -> {new}' if old and new else \ + f'(new) {new}' if new else f'{old} (dropped)' + attr = fmt_attribution(pkg, change_map) + print(f' {pkg:<{name_w}} {ver_str:<{ver_w}}{attr}') + + +def _print_branch_commits(key, branch, commits, indent_w=10): + """Print branch commit list under an OVS/OVN branch entry.""" + pad = ' ' * (indent_w + 4) + if commits is None: + print(f'{pad}(repo not found for {branch} commits)') + elif not commits: + print(f'{pad}(no commits on {branch} in this date range)') + else: + print(f'{pad}{len(commits)} commit(s) on {branch}:') + for sha, subj in commits: + subj_t = subj if len(subj) <= 72 else subj[:69] + '...' + print(f'{pad} {sha} {subj_t}') + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def _resolve_refs(args, neutron_repo, req_repo): + """Resolve start/end refs for both the requirements and neutron repos. + + Returns: + (req_start, req_end, neutron_ref, neutron_start_ref, + start_date, end_date, start_label, end_label) + """ + if args.neutron_start: + nstart = resolve_ref(neutron_repo, args.neutron_start) + if not nstart: + sys.exit( + f'error: cannot resolve neutron start: {args.neutron_start}') + start_date = commit_date(neutron_repo, nstart) + req_start = req_ref_for_date(req_repo, start_date) + neutron_start_ref = nstart + start_label = ( + f'{args.neutron_start[:8]} (neutron) -> {start_date}') + else: + req_start = resolve_ref(req_repo, args.start) + start_date = commit_date(req_repo, req_start) + neutron_start_ref = resolve_ref(neutron_repo, start_date) + start_label = args.start + + if args.neutron_end: + nend = resolve_ref(neutron_repo, args.neutron_end) + if not nend: + sys.exit( + f'error: cannot resolve neutron end: {args.neutron_end}') + end_date = commit_date(neutron_repo, nend) + req_end = req_ref_for_date(req_repo, end_date) + end_label = f'{args.neutron_end[:8]} (neutron) -> {end_date}' + neutron_ref = nend + else: + req_end = resolve_ref(req_repo, args.end) + end_label = args.end + neutron_ref = resolve_ref(neutron_repo, args.neutron_ref) + end_date = commit_date(req_repo, req_end) + + for label, val in [('requirements start', req_start), + ('requirements end', req_end), + ('neutron ref', neutron_ref)]: + if not val: + sys.exit(f'error: cannot resolve {label}') + + return (req_start, req_end, neutron_ref, neutron_start_ref, + start_date, end_date, start_label, end_label) + + +def main(): + p = argparse.ArgumentParser( + description='Diff pinned dependency versions that affect a neutron ' + 'job.', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__.split('Usage examples:')[1]) + + start_group = p.add_mutually_exclusive_group(required=True) + start_group.add_argument( + '--start', metavar='DATE_OR_REF', + help='Start date (YYYY-MM-DD) or requirements repo commit/ref') + start_group.add_argument( + '--neutron-start', metavar='COMMIT', + help='Start neutron commit; date is derived to find requirements ' + 'state') + + p.add_argument( + '--end', metavar='DATE_OR_REF', default='HEAD', + help='End date or requirements repo ref (default: HEAD)') + p.add_argument( + '--neutron-end', metavar='COMMIT', + help='End neutron commit; date is derived to find requirements state') + + p.add_argument( + '--neutron-ref', metavar='REF', default='HEAD', + help='Neutron ref to read dep files from (default: HEAD). ' + 'Ignored when --neutron-end is set.') + p.add_argument( + '--job', metavar='JOB_NAME', + help='Zuul job name; packages installed from source for this job ' + 'are excluded from the diff (e.g. neutron-functional, ' + 'neutron-functional-with-neutron-lib-master)') + p.add_argument( + '--all', dest='show_all', action='store_true', + help='Show all constraint changes, not just neutron direct deps') + + p.add_argument( + '--neutron-repo', type=Path, default=DEFAULT_NEUTRON_REPO, + help=f'Path to neutron repo (default: {DEFAULT_NEUTRON_REPO})') + p.add_argument( + '--requirements-repo', type=Path, default=None, + help='Path to requirements repo (auto-discovered if omitted)') + p.add_argument( + '--ovn-repo', type=Path, default=None, + help='Path to OVN repo (auto-discovered if omitted)') + p.add_argument( + '--ovs-repo', type=Path, default=None, + help='Path to OVS repo (auto-discovered if omitted)') + p.add_argument( + '--path', metavar='DIR', + help='Workspace directory for companion repos (requirements, ovn, ' + 'ovs). Repos already present are reused; missing ones are ' + 'cloned from upstream. Overrides auto-discovery.') + p.add_argument( + '--branch-commits', action='store_true', + help='For moving OVS/OVN branches, show commits within the date range') + + args = p.parse_args() + neutron_repo = args.neutron_repo + + req_repo = ensure_repo('requirements', args.requirements_repo, + neutron_repo, args.path) + ovn_repo = ensure_repo('ovn', args.ovn_repo, neutron_repo, args.path) + ovs_repo = ensure_repo('ovs', args.ovs_repo, neutron_repo, args.path) + + if not req_repo or not (req_repo / '.git').exists(): + sys.exit( + 'error: requirements repo not found — use --requirements-repo ' + 'or --path to provide it') + + (req_start, req_end, neutron_ref, neutron_start_ref, + start_date, end_date, start_label, end_label) = _resolve_refs( + args, neutron_repo, req_repo) + + # --- Load constraints --- + start_uc = parse_upper_constraints( + file_at(req_repo, req_start, 'upper-constraints.txt')) + end_uc = parse_upper_constraints( + file_at(req_repo, req_end, 'upper-constraints.txt')) + + if not start_uc: + sys.exit('error: could not read upper-constraints.txt at start ref') + if not end_uc: + sys.exit('error: could not read upper-constraints.txt at end ref') + + # --- Build filter sets --- + start_deps = neutron_dep_names(neutron_repo, neutron_start_ref) \ + if neutron_start_ref else set() + end_deps = neutron_dep_names(neutron_repo, neutron_ref) + + relevant = None if args.show_all else end_deps + if relevant is not None and not relevant: + print('warning: could not read neutron dep files; showing all changes', + file=sys.stderr) + relevant = None + + source_pkgs = set() + if args.job: + source_pkgs = from_source_packages(neutron_repo, neutron_ref, args.job) + + # --- Diff constraints --- + changed, added, removed = diff_constraints(start_uc, end_uc, + relevant=relevant, + exclude=source_pkgs) + + # --- Neutron dep set changes (independent of constraint changes) --- + # Packages newly added to or removed from neutron's requirements files. + newly_required = [] + no_longer_required = [] + if not args.show_all and start_deps: + for pkg in sorted((end_deps - start_deps) - source_pkgs): + ver = end_uc.get(pkg, '(unconstrained)') + newly_required.append((pkg, None, ver)) + for pkg in sorted((start_deps - end_deps) - source_pkgs): + ver = start_uc.get(pkg, '(unconstrained)') + no_longer_required.append((pkg, ver, None)) + + # --- OVS/OVN binary branch changes --- + ovs_ovn_changes = [] + ovs_ovn_change_map = {} + end_branches = {} + if neutron_start_ref or args.branch_commits: + end_branches = get_ovs_ovn_branches( + neutron_repo, neutron_ref, args.job) + if neutron_start_ref: + start_branches = get_ovs_ovn_branches(neutron_repo, neutron_start_ref, + args.job) + for key in ('OVS_BRANCH', 'OVN_BRANCH'): + old = start_branches.get(key, '?') + new = end_branches.get(key, '?') + if old != new: + ovs_ovn_changes.append((key, old, new)) + if ovs_ovn_changes: + ovs_ovn_change_map = build_ovs_ovn_change_map( + neutron_repo, neutron_start_ref, neutron_ref, + args.job or 'neutron-functional') + + # --- Attribution: find which commit introduced each change --- + change_map = build_change_map(req_repo, req_start, req_end) + if neutron_start_ref: + change_map.update( + build_neutron_dep_change_map(neutron_repo, neutron_start_ref, + neutron_ref)) + change_map.update(ovs_ovn_change_map) + + # --- Output --- + req_start_date = commit_date(req_repo, req_start) + req_end_date = commit_date(req_repo, req_end) + + scope = 'all constraints' if args.show_all else 'neutron direct deps' + print(f'Dependency changes ({scope})') + print(f' start : {start_label}') + print(f' -> requirements {req_start[:8]} ({req_start_date})') + print(f' end : {end_label}') + print(f' -> requirements {req_end[:8]} ({req_end_date})') + print(f' neutron deps read from: {neutron_ref[:8]}') + if args.job: + print(f' job: {args.job}') + if source_pkgs: + print( + f' from-source (excluded): {", ".join(sorted(source_pkgs))}') + print() + + has_branch_content = args.branch_commits and any( + classify_ref(end_branches.get(k, '')) == 'branch (moving)' + for k in ('OVS_BRANCH', 'OVN_BRANCH') + ) + if not any([changed, added, removed, newly_required, no_longer_required, + ovs_ovn_changes]) and not has_branch_content: + print('No relevant dependency changes in this range.') + return + + if changed: + print_rows(changed, 'Changed', change_map) + + if added: + print() + print_rows(added, 'Added to constraints', change_map) + + if removed: + print() + print_rows(removed, 'Removed from constraints', change_map) + + if newly_required: + print() + print_rows(newly_required, 'Newly required by neutron', change_map) + + if no_longer_required: + print() + print_rows(no_longer_required, + 'No longer required by neutron', change_map) + + if ovs_ovn_changes or (args.branch_commits and not args.show_all): + # Also show unchanged moving branches when --branch-commits requested + all_branch_keys = set(k for k, _, _ in ovs_ovn_changes) + if args.branch_commits: + for key in ('OVS_BRANCH', 'OVN_BRANCH'): + val = end_branches.get(key, '') + if classify_ref(val) == 'branch (moving)': + all_branch_keys.add(key) + + if ovs_ovn_changes or all_branch_keys: + print() + print('OVS/OVN binary branches:') + + repo_for = {'OVN_BRANCH': ovn_repo, 'OVS_BRANCH': ovs_repo} + + printed_keys = set() + if ovs_ovn_changes: + name_w = max(len(k) for k, _, _ in ovs_ovn_changes) + ver_w = max(len(f'{o} -> {n}') for _, o, n in ovs_ovn_changes) + for key, old, new in ovs_ovn_changes: + printed_keys.add(key) + stability = [f'was: {classify_ref(old)}', + f'now: {classify_ref(new)}'] + attr = fmt_attribution(key, change_map) + ver_str = f'{old} -> {new}' + print(f' {key:<{name_w}} {ver_str:<{ver_w}}{attr}') + print(f' {"":<{name_w}} {"; ".join(stability)}') + if args.branch_commits and \ + classify_ref(new) == 'branch (moving)': + commits = get_branch_commits( + repo_for[key], new, start_date, end_date) + _print_branch_commits(key, new, commits, name_w) + + # Unchanged moving branches requested via --branch-commits + for key in sorted(all_branch_keys - printed_keys): + val = end_branches.get(key, '') + print(f' {key} {val} (unchanged, {classify_ref(val)})') + if args.branch_commits: + commits = get_branch_commits( + repo_for[key], val, start_date, end_date) + _print_branch_commits(key, val, commits) + + +if __name__ == '__main__': + main() From db196cfd91950390ec3f461a1a21d67960908211 Mon Sep 17 00:00:00 2001 From: Elvira Garcia Date: Wed, 3 Jun 2026 10:43:18 +0200 Subject: [PATCH 14/63] 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 15/63] 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 16/63] 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 17/63] 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 18/63] 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 19/63] 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 20/63] 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 21/63] 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 22/63] 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 23/63] 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 24/63] 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 25/63] 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 26/63] 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 16fadc5b203ea77efc510365ef9ab33f78331163 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 8 Jun 2026 17:05:51 +0200 Subject: [PATCH 27/63] Remove the unused ``api_workers`` config knob The ``HashRingManager`` and ``HashRingHealthCheckPeriodics`` classes previously obtained the API worker count reading ``[DEFAULT]api_workers`` configuration option and fell back to a CPU/memory heuristic. This value could diverge from the actual number of workers spawned by uWSGI. Replace that with a new helper ``wsgi_utils.get_api_worker_count()`` that returns ``uwsgi.numproc``, the real worker count known to the uWSGI master process. The value is captured once at object initialisation instead of being re-read on every hash-ring check, and a ``RuntimeError`` is raised if the count is unavailable (i.e. the code is not running under uWSGI). The now-unused ``service._get_api_workers()`` function and its functional tests are removed. The default ``rpc_workers`` calculation is updated to use ``_get_worker_count()`` directly. Closes-Bug: #2155306 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I93f595f2bfb6cd4cb58592a19033a7e7f3eed89a --- .../admin/config-neutron-server-processes.rst | 2 +- doc/source/admin/config-wsgi.rst | 5 +- neutron/cmd/upgrade_checks/checks.py | 8 +-- neutron/common/ovn/hash_ring_manager.py | 18 +++-- neutron/common/wsgi_utils.py | 10 +++ neutron/conf/service.py | 10 +-- .../ovn/mech_driver/ovsdb/maintenance.py | 8 +-- neutron/service.py | 12 +--- neutron/tests/functional/base.py | 4 +- neutron/tests/functional/test_service.py | 15 ----- .../unit/cmd/upgrade_checks/test_checks.py | 16 +---- .../unit/common/ovn/test_hash_ring_manager.py | 18 +++-- neutron/tests/unit/common/test_wsgi_utils.py | 66 +++++++++++++++++++ neutron/tests/unit/test_service.py | 6 +- ...sgi-api-worker-count-268549c34f586794.yaml | 19 ++++++ 15 files changed, 144 insertions(+), 73 deletions(-) create mode 100644 neutron/tests/unit/common/test_wsgi_utils.py create mode 100644 releasenotes/notes/uwsgi-api-worker-count-268549c34f586794.yaml diff --git a/doc/source/admin/config-neutron-server-processes.rst b/doc/source/admin/config-neutron-server-processes.rst index 903494bdb65..fde190ecd50 100644 --- a/doc/source/admin/config-neutron-server-processes.rst +++ b/doc/source/admin/config-neutron-server-processes.rst @@ -35,7 +35,7 @@ The following table summarizes the Neutron server-side processes: - ``neutron-rpc-server`` (spawns ``rpc worker`` and ``rpc reports worker`` child processes) - When agents require RPC - - ``api_workers``, ``rpc_workers``, ``rpc_state_report_workers`` + - uWSGI ``processes``; Neutron ``rpc_workers`` and ``rpc_state_report_workers`` * - Periodic - ``neutron-periodic-workers`` (runs plugin periodic tasks as threads) - With any ML2 mechanism driver and WSGI API diff --git a/doc/source/admin/config-wsgi.rst b/doc/source/admin/config-wsgi.rst index 4a16d3b5d32..87f1daf4d32 100644 --- a/doc/source/admin/config-wsgi.rst +++ b/doc/source/admin/config-wsgi.rst @@ -140,10 +140,11 @@ further limited by available memory, and the number of RPC workers is set to half that number. It is strongly recommended that all deployers set these values themselves, -via the api_workers and rpc_workers configuration parameters. +via the uWSGI ``processes`` and Neutron ``rpc_workers`` configuration +parameters. For a cloud with a high load to a relatively small number of objects, -a smaller value for api_workers will provide better performance than +a smaller value for API workers will provide better performance than many (somewhere around 4-8.) For a cloud with a high load to lots of different objects, then the more the better. Budget neutron-server using about 2GB of RAM in steady-state. diff --git a/neutron/cmd/upgrade_checks/checks.py b/neutron/cmd/upgrade_checks/checks.py index 9f877aa6be6..4393e9858bf 100644 --- a/neutron/cmd/upgrade_checks/checks.py +++ b/neutron/cmd/upgrade_checks/checks.py @@ -239,16 +239,16 @@ def get_checks(self): @staticmethod def worker_count_check(checker): - if cfg.CONF.api_workers and conf_service.get_rpc_workers(): + if conf_service.get_rpc_workers(): return upgradecheck.Result( - upgradecheck.Code.SUCCESS, _("Number of workers already " + upgradecheck.Code.SUCCESS, _("Number of RPC workers already " "defined in config")) return upgradecheck.Result( upgradecheck.Code.WARNING, - _("The default number of workers " + _("The default number of RPC workers " "has changed. Please see release notes for the new values, " "but it is strongly encouraged for deployers to manually " - "set the values for api_workers and rpc_workers.")) + "set the values for rpc_workers.")) @staticmethod def network_mtu_check(checker): diff --git a/neutron/common/ovn/hash_ring_manager.py b/neutron/common/ovn/hash_ring_manager.py index 7c1e9922726..a9c538260ae 100644 --- a/neutron/common/ovn/hash_ring_manager.py +++ b/neutron/common/ovn/hash_ring_manager.py @@ -19,10 +19,11 @@ from oslo_utils import timeutils from tooz import hashring +from neutron._i18n import _ from neutron.common.ovn import constants from neutron.common.ovn import exceptions +from neutron.common import wsgi_utils from neutron.db import ovn_hash_ring_db as db_hash_ring -from neutron import service from neutron_lib import context LOG = log.getLogger(__name__) @@ -40,6 +41,7 @@ def __init__(self, group_name): self._prev_num_nodes = -1 self.admin_ctx = context.get_admin_context() self._offline_node_count = 0 + self._api_workers = wsgi_utils.get_api_worker_count() @property def _wait_startup_before_caching(self): @@ -55,21 +57,27 @@ def _wait_startup_before_caching(self): if not self._check_hashring_startup: return False - api_workers = service._get_api_workers() + if self._api_workers is None: + msg = _('The Neutron API workers count is zero') + LOG.error(msg) + raise RuntimeError(msg) + nodes = db_hash_ring.get_active_nodes( self.admin_ctx, constants.HASH_RING_CACHE_TIMEOUT, self._group, from_host=True) num_nodes = len(nodes) - if num_nodes >= api_workers: - LOG.debug("Allow caching, nodes %s>=%s", num_nodes, api_workers) + if num_nodes >= self._api_workers: + LOG.debug("Allow caching, nodes %s>=%s", num_nodes, + self._api_workers) self._check_hashring_startup = False return False # NOTE(lucasagomes): We only log when the number of connected # nodes are different to prevent this message from being spammed if self._prev_num_nodes != num_nodes: - LOG.debug("Disallow caching, nodes %s<%s", num_nodes, api_workers) + LOG.debug("Disallow caching, nodes %s<%s", num_nodes, + self._api_workers) self._prev_num_nodes = num_nodes return True diff --git a/neutron/common/wsgi_utils.py b/neutron/common/wsgi_utils.py index 22a6c51bde4..84a8a0d9bc5 100644 --- a/neutron/common/wsgi_utils.py +++ b/neutron/common/wsgi_utils.py @@ -57,3 +57,13 @@ def get_api_worker_id() -> int | None: return uwsgi.worker_id() except (ImportError, ModuleNotFoundError): return None + + +def get_api_worker_count() -> int | None: + """Return the configured worker number provided to uWSGI""" + try: + # pylint: disable=import-outside-toplevel + import uwsgi + return uwsgi.numproc + except (ImportError, ModuleNotFoundError): + return None diff --git a/neutron/conf/service.py b/neutron/conf/service.py index 5c4a3289753..19a21994204 100644 --- a/neutron/conf/service.py +++ b/neutron/conf/service.py @@ -23,18 +23,12 @@ cfg.IntOpt('periodic_interval', default=40, help=_('Seconds between running periodic tasks.')), - cfg.IntOpt('api_workers', - min=1, - help=_('Number of separate API worker processes for service. ' - 'If not specified, the default is equal to the number ' - 'of CPUs available for best performance, capped by ' - 'potential RAM usage.')), cfg.IntOpt('rpc_workers', min=0, help=_('Number of RPC worker processes for service. ' 'If not specified, the default is equal to half the ' - 'number of API workers. If set to 0, no RPC worker ' - 'is launched.')), + 'CPU count, never using more than half of the system ' + 'memory. If set to 0, no RPC worker is launched.')), cfg.IntOpt('rpc_state_report_workers', default=1, min=0, 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..57fa9fdea76 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -20,6 +20,7 @@ import futurist from futurist import periodics +from neutron.common import wsgi_utils from neutron_lib.api.definitions import external_net from neutron_lib.api.definitions import portbindings from neutron_lib.api.definitions import provider_net as pnet @@ -43,7 +44,6 @@ from neutron.objects import ports as ports_obj from neutron.objects import router as router_obj from neutron.objects import securitygroup as sg_obj -from neutron import service CONF = cfg.CONF @@ -1412,6 +1412,7 @@ def __init__(self, group, node_uuid): self._group = group self._node_uuid = node_uuid self.ctx = n_context.get_admin_context() + self._api_workers = wsgi_utils.get_api_worker_count() @periodics.periodic(spacing=ovn_const.HASH_RING_TOUCH_INTERVAL) def touch_hash_ring_node(self): @@ -1426,10 +1427,9 @@ def touch_hash_ring_node(self): # Check the number of the nodes in the ring and log a message in # case they are out of sync. See LP #2024205 for more information # on this issue. - api_workers = service._get_api_workers() num_nodes = hash_ring_db.count_nodes_from_host(self.ctx, self._group) - if num_nodes > api_workers: + if num_nodes > self._api_workers: LOG.critical( 'The number of nodes in the Hash Ring (%d) is higher than ' 'the number of API workers (%d) for host "%s". Something is ' @@ -1438,4 +1438,4 @@ def touch_hash_ring_node(self): 'happen when the API workers are killed and restarted. ' 'Restarting the service should fix the issue, see LP ' '#2024205 for more information.', - num_nodes, api_workers, cfg.CONF.host) + num_nodes, self._api_workers, cfg.CONF.host) diff --git a/neutron/service.py b/neutron/service.py index fef91b55d0f..cf9ea1a0a95 100644 --- a/neutron/service.py +++ b/neutron/service.py @@ -133,8 +133,9 @@ def _get_rpc_workers(plugin=None): workers = cfg.CONF.rpc_workers if workers is None: - # By default, half as many rpc workers as api workers - workers = int(_get_api_workers() / 2) + # By default, half of the CPU threads, never using more than half of + # the system memory. + workers = int(_get_worker_count() / 2) workers = max(workers, 1) # If workers > 0 then start_rpc_listeners would be called in a @@ -333,13 +334,6 @@ def start_ovn_maintenance_worker(): return _start_workers([ovn_maintenance_worker]) -def _get_api_workers(): - workers = cfg.CONF.api_workers - if workers is None: - workers = _get_worker_count() - return workers - - class Service(n_rpc.Service): """Service object for binaries running on hosts. diff --git a/neutron/tests/functional/base.py b/neutron/tests/functional/base.py index 66b16da3959..27dbd199a06 100644 --- a/neutron/tests/functional/base.py +++ b/neutron/tests/functional/base.py @@ -37,6 +37,7 @@ from neutron.api import extensions as exts from neutron.api import wsgi from neutron.common import utils as n_utils +from neutron.common import wsgi_utils from neutron.conf.agent import common as config from neutron.conf.agent import ovs_conf from neutron.conf.plugins.ml2 import config as ml2_config @@ -225,7 +226,8 @@ def setUp(self, maintenance_worker=False, service_plugins=None): ovn_conf.cfg.CONF.set_override('dns_servers', ['10.10.10.10'], group='ovn') - ovn_conf.cfg.CONF.set_override('api_workers', 1) + mock.patch.object(wsgi_utils, 'get_api_worker_count', + return_value=1).start() self.addCleanup(exts.PluginAwareExtensionManager.clear_instance) self.ovsdb_server_mgr = None diff --git a/neutron/tests/functional/test_service.py b/neutron/tests/functional/test_service.py index 0991f378211..5e40b77ba4e 100644 --- a/neutron/tests/functional/test_service.py +++ b/neutron/tests/functional/test_service.py @@ -12,28 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. -from oslo_concurrency import processutils from oslo_config import cfg from oslo_service import service from neutron import service as neutron_service -from neutron.tests.functional import base from neutron.tests.functional import test_server -class TestService(base.BaseLoggingTestCase): - - def test_api_workers_default(self): - # This value may end being scaled downward based on available RAM. - self.assertGreaterEqual(processutils.get_worker_count(), - neutron_service._get_api_workers()) - - def test_api_workers_from_config(self): - cfg.CONF.set_override('api_workers', 1234) - self.assertEqual(1234, - neutron_service._get_api_workers()) - - class TestServiceRestart(test_server.TestNeutronServer): def _start_service(self, host, binary, topic, manager, workers, diff --git a/neutron/tests/unit/cmd/upgrade_checks/test_checks.py b/neutron/tests/unit/cmd/upgrade_checks/test_checks.py index 7fc31d79923..079c97a22dc 100644 --- a/neutron/tests/unit/cmd/upgrade_checks/test_checks.py +++ b/neutron/tests/unit/cmd/upgrade_checks/test_checks.py @@ -20,6 +20,7 @@ from neutron.cmd.upgrade_checks import checks from neutron.common.ovn import exceptions as ovn_exc from neutron.common.ovn import utils as ovn_utils +from neutron.conf import service from neutron.tests import base @@ -28,30 +29,17 @@ class TestChecks(base.BaseTestCase): def setUp(self): super().setUp() self.checks = checks.CoreChecks() + service.register_service_opts(service.SERVICE_OPTS) def test_get_checks_list(self): self.assertIsInstance(self.checks.get_checks(), list) def test_worker_check_good(self): - cfg.CONF.set_override("api_workers", 2) cfg.CONF.set_override("rpc_workers", 2) result = checks.CoreChecks.worker_count_check(mock.Mock()) self.assertEqual(Code.SUCCESS, result.code) - def test_worker_check_api_missing(self): - cfg.CONF.set_override("api_workers", None) - cfg.CONF.set_override("rpc_workers", 2) - result = checks.CoreChecks.worker_count_check(mock.Mock()) - self.assertEqual(Code.WARNING, result.code) - def test_worker_check_rpc_missing(self): - cfg.CONF.set_override("api_workers", 2) - cfg.CONF.set_override("rpc_workers", None) - result = checks.CoreChecks.worker_count_check(mock.Mock()) - self.assertEqual(Code.WARNING, result.code) - - def test_worker_check_both_missing(self): - cfg.CONF.set_override("api_workers", None) cfg.CONF.set_override("rpc_workers", None) result = checks.CoreChecks.worker_count_check(mock.Mock()) self.assertEqual(Code.WARNING, result.code) diff --git a/neutron/tests/unit/common/ovn/test_hash_ring_manager.py b/neutron/tests/unit/common/ovn/test_hash_ring_manager.py index 405ac060b74..36d1db6fb05 100644 --- a/neutron/tests/unit/common/ovn/test_hash_ring_manager.py +++ b/neutron/tests/unit/common/ovn/test_hash_ring_manager.py @@ -22,8 +22,8 @@ from neutron.common.ovn import constants from neutron.common.ovn import exceptions from neutron.common.ovn import hash_ring_manager +from neutron.common import wsgi_utils from neutron.db import ovn_hash_ring_db as db_hash_ring -from neutron import service from neutron.tests.unit import testlib_api HASH_RING_TEST_GROUP = 'test_group' @@ -33,8 +33,11 @@ class TestHashRingManager(testlib_api.SqlTestCaseLight): def setUp(self): super().setUp() - self.hash_ring_manager = hash_ring_manager.HashRingManager( - HASH_RING_TEST_GROUP) + self.api_processes = 2 + with mock.patch.object(wsgi_utils, 'get_api_worker_count', + return_value=self.api_processes): + self.hash_ring_manager = hash_ring_manager.HashRingManager( + HASH_RING_TEST_GROUP) self.admin_ctx = context.get_admin_context() def _verify_hashes(self, hash_dict): @@ -126,8 +129,7 @@ def test_ring_rebalance(self): self._verify_hashes(hash_dict_before) @mock.patch.object(hash_ring_manager.LOG, 'debug') - @mock.patch.object(service, '_get_api_workers', return_value=2) - def test__wait_startup_before_caching(self, api_workers, mock_log): + def test__wait_startup_before_caching(self, mock_log): db_hash_ring.add_node(self.admin_ctx, HASH_RING_TEST_GROUP, 'node-1') # Assert it will return True until until we equal api_workers @@ -155,3 +157,9 @@ def test__wait_startup_before_caching(self, api_workers, mock_log): self.assertFalse( self.hash_ring_manager._wait_startup_before_caching) self.assertFalse(get_nodes_mock.called) + + def test__wait_startup_before_caching_no_api_workers(self): + self.hash_ring_manager._api_workers = None + self.assertRaises(RuntimeError, + getattr, self.hash_ring_manager, + '_wait_startup_before_caching') diff --git a/neutron/tests/unit/common/test_wsgi_utils.py b/neutron/tests/unit/common/test_wsgi_utils.py new file mode 100644 index 00000000000..ae5a1103b1f --- /dev/null +++ b/neutron/tests/unit/common/test_wsgi_utils.py @@ -0,0 +1,66 @@ +# 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 types +from unittest import mock + +from neutron.common import wsgi_utils +from neutron.tests import base + + +class TestGetApiWorkerCount(base.BaseTestCase): + + def test_get_api_worker_count_with_uwsgi(self): + uwsgi_mod = types.ModuleType('uwsgi') + uwsgi_mod.numproc = 4 + with mock.patch.dict('sys.modules', uwsgi=uwsgi_mod): + self.assertEqual(4, wsgi_utils.get_api_worker_count()) + + def test_get_api_worker_count_without_uwsgi(self): + with mock.patch.dict('sys.modules', uwsgi=None): + self.assertIsNone(wsgi_utils.get_api_worker_count()) + + +class TestGetApiWorkerId(base.BaseTestCase): + + def test_get_api_worker_id_with_uwsgi(self): + uwsgi_mod = types.ModuleType('uwsgi') + uwsgi_mod.worker_id = mock.Mock(return_value=3) + with mock.patch.dict('sys.modules', uwsgi=uwsgi_mod): + self.assertEqual(3, wsgi_utils.get_api_worker_id()) + + def test_get_api_worker_id_without_uwsgi(self): + with mock.patch.dict('sys.modules', uwsgi=None): + self.assertIsNone(wsgi_utils.get_api_worker_id()) + + +class TestGetStartTime(base.BaseTestCase): + + def test_get_start_time_with_uwsgi(self): + uwsgi_mod = types.ModuleType('uwsgi') + uwsgi_mod.opt = {'start-time': b'1700000000'} + with mock.patch.dict('sys.modules', uwsgi=uwsgi_mod): + self.assertEqual(1700000000, wsgi_utils.get_start_time()) + + def test_get_start_time_without_uwsgi(self): + with mock.patch.dict('sys.modules', uwsgi=None): + self.assertIsNone(wsgi_utils.get_start_time()) + + def test_get_start_time_without_uwsgi_with_default(self): + with mock.patch.dict('sys.modules', uwsgi=None): + self.assertEqual(42, wsgi_utils.get_start_time(default=42)) + + def test_get_start_time_no_opt_value(self): + uwsgi_mod = types.ModuleType('uwsgi') + uwsgi_mod.opt = {} + with mock.patch.dict('sys.modules', uwsgi=uwsgi_mod): + self.assertIsNone(wsgi_utils.get_start_time()) diff --git a/neutron/tests/unit/test_service.py b/neutron/tests/unit/test_service.py index bed7badcb07..cc95162441e 100644 --- a/neutron/tests/unit/test_service.py +++ b/neutron/tests/unit/test_service.py @@ -126,13 +126,9 @@ def _test_rpc_workers(self, config_value, expected_passed_value): def test_rpc_workers_zero(self): self._test_rpc_workers(0, 0) - def test_rpc_workers_default_api_workers_default(self): + def test_rpc_workers_default_worker_count(self): workers = max(int(self.worker_count / 2), 1) self._test_rpc_workers(None, workers) - def test_rpc_workers_default_api_workers_set(self): - cfg.CONF.set_override('api_workers', 18) - self._test_rpc_workers(None, 9) - def test_rpc_workers_defined(self): self._test_rpc_workers(42, 42) diff --git a/releasenotes/notes/uwsgi-api-worker-count-268549c34f586794.yaml b/releasenotes/notes/uwsgi-api-worker-count-268549c34f586794.yaml new file mode 100644 index 00000000000..e34da4b8df8 --- /dev/null +++ b/releasenotes/notes/uwsgi-api-worker-count-268549c34f586794.yaml @@ -0,0 +1,19 @@ +--- +fixes: + - | + The OVN hash ring manager and the hash ring health check periodics now + read the API worker count directly from the uWSGI runtime via + ``uwsgi.numproc`` instead of the ``[DEFAULT] api_workers`` configuration + option. This ensures the count always matches the actual number of + workers spawned by uWSGI, avoiding mismatches when the configuration + value is unset or differs from the uWSGI process count. + For more information see bug `2024205 `_. +deprecations: + - | + The ``[DEFAULT] api_workers`` configuration option has been removed. + The number of API worker processes is now determined solely by the + uWSGI ``processes`` setting. The default number of RPC workers, when + ``[DEFAULT] rpc_workers`` is not set, is now half the number of CPU + threads (capped by available memory) instead of half of + ``api_workers``. Deployers should ensure the desired worker count is + configured directly in the uWSGI configuration file. From 8ccf3f9e7990ea417adfb6776e99809437757e07 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 9 Jun 2026 14:07:27 +0200 Subject: [PATCH 28/63] 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 29/63] 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 30/63] 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 31/63] 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 32/63] 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 33/63] 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 34/63] 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 35/63] [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 36/63] 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 37/63] 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 38/63] 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 39/63] 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) From 6ffe5056af017a0517eeedfd42ddb94eebe178e4 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 11 Jun 2026 12:10:13 +0200 Subject: [PATCH 40/63] policy: remove ``OwnershipValidationHook``, update ``create_port`` rule The ``OwnershipValidationHook`` performed a network ownership check on every port and subnet POST request, fetching the network from the database to verify that the caller owned the network or that the network was shared. This check was redundant and has been removed. For subnets, the ``create_subnet`` policy already requires ``ADMIN_OR_NET_OWNER_MEMBER``, which is stricter than the hook (the hook allowed shared networks; the policy does not). Every request the hook would deny was also denied by the policy engine. For ports, the ``create_port`` policy only checked the port's ``project_id`` and had no network-level access check. The hook was the sole guard preventing port creation on non-shared networks belonging to other projects. The ``create_port`` rule is now updated to require network ownership or a shared network, making the hook fully redundant. This eliminates one ``get_network()`` database call per port and subnet create request and makes the authorization model fully declarative via policy rules. Closes-Bug: #2156444 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I3b9c9f4d362002ab35a237fe0383b664be9a5456 --- neutron/conf/policies/port.py | 6 +- neutron/pecan_wsgi/app.py | 1 - neutron/pecan_wsgi/hooks/__init__.py | 2 - .../pecan_wsgi/hooks/ownership_validation.py | 56 ------------------- .../tests/common/test_db_base_plugin_v2.py | 4 +- .../tests/functional/pecan_wsgi/test_hooks.py | 16 ------ neutron/tests/unit/conf/policies/test_port.py | 6 +- ...ship-validation-hook-a0739434475b4780.yaml | 10 ++++ 8 files changed, 17 insertions(+), 84 deletions(-) delete mode 100644 neutron/pecan_wsgi/hooks/ownership_validation.py create mode 100644 releasenotes/notes/remove-ownership-validation-hook-a0739434475b4780.yaml diff --git a/neutron/conf/policies/port.py b/neutron/conf/policies/port.py index 5bd8ff56539..b991d9a0e8e 100644 --- a/neutron/conf/policies/port.py +++ b/neutron/conf/policies/port.py @@ -71,8 +71,10 @@ policy.DocumentedRuleDefault( name='create_port', check_str=neutron_policy.policy_or( - lib_rules.ADMIN_OR_PROJECT_MEMBER, - lib_rules.SERVICE), + lib_rules.ADMIN_OR_SERVICE, + base.NET_OWNER_MEMBER, + 'rule:shared', + ), scope_types=['project'], description='Create a port', operations=ACTION_POST, diff --git a/neutron/pecan_wsgi/app.py b/neutron/pecan_wsgi/app.py index 110b16dd3a3..6b4668f9eea 100644 --- a/neutron/pecan_wsgi/app.py +++ b/neutron/pecan_wsgi/app.py @@ -33,7 +33,6 @@ def v2_factory(global_config, **local_config): hooks.ContextHook(), # priority 95 hooks.ExceptionTranslationHook(), # priority 100 hooks.BodyValidationHook(), # priority 120 - hooks.OwnershipValidationHook(), # priority 125 hooks.QuotaEnforcementHook(), # priority 130 hooks.NotifierHook(), # priority 135 hooks.QueryParametersHook(), # priority 139 diff --git a/neutron/pecan_wsgi/hooks/__init__.py b/neutron/pecan_wsgi/hooks/__init__.py index e557988dab9..c7a11287fad 100644 --- a/neutron/pecan_wsgi/hooks/__init__.py +++ b/neutron/pecan_wsgi/hooks/__init__.py @@ -16,7 +16,6 @@ from neutron.pecan_wsgi.hooks import body_validation from neutron.pecan_wsgi.hooks import context from neutron.pecan_wsgi.hooks import notifier -from neutron.pecan_wsgi.hooks import ownership_validation from neutron.pecan_wsgi.hooks import policy_enforcement from neutron.pecan_wsgi.hooks import query_parameters from neutron.pecan_wsgi.hooks import quota_enforcement @@ -27,7 +26,6 @@ ExceptionTranslationHook = translation.ExceptionTranslationHook ContextHook = context.ContextHook BodyValidationHook = body_validation.BodyValidationHook -OwnershipValidationHook = ownership_validation.OwnershipValidationHook PolicyHook = policy_enforcement.PolicyHook QuotaEnforcementHook = quota_enforcement.QuotaEnforcementHook NotifierHook = notifier.NotifierHook diff --git a/neutron/pecan_wsgi/hooks/ownership_validation.py b/neutron/pecan_wsgi/hooks/ownership_validation.py deleted file mode 100644 index 3311e023402..00000000000 --- a/neutron/pecan_wsgi/hooks/ownership_validation.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) 2015 Mirantis, 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 neutron_lib.plugins import directory -from pecan import hooks -import webob - -from neutron._i18n import _ - - -class OwnershipValidationHook(hooks.PecanHook): - - priority = 125 - - def before(self, state): - if state.request.method != 'POST': - return - for item in state.request.context.get('resources', []): - self._validate_network_project_ownership(state, item) - - def _validate_network_project_ownership(self, state, resource_item): - # TODO(salvatore-orlando): consider whether this check can be folded - # in the policy engine - neutron_context = state.request.context.get('neutron_context') - resource = state.request.context.get('resource') - if (neutron_context.is_admin or neutron_context.is_service_role or - resource not in ('port', 'subnet')): - return - plugin = directory.get_plugin() - network = plugin.get_network(neutron_context, - resource_item['network_id']) - # do not perform the check on shared networks - if network.get('shared'): - return - - network_owner = network['project_id'] - - if network_owner != resource_item['project_id']: - msg = _("Project %(project_id)s not allowed to " - "create %(resource)s on this network") - raise webob.exc.HTTPForbidden(msg % { - "project_id": resource_item['project_id'], - "resource": resource, - }) diff --git a/neutron/tests/common/test_db_base_plugin_v2.py b/neutron/tests/common/test_db_base_plugin_v2.py index 1f2e239428a..040f09dce31 100644 --- a/neutron/tests/common/test_db_base_plugin_v2.py +++ b/neutron/tests/common/test_db_base_plugin_v2.py @@ -3409,7 +3409,7 @@ def test_create_subnet_with_network_different_project(self): req = self.new_create_request('subnets', data, self.fmt, context=ctx) res = req.get_response(self.api) - self._check_http_response(res, webob.exc.HTTPNotFound.code) + self._check_http_response(res, webob.exc.HTTPForbidden.code) def test_create_two_subnets(self): gateway_ips = ['10.0.0.1', '10.0.1.1'] @@ -3868,7 +3868,7 @@ def test_create_subnet_bad_project(self): self._create_subnet(self.fmt, network['network']['id'], '10.0.2.0/24', - webob.exc.HTTPNotFound.code, + webob.exc.HTTPForbidden.code, ip_version=constants.IP_VERSION_4, project_id='bad_project_id', gateway_ip='10.0.2.1', diff --git a/neutron/tests/functional/pecan_wsgi/test_hooks.py b/neutron/tests/functional/pecan_wsgi/test_hooks.py index f3d5382f9c0..bf12e62fb49 100644 --- a/neutron/tests/functional/pecan_wsgi/test_hooks.py +++ b/neutron/tests/functional/pecan_wsgi/test_hooks.py @@ -31,22 +31,6 @@ from neutron.tests.functional.pecan_wsgi import test_functional -class TestOwnershipHook(test_functional.PecanFunctionalTest): - - def test_network_ownership_check(self): - net_response = self.app.post_json( - '/v2.0/networks.json', - params={'network': {'name': 'meh'}}, - headers={'X-Project-Id': 'projid'}) - network_id = jsonutils.loads(net_response.body)['network']['id'] - port_response = self.app.post_json( - '/v2.0/ports.json', - params={'port': {'network_id': network_id, - 'admin_state_up': True}}, - headers={'X-Project-Id': 'projid'}) - self.assertEqual(201, port_response.status_int) - - class TestQueryParametersHook(test_functional.PecanFunctionalTest): def test_if_match_on_update(self): diff --git a/neutron/tests/unit/conf/policies/test_port.py b/neutron/tests/unit/conf/policies/test_port.py index 9c27736f9d9..b579e5e243d 100644 --- a/neutron/tests/unit/conf/policies/test_port.py +++ b/neutron/tests/unit/conf/policies/test_port.py @@ -881,11 +881,7 @@ def setUp(self): self.context = self.project_manager_ctx def test_create_port(self): - self.assertTrue( - policy.enforce(self.context, 'create_port', self.target)) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port', self.alt_target) + self._assert_network_owner_policy('create_port') def test_create_port_with_device_id(self): self.assertTrue( diff --git a/releasenotes/notes/remove-ownership-validation-hook-a0739434475b4780.yaml b/releasenotes/notes/remove-ownership-validation-hook-a0739434475b4780.yaml new file mode 100644 index 00000000000..2310a4bbe48 --- /dev/null +++ b/releasenotes/notes/remove-ownership-validation-hook-a0739434475b4780.yaml @@ -0,0 +1,10 @@ +--- +upgrade: + - | + The ``OwnershipValidationHook`` Pecan hook has been removed. Network + access validation for port creation is now handled entirely by the + policy engine. The ``create_port`` policy rule has been updated to + require network ownership or a shared network, replacing the + previous hook-based check. Operators who have customized the + ``create_port`` rule in ``policy.yaml`` should review their + overrides to ensure they include appropriate network access checks. From c40909abed08c33b400ef840fa8652322c9dc0d0 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 12 Jun 2026 15:26:25 +0200 Subject: [PATCH 41/63] Bump os-ken to 4.2.1 version Related-Bug: #2131666 Related-Bug: #2155883 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Ic9684b677013713f97ed917e25c74960b4658546 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1e04e6a9cd8..a614cc943d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,7 +41,7 @@ oslo.upgradecheck>=1.3.0 # Apache-2.0 oslo.utils>=7.3.0 # Apache-2.0 oslo.versionedobjects>=1.35.1 # Apache-2.0 osprofiler>=2.3.0 # Apache-2.0 -os-ken>=4.1.1 # Apache-2.0 +os-ken>=4.2.1 # Apache-2.0 os-resource-classes>=1.1.0 # Apache-2.0 ovs>3.3.0 # Apache-2.0 ovsdbapp>=2.18.0 # Apache-2.0 From 5d598cf16eff431729b952f3ec26f2f69c88bbbc Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 12 Jun 2026 16:43:19 +0200 Subject: [PATCH 42/63] pecan: Optimize ``_exclude_attributes_by_policy`` on list responses In ``PolicyHook.after()``, ``_exclude_attributes_by_policy`` was called per item via ``_get_filtered_item()``. For a list of N resources each having A attributes, this produced N * A ``policy.check()`` calls. Most short-circuit via the ``might_not_exist`` fast path, but attributes with explicit rules (e.g. ``binding:host_id``, ``binding:profile``, ``resource_request``) trigger full oslo.policy evaluation on every item, all yielding the same result because attribute-level visibility depends on the user's roles, not on the individual resource. Compute the attribute exclusion set once from the first item and reuse it for the rest, matching the legacy ``base.py`` path which already does this. This removes the now-unused ``_get_filtered_item`` method. Closes-Bug: #2156609 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I8ba15ab77d49e487d89293a2c34db357dada389e --- .../pecan_wsgi/hooks/policy_enforcement.py | 21 ++++---- .../tests/functional/pecan_wsgi/test_hooks.py | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/neutron/pecan_wsgi/hooks/policy_enforcement.py b/neutron/pecan_wsgi/hooks/policy_enforcement.py index 5ced7fd3477..1c4604d9449 100644 --- a/neutron/pecan_wsgi/hooks/policy_enforcement.py +++ b/neutron/pecan_wsgi/hooks/policy_enforcement.py @@ -176,8 +176,18 @@ def after(self, state): # in the plural case, we just check so violating items are hidden policy_method = policy.enforce if is_single else policy.check try: - resp = [self._get_filtered_item(state.request, controller, - resource, collection, item) + # Retrieve once only the fields to be stripped from the items, that + # are all the same type, instead of calculating this list every + # time. + if to_process: + fields_to_strip = self._exclude_attributes_by_policy( + neutron_context, controller, resource, collection, + to_process[0]) + else: + fields_to_strip = [] + + resp = [self._filter_attributes(state.request, item, + fields_to_strip) for item in to_process if (state.request.method != 'GET' or policy_method(neutron_context, action, item, @@ -203,13 +213,6 @@ def after(self, state): resp = resp[0] state.response.json = {key: resp} - def _get_filtered_item(self, request, controller, resource, collection, - data): - neutron_context = request.context.get('neutron_context') - to_exclude = self._exclude_attributes_by_policy( - neutron_context, controller, resource, collection, data) - return self._filter_attributes(request, data, to_exclude) - def _filter_attributes(self, request, data, fields_to_strip): # This routine will remove the fields that were requested to the # plugin for policy evaluation but were not specified in the diff --git a/neutron/tests/functional/pecan_wsgi/test_hooks.py b/neutron/tests/functional/pecan_wsgi/test_hooks.py index f3d5382f9c0..61858c8c28c 100644 --- a/neutron/tests/functional/pecan_wsgi/test_hooks.py +++ b/neutron/tests/functional/pecan_wsgi/test_hooks.py @@ -27,6 +27,7 @@ from neutron.db.quota import driver as quota_driver from neutron import manager from neutron.pecan_wsgi.controllers import resource +from neutron.pecan_wsgi.hooks import policy_enforcement from neutron import policy from neutron.tests.functional.pecan_wsgi import test_functional @@ -320,6 +321,55 @@ def test_after_on_list_excludes_admin_attribute(self): json_response = jsonutils.loads(response.body) self.assertNotIn('restricted_attr', json_response['mehs'][0]) + def test_after_on_list_excludes_admin_attribute_all_items(self): + self.mock_plugin.get_mehs.return_value = [ + {'id': 'xxx', 'attr': 'meh1', + 'restricted_attr': 'secret1', 'project_id': 'projid'}, + {'id': 'xxx', 'attr': 'meh2', + 'restricted_attr': 'secret2', 'project_id': 'projid'}, + {'id': 'xxx', 'attr': 'meh3', + 'restricted_attr': 'secret3', 'project_id': 'projid'}, + ] + response = self.app.get('/v2.0/mehs', + headers={'X-Project-Id': 'projid'}) + self.assertEqual(200, response.status_int) + json_response = jsonutils.loads(response.body) + for item in json_response['mehs']: + self.assertNotIn('restricted_attr', item) + self.assertIn('attr', item) + self.assertIn('id', item) + + def test_after_on_list_calls_exclude_attributes_once(self): + self.mock_plugin.get_mehs.return_value = [ + {'id': 'xxx', 'attr': 'meh1', + 'restricted_attr': '', 'project_id': 'projid'}, + {'id': 'xxx', 'attr': 'meh2', + 'restricted_attr': '', 'project_id': 'projid'}, + {'id': 'xxx', 'attr': 'meh3', + 'restricted_attr': '', 'project_id': 'projid'}, + ] + orig = policy_enforcement.PolicyHook._exclude_attributes_by_policy + with mock.patch.object( + policy_enforcement.PolicyHook, + '_exclude_attributes_by_policy', + side_effect=orig, autospec=True) as mock_exclude: + response = self.app.get('/v2.0/mehs', + headers={'X-Project-Id': 'projid'}) + self.assertEqual(200, response.status_int) + mock_exclude.assert_called_once() + + def test_after_on_list_empty_skips_exclude_attributes(self): + self.mock_plugin.get_mehs.return_value = [] + orig = policy_enforcement.PolicyHook._exclude_attributes_by_policy + with mock.patch.object( + policy_enforcement.PolicyHook, + '_exclude_attributes_by_policy', + side_effect=orig, autospec=True) as mock_exclude: + response = self.app.get('/v2.0/mehs', + headers={'X-Project-Id': 'projid'}) + self.assertEqual(200, response.status_int) + mock_exclude.assert_not_called() + def test_after_inits_policy(self): self.mock_plugin.get_mehs.return_value = [{ 'id': 'xxx', From b2f6cb8fff2c2565dcaa330c6f5e573864be01c7 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 12 Jun 2026 17:16:56 +0200 Subject: [PATCH 43/63] l3: Remove unused ``ha_keepalived_state_change_server_threads`` config option The ``ha_keepalived_state_change_server_threads`` configuration option is no longer used since the keepalived state change server was refactored. Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I694ab58a6965436b0e2f3a4b5c6d7e8f9a0b1c2d --- neutron/conf/agent/l3/ha.py | 9 --------- neutron/tests/fullstack/resources/config.py | 1 - ...ved-state-change-server-threads-694ab58a6965436b.yaml | 6 ++++++ 3 files changed, 6 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/remove-ha-keepalived-state-change-server-threads-694ab58a6965436b.yaml diff --git a/neutron/conf/agent/l3/ha.py b/neutron/conf/agent/l3/ha.py index 3d6dcede68d..a10bd45ce0c 100644 --- a/neutron/conf/agent/l3/ha.py +++ b/neutron/conf/agent/l3/ha.py @@ -13,7 +13,6 @@ # License for the specific language governing permissions and limitations # under the License. -from neutron_lib.utils import host from oslo_config import cfg from neutron._i18n import _ @@ -34,14 +33,6 @@ cfg.IntOpt('ha_vrrp_advert_int', default=2, help=_('The advertisement interval in seconds')), - cfg.IntOpt('ha_keepalived_state_change_server_threads', - default=(1 + host.cpu_count()) // 2, - sample_default='(1 + ) / 2', - min=1, - help=_('Number of concurrent threads for ' - 'keepalived server connection requests. ' - 'More threads create a higher CPU load ' - 'on the agent node.')), cfg.IntOpt('ha_vrrp_health_check_interval', default=0, help=_('The VRRP health check interval in seconds. Values > 0 ' diff --git a/neutron/tests/fullstack/resources/config.py b/neutron/tests/fullstack/resources/config.py index 90bccd7dc4e..acb6c7b18e5 100644 --- a/neutron/tests/fullstack/resources/config.py +++ b/neutron/tests/fullstack/resources/config.py @@ -405,7 +405,6 @@ def __init__(self, env_desc, host_desc, temp_dir, integration_bridge=None): self.config['DEFAULT'].update({ 'debug': 'True', 'test_namespace_suffix': self._generate_namespace_suffix(), - 'ha_keepalived_state_change_server_threads': '1', }) self.config.update({ 'agent': {'use_helper_for_ns_read': 'False'} diff --git a/releasenotes/notes/remove-ha-keepalived-state-change-server-threads-694ab58a6965436b.yaml b/releasenotes/notes/remove-ha-keepalived-state-change-server-threads-694ab58a6965436b.yaml new file mode 100644 index 00000000000..5899db60b7d --- /dev/null +++ b/releasenotes/notes/remove-ha-keepalived-state-change-server-threads-694ab58a6965436b.yaml @@ -0,0 +1,6 @@ +--- +upgrade: + - | + The ``ha_keepalived_state_change_server_threads`` configuration option + has been removed. This option was no longer used since the keepalived + state change server was refactored. From 25a7c5951259afc2ef9a6057f131f714d0acddf8 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 12 Jun 2026 17:23:25 +0200 Subject: [PATCH 44/63] Remove UnknownNetworkType InvalidAddressRequest and IPAllocationFailed These exception classes are defined but never raised or referenced anywhere in the codebase. Remove them to reduce dead code. Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Ia1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 --- neutron/ipam/exceptions.py | 9 --------- neutron/plugins/ml2/common/exceptions.py | 5 ----- 2 files changed, 14 deletions(-) diff --git a/neutron/ipam/exceptions.py b/neutron/ipam/exceptions.py index 0d207f2450f..47c74293f99 100644 --- a/neutron/ipam/exceptions.py +++ b/neutron/ipam/exceptions.py @@ -39,11 +39,6 @@ class InvalidIpForSubnet(exceptions.BadRequest): message = _("IP address %(ip)s does not belong to subnet %(subnet_id)s") -class InvalidAddressRequest(exceptions.BadRequest): - message = _("The address allocation request could not be satisfied " - "because: %(reason)s") - - class InvalidSubnetRequest(exceptions.BadRequest): message = _("The subnet request could not be satisfied because: " "%(reason)s") @@ -72,10 +67,6 @@ class IpAddressGenerationFailureNoMatchingSubnet(IpAddressGenerationFailure): "network %(network_id)s, service type %(service_type)s.") -class IPAllocationFailed(exceptions.NeutronException): - message = _("IP allocation failed. Try again later.") - - class IpamValueInvalid(exceptions.Conflict): def __init__(self, message=None): self.message = message diff --git a/neutron/plugins/ml2/common/exceptions.py b/neutron/plugins/ml2/common/exceptions.py index 4837947cb02..200bc8414a5 100644 --- a/neutron/plugins/ml2/common/exceptions.py +++ b/neutron/plugins/ml2/common/exceptions.py @@ -40,8 +40,3 @@ class ExtensionDriverNotFound(exceptions.InvalidConfigurationOption): """Required extension driver not found in ML2 config.""" message = _("Extension driver %(driver)s required for " "service plugin %(service_plugin)s not found.") - - -class UnknownNetworkType(exceptions.NeutronException): - """Network with unknown type.""" - message = _("Unknown network type %(network_type)s.") From 75b6c30bb60a13dfa6efb68c1e292df0570e35f3 Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Fri, 12 Jun 2026 06:12:23 +0000 Subject: [PATCH 45/63] Fix EVPN Port_Binding events The Port_Binding events need to check the chassis and also need to be guaranteed an EvpnFSM that isn't None since _load_sb_idl() is run before ext.start(). This required splitting EvpnFSM init into another step to avoid creating side-effects upon __init__. The chassis requirement ends up reqquiring something to actually set the chassis on the Port_Binding. Manually adding it can lead to northd deleting it, so this also adds setting up ovn-controller. This is done by patching out the base class ovsdb-server/northd setup and using ovsdbapp's venv which already handles ovn-controller. We could add a version of this baseclass to the functional base. ovsdbapp.venv doesn't currently handle SSL, so for now I've left the class here. Assisted-By: Claude Opus 4.6 Change-Id: Iaa9b749ab139f351134458184f6ba93b627e6618 Signed-off-by: Terry Wilson --- neutron/agent/ovn/extensions/evpn/__init__.py | 14 +- neutron/agent/ovn/extensions/evpn/events.py | 16 +- neutron/agent/ovn/extensions/evpn/fsm.py | 7 +- .../agent/ovn/extensions/evpn/test_events.py | 209 +++++++++++++++--- .../agent/ovn/extensions/test_evpn.py | 7 +- .../agent/ovn/extensions/evpn/test_fsm.py | 4 +- .../unit/agent/ovn/extensions/test_evpn.py | 3 +- 7 files changed, 207 insertions(+), 53 deletions(-) diff --git a/neutron/agent/ovn/extensions/evpn/__init__.py b/neutron/agent/ovn/extensions/evpn/__init__.py index aa6bd216f4a..299f678fa66 100644 --- a/neutron/agent/ovn/extensions/evpn/__init__.py +++ b/neutron/agent/ovn/extensions/evpn/__init__.py @@ -48,7 +48,7 @@ class EvpnConfig: class EVPNAgentExtension(ovn_ext_mgr.OVNAgentExtension): def __init__(self): super().__init__() - self._evpn_fsm = None + self._evpn_fsm = fsm.EvpnFSM() self.nl_dispatcher = None def _get_evpn_config(self): @@ -68,7 +68,6 @@ def _get_evpn_config(self): self.cfg.dstport, self.cfg.mac) def start(self): - super().start() self._get_evpn_config() privileged_svd.register_vxlan_vnifilter() @@ -85,7 +84,7 @@ def start(self): 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) + self._evpn_fsm.setup(self.svd, self.cfg, driver) vrf_handler = netlink_monitor.VrfHandler(self._evpn_fsm) self.nl_dispatcher = nl_dispatcher.NetlinkDispatcher( rtnl.RTMGRP_LINK) @@ -98,6 +97,7 @@ def start(self): on_end=vrf_handler.replay_end) self.nl_dispatcher.start() LOG.info("NetlinkDispatcher started as part of EVPN extension") + super().start() @property def name(self): @@ -117,11 +117,13 @@ def nb_idl_events(self): @property def sb_idl_tables(self): - return ['Port_Binding'] + return ['Port_Binding', 'Chassis'] @property def sb_idl_events(self): return [ - evpn_events.PortBindingLrpEvpnCreateEvent(self._evpn_fsm), - evpn_events.PortBindingLrpEvpnDeleteEvent(self._evpn_fsm), + evpn_events.PortBindingLrpEvpnCreateEvent(self._evpn_fsm, + self.agent_api.chassis), + evpn_events.PortBindingLrpEvpnDeleteEvent(self._evpn_fsm, + self.agent_api.chassis), ] diff --git a/neutron/agent/ovn/extensions/evpn/events.py b/neutron/agent/ovn/extensions/evpn/events.py index ab9aa4665c2..5e873ef8659 100644 --- a/neutron/agent/ovn/extensions/evpn/events.py +++ b/neutron/agent/ovn/extensions/evpn/events.py @@ -26,8 +26,9 @@ class EVPNAgentEvent(row_event.RowEvent): - def __init__(self, fsm): + def __init__(self, fsm, chassis): self.fsm = fsm + self.chassis = chassis super().__init__(self.EVENTS, self.TABLE, None) @@ -35,16 +36,25 @@ class EVPNPortBindingEvent(EVPNAgentEvent): TABLE = 'Port_Binding' def match_fn(self, event, row, old): - return (ovn_const.LR_OPTIONS_DR_VRF_NAME in row.options and + try: + chassis_name = row.chassis[0].name + except IndexError: + return False + return (chassis_name == self.chassis and + ovn_const.LR_OPTIONS_DR_VRF_NAME in row.options and 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): TABLE = 'Port_Binding' - EVENTS = (EVPNAgentEvent.ROW_CREATE,) + EVENTS = (EVPNAgentEvent.ROW_CREATE, EVPNAgentEvent.ROW_UPDATE) def match_fn(self, event, row, old): + if event == self.ROW_UPDATE: + # Only process updates where chassis actually changed. + if not hasattr(old, 'chassis'): + return False if not super().match_fn(event, row, old): return False try: diff --git a/neutron/agent/ovn/extensions/evpn/fsm.py b/neutron/agent/ovn/extensions/evpn/fsm.py index 1d340baf641..5932f0d3f43 100644 --- a/neutron/agent/ovn/extensions/evpn/fsm.py +++ b/neutron/agent/ovn/extensions/evpn/fsm.py @@ -77,8 +77,13 @@ class EvpnFSM: (Evpn.DESTROY, "_destroy"), } - def __init__(self, svd, config, frr_driver): + def __init__(self): self.instances = {} # vrf -> Evpn + self._svd = None + self._cfg = None + self._driver = None + + def setup(self, svd, config, frr_driver): self._svd = svd self._cfg = config self._driver = frr_driver 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 bfd6fdd61e7..7a3852ebdaf 100644 --- a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py +++ b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py @@ -13,57 +13,190 @@ # License for the specific language governing permissions and limitations # under the License. +import os from unittest import mock +from oslo_config import cfg +from oslo_config import fixture as fixture_config +from ovsdbapp.backend.ovs_idl import event as ovs_idl_event +from ovsdbapp import venv as ovn_venv import testtools -from neutron.agent.ovn.extensions.evpn import events as evpn_events +from neutron.agent.linux import nl_dispatcher +from neutron.agent.ovn.agent import ovn_neutron_agent 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 svd as evpn_svd 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 +from neutron.conf.agent.ovn.evpn import config as evpn_conf +from neutron.conf.agent.ovn.ovn_neutron_agent import config as config_ovn_agent +from neutron.privileged.agent.linux import svd as privileged_svd from neutron.services.evpn import constants as svc_const -from neutron.tests.functional.services import bgp as bgp_base +from neutron.tests.functional import base -_OVN_SB_TABLES_WITH_PB = bgp_ovn.OVN_SB_TABLES + ( - 'Datapath_Binding', 'Encap', 'Port_Binding') +EVPN_EXT = 'ovn-evpn' -class BaseEvpnEventsTestCase(bgp_base.BaseBgpIDLTestCase): - schemas = ['OVN_Northbound', 'OVN_Southbound'] +class PreCheckWaitEvent(ovs_idl_event.WaitEvent): + """WaitEvent that pre-checks current IDL state when wait() is called. + + Eliminates the race between registering the event and the condition + already being true. Reuses conditions and match_fn with no duplication. + """ + + def __init__(self, api, *args, **kwargs): + self.api = api + super().__init__(*args, **kwargs) + + def pre_check(self): + table = self.api.idl.tables.get(self.table) + if not table: + return False + for row in table.rows.values(): + if (self.base_match(None, row, None) and + self.match_fn(None, row, None)): + return True + return False + + def wait(self): + if not self.event.is_set() and self.pre_check(): + self.event.set() + return self.event.wait(self.timeout) + + +class OvnControllerChassisEvent(PreCheckWaitEvent): + """Fires when ovn-controller registers a chassis with the given name.""" + + def __init__(self, api, chassis_name, timeout=15): + super().__init__( + api, + (self.ROW_CREATE,), + 'Chassis', + (('name', '=', chassis_name),), + timeout=timeout) + + +class _EvpnOvsOvnVenvFixture(ovn_venv.OvsOvnVenvFixture): + """Extends the ovsdbapp venv to pre-populate EVPN OVS external_ids.""" + + def init_ovn_processes(self): + super().init_ovn_processes() + self.venv.call([ + 'ovs-vsctl', f'--db={self.ovs_connection}', + 'set', 'open', '.', + 'external_ids:ovn-evpn-local-ip=10.0.0.1', + 'external_ids:ovn-evpn-vxlan-ports=49152', + ]) + + +class _VenvOvsdbServerMgr: + """Minimal ovsdb_server_mgr shim backed by an ovsdbapp venv.""" + + def __init__(self, nb_connection, sb_connection): + self._nb = nb_connection + self._sb = sb_connection + self.private_key = '' + self.certificate = '' + self.ca_cert = '' + + def get_ovsdb_connection_path(self, db_type='nb'): + return self._nb if db_type == 'nb' else self._sb + + +class BaseEvpnEventsTestCase(base.TestOVNFunctionalBase): + + def _start_ovsdb_server(self): + self._ovn_venv = self.useFixture( + _EvpnOvsOvnVenvFixture( + self.temp_dir, + ovsdir=os.getenv('OVS_SRCDIR'), + ovndir=os.getenv('OVN_SRCDIR'), + add_chassis=True, + remove=False)) + set_cfg = cfg.CONF.set_override + set_cfg('ovn_nb_connection', [self._ovn_venv.ovnnb_connection], 'ovn') + set_cfg('ovn_sb_connection', [self._ovn_venv.ovnsb_connection], 'ovn') + for key in ('ovn_nb_private_key', 'ovn_nb_certificate', + 'ovn_nb_ca_cert', 'ovn_sb_private_key', + 'ovn_sb_certificate', 'ovn_sb_ca_cert'): + set_cfg(key, '', 'ovn') + cfg.CONF.set_override('ovsdb_connection_timeout', 30, 'ovn') + config_ovn_agent.register_opts() + set_cfg('ovsdb_connection', self._ovn_venv.ovs_connection, 'OVS') + self.ovsdb_server_mgr = _VenvOvsdbServerMgr( + self._ovn_venv.ovnnb_connection, + self._ovn_venv.ovnsb_connection) + + def _start_ovn_northd(self): + pass # already started by OvsOvnVenvFixture def setUp(self): - bgp_ovn.OvnSbIdl.tables = _OVN_SB_TABLES_WITH_PB - try: - super().setUp() - 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(), - 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( - self.mock_evpn_ext._evpn_fsm)) - self.sb_api.idl.notify_handler.watch_event( - evpn_events.PortBindingLrpEvpnDeleteEvent( - self.mock_evpn_ext._evpn_fsm)) + super().setUp() + evpn_conf.register_opts() + system_id = self._ovn_venv.venv.call([ + 'ovs-vsctl', f'--db={self._ovn_venv.ovs_connection}', + 'get', 'open', '.', 'external_ids:system-id', + ]).decode().strip().strip('"') + self.chassis_name = system_id + event = OvnControllerChassisEvent(self.sb_api, self.chassis_name) + self.sb_api.idl.notify_handler.watch_event(event) + self.assertTrue(event.wait(), + 'ovn-controller did not register chassis ' + + self.chassis_name) + self.ovn_agent = self._start_evpn_agent() + evpn_extension = self.ovn_agent[EVPN_EXT] + self.fsm = evpn_extension._evpn_fsm + self.fsm_advance = mock.patch.object( + self.fsm, 'advance', wraps=self.fsm.advance).start() + + def _start_evpn_agent(self): + conf = self.useFixture(fixture_config.Config()).conf + conf.set_override('extensions', EVPN_EXT, group='agent') + conf.set_override('ovn_nb_connection', + cfg.CONF.ovn.ovn_nb_connection, group='ovn') + conf.set_override('ovn_sb_connection', + cfg.CONF.ovn.ovn_sb_connection, group='ovn') + + agt = ovn_neutron_agent.OVNNeutronAgent(conf) + + with mock.patch.object(ovn_neutron_agent.OVNNeutronAgent, 'wait'), \ + mock.patch.object(privileged_svd, + 'register_vxlan_vnifilter'), \ + mock.patch.object(evpn_svd.EvpnSvd, 'create'), \ + mock.patch('neutron.agent.ovn.extensions.evpn' + '.fsm_frr_driver.FsmFrrVtyshDriver', + return_value=mock.Mock()), \ + mock.patch.object(nl_dispatcher.NetlinkDispatcher, 'start'): + agt.start() + + self.addCleanup(agt.ext_manager_api.sb_idl.ovsdb_connection.stop) + if agt.ext_manager_api.nb_idl: + self.addCleanup(agt.ext_manager_api.nb_idl.ovsdb_connection.stop) + return agt 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}' lsp_name = f'lsp-to-evpn-{vni}' + hcg_name = f'hcg-evpn-{vni}' lr = self.nb_api.lr_add(lr_name).execute(check_error=True) vrf = evpn_utils.evpn_vrf_name(lr.uuid) + # Create an HA_Chassis_Group for the LRP, matching the real EVPN + # command which uses ha_chassis_group rather than options:chassis on + # the LR (the latter produces l3gateway ports where the chassis column + # is never persistently set). + hcg = self.nb_api.ha_chassis_group_add( + hcg_name).execute(check_error=True) + self.nb_api.ha_chassis_group_add_chassis( + hcg_name, self.chassis_name, 100).execute(check_error=True) with self.nb_api.transaction(check_error=True) as txn: txn.add(self.nb_api.db_set( 'Logical_Router', lr_name, options={ 'dynamic-routing': 'true', - 'chassis': 'fake-chassis', ovn_const.LR_OPTIONS_DR_VRF_NAME: vrf, })) txn.add(self.nb_api.ls_add(ls_name)) @@ -73,9 +206,11 @@ def _create_evpn_lrp(self, vni, mac, vlan=100): 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', - })) + # Omit 'dynamic-routing-maintain-vrf: true' — that option + # causes ovn-controller to create a kernel VRF device, which + # requires CAP_NET_ADMIN and is not needed to test events. + options={}, + ha_chassis_group=hcg.uuid)) txn.add(self.nb_api.lsp_add( ls_name, lsp_name, type='router', @@ -90,7 +225,7 @@ def _create_lrp_without_evpn_match(self, vni, mac, lrp_name = f'lrp-no-evpn-{vni}' lsp_name = f'lsp-no-evpn-{vni}' lr = self.nb_api.lr_add(lr_name).execute(check_error=True) - options = {'dynamic-routing': 'true', 'chassis': 'fake-chassis'} + options = {'dynamic-routing': 'true'} if set_vrf: options[ovn_const.LR_OPTIONS_DR_VRF_NAME] = ( evpn_utils.evpn_vrf_name(lr.uuid)) @@ -106,7 +241,9 @@ def _create_lrp_without_evpn_match(self, vni, mac, txn.add(self.nb_api.lrp_add( lr_name, lrp_name, mac, [], external_ids=external_ids, - options={'dynamic-routing-maintain-vrf': 'true'})) + # Omit 'dynamic-routing-maintain-vrf: true' + # see _create_evpn_lrp + options={})) txn.add(self.nb_api.lsp_add( ls_name, lsp_name, type='router', options={'router-port': lrp_name})) @@ -118,7 +255,7 @@ def _delete_evpn_lrp(self, vni): def _wait_for_advance(self, timeout=5): common_utils.wait_until_true( - lambda: self.mock_evpn_ext._evpn_fsm.advance.called, + lambda: self.fsm_advance.called, sleep=0.2, timeout=timeout, exception=AssertionError('FSM advance was not called')) @@ -132,8 +269,8 @@ def test_create_event_advances_fsm(self): mac = 'aa:bb:cc:dd:ee:ff' 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.assertIn(vrf, self.fsm.instances) + instance = self.fsm.instances[vrf] self.assertEqual(evpn_fsm.Evpn.WAITING_FOR_ROUTER, instance.state) self.assertEqual(mac, instance.mac) self.assertEqual(vni, instance.vni) @@ -158,7 +295,7 @@ def test_create_event_not_triggered_missing_vlan(self): self._wait_for_advance(timeout=2) def test_create_event_illegal_fsm_transition(self): - self.mock_evpn_ext._evpn_fsm.advance.side_effect = \ + self.fsm_advance.side_effect = \ evpn_exc.FSMIllegalTransition("forced bad state") self._create_evpn_lrp(10004, 'aa:bb:cc:dd:ee:ff') self._wait_for_advance() @@ -171,19 +308,19 @@ def test_delete_event_advances_fsm(self): mac = 'cc:dd:ee:ff:00:11' vrf = self._create_evpn_lrp(vni, mac) self._wait_for_advance() - self.mock_evpn_ext._evpn_fsm.advance.reset_mock() + self.fsm_advance.reset_mock() self._delete_evpn_lrp(vni) self._wait_for_advance() - self.assertNotIn(vrf, self.real_fsm.instances) + self.assertNotIn(vrf, self.fsm.instances) def test_delete_event_illegal_fsm_transition(self): vni = 20001 mac = 'cc:dd:ee:ff:00:12' self._create_evpn_lrp(vni, mac) self._wait_for_advance() - self.mock_evpn_ext._evpn_fsm.advance.reset_mock() - self.mock_evpn_ext._evpn_fsm.advance.side_effect = \ + self.fsm_advance.reset_mock() + self.fsm_advance.side_effect = \ evpn_exc.FSMIllegalTransition("forced bad state") self._delete_evpn_lrp(vni) diff --git a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py index f146ff13343..824521d3608 100644 --- a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py @@ -44,8 +44,7 @@ def _safe_delete(name): pass def test_vrf_handler_lifecycle(self): - vrf_handler = netlink_monitor.VrfHandler( - fsm.EvpnFSM(svd=None, config=None, frr_driver=None)) + vrf_handler = netlink_monitor.VrfHandler(fsm.EvpnFSM()) dispatcher = nl_dispatcher.NetlinkDispatcher(rtnl.RTMGRP_LINK) dispatcher.register_handler( @@ -152,8 +151,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, - frr_driver=mock.Mock()) + self._evpn_fsm = fsm.EvpnFSM() + self._evpn_fsm.setup(self.svd, self.cfg, mock.Mock()) def _advance_to_advertising(self, vni, vid): self._evpn_fsm.advance( 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 8aa41ff2407..0e0b3b12524 100644 --- a/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py +++ b/neutron/tests/unit/agent/ovn/extensions/evpn/test_fsm.py @@ -30,8 +30,8 @@ def setUp(self): self.mock_svd = mock.Mock() self.mock_config = mock.Mock() self.mock_driver = mock.Mock() - self.evpn_fsm = fsm.EvpnFSM(self.mock_svd, - self.mock_config, self.mock_driver) + self.evpn_fsm = fsm.EvpnFSM() + self.evpn_fsm.setup(self.mock_svd, self.mock_config, self.mock_driver) self.mock_config.br_mtu = BR_MTU def test_vrf_then_port_binding_create(self): diff --git a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py index e288f2e8442..05689ac9f3f 100644 --- a/neutron/tests/unit/agent/ovn/extensions/test_evpn.py +++ b/neutron/tests/unit/agent/ovn/extensions/test_evpn.py @@ -76,7 +76,8 @@ class TestVrfHandler(base.BaseTestCase): def setUp(self): super().setUp() - self._evpn_fsm = fsm.EvpnFSM(mock.Mock(), mock.Mock(), mock.Mock()) + self._evpn_fsm = fsm.EvpnFSM() + self._evpn_fsm.setup(mock.Mock(), mock.Mock(), mock.Mock()) self.handler = netlink_monitor.VrfHandler(self._evpn_fsm) def test_handle_newlink_evpn_vrf(self): From a8feb94e411431e084adaa9e8ee1724cc3ff1b6f Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Fri, 12 Jun 2026 21:08:35 +0000 Subject: [PATCH 46/63] More tag_request fixes create_provnet_port blindly sets tag from segment.get(SEGMENTATION_ID, []) which if segment contains that key, but it's set to None will return None and not []. The recent tag -> tag_request change lost code that would defend against that case. This adds the check in the calling code and also adds a guard in the tag handling LSwitch commands. Related-Bug: #2155155 Change-Id: I98425aa16fbd8c38d70f507fca29deaaefa7deff Signed-off-by: Terry Wilson --- neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py | 3 +++ .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 1 + 2 files changed, 4 insertions(+) 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 8fe3ea65ea2..34b464445e0 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py @@ -204,6 +204,9 @@ def _tag_column_to_tag_request(columns): 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 + # Defensively handle unchecked tag_request=None + if columns.get('tag_request') is None: + columns['tag_request'] = [] return columns 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 998f68013a5..2dfa003ee9f 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 @@ -2165,6 +2165,7 @@ def update_nat_rules(self, context, router_id, enable_snat, cidrs=None, def create_provnet_port(self, context, network_id, segment, txn=None, network=None): tag = segment.get(segment_def.SEGMENTATION_ID, []) + tag = [] if tag is None else tag physnet = segment.get(segment_def.PHYSICAL_NETWORK) fdb_enabled = ('true' if ovn_conf.is_learn_fdb_enabled() else 'false') From 129e88e46197e40e91caecf31a4276e2e29c3a15 Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Fri, 12 Jun 2026 22:19:14 -0400 Subject: [PATCH 47/63] Explicitly create log directories in the FrrFixture While debugging some frr related tests I noticed that frr would not log into dedicated pathspaces because they did not exists. Change-Id: I0d6eff474e74f0813f5b699fdc3ede2fca210cb6 Signed-off-by: Miro Tomaska --- neutron/tests/common/net_helpers.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/neutron/tests/common/net_helpers.py b/neutron/tests/common/net_helpers.py index b566002e18b..69f7883db0a 100644 --- a/neutron/tests/common/net_helpers.py +++ b/neutron/tests/common/net_helpers.py @@ -1109,6 +1109,7 @@ class FrrFixture(fixtures.Fixture): FRR_CONF_DIR_BASE = '/etc/frr' FRR_STATE_DIR_BASE = '/var/run/frr' + FRR_LOG_DIR_BASE = '/var/log/frr' 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)), @@ -1152,6 +1153,7 @@ def __init__(self, namespace): self.namespace = namespace self._conf_dir = os.path.join(self.FRR_CONF_DIR_BASE, namespace) self._state_dir = os.path.join(self.FRR_STATE_DIR_BASE, namespace) + self._log_dir = os.path.join(self.FRR_LOG_DIR_BASE, namespace) def _setUp(self): self.addCleanup(self._cleanup_frr) @@ -1166,8 +1168,11 @@ def _write_file(path, content): utils.execute(['cp', tmp.name, path], run_as_root=True) def _create_config(self): - utils.execute( - ['mkdir', '-p', self._conf_dir], run_as_root=True) + for pathspace_dir in (self._conf_dir, self._log_dir): + utils.execute( + ['mkdir', '-p', pathspace_dir], run_as_root=True) + utils.execute( + ['chown', '-R', 'frr:frr', pathspace_dir], run_as_root=True) self._write_file( os.path.join(self._conf_dir, 'daemons'), @@ -1181,11 +1186,7 @@ def _create_config(self): os.path.join(self._conf_dir, 'frr.conf'), self.FRR_CONF % { 'hostname': self.namespace, - 'log_file': '/var/log/frr/%s/frr.log' % self.namespace}) - - utils.execute( - ['chown', '-R', 'frr:frr', self._conf_dir], - run_as_root=True) + 'log_file': '%s/frr.log' % self._log_dir}) def start_frr(self): utils.execute( From cea1a8ab58797e14ed67a58fc0f50371335b44fb Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Thu, 11 Jun 2026 14:31:47 -0400 Subject: [PATCH 48/63] Frrinit.sh scripts spawns background deamons When frrinit.sh spawns its child processes (e.g. zebra) it keeps PIPES open, causing `execute` to hang indefinitely. This patch addresses it by discarding stdout/stderr. Also adding rootwrap testing filters when execution is done via rootwrap. Closes-Bug: #2156611 Change-Id: I66e3298a245edb144f312f6cc97092763fc628aa Signed-off-by: Miro Tomaska --- neutron/tests/common/net_helpers.py | 20 +++++++++++++++----- tools/rootwrap/testing.filters | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/neutron/tests/common/net_helpers.py b/neutron/tests/common/net_helpers.py index b566002e18b..ebb2c962fac 100644 --- a/neutron/tests/common/net_helpers.py +++ b/neutron/tests/common/net_helpers.py @@ -1187,20 +1187,30 @@ def _create_config(self): ['chown', '-R', 'frr:frr', self._conf_dir], run_as_root=True) - def start_frr(self): + def _excute_with_std_discard(self, frr_action): + """Redirect stdout/stderr to /dev/null. + + frrinit.sh spawns background daemons (e.g. watchfrr) + that inherit the script's stdout/stderr pipes, + causing execute() to hang waiting for pipe close. + """ utils.execute( - [self.FRRINIT, 'start', self.namespace], + ['bash', '-c', + '%s %s %s > /dev/null 2>&1' + % (self.FRRINIT, frr_action, + self.namespace)], run_as_root=True) + def start_frr(self): + self._excute_with_std_discard('start') + 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) + self._excute_with_std_discard('restart') def _cleanup_frr(self): # NOTE: frrinit.sh returns 0 when stopping an already-stopped diff --git a/tools/rootwrap/testing.filters b/tools/rootwrap/testing.filters index 2ea075d691d..01a611dbf42 100644 --- a/tools/rootwrap/testing.filters +++ b/tools/rootwrap/testing.filters @@ -54,3 +54,17 @@ tcpdump: CommandFilter, tcpdump, root #needed to create transient systemd services for fullstack tests systemd_run: CommandFilter, systemd-run, root systemctl: CommandFilter, systemctl, root + +# FRR functional tests (FrrFixture) +frr_mkdir: RegExpFilter, /bin/mkdir, root, mkdir, -p, /etc/frr/[a-z]+-[0-9a-f-]+ +frr_cp: RegExpFilter, /bin/cp, root, cp, /tmp/.+, /etc/frr/[a-z]+-[0-9a-f-]+/.+ +frr_chown: RegExpFilter, /bin/chown, root, chown, -R, frr:frr, /etc/frr/[a-z]+-[0-9a-f-]+ +frr_rm_conf: RegExpFilter, /bin/rm, root, rm, -rf, /etc/frr/[a-z]+-[0-9a-f-]+ +frr_rm_state: RegExpFilter, /bin/rm, root, rm, -rf, /var/run/frr/[a-z]+-[0-9a-f-]+ +frr_init_start_bash: RegExpFilter, bash, root, bash, -c, /usr/lib(exec)?/frr/frrinit\.sh start [a-z]+-[0-9a-f-]+ > /dev/null 2>&1 +frr_init_restart_bash: RegExpFilter, bash, root, bash, -c, /usr/lib(exec)?/frr/frrinit\.sh restart [a-z]+-[0-9a-f-]+ > /dev/null 2>&1 +frr_init_stop: RegExpFilter, /usr/lib/frr/frrinit.sh, root, /usr/lib/frr/frrinit.sh, stop, [a-z]+-[0-9a-f-]+ +frr_init_stop_libexec: RegExpFilter, /usr/libexec/frr/frrinit.sh, root, /usr/libexec/frr/frrinit.sh, stop, [a-z]+-[0-9a-f-]+ +vtysh_cmd: RegExpFilter, vtysh, root, vtysh, -N, [a-z]+-[0-9a-f-]+, -c, .* +vtysh_dryrun: RegExpFilter, vtysh, root, vtysh, -N, [a-z]+-[0-9a-f-]+, --dryrun, -f, .* +vtysh_apply: RegExpFilter, vtysh, root, vtysh, -N, [a-z]+-[0-9a-f-]+, -f, .* \ No newline at end of file From 92181b39e793e917483b9a9536bc6925961060cb Mon Sep 17 00:00:00 2001 From: Seyeong Kim Date: Fri, 12 Jun 2026 07:06:19 +0000 Subject: [PATCH 49/63] Guard tun_br in _restore_local_vlan_map when no br-tun With tunneling disabled (enable_tunneling=False), there is no br-tun and self.tun_br is None. _restore_local_vlan_map(), called from __init__(), calls self.tun_br.get_flood_to_tun_ofports() unconditionally for every integration-bridge port that carries a net_uuid, so the agent crashes: AttributeError: 'NoneType' object has no attribute 'get_flood_to_tun_ofports' The branch is only reached once the agent has populated net_uuid on the ports, so the crash hits on agent restart and recurs on every restart, preventing the agent from restoring dataplane flows until manual recovery. Skip the flood-port restore when tunneling is disabled, leaving tun_ofports as an empty set as for ports with no tunnel flood entries. Closes-Bug: #2156566 Change-Id: I4741480f8a7fbce51f521579e78d5687773c29b8 Signed-off-by: Seyeong Kim --- .../openvswitch/agent/ovs_neutron_agent.py | 6 ++-- .../agent/test_ovs_neutron_agent.py | 36 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 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 6b255169d55..4ea25341cb6 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py @@ -518,8 +518,10 @@ def _restore_local_vlan_map(self): self.available_local_vlans.remove(local_vlan) # Restore the br-tun flood output ports # See LP #1978088 - tun_ofports = self.tun_br.get_flood_to_tun_ofports( - local_vlan) + tun_ofports = set() + if self.enable_tunneling: + tun_ofports = self.tun_br.get_flood_to_tun_ofports( + local_vlan) self._local_vlan_hints[key] = { 'vlan': local_vlan, 'tun_ofports': tun_ofports} 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 894ca0317e2..d211d8c3acf 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 @@ -287,7 +287,14 @@ def test_agent_available_local_vlans(self): self.assertNotIn(tag, available_vlan) def _test_restore_local_vlan_maps(self, tag, segmentation_id='1', - tun_ofports=None): + tun_ofports=None, no_tun_br=False): + if no_tun_br: + self.agent.enable_tunneling = False + self.agent.tun_br = None + else: + # _make_agent() leaves enable_tunneling False (tunnel_types=[]); + # enable it so the gated get_flood_to_tun_ofports() runs. + self.agent.enable_tunneling = True tun_ofports = tun_ofports or set() port = mock.Mock() port.port_name = 'fake_port' @@ -319,19 +326,25 @@ def _test_restore_local_vlan_maps(self, tag, segmentation_id='1', 'other_config': local_vlan_map, 'tag': tag}] - with mock.patch.object(self.agent.int_br, - 'get_ports_attributes', - side_effect=[get_interfaces, - get_ports]) as gpa,\ - mock.patch.object(self.agent.tun_br, - 'get_flood_to_tun_ofports') as gftto: - gftto.return_value = tun_ofports + if no_tun_br: + # enable_tunneling=False: no br-tun to mock. + tun_ctx = contextlib.nullcontext() + expected_tun_ofports = set() + else: + tun_ctx = mock.patch.object(self.agent.tun_br, + 'get_flood_to_tun_ofports', + return_value=tun_ofports) + expected_tun_ofports = tun_ofports + with tun_ctx, mock.patch.object(self.agent.int_br, + 'get_ports_attributes', + side_effect=[get_interfaces, + get_ports]) as gpa: self.agent._restore_local_vlan_map() expected_hints = {} if tag: key = f"{net_uuid}/{segmentation_id}" expected_hints[key] = {'vlan': tag, - 'tun_ofports': tun_ofports} + 'tun_ofports': expected_tun_ofports} self.assertEqual(expected_hints, self.agent._local_vlan_hints) # make sure invalid and unassigned ports were skipped gpa.assert_has_calls([ @@ -354,6 +367,11 @@ def test_restore_local_vlan_map_segmentation_id_compat(self): def test_restore_local_vlan_map_tun_ofports(self): self._test_restore_local_vlan_maps(2, tun_ofports={2, 3}) + def test_restore_local_vlan_map_no_tun_br(self): + # Non-tunneling deployment (enable_tunneling=False): tun_br is None, + # so the flood-port restore must be skipped instead of crashing. + self._test_restore_local_vlan_maps(2, no_tun_br=True) + def test_check_agent_configurations_for_dvr_raises(self): self.agent.enable_distributed_routing = True self.agent.enable_tunneling = True From fa408cd7c3daa6dfec2dd20a152e56bcf45e0584 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 15 Jun 2026 17:02:37 +0200 Subject: [PATCH 50/63] doc: fix RST formatting in ``modify_fields_to_db`` and ``modify_fields_from_db`` The docstrings used plain indented text for the dict example, which Sphinx parses as a block quote with unexpected indentation. Use a literal block (``::`` + indented content) instead. Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Ie3b5c7a2f8d14e6a9b0c3d5f7a1e4b8c2d6f0a3e --- neutron/objects/base.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/neutron/objects/base.py b/neutron/objects/base.py index 4d0829d5676..5cd5412db18 100644 --- a/neutron/objects/base.py +++ b/neutron/objects/base.py @@ -515,10 +515,11 @@ def modify_fields_to_db(cls, fields): This method enables to modify the fields and its content before data is inserted into DB. - It uses the fields_need_translation dict with structure: - { - 'field_name_in_object': 'field_name_in_db' - } + It uses the fields_need_translation dict with structure:: + + { + 'field_name_in_object': 'field_name_in_db' + } :param fields: dict of fields from NeutronDbObject :return: modified dict of fields @@ -544,10 +545,11 @@ def _get_lazy_iterator(cls, field, appender_query): def modify_fields_from_db(cls, db_obj): """Modify the fields after data were fetched from DB. - It uses the fields_need_translation dict with structure: - { - 'field_name_in_object': 'field_name_in_db' - } + It uses the fields_need_translation dict with structure:: + + { + 'field_name_in_object': 'field_name_in_db' + } :param db_obj: model fetched from database :return: modified dict of DB values From 7e6863125283665182b5e906cbe09a917ccb1a51 Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Sat, 13 Jun 2026 11:31:40 -0400 Subject: [PATCH 51/63] Rootwrap filters for vtysh Add rootwrap filters for executing frr_driver.py vtysh commands. This is a stop gap solution until we add privsep support for vtysh Change-Id: I9b356da0bd62ab7fd17f6cbefb8d8c90f36ca82f Signed-off-by: Miro Tomaska --- etc/neutron/rootwrap.d/rootwrap.filters | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/etc/neutron/rootwrap.d/rootwrap.filters b/etc/neutron/rootwrap.d/rootwrap.filters index df8021288cc..6bab79daaca 100644 --- a/etc/neutron/rootwrap.d/rootwrap.filters +++ b/etc/neutron/rootwrap.d/rootwrap.filters @@ -56,6 +56,11 @@ keepalived_state_change_env: EnvFilter, env, root, PROCESS_TAG=, neutron-keepali conntrackd: CommandFilter, conntrackd, root conntrackd_env: EnvFilter, env, root, PROCESS_TAG=, conntrackd +# FRR +vtysh_cmd: RegExpFilter, vtysh, root, vtysh, -c, .* +vtysh_dryrun: RegExpFilter, vtysh, root, vtysh, --dryrun, -f, .* +vtysh_apply: RegExpFilter, vtysh, root, vtysh, -f, .* + # OPEN VSWITCH ovs-ofctl: CommandFilter, ovs-ofctl, root ovsdb-client: CommandFilter, ovsdb-client, root From b4c11448829adeb6cc9d25afc9588ad2fa9947c6 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 15 Jun 2026 15:29:05 +0200 Subject: [PATCH 52/63] Call static method ``RangeAllocator._make_params`` ... instead of using ``super()._make_params`` in ``RandomRangeAllocator(RangeAllocator)``, because the method is static and has not ``__class__`` reference. Test class ``TestRandomRangeAllocator`` now uses ``RandomRangeAllocator`` class instance for testing. Closes-Bug: #2156752 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I0fc36120f3079034e749152b3b3afea9176673a0 --- neutron/db/rangeallocator.py | 2 +- .../functional/db/test_rangeallocator.py | 27 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/neutron/db/rangeallocator.py b/neutron/db/rangeallocator.py index 654193fd806..01fd8275ee0 100644 --- a/neutron/db/rangeallocator.py +++ b/neutron/db/rangeallocator.py @@ -242,7 +242,7 @@ def _gap_source(self): @staticmethod def _make_params(min_val, max_val, scope_val, allocation_id): - params = super()._make_params( + params = RangeAllocator._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 20e8b42b224..7d8226b463d 100644 --- a/neutron/tests/functional/db/test_rangeallocator.py +++ b/neutron/tests/functional/db/test_rangeallocator.py @@ -44,13 +44,6 @@ def setUp(self): 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): @@ -80,6 +73,16 @@ def _all_vnis(self, physnet=_PHYSNET): class TestRangeAllocator(TestRangeAllocatorBase): + def setUp(self): + super().setUp() + 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 test_allocate_from_empty_gets_min(self): alloc_id, vni = self._allocate(min_vni=10, max_vni=100) self.assertEqual(10, vni) @@ -243,6 +246,16 @@ class TestRandomRangeAllocator(TestRangeAllocatorBase): TestRandomRangeAllocatorMySQL runs the same suite against MySQL. """ + def setUp(self): + super().setUp() + self.allocator = rangeallocator.RandomRangeAllocator( + table=self.table, + value_col_name='vni', + scope_col_name='physnet', + scope_param_type=sa.String, + exception_class=evpn_exc.EVPNNoVniAvailable, + ) + def test_random_result_within_range(self): _, vni = self._allocate(min_vni=5, max_vni=10) self.assertGreaterEqual(vni, 5) From 07c07d9574f0fc5bdfb4f38a3ff15705d8f425f9 Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Sat, 13 Jun 2026 10:57:08 -0400 Subject: [PATCH 53/63] Skip test for vrf delete on Centos9 with frr 8.5 This patch is a stopgap until finding better solution where Frr 8.5 with Centos9(kernel 5.14) will stop advertising on all vrfs if one vrf is deleted or just set to DOWN state. See LP#2156642 for more details. Related-Bug: #2156642 Change-Id: I7394b7465009de8eb1c762a525e7629ee169f434 Signed-off-by: Miro Tomaska --- .../linux/evpn_router/frr/test_frr_driver.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 b4a0f913c71..8db607a1ce0 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 @@ -13,6 +13,9 @@ # License for the specific language governing permissions and limitations # under the License. +import platform +import unittest + from pyroute2.netlink import rtnl from neutron.agent.linux.evpn_router.frr import exceptions as frr_exceptions @@ -24,6 +27,7 @@ from neutron.tests.common import net_helpers from neutron.tests.functional import base from neutron_lib import exceptions +from oslo_serialization import jsonutils class FrrVtyshExecutorNamespaced(frr_driver.FrrVtyshExecutor): @@ -42,6 +46,24 @@ def _vtysh_base_cmd(self) -> list[str]: return ['vtysh', '-N', self._namespace] +def _is_centos9_frr85(): + """Return True on CentOS 9 with FRR 8.5 (LP#2156642).""" + try: + info = platform.freedesktop_os_release() + except OSError: + return False + if info.get('ID') != 'centos' or not info.get( + 'VERSION_ID', '').startswith('9'): + return False + try: + executor = FrrVtyshExecutorNamespaced("") + output = executor.execute_cli_cmd('show version json') + frr_version = jsonutils.loads(output).get('version', '') + return frr_version.startswith('8.5') + except Exception: + return False + + class NamespacedVRFHandler(interface.EVPNRouterVrfHandler): """VRF handler that creates VRFs and required linux interfaces for a FRR service. @@ -429,6 +451,8 @@ def test_routes_get_advertised(self): assert_routes(self.ns_b, table_id=vni, present=advertised_routes_v6, ip_version=6) + @unittest.skipIf(_is_centos9_frr85(), + 'CentOS 9 with FRR 8.5 VRF deletion bug LP#2156642') def test_multiple_routers_then_delete_one(self): vni_1 = 10 vni_2 = 20 From b877e9b4bdf0910487eba0f4030e71d9349ede0c Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Mon, 15 Jun 2026 21:35:23 -0400 Subject: [PATCH 54/63] Fix superfluous-parens pylint warnings I had noticed a few of these had snuck-in over the past cycle, let's fix them and enable the pylint check. TrivialFix Assisted-by: Claude Sonnet 4.6 Signed-off-by: Brian Haley Change-Id: I8fcd95fd8be0328277c794280900f8fb3d65ccbb --- .pylintrc | 1 - neutron/agent/l2/extensions/dhcp/ipv6.py | 2 +- .../agent/l3/fip_rule_priority_allocator.py | 2 +- neutron/agent/linux/ip_lib.py | 2 +- neutron/agent/linux/ipset_manager.py | 2 +- neutron/agent/linux/iptables_firewall.py | 2 +- neutron/agent/linux/iptables_manager.py | 2 +- .../linux/openvswitch_firewall/exceptions.py | 2 +- neutron/agent/metadata/driver.py | 2 +- neutron/api/v2/base.py | 2 +- neutron/common/ipv6_utils.py | 2 +- neutron/common/utils.py | 2 +- neutron/core_extensions/qos.py | 2 +- neutron/db/agents_db.py | 2 +- neutron/db/db_base_plugin_v2.py | 4 +- neutron/db/ovn_hash_ring_db.py | 2 +- neutron/db/quota/driver_nolock.py | 2 +- neutron/db/securitygroups_db.py | 2 +- neutron/hacking/checks.py | 2 +- neutron/objects/quota.py | 6 +-- .../ml2/drivers/agent/_common_agent.py | 6 +-- .../plugins/ml2/drivers/l2pop/mech_driver.py | 2 +- .../mech_sriov/agent/sriov_nic_agent.py | 6 +-- .../openvswitch/agent/ovs_neutron_agent.py | 4 +- .../mech_driver/mech_openvswitch.py | 2 +- .../drivers/ovn/mech_driver/mech_driver.py | 2 +- .../ovn/mech_driver/ovsdb/ovn_client.py | 2 +- neutron/plugins/ml2/managers.py | 2 +- neutron/plugins/ml2/plugin.py | 4 +- neutron/services/ndp_proxy/plugin.py | 2 +- neutron/services/placement_report/plugin.py | 2 +- neutron/services/segments/db.py | 2 +- .../tests/common/test_db_base_plugin_v2.py | 4 +- .../tests/functional/agent/l3/framework.py | 4 +- .../agent/linux/test_ovsdb_monitor.py | 2 +- .../privileged/agent/linux/test_ip_lib.py | 2 +- .../scheduler/test_l3_agent_scheduler.py | 2 +- neutron/tests/unit/agent/dhcp/test_agent.py | 2 +- neutron/tests/unit/agent/linux/test_ip_lib.py | 50 +++++++++---------- .../unit/agent/linux/test_iptables_manager.py | 8 +-- neutron/tests/unit/api/test_wsgi.py | 2 +- neutron/tests/unit/db/test_agents_db.py | 2 +- neutron/tests/unit/db/test_migration.py | 6 +-- .../extensions/test_l3_conntrack_helper.py | 2 +- .../test_port_hardware_offload_type.py | 1 + .../ml2/drivers/l2pop/test_mech_driver.py | 2 +- .../unit/privileged/agent/linux/test_utils.py | 12 ++--- .../tests/unit/services/ovn_l3/test_plugin.py | 2 +- 48 files changed, 92 insertions(+), 92 deletions(-) diff --git a/.pylintrc b/.pylintrc index 302bb083f2e..773ca797cc8 100644 --- a/.pylintrc +++ b/.pylintrc @@ -83,7 +83,6 @@ disable= invalid-name, missing-docstring, singleton-comparison, - superfluous-parens, ungrouped-imports, wrong-import-order, consider-using-f-string, diff --git a/neutron/agent/l2/extensions/dhcp/ipv6.py b/neutron/agent/l2/extensions/dhcp/ipv6.py index 35f21fd453e..f5743fb7a94 100644 --- a/neutron/agent/l2/extensions/dhcp/ipv6.py +++ b/neutron/agent/l2/extensions/dhcp/ipv6.py @@ -231,7 +231,7 @@ def get_dhcp_options(self, mac, ip_info, req_options, req_type): # Client FQDN: host- fqdn_bin = struct.pack('!%ds' % len(fqdn), bytes(str(fqdn).encode())) fqdn_str_len = struct.pack('!b', len(fqdn_bin)) - dns_data = (dns_tag + fqdn_str_len + fqdn_bin) + dns_data = dns_tag + fqdn_str_len + fqdn_bin option_list.append( dhcp6.option(code=DHCPV6_OPTION_FQDN, data=dns_data, length=len(dns_data))) diff --git a/neutron/agent/l3/fip_rule_priority_allocator.py b/neutron/agent/l3/fip_rule_priority_allocator.py index 3294084b71a..f7a889c1dcf 100644 --- a/neutron/agent/l3/fip_rule_priority_allocator.py +++ b/neutron/agent/l3/fip_rule_priority_allocator.py @@ -27,7 +27,7 @@ def __hash__(self): def __eq__(self, other): if isinstance(other, FipPriority): - return (self.index == other.index) + return self.index == other.index return False def __int__(self): diff --git a/neutron/agent/linux/ip_lib.py b/neutron/agent/linux/ip_lib.py index 18fb6a123a0..17645f43d31 100644 --- a/neutron/agent/linux/ip_lib.py +++ b/neutron/agent/linux/ip_lib.py @@ -329,7 +329,7 @@ def add_vxlan(self, name, vni, dev, group=None, ttl=None, tos=None, if dstport: kwargs['vxlan_port'] = dstport privileged.create_interface(name, self.namespace, "vxlan", **kwargs) - return (IPDevice(name, namespace=self.namespace)) + return IPDevice(name, namespace=self.namespace) class IPDevice(SubProcessBase): diff --git a/neutron/agent/linux/ipset_manager.py b/neutron/agent/linux/ipset_manager.py index ad14ac13a4e..e35d1ce5ec3 100644 --- a/neutron/agent/linux/ipset_manager.py +++ b/neutron/agent/linux/ipset_manager.py @@ -100,7 +100,7 @@ def set_members_mutate(self, set_name, ethertype, member_ips): else: add_ips = self._get_new_set_ips(set_name, member_ips) del_ips = self._get_deleted_set_ips(set_name, member_ips) - if (len(add_ips) + len(del_ips) < IPSET_ADD_BULK_THRESHOLD): + if len(add_ips) + len(del_ips) < IPSET_ADD_BULK_THRESHOLD: self._add_members_to_set(set_name, add_ips) self._del_members_from_set(set_name, del_ips) else: diff --git a/neutron/agent/linux/iptables_firewall.py b/neutron/agent/linux/iptables_firewall.py index 4643e16f480..04f4547e776 100644 --- a/neutron/agent/linux/iptables_firewall.py +++ b/neutron/agent/linux/iptables_firewall.py @@ -1015,7 +1015,7 @@ def _clean_deleted_remote_sg_members_conntrack_entries(self): self.pre_sg_members, sg_id, ethertype) cur_ips = self._get_sg_members( self.sg_members, sg_id, ethertype) - ips = (pre_ips - cur_ips) + ips = pre_ips - cur_ips if devices and ips: self.ipconntrack.delete_conntrack_state_by_remote_ips( devices, ethertype, ips) diff --git a/neutron/agent/linux/iptables_manager.py b/neutron/agent/linux/iptables_manager.py index ebf9f0790f8..4c9ab24c1d7 100644 --- a/neutron/agent/linux/iptables_manager.py +++ b/neutron/agent/linux/iptables_manager.py @@ -225,7 +225,7 @@ def add_rule(self, chain, rule, wrap=True, top=False, tag=None, def _wrap_target_chain(self, s, wrap): if s.startswith('$'): - s = (f'{self.wrap_name}-{get_chain_name(s[1:], wrap)}') + s = f'{self.wrap_name}-{get_chain_name(s[1:], wrap)}' return s diff --git a/neutron/agent/linux/openvswitch_firewall/exceptions.py b/neutron/agent/linux/openvswitch_firewall/exceptions.py index d0c241d36a1..e6d7d916631 100644 --- a/neutron/agent/linux/openvswitch_firewall/exceptions.py +++ b/neutron/agent/linux/openvswitch_firewall/exceptions.py @@ -29,4 +29,4 @@ class OVSFWTagNotFound(exceptions.NeutronException): class OVSFWPortNotHandled(exceptions.NeutronException): - message = ("Port %(port_id)s is not handled by the firewall.") + message = "Port %(port_id)s is not handled by the firewall." diff --git a/neutron/agent/metadata/driver.py b/neutron/agent/metadata/driver.py index 6389ca3ffd3..5bb03e8cd8d 100644 --- a/neutron/agent/metadata/driver.py +++ b/neutron/agent/metadata/driver.py @@ -132,6 +132,6 @@ def apply_metadata_nat_rules(router, proxy): if netutils.is_ipv6_enabled(): for c, r in metadata_nat_rules( proxy.metadata_port, - metadata_address=(constants.METADATA_V6_CIDR)): + metadata_address=constants.METADATA_V6_CIDR): router.iptables_manager.ipv6['nat'].add_rule(c, r) router.iptables_manager.apply() diff --git a/neutron/api/v2/base.py b/neutron/api/v2/base.py index 7aa898e73ca..1c7c87652c5 100644 --- a/neutron/api/v2/base.py +++ b/neutron/api/v2/base.py @@ -210,7 +210,7 @@ def _filter_attributes(self, data, fields_to_strip=None): if not fields_to_strip: return data return dict(item for item in data.items() - if (item[0] not in fields_to_strip)) + if item[0] not in fields_to_strip) def _do_field_list(self, original_fields): fields_to_add = None diff --git a/neutron/common/ipv6_utils.py b/neutron/common/ipv6_utils.py index 766a2254153..ae875bc591c 100644 --- a/neutron/common/ipv6_utils.py +++ b/neutron/common/ipv6_utils.py @@ -36,7 +36,7 @@ def is_eui64_address(ip_address): ip = netaddr.IPAddress(ip_address) # '0xfffe' addition is used to build EUI-64 from MAC (RFC4291) # Look for it in the middle of the EUI-64 part of address - return ip.version == 6 and not ((ip & 0xffff000000) ^ 0xfffe000000) + return ip.version == 6 and not (ip & 0xffff000000) ^ 0xfffe000000 def is_ipv6_pd_enabled(subnet): diff --git a/neutron/common/utils.py b/neutron/common/utils.py index 0c474b895b3..28a840d3c51 100644 --- a/neutron/common/utils.py +++ b/neutron/common/utils.py @@ -963,7 +963,7 @@ def timecost(f): call_id = uuidutils.generate_uuid() message_base = ("Time-cost: call %(call_id)s function %(fname)s ") % { "call_id": call_id, "fname": f.__name__} - end_message = (message_base + "took %(seconds).3fs seconds to run") + end_message = message_base + "took %(seconds).3fs seconds to run" @timeutils.time_it(LOG, message=end_message, min_duration=None) def wrapper(*args, **kwargs): diff --git a/neutron/core_extensions/qos.py b/neutron/core_extensions/qos.py index 4184f12674f..c29c82c3b0e 100644 --- a/neutron/core_extensions/qos.py +++ b/neutron/core_extensions/qos.py @@ -41,7 +41,7 @@ def _check_policy_change_permission(self, context, old_policy): Using is_accessible expresses these conditions. """ - if not (policy_object.QosPolicy.is_accessible(context, old_policy)): + if not policy_object.QosPolicy.is_accessible(context, old_policy): raise qos_exc.PolicyRemoveAuthorizationError( policy_id=old_policy.id) diff --git a/neutron/db/agents_db.py b/neutron/db/agents_db.py index f471e01578d..da81881eead 100644 --- a/neutron/db/agents_db.py +++ b/neutron/db/agents_db.py @@ -240,7 +240,7 @@ def _get_agent_load(self, agent): configs = agent.get('configurations', {}) load_type = None load = 0 - if (agent['agent_type'] == constants.AGENT_TYPE_DHCP): + if agent['agent_type'] == constants.AGENT_TYPE_DHCP: load_type = cfg.CONF.dhcp_load_type if load_type: load = int(configs.get(load_type, 0)) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index 3dd90e66f6f..0186ffae949 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -401,7 +401,7 @@ def _validate_eui64_applicable(self, subnet): 'OpenStack uses the EUI-64 address format, ' 'which requires the prefix to be /64') raise exc.InvalidInput( - error_message=(msg % subnet['cidr'])) + error_message=msg % subnet['cidr']) def _validate_ipv6_combination(self, ra_mode, address_mode): if ra_mode != address_mode: @@ -811,7 +811,7 @@ def _validate_subnet(self, context, s, cur_subnet=None, is_pd=False): def _validate_subnet_for_pd(self, subnet): """Validates that subnet parameters are correct for IPv6 PD""" - if (subnet.get('ip_version') != constants.IP_VERSION_6): + if subnet.get('ip_version') != constants.IP_VERSION_6: reason = _("Prefix Delegation can only be used with IPv6 " "subnets.") raise exc.BadRequest(resource='subnets', msg=reason) diff --git a/neutron/db/ovn_hash_ring_db.py b/neutron/db/ovn_hash_ring_db.py index f68e0ce4716..34ada36b80c 100644 --- a/neutron/db/ovn_hash_ring_db.py +++ b/neutron/db/ovn_hash_ring_db.py @@ -76,7 +76,7 @@ def get_node(context, group_name, node_uuid): @db_api.retry_if_session_inactive() def remove_nodes_from_host(context, group_name, created_at=None): - with (db_api.CONTEXT_WRITER.using(context)): + with db_api.CONTEXT_WRITER.using(context): query = context.session.query(ovn_models.OVNHashRing).filter( ovn_models.OVNHashRing.hostname == CONF.host, ovn_models.OVNHashRing.group_name == group_name) diff --git a/neutron/db/quota/driver_nolock.py b/neutron/db/quota/driver_nolock.py index c47e764af41..4604edfeeab 100644 --- a/neutron/db/quota/driver_nolock.py +++ b/neutron/db/quota/driver_nolock.py @@ -66,7 +66,7 @@ def make_reservation(self, context, project_id, resources, deltas, plugin): unlimited_resources = { resource for (resource, limit) in limits.items() if limit <= quota_api.UNLIMITED_QUOTA} - requested_resources = (set(deltas.keys()) - unlimited_resources) + requested_resources = set(deltas.keys()) - unlimited_resources # Count the number of (1) used and (2) reserved resources for this # project_id. If any resource limit is exceeded, raise exception. diff --git a/neutron/db/securitygroups_db.py b/neutron/db/securitygroups_db.py index 5712c114b46..3a38bd0cc13 100644 --- a/neutron/db/securitygroups_db.py +++ b/neutron/db/securitygroups_db.py @@ -783,7 +783,7 @@ def _validate_port_range(self, rule): constants.PROTO_NUM_IPV6_ICMP]: for attr, field in [('port_range_min', 'type'), ('port_range_max', 'code')]: - if rule[attr] is not None and not (0 <= rule[attr] <= 255): + if rule[attr] is not None and not 0 <= rule[attr] <= 255: raise ext_sg.SecurityGroupInvalidIcmpValue( field=field, attr=attr, value=rule[attr]) if (rule['port_range_min'] is None and diff --git a/neutron/hacking/checks.py b/neutron/hacking/checks.py index a066b8e7988..3e617387107 100644 --- a/neutron/hacking/checks.py +++ b/neutron/hacking/checks.py @@ -187,7 +187,7 @@ def check_builtins_gettext(logical_line, tokens, filename, lines, noqa): def check_no_imports_from_tests(logical_line, filename, noqa): """N343 - Production code must not import from neutron.tests.* """ - msg = ("N343: Production code must not import from neutron.tests.*") + msg = "N343: Production code must not import from neutron.tests.*" if noqa: return diff --git a/neutron/objects/quota.py b/neutron/objects/quota.py index c7a5ec3faac..2371d4c6ee4 100644 --- a/neutron/objects/quota.py +++ b/neutron/objects/quota.py @@ -73,7 +73,7 @@ def create(self): def delete_expired(cls, context, expiring_time, project_id): resv_query = context.session.query(models.Reservation) if project_id: - project_expr = (models.Reservation.project_id == project_id) + project_expr = models.Reservation.project_id == project_id else: project_expr = sql.true() # TODO(manjeets) Fetch and delete objects using @@ -95,9 +95,9 @@ def get_total_reservations_map(cls, context, now, project_id, sql.func.sum(models.ResourceDelta.amount), sqltypes.Integer)).join(models.Reservation) if expired: - exp_expr = (models.Reservation.expiration < now) + exp_expr = models.Reservation.expiration < now else: - exp_expr = (models.Reservation.expiration >= now) + exp_expr = models.Reservation.expiration >= now resv_query = resv_query.filter(sa.and_( models.Reservation.project_id == project_id, models.ResourceDelta.resource.in_(resources), diff --git a/neutron/plugins/ml2/drivers/agent/_common_agent.py b/neutron/plugins/ml2/drivers/agent/_common_agent.py index b43f7742b4c..a9624020277 100644 --- a/neutron/plugins/ml2/drivers/agent/_common_agent.py +++ b/neutron/plugins/ml2/drivers/agent/_common_agent.py @@ -223,7 +223,7 @@ def process_network_devices(self, device_info): if device_info.get('removed'): resync_b = self.treat_devices_removed(device_info['removed']) # If one of the above operations fails => resync with plugin - return (resync_a | resync_b) + return resync_a | resync_b def treat_devices_added_updated(self, devices): try: @@ -478,8 +478,8 @@ def daemon_loop(self): sync = True # sleep till end of polling interval - elapsed = (time.time() - start) - if (elapsed < self.polling_interval): + elapsed = time.time() - start + if elapsed < self.polling_interval: time.sleep(self.polling_interval - elapsed) else: LOG.debug("Loop iteration exceeded interval " diff --git a/neutron/plugins/ml2/drivers/l2pop/mech_driver.py b/neutron/plugins/ml2/drivers/l2pop/mech_driver.py index 603b8832f36..d2c7ee393c6 100644 --- a/neutron/plugins/ml2/drivers/l2pop/mech_driver.py +++ b/neutron/plugins/ml2/drivers/l2pop/mech_driver.py @@ -108,7 +108,7 @@ def _get_diff_ips(self, orig, port): def _fixed_ips_changed(self, context, orig, port, diff_ips): orig_ips, port_ips = diff_ips - if (port['device_owner'] == const.DEVICE_OWNER_DVR_INTERFACE): + if port['device_owner'] == const.DEVICE_OWNER_DVR_INTERFACE: agent_host = context.host else: agent_host = context.original_host diff --git a/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py b/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py index 76c7c24f3bd..9abdec36485 100644 --- a/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py +++ b/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py @@ -300,7 +300,7 @@ def process_network_devices(self, device_info): if device_info.get('removed'): resync_b = self.treat_devices_removed(device_info['removed']) # If one of the above operations fails => resync with plugin - return (resync_a | resync_b) + return resync_a | resync_b def treat_device(self, device_info, admin_state_up, spoofcheck=True, propagate_uplink_state=False): @@ -521,8 +521,8 @@ def daemon_loop(self): self.activated_bindings |= activated_bindings_copy # sleep till end of polling interval - elapsed = (time.time() - start) - if (elapsed < self.polling_interval): + elapsed = time.time() - start + if elapsed < self.polling_interval: self.daemon_loop_event.wait(self.polling_interval - elapsed) else: LOG.debug("Loop iteration exceeded interval " 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 6b255169d55..09a94fda299 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py @@ -2306,7 +2306,7 @@ def process_network_ports(self, port_info, provisioning_needed): # Update the list of current ports storing only those which # have been actually processed. skipped_devices = set(skipped_devices) - port_info['current'] = (port_info['current'] - skipped_devices) + port_info['current'] = port_info['current'] - skipped_devices # TODO(salv-orlando): Optimize avoiding applying filters # unnecessarily, (eg: when there are no IP address changes) @@ -2635,7 +2635,7 @@ def process_port_info(self, start, polling_manager, sync, # AlwaysPoll used by windows implementations # REVISIT (rossella_s) This needs to be reworked to hide implementation # details regarding polling in BasePollingManager subclasses - if sync or not (hasattr(polling_manager, 'get_events')): + if sync or not hasattr(polling_manager, 'get_events'): if sync: LOG.info("Agent out of sync with plugin!") consecutive_resyncs = consecutive_resyncs + 1 diff --git a/neutron/plugins/ml2/drivers/openvswitch/mech_driver/mech_openvswitch.py b/neutron/plugins/ml2/drivers/openvswitch/mech_driver/mech_openvswitch.py index c0417861753..847a61a8e80 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/mech_driver/mech_openvswitch.py +++ b/neutron/plugins/ml2/drivers/openvswitch/mech_driver/mech_openvswitch.py @@ -155,7 +155,7 @@ def get_vhost_mode(self, iface_types): # NOTE(sean-k-mooney): this function converts the ovs vhost user # driver mode into the qemu vhost user mode. If OVS is the server, # qemu is the client and vice-versa. - if (ovs_const.OVS_DPDK_VHOST_USER_CLIENT in iface_types): + if ovs_const.OVS_DPDK_VHOST_USER_CLIENT in iface_types: return portbindings.VHOST_USER_MODE_SERVER return portbindings.VHOST_USER_MODE_CLIENT diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index 48fd291cad3..ef0a7ae9e16 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -1245,7 +1245,7 @@ def bind_port(self, context): vif_details = copy.deepcopy(self.vif_details[vif_type]) vif_details[portbindings.VHOST_USER_SOCKET] = ( vhost_user_socket) - elif (vnic_type == portbindings.VNIC_VIRTIO_FORWARDER): + elif vnic_type == portbindings.VNIC_VIRTIO_FORWARDER: vhost_user_socket = ovn_utils.ovn_vhu_sockpath( ovn_conf.get_ovn_vhost_sock_dir(), port['id']) vif_type = portbindings.VIF_TYPE_AGILIO_OVS 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 2dfa003ee9f..07c0db56601 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 @@ -2701,7 +2701,7 @@ def _update_subnet_dhcp_options(self, context, subnet, network, txn): if 'options' in new_options and 'options' in original_options: orig_dns_server = original_options['options'].get('dns_server') new_dns_server = new_options['options'].get('dns_server') - dns_server_changed = (orig_dns_server != new_dns_server) + dns_server_changed = orig_dns_server != new_dns_server else: dns_server_changed = False diff --git a/neutron/plugins/ml2/managers.py b/neutron/plugins/ml2/managers.py index c704e555546..b296bc3adbf 100644 --- a/neutron/plugins/ml2/managers.py +++ b/neutron/plugins/ml2/managers.py @@ -242,7 +242,7 @@ def update_network_segment(self, context, network, net_data, segment): segmentation_id = net_data.get(provider.SEGMENTATION_ID) network_type = segment[api.NETWORK_TYPE] if network_type != constants.TYPE_VLAN: - msg = (_('Only VLAN type networks can be updated.')) + msg = _('Only VLAN type networks can be updated.') raise exc.InvalidInput(error_message=msg) if not segmentation_id: msg = (_('Only %s field can be updated in VLAN type networks') % diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index fa32633966b..72def961eb4 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -2679,7 +2679,7 @@ def _handle_segment_change(self, rtype, event, trigger, payload=None): network_db = self._get_network(context, network_id) network_db.mtu = self._get_network_mtu( network_db, - validate=(event != events.PRECOMMIT_DELETE)) + validate=event != events.PRECOMMIT_DELETE) network_db.save(session=context.session) try: @@ -2886,7 +2886,7 @@ def update_port_binding(self, context, host, port_id, binding): port_db.port_bindings, host) if not original_binding: raise exc.PortBindingNotFound(port_id=port_id, host=host) - is_active_binding = (original_binding.status == const.ACTIVE) + is_active_binding = original_binding.status == const.ACTIVE network = self.get_network(context, port_db['network_id']) port_dict = self._make_port_dict(port_db) mech_context = driver_context.PortContext(self, context, port_dict, diff --git a/neutron/services/ndp_proxy/plugin.py b/neutron/services/ndp_proxy/plugin.py index c3acd080f87..42693eebe14 100644 --- a/neutron/services/ndp_proxy/plugin.py +++ b/neutron/services/ndp_proxy/plugin.py @@ -147,7 +147,7 @@ def _gateway_is_valid(self, context, gw_port_id): return False v6_fixed_ips = [ fixed_ip for fixed_ip in port_dict['fixed_ips'] - if (netaddr.IPNetwork(fixed_ip['ip_address']).version == V6)] + if netaddr.IPNetwork(fixed_ip['ip_address']).version == V6] # If the router's external gateway port user LLA address, The # external network needn't IPv6 subnet. if v6_fixed_ips: diff --git a/neutron/services/placement_report/plugin.py b/neutron/services/placement_report/plugin.py index 4ed34f792a0..13d809ba871 100644 --- a/neutron/services/placement_report/plugin.py +++ b/neutron/services/placement_report/plugin.py @@ -172,7 +172,7 @@ def batch(): errors = True placement_error_str = \ 're-parenting a provider is not currently allowed' - if (placement_error_str in str(e)): + if placement_error_str in str(e): msg = ( 'placement client call failed' ' (this may be due to bug' diff --git a/neutron/services/segments/db.py b/neutron/services/segments/db.py index cfa924f45e1..e2deab649dd 100644 --- a/neutron/services/segments/db.py +++ b/neutron/services/segments/db.py @@ -134,7 +134,7 @@ def _create_segment_db(self, context, segment_id, segment): # NOTE(xiaohhui): The new index is the last index + 1, this # may cause discontinuous segment_index. But segment_index # can functionally work as the order index for segments. - segment_index = (segments[-1].get('segment_index') + 1) + segment_index = segments[-1].get('segment_index') + 1 args['segment_index'] = segment_index new_segment = network.NetworkSegment(context, **args) diff --git a/neutron/tests/common/test_db_base_plugin_v2.py b/neutron/tests/common/test_db_base_plugin_v2.py index 040f09dce31..00c99e72f5e 100644 --- a/neutron/tests/common/test_db_base_plugin_v2.py +++ b/neutron/tests/common/test_db_base_plugin_v2.py @@ -5510,7 +5510,7 @@ def test_list_subnets_filtering_by_cidr_used_on_create(self): gateway_ip='10.0.1.1', cidr='10.0.1.11/24') as v2: subnets = (v1, v2) - query_params = ('cidr=10.0.0.11/24&cidr=10.0.1.11/24') + query_params = 'cidr=10.0.0.11/24&cidr=10.0.1.11/24' self._test_list_resources('subnet', subnets, query_params=query_params) @@ -7414,7 +7414,7 @@ def _test_update_shared_net_used(self, network['network']['shared'] = False - if (expected_exception): + if expected_exception: with testlib_api.ExpectedException(expected_exception): plugin.update_network(ctx, net_id, network) else: diff --git a/neutron/tests/functional/agent/l3/framework.py b/neutron/tests/functional/agent/l3/framework.py index a5f5817611a..5ea4cd226d4 100644 --- a/neutron/tests/functional/agent/l3/framework.py +++ b/neutron/tests/functional/agent/l3/framework.py @@ -346,7 +346,7 @@ def _router_lifecycle(self, enable_ha, ip_version=constants.IP_VERSION_4, router_info=None): router_info = router_info or self.generate_router_info( enable_ha, ip_version, dual_stack=dual_stack, - v6_ext_gw_with_sub=(v6_ext_gw_with_sub)) + v6_ext_gw_with_sub=v6_ext_gw_with_sub) return_copy = copy.deepcopy(router_info) router = self.manage_router(self.agent, router_info) @@ -562,7 +562,7 @@ def _assert_iptables_rules_converged(self, router): def _assert_metadata_chains(self, router): def metadata_port_filter(rule): - return (str(self.agent.conf.metadata_port) in rule.rule) + return str(self.agent.conf.metadata_port) in rule.rule self.assertTrue(self._get_rule(router.iptables_manager, 'nat', diff --git a/neutron/tests/functional/agent/linux/test_ovsdb_monitor.py b/neutron/tests/functional/agent/linux/test_ovsdb_monitor.py index 0d5a9c2bde7..6af8853d5ab 100644 --- a/neutron/tests/functional/agent/linux/test_ovsdb_monitor.py +++ b/neutron/tests/functional/agent/linux/test_ovsdb_monitor.py @@ -39,7 +39,7 @@ class BaseMonitorTest(linux_base.BaseOVSLinuxTestCase): def setUp(self): super().setUp() - rootwrap_not_configured = (cfg.CONF.AGENT.root_helper == base.SUDO_CMD) + rootwrap_not_configured = cfg.CONF.AGENT.root_helper == base.SUDO_CMD if rootwrap_not_configured: # The monitor tests require a nested invocation that has # to be emulated by double sudo if rootwrap is not diff --git a/neutron/tests/functional/privileged/agent/linux/test_ip_lib.py b/neutron/tests/functional/privileged/agent/linux/test_ip_lib.py index ab74d0483d0..1cfc45e2445 100644 --- a/neutron/tests/functional/privileged/agent/linux/test_ip_lib.py +++ b/neutron/tests/functional/privileged/agent/linux/test_ip_lib.py @@ -581,7 +581,7 @@ def _check_gateway(self, gateway, table=None, metric=None): scope = 0 routes = priv_ip_lib.list_ip_routes(self.namespace, ip_version) for route in routes: - if not (linux_utils.get_attr(route, 'RTA_GATEWAY') == gateway): + if not linux_utils.get_attr(route, 'RTA_GATEWAY') == gateway: continue self.assertEqual(table, route['table']) self.assertEqual( diff --git a/neutron/tests/functional/scheduler/test_l3_agent_scheduler.py b/neutron/tests/functional/scheduler/test_l3_agent_scheduler.py index 489c57469cc..b1cb8a17d79 100644 --- a/neutron/tests/functional/scheduler/test_l3_agent_scheduler.py +++ b/neutron/tests/functional/scheduler/test_l3_agent_scheduler.py @@ -80,7 +80,7 @@ def _create_legacy_agents(self, agent_count, down_agent_count): def _create_routers(self, scheduled_router_count, expected_scheduled_router_count): routers = [] - if (scheduled_router_count + expected_scheduled_router_count): + if scheduled_router_count + expected_scheduled_router_count: for i in range(scheduled_router_count + expected_scheduled_router_count): router = self._create_router('schd_rtr' + str(i)) diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index 1434c34c79a..08cd8ff7ae3 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -2112,7 +2112,7 @@ def setUp(self): self.ensure_device_is_ready_p = mock.patch( 'neutron.agent.linux.ip_lib.ensure_device_is_ready') - self.ensure_device_is_ready = (self.ensure_device_is_ready_p.start()) + self.ensure_device_is_ready = self.ensure_device_is_ready_p.start() self.dvr_cls_p = mock.patch('neutron.agent.linux.interface.NullDriver') self.iproute_cls_p = mock.patch('neutron.agent.linux.' diff --git a/neutron/tests/unit/agent/linux/test_ip_lib.py b/neutron/tests/unit/agent/linux/test_ip_lib.py index d11c71f4a07..daf976f1361 100644 --- a/neutron/tests/unit/agent/linux/test_ip_lib.py +++ b/neutron/tests/unit/agent/linux/test_ip_lib.py @@ -43,57 +43,57 @@ 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'cccccccc-cccc-cccc-cccc-cccccccccccc'] -GATEWAY_SAMPLE1 = (""" +GATEWAY_SAMPLE1 = """ default via 10.35.19.254 metric 100 10.35.16.0/22 proto kernel scope link src 10.35.17.97 -""") +""" -GATEWAY_SAMPLE2 = (""" +GATEWAY_SAMPLE2 = """ default via 10.35.19.254 metric 100 -""") +""" -GATEWAY_SAMPLE3 = (""" +GATEWAY_SAMPLE3 = """ 10.35.16.0/22 proto kernel scope link src 10.35.17.97 -""") +""" -GATEWAY_SAMPLE4 = (""" +GATEWAY_SAMPLE4 = """ default via 10.35.19.254 -""") +""" -GATEWAY_SAMPLE5 = (""" +GATEWAY_SAMPLE5 = """ default via 192.168.99.1 proto static -""") +""" -GATEWAY_SAMPLE6 = (""" +GATEWAY_SAMPLE6 = """ default via 192.168.99.1 proto static metric 100 -""") +""" -GATEWAY_SAMPLE7 = (""" +GATEWAY_SAMPLE7 = """ default dev qg-31cd36 metric 1 -""") +""" -IPv6_GATEWAY_SAMPLE1 = (""" +IPv6_GATEWAY_SAMPLE1 = """ default via 2001:470:9:1224:4508:b885:5fb:740b metric 100 2001:db8::/64 proto kernel scope link src 2001:470:9:1224:dfcc:aaff:feb9:76ce -""") +""" -IPv6_GATEWAY_SAMPLE2 = (""" +IPv6_GATEWAY_SAMPLE2 = """ default via 2001:470:9:1224:4508:b885:5fb:740b metric 100 -""") +""" -IPv6_GATEWAY_SAMPLE3 = (""" +IPv6_GATEWAY_SAMPLE3 = """ 2001:db8::/64 proto kernel scope link src 2001:470:9:1224:dfcc:aaff:feb9:76ce -""") +""" -IPv6_GATEWAY_SAMPLE4 = (""" +IPv6_GATEWAY_SAMPLE4 = """ default via fe80::dfcc:aaff:feb9:76ce -""") +""" -IPv6_GATEWAY_SAMPLE5 = (""" +IPv6_GATEWAY_SAMPLE5 = """ default via 2001:470:9:1224:4508:b885:5fb:740b metric 1024 -""") +""" -DEVICE_ROUTE_SAMPLE = ("10.0.0.0/24 scope link src 10.0.0.2") +DEVICE_ROUTE_SAMPLE = "10.0.0.0/24 scope link src 10.0.0.2" SUBNET_SAMPLE1 = ("10.0.0.0/24 dev qr-23380d11-d2 scope link src 10.0.0.1\n" "10.0.0.0/24 dev tap1d7888a7-10 scope link src 10.0.0.2") diff --git a/neutron/tests/unit/agent/linux/test_iptables_manager.py b/neutron/tests/unit/agent/linux/test_iptables_manager.py index 97f5ceaff82..5e8a1339c66 100644 --- a/neutron/tests/unit/agent/linux/test_iptables_manager.py +++ b/neutron/tests/unit/agent/linux/test_iptables_manager.py @@ -1136,7 +1136,7 @@ def test_get_traffic_counters_and_zero(self): def test_add_blank_rule(self): iptables_args = {} iptables_args.update(IPTABLES_ARG) - filter_rules = ('-A %(bn)s-test-filter\n' % iptables_args) + filter_rules = '-A %(bn)s-test-filter\n' % iptables_args iptables_args['filter_rules'] = filter_rules filter_dump_mod = FILTER_RESTORE_DUMP % iptables_args @@ -1217,7 +1217,7 @@ class IptablesManagerStateFulTestCaseIPv6(IptablesManagerStateFulTestCase): class IptablesManagerStateFulTestCaseCustomBinaryName( IptablesManagerBaseTestCase): use_ipv6 = False - bn = ("xbcdef" * 5) + bn = "xbcdef" * 5 def setUp(self): super().setUp() @@ -1354,7 +1354,7 @@ class IptablesManagerStateLessTestCase(base.BaseTestCase): def setUp(self): super().setUp() cfg.CONF.set_override('comment_iptables_rules', False, 'AGENT') - self.iptables = (iptables_manager.IptablesManager(state_less=True)) + self.iptables = iptables_manager.IptablesManager(state_less=True) def test_nat_found(self): self.assertIn('nat', self.iptables.ipv4) @@ -1380,7 +1380,7 @@ class IptablesManagerNoNatTestCase(base.BaseTestCase): def setUp(self): super().setUp() cfg.CONF.set_override('comment_iptables_rules', False, 'AGENT') - self.iptables = (iptables_manager.IptablesManager(nat=False)) + self.iptables = iptables_manager.IptablesManager(nat=False) def test_nat_found(self): self.assertIn('nat', self.iptables.ipv4) diff --git a/neutron/tests/unit/api/test_wsgi.py b/neutron/tests/unit/api/test_wsgi.py index 0c5250fa55e..7665f5f85fb 100644 --- a/neutron/tests/unit/api/test_wsgi.py +++ b/neutron/tests/unit/api/test_wsgi.py @@ -227,7 +227,7 @@ def test_content_type_from_accept(self): self.assertEqual("application/json", result) request = wsgi.Request.blank('/tests/123') - request.headers["Accept"] = ("application/json; q=0.3") + request.headers["Accept"] = "application/json; q=0.3" result = request.best_match_content_type() self.assertEqual("application/json", result) diff --git a/neutron/tests/unit/db/test_agents_db.py b/neutron/tests/unit/db/test_agents_db.py index da200a434db..5cd7184c49d 100644 --- a/neutron/tests/unit/db/test_agents_db.py +++ b/neutron/tests/unit/db/test_agents_db.py @@ -89,7 +89,7 @@ def _create_and_save_agents(self, hosts, agent_type, down_agents_count=0, for agent in agents[down_agents_count:( down_but_version_considered + down_agents_count)]: agent['heartbeat_timestamp'] -= datetime.timedelta( - seconds=(cfg.CONF.agent_down_time + 1)) + seconds=cfg.CONF.agent_down_time + 1) for agent in agents: agent.create() diff --git a/neutron/tests/unit/db/test_migration.py b/neutron/tests/unit/db/test_migration.py index a2408f74cce..acc5f1ba6f7 100644 --- a/neutron/tests/unit/db/test_migration.py +++ b/neutron/tests/unit/db/test_migration.py @@ -410,7 +410,7 @@ def test_upgrade_rejects_delta_with_relative_revision(self): def _test_validate_head_files_helper(self, heads, contract_head='', expand_head=''): fake_config = self.configs[0] - head_files_not_exist = (contract_head == expand_head == '') + head_files_not_exist = contract_head == expand_head == '' with mock.patch('alembic.script.ScriptDirectory.from_config') as fc,\ mock.patch('os.path.exists') as os_mock: if head_files_not_exist: @@ -706,11 +706,11 @@ def _get_regex(s): alembic_ag_api.render_python_code(expand.upgrade_ops), matchers.MatchesRegex(_get_regex(ALEMBIC_EXPECTED_REGEX))) - expected_regex = ("""\ + expected_regex = """\ ### commands auto generated by Alembic - please adjust! ### op.drop_constraint('user', 'uq_user_org'.*) op.drop_column('user', 'organization_name') - ### end Alembic commands ###""") + ### end Alembic commands ###""" self.assertThat( alembic_ag_api.render_python_code(contract.upgrade_ops), matchers.MatchesRegex(_get_regex(expected_regex))) diff --git a/neutron/tests/unit/extensions/test_l3_conntrack_helper.py b/neutron/tests/unit/extensions/test_l3_conntrack_helper.py index 48d0f12cb7a..0623943dc79 100644 --- a/neutron/tests/unit/extensions/test_l3_conntrack_helper.py +++ b/neutron/tests/unit/extensions/test_l3_conntrack_helper.py @@ -57,7 +57,7 @@ def setUp(self): 'neutron.tests.unit.extensions.' 'test_l3_conntrack_helper.' 'TestL3ConntrackHelperServicePlugin') - plugin = ('neutron.tests.unit.extensions.test_l3.TestL3NatIntPlugin') + plugin = 'neutron.tests.unit.extensions.test_l3.TestL3NatIntPlugin' ext_mgr = ExtendL3ConntrackHelperExtensionManager() super().setUp( ext_mgr=ext_mgr, service_plugins=svc_plugins, plugin=plugin) diff --git a/neutron/tests/unit/extensions/test_port_hardware_offload_type.py b/neutron/tests/unit/extensions/test_port_hardware_offload_type.py index 2caabb143b6..dcaf9d63868 100644 --- a/neutron/tests/unit/extensions/test_port_hardware_offload_type.py +++ b/neutron/tests/unit/extensions/test_port_hardware_offload_type.py @@ -54,6 +54,7 @@ def _create_and_check_port_hw_offload_type(self, hardware_offload_type): if hardware_offload_type in constants.VALID_HWOL_TYPES: port_args['hardware_offload_type'] = hardware_offload_type with self.port(**port_args) as port: + print('port:', port) for k, v in keys: self.assertEqual(v, port['port'][k]) diff --git a/neutron/tests/unit/plugins/ml2/drivers/l2pop/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/l2pop/test_mech_driver.py index 88e9e3bf2a8..5661003f9db 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/l2pop/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/l2pop/test_mech_driver.py @@ -119,7 +119,7 @@ def setUp(self): cast_patch = mock.patch(cast) self.mock_cast = cast_patch.start() - uptime = ('neutron.plugins.ml2.drivers.l2pop.db.get_agent_uptime') + uptime = 'neutron.plugins.ml2.drivers.l2pop.db.get_agent_uptime' uptime_patch = mock.patch(uptime, return_value=190) uptime_patch.start() # Extend network HA extension. diff --git a/neutron/tests/unit/privileged/agent/linux/test_utils.py b/neutron/tests/unit/privileged/agent/linux/test_utils.py index 6141731b28e..516deaff05d 100644 --- a/neutron/tests/unit/privileged/agent/linux/test_utils.py +++ b/neutron/tests/unit/privileged/agent/linux/test_utils.py @@ -20,7 +20,7 @@ from neutron.tests import base -NETSTAT_NETNS_OUTPUT = (""" +NETSTAT_NETNS_OUTPUT = """ Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State\ PID/Program name @@ -37,21 +37,21 @@ Path unix 2 [ ACC ] STREAM LISTENING 82039530 1353/python\ /tmp/rootwrap-VKSm8a/rootwrap.sock -""") +""" -NETSTAT_NO_NAMESPACE = (""" +NETSTAT_NO_NAMESPACE = """ Cannot open network namespace "qrouter-e6f206b2-4e8d-4597-a7e1-c3a20337e9c6":\ No such file or directory -""") +""" -NETSTAT_NO_LISTEN_PROCS = (""" +NETSTAT_NO_LISTEN_PROCS = """ Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State\ PID/Program name Active UNIX domain sockets (only servers) Proto RefCnt Flags Type State I-Node PID/Program name\ Path -""") +""" class FindListenPidsNamespaceTestCase(base.BaseTestCase): diff --git a/neutron/tests/unit/services/ovn_l3/test_plugin.py b/neutron/tests/unit/services/ovn_l3/test_plugin.py index c37d93e20eb..34ab9dfc77e 100644 --- a/neutron/tests/unit/services/ovn_l3/test_plugin.py +++ b/neutron/tests/unit/services/ovn_l3/test_plugin.py @@ -2373,7 +2373,7 @@ def _start_mock(self, path, return_value, new_callable=None): def setUp(self): plugin = 'neutron.tests.unit.extensions.test_l3.TestOVNL3Plugin' - l3_plugin = ('neutron.services.ovn_l3.plugin.OVNL3RouterPlugin') + l3_plugin = 'neutron.services.ovn_l3.plugin.OVNL3RouterPlugin' service_plugins = {'l3_plugin_name': l3_plugin} config.register_opts() cfg.CONF.set_default('max_routes', 3) From ad15787bf2c31111a0260419fdf6f2feb2df5d55 Mon Sep 17 00:00:00 2001 From: Bence Romsics Date: Thu, 9 Apr 2026 15:34:30 +0200 Subject: [PATCH 55/63] Re-install extension flows lost during ovs restart Flows of the managed ovs are lost when ovs is restarted (as expected). We detect the restart and we re-install most of the lost flows, but in bug #2131666 we realized that we missed re-installing the flows originally installed by agent extensions (in the original bug report this is reported for the qos agent extension). As noted in an earlier review comment it is not a good idea to fully re-initialize the agent extension because with that we may do too much. (https://review.opendev.org/c/openstack/neutron/+/967378/2/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py#2759) Instead in neutron-lib we added the method 'handle_switch_restart' to the L2AgentExtension API and here we implement it: * The `qos` and `metadata_path` agent extensions install flows from their initialize(), therefore we need them to re-install their flows from handle_switch_restart(). * The `dhcp`, `local_ip` and `fdb_population` agent extensions do not install flows from their initialize(), therefore we did not add handle_switch_restart() to them. * Out of tree agent extensions need to provide an implementation handle_switch_restart() if and only if they install flows from initialize(). This patch started triggering a latent bug in os-ken. Depending on timing we: * either successfully reinstalled all flows as intended by this fix, * or the agent got deadlocked while trying to send on a stale datapath after ovs restarted. This is being properly fixed in the os-ken change we depend on, however for defense in depth we also add a timeout on the join() that got deadlocked. Assisted-By: Claude Opus 4.6 Change-Id: I10d27d918c39a6c394bddcdedfbd3810cac247fa Closes-Bug: #2131666 Depends-On: https://review.opendev.org/c/openstack/neutron-lib/+/992461 Depends-On: https://review.opendev.org/c/openstack/os-ken/+/992460 Signed-off-by: Bence Romsics --- .../agent/l2/extensions/metadata/metadata_path.py | 8 ++++++++ neutron/agent/l2/extensions/qos.py | 3 +++ neutron/agent/l2/l2_agent_extensions_manager.py | 12 ++++++++++++ .../agent/extension_drivers/qos_driver.py | 3 +++ .../openvswitch/agent/openflow/native/ofswitch.py | 2 +- .../drivers/openvswitch/agent/ovs_neutron_agent.py | 1 + .../agent/l2/test_l2_agent_extensions_manager.py | 6 ++++++ requirements.txt | 2 +- 8 files changed, 35 insertions(+), 2 deletions(-) diff --git a/neutron/agent/l2/extensions/metadata/metadata_path.py b/neutron/agent/l2/extensions/metadata/metadata_path.py index dcca727e59d..71755e745ac 100644 --- a/neutron/agent/l2/extensions/metadata/metadata_path.py +++ b/neutron/agent/l2/extensions/metadata/metadata_path.py @@ -462,3 +462,11 @@ def remove_dhcp_ports_arp_responder(self, port_info): table=p_const.ARP_SPOOF_TABLE, in_port=port_info["ofport"]) self.NETWORK_PORTS.pop(port_info['network_id'], []) + + def handle_switch_restart(self): + self.install_arp_responder( + bridge=self.int_br, + ip=metadata_flows_process.METADATA_V4_IP, + mac=METADATA_DEFAULT_MAC, + table=p_const.TRANSIENT_TABLE) + self.init_br_snat_metadata_path() diff --git a/neutron/agent/l2/extensions/qos.py b/neutron/agent/l2/extensions/qos.py index 215c405d7e9..f6ed9a605bd 100644 --- a/neutron/agent/l2/extensions/qos.py +++ b/neutron/agent/l2/extensions/qos.py @@ -295,3 +295,6 @@ def _process_update_policy(self, qos_policy): def _process_reset_port(self, port): self.policy_map.clean_by_port(port) self.qos_driver.delete(port) + + def handle_switch_restart(self): + self.qos_driver.handle_switch_restart() diff --git a/neutron/agent/l2/l2_agent_extensions_manager.py b/neutron/agent/l2/l2_agent_extensions_manager.py index 776abdd3cc4..7c405b1c9ff 100644 --- a/neutron/agent/l2/l2_agent_extensions_manager.py +++ b/neutron/agent/l2/l2_agent_extensions_manager.py @@ -57,3 +57,15 @@ def delete_port(self, context, data): "implement method delete_port", {'name': extension.name} ) + + def handle_switch_restart(self): + """Notify agent extensions that the managed switch was restarted.""" + for extension in self: + if hasattr(extension.obj, 'handle_switch_restart'): + extension.obj.handle_switch_restart() + else: + LOG.debug( + "Agent Extension '%(name)s' does not " + "implement method handle_switch_restart", + {'name': extension.name} + ) diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/extension_drivers/qos_driver.py b/neutron/plugins/ml2/drivers/openvswitch/agent/extension_drivers/qos_driver.py index a2f1c7ffac5..ba721757337 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/extension_drivers/qos_driver.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/extension_drivers/qos_driver.py @@ -341,6 +341,9 @@ def initialize(self): self.meter_cache_bps = MeterRuleManager( self.br_int, type_=comm_consts.METER_FLAG_BPS) + def handle_switch_restart(self): + self._qos_bandwidth_initialize() + def create_bandwidth_limit(self, port, rule): self.update_bandwidth_limit(port, rule) diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/ofswitch.py b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/ofswitch.py index db682c7074f..5a789aaf939 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/ofswitch.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/ofswitch.py @@ -143,7 +143,7 @@ def timeout_handler(): raise RuntimeError(m) finally: timer.cancel() - worker_thread.join() + worker_thread.join(timeout=timeout_sec) LOG.debug("ofctl request %(request)s result %(result)s", {"request": msg, "result": result}) 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 4ea25341cb6..9007751954e 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py @@ -2768,6 +2768,7 @@ def _handle_ovs_restart(self, polling_manager): self.dvr_agent.reset_dvr_flows( self.int_br, self.tun_br, self.phys_brs, self.patch_int_ofport, self.patch_tun_ofport) + self.ext_manager.handle_switch_restart() # notify that OVS has restarted registry.publish( callback_resources.AGENT, diff --git a/neutron/tests/unit/agent/l2/test_l2_agent_extensions_manager.py b/neutron/tests/unit/agent/l2/test_l2_agent_extensions_manager.py index 7a062349019..0a9df0da3b7 100644 --- a/neutron/tests/unit/agent/l2/test_l2_agent_extensions_manager.py +++ b/neutron/tests/unit/agent/l2/test_l2_agent_extensions_manager.py @@ -51,3 +51,9 @@ def test_delete_port(self): self.manager.delete_port(context, data) ext = self._get_extension() ext.delete_port.assert_called_once_with(context, data) + + def test_handle_switch_restart(self): + self.manager.initialize(object(), 'fake_driver_type') + self.manager.handle_switch_restart() + ext = self._get_extension() + ext.handle_switch_restart.assert_called_once() diff --git a/requirements.txt b/requirements.txt index a614cc943d0..80e8384a08c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ requests>=2.32.3 # Apache-2.0 Jinja2>=2.10 # BSD License (3 clause) keystonemiddleware>=5.1.0 # Apache-2.0 netaddr>=0.7.18 # BSD -neutron-lib>=4.0.0 # Apache-2.0 +neutron-lib>=4.1.0 # Apache-2.0 python-neutronclient>=7.8.0 # Apache-2.0 tenacity>=6.0.0 # Apache-2.0 SQLAlchemy>=1.4.23 # MIT From 0b8c42474b9168e1cc78f63ef4a6d1517ab71a71 Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Thu, 3 Jul 2025 02:32:45 +0900 Subject: [PATCH 56/63] Drop redundant validation of ha_vrrp_auth_password The option is validated when config files are loaded, so don't have to be validated again in internal logic. Change-Id: Ie334cf08159ba406d55ffbce8bf60be7de184010 Signed-off-by: Takashi Kajinami --- neutron/agent/l3/ha_router.py | 8 ++--- neutron/agent/linux/keepalived.py | 30 +++++-------------- .../tests/unit/agent/linux/test_keepalived.py | 20 ++----------- 3 files changed, 11 insertions(+), 47 deletions(-) diff --git a/neutron/agent/l3/ha_router.py b/neutron/agent/l3/ha_router.py index 1d89958a9b6..e22d39cb832 100644 --- a/neutron/agent/l3/ha_router.py +++ b/neutron/agent/l3/ha_router.py @@ -214,15 +214,11 @@ def _init_keepalived_manager(self, process_monitor): vrrp_health_check_interval=( self.agent_conf.ha_vrrp_health_check_interval), ha_conf_dir=self.keepalived_manager.get_conf_dir(), + vrrp_auth_type=self.agent_conf.ha_vrrp_auth_type, + vrrp_auth_password=self.agent_conf.ha_vrrp_auth_password ) instance.track_interfaces.append(interface_name) - if self.agent_conf.ha_vrrp_auth_password: - # TODO(safchain): use oslo.config types when it will be available - # in order to check the validity of ha_vrrp_auth_type - instance.set_authentication(self.agent_conf.ha_vrrp_auth_type, - self.agent_conf.ha_vrrp_auth_password) - config.add_instance(instance) def _disable_manager(self, manager, remove_config): diff --git a/neutron/agent/linux/keepalived.py b/neutron/agent/linux/keepalived.py index ef5cdc3162e..8f6e3c3b4c6 100644 --- a/neutron/agent/linux/keepalived.py +++ b/neutron/agent/linux/keepalived.py @@ -85,16 +85,6 @@ def __init__(self, **kwargs): super().__init__(**kwargs) -class InvalidAuthenticationTypeException(exceptions.NeutronException): - message = _('Invalid authentication type: %(auth_type)s, ' - 'valid types are: %(valid_auth_types)s') - - def __init__(self, **kwargs): - if 'valid_auth_types' not in kwargs: - kwargs['valid_auth_types'] = ', '.join(VALID_AUTH_TYPES) - super().__init__(**kwargs) - - class KeepalivedVipAddress: """A virtual address entry of a keepalived configuration.""" @@ -187,8 +177,8 @@ def __init__(self, state, interface, vrouter_id, ha_cidrs, priority=HA_DEFAULT_PRIORITY, advert_int=None, mcast_src_ip=None, nopreempt=False, garp_primary_delay=GARP_PRIMARY_DELAY, - vrrp_health_check_interval=0, - ha_conf_dir=None): + vrrp_health_check_interval=0, ha_conf_dir=None, + vrrp_auth_type=None, vrrp_auth_password=None): self.name = 'VR_%s' % vrouter_id if state not in VALID_STATES: @@ -202,10 +192,11 @@ def __init__(self, state, interface, vrouter_id, ha_cidrs, self.advert_int = advert_int self.mcast_src_ip = mcast_src_ip self.garp_primary_delay = garp_primary_delay + self.vrrp_auth_type = vrrp_auth_type + self.vrrp_auth_password = vrrp_auth_password self.track_interfaces = [] self.vips = [] self.virtual_routes = KeepalivedInstanceRoutes() - self.authentication = None self.track_script = None self.primary_vip_range = get_free_range( parent_range=constants.PRIVATE_CIDR_RANGE, @@ -217,12 +208,6 @@ def __init__(self, state, interface, vrouter_id, ha_cidrs, self.track_script = KeepalivedTrackScript( vrrp_health_check_interval, ha_conf_dir, self.vrouter_id) - def set_authentication(self, auth_type, password): - if auth_type not in VALID_AUTH_TYPES: - raise InvalidAuthenticationTypeException(auth_type=auth_type) - - self.authentication = (auth_type, password) - def add_vip(self, ip_cidr, interface_name, scope): track = interface_name in self.track_interfaces vip = KeepalivedVipAddress(ip_cidr, interface_name, scope, track=track) @@ -322,11 +307,10 @@ def build_config(self): if self.advert_int: config.append(' advert_int %s' % self.advert_int) - if self.authentication: - auth_type, password = self.authentication + if self.vrrp_auth_password: authentication = [' authentication {', - ' auth_type %s' % auth_type, - ' auth_pass %s' % password, + ' auth_type %s' % self.vrrp_auth_type, + ' auth_pass %s' % self.vrrp_auth_password, ' }'] config.extend(authentication) diff --git a/neutron/tests/unit/agent/linux/test_keepalived.py b/neutron/tests/unit/agent/linux/test_keepalived.py index 361fdf66d94..638950d8d40 100644 --- a/neutron/tests/unit/agent/linux/test_keepalived.py +++ b/neutron/tests/unit/agent/linux/test_keepalived.py @@ -96,8 +96,8 @@ def _get_config(self): config = keepalived.KeepalivedConf() instance1 = keepalived.KeepalivedInstance( 'MASTER', 'eth0', 1, ['169.254.192.0/18'], - advert_int=5) - instance1.set_authentication('AH', 'pass123') + advert_int=5, vrrp_auth_type='AH', + vrrp_auth_password='pass123') # noqa: S106 instance1.track_interfaces.append("eth0") vip_address1 = keepalived.KeepalivedVipAddress('192.168.1.0/24', @@ -224,22 +224,6 @@ def test_get_existing_vip_ip_addresses_returns_list(self): self.assertEqual(['192.168.2.0/24', '192.168.3.0/24'], current_vips) -class KeepalivedStateExceptionTestCase(KeepalivedBaseTestCase): - def test_state_exception(self): - invalid_vrrp_state = 'a seal walks' - self.assertRaises(keepalived.InvalidInstanceStateException, - keepalived.KeepalivedInstance, - invalid_vrrp_state, 'eth0', 33, - ['169.254.192.0/18']) - - invalid_auth_type = 'into a club' - instance = keepalived.KeepalivedInstance('MASTER', 'eth0', 1, - ['169.254.192.0/18']) - self.assertRaises(keepalived.InvalidAuthenticationTypeException, - instance.set_authentication, - invalid_auth_type, 'some_password') - - class KeepalivedInstanceRoutesTestCase(KeepalivedBaseTestCase): @classmethod def _get_instance_routes(cls): From e528d8b715094ed4defba0c44157e9c3514251b8 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 16 Jun 2026 16:50:45 +0200 Subject: [PATCH 57/63] Remove the OVN Metadata agent grenade and n-t-p CI jobs This patch removes the OVN Metadata agent grenade and neutron-tempest-plugin CI jobs. Both jobs are marked for removal in 2026.2 release. Related-Bug: #2112313 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Id9994994b28ffb27670970901466e454815dee20 --- zuul.d/base.yaml | 10 ---------- zuul.d/grenade.yaml | 10 ---------- zuul.d/job-templates.yaml | 2 -- 3 files changed, 22 deletions(-) diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index f9b25cde5d5..90081fcf115 100644 --- a/zuul.d/base.yaml +++ b/zuul.d/base.yaml @@ -185,13 +185,3 @@ - openstack/neutron-lib - name: github.com/sqlalchemy/alembic override-checkout: main - -# NOTE(ralonsoh): to be removed in 2026.2, once the OVN Metadata agent -# replacement finishes. See LP#2112313. -- job: - name: neutron-tempest-plugin-ovn-with-ovn-metadata-agent - parent: neutron-tempest-plugin-ovn - vars: - devstack_services: - q-ovn-metadata-agent: true - q-ovn-agent: false diff --git a/zuul.d/grenade.yaml b/zuul.d/grenade.yaml index b8e78ac03f2..63ad0a6b5c7 100644 --- a/zuul.d/grenade.yaml +++ b/zuul.d/grenade.yaml @@ -431,13 +431,3 @@ grenade_from_branch: stable/2025.2 grenade_localrc: NOVA_ENABLE_UPGRADE_WORKAROUND: True - -# NOTE(ralonsoh): to be removed in 2026.2, once the OVN Metadata agent -# replacement finishes. See LP#2112313. -- job: - name: neutron-ovn-grenade-multinode-ovn-metadata-agent - parent: neutron-ovn-grenade-multinode - vars: - devstack_services: - q-ovn-metadata-agent: true - q-ovn-agent: false diff --git a/zuul.d/job-templates.yaml b/zuul.d/job-templates.yaml index 3b1f4277175..4c7c5fefb06 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -116,8 +116,6 @@ - neutron-functional-with-oslo-master - neutron-ovs-tempest-with-oslo-master - neutron-ovn-tempest-ovs-release-with-oslo-master - - 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: From f95e8ce312a7119871315100e7433fde2f0be9ad Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 16 Jun 2026 16:54:24 +0200 Subject: [PATCH 58/63] Remove unused CI job definitions Removed the following CI job definitions: * neutron-tempest-dvr * neutron-tempest-iptables_hybrid * neutron-grenade-multinode Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I9a47b9251c408c1fd60cdcd29e249815535bd61b --- zuul.d/grenade.yaml | 6 ------ zuul.d/tempest-singlenode.yaml | 12 ------------ 2 files changed, 18 deletions(-) diff --git a/zuul.d/grenade.yaml b/zuul.d/grenade.yaml index 63ad0a6b5c7..bb7af6ef65f 100644 --- a/zuul.d/grenade.yaml +++ b/zuul.d/grenade.yaml @@ -108,12 +108,6 @@ agent: debug_iptables_rules: True -# TODO(ralonsoh): remove this duplicated definition when "devstack" and -# "tempest" adopt the new name. -- job: - name: neutron-grenade-multinode - parent: neutron-ovs-grenade-multinode - - job: name: neutron-ovs-grenade-dvr-multinode parent: grenade-multinode diff --git a/zuul.d/tempest-singlenode.yaml b/zuul.d/tempest-singlenode.yaml index 99effe975b7..ef89c34b7aa 100644 --- a/zuul.d/tempest-singlenode.yaml +++ b/zuul.d/tempest-singlenode.yaml @@ -107,12 +107,6 @@ br-ex-tcpdump: true br-int-flows: true -# TODO(ralonsoh): remove this duplicated definition when "devstack", -# "tempest" and "nova" adopt the new name. -- job: - name: neutron-tempest-dvr - parent: neutron-ovs-tempest-dvr - - job: name: neutron-ovs-tempest-iptables_hybrid parent: neutron-ovs-tempest-base @@ -161,12 +155,6 @@ image_is_advanced: true available_type_drivers: flat,geneve,vlan,gre,local,vxlan -# TODO(ralonsoh): remove this duplicated definition when "nova" adopts the -# new name. -- job: - name: neutron-tempest-iptables_hybrid - parent: neutron-ovs-tempest-iptables_hybrid - - job: name: neutron-ovn-tempest-mariadb-full parent: tempest-integrated-networking From d70bdc9df51f46ea6d64c3c11ce09054c86fa155 Mon Sep 17 00:00:00 2001 From: Pavlo Shchelokovskyy Date: Thu, 11 Dec 2025 18:33:29 +0200 Subject: [PATCH 59/63] [metadata] cleanup response headers Python's `requests` lib is always asking for 'gzip,deflate' encoding by itself, and decoding those by itself too, but does not remove the content-encoding header from response, so don't rely on this header alone to decide if content should be decoded or not. Also, content-length and transfer-encoding are left untouched, even if they correspond to originally gzip-ed content. This all interferes when user data is itself gzip-ed and there's a proper web server like apache2 that itself generates proper gzip-ed response. Clean-up erroneous headers, and let webob recalculate them as needed. Related-Bug: #2120723 Closes-Bug: #2156587 Change-Id: Ia5d0cbdc5c715462438e38135e441ce9f5ea98c1 Signed-off-by: Pavlo Shchelokovskyy --- neutron/common/metadata.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/neutron/common/metadata.py b/neutron/common/metadata.py index 04768398a68..66dc1460c48 100644 --- a/neutron/common/metadata.py +++ b/neutron/common/metadata.py @@ -166,17 +166,32 @@ class MetadataProxyHandlerBaseSocketServer( metaclass=abc.ABCMeta): @staticmethod def _http_response(http_response, request): - headerlist = list(http_response.headers.items()) - # We detect if content is compressed by magic signature, - # when `content-encoding` is not present. - if not http_response.headers.get('content-encoding'): - if http_response.content[:3] == CONTENT_ENCODERS['gzip']: - headerlist.append(('content-encoding', 'gzip')) - + resp_headers = http_response.headers.copy() + resp_body_encoding = resp_headers.get('content-encoding') + resp_content_magic = http_response.content[:3] + if resp_content_magic == CONTENT_ENCODERS["gzip"]: + # python-requests asks for and unpacks the gzip itself, + # but leaves the content-encoding header in response untouched. + # however, specifically user_data could itself be gzipped still. + resp_headers['content-encoding'] = 'gzip' + elif ( + resp_content_magic not in CONTENT_ENCODERS.values() and + resp_body_encoding in CONTENT_ENCODERS.keys() + ): + # content-encoding is set but content is not actually encoded, + # this is normal for how `requests` behaves, it decodes gzip + # itself but not removes the content-encoding header from response. + resp_headers.pop('content-encoding', None) + + # remove content-length and transfer-encoding, possibly from original + # gzip/deflate (python-requests does not modify those headers), + # let webob recalculate these. + resp_headers.pop('content-length', None) + resp_headers.pop('transfer-encoding', None) _res = webob.Response( body=http_response.content, status=http_response.status_code, - headerlist=headerlist) + headerlist=list(resp_headers.items())) # The content of the response is decoded depending on the # "Context-Enconding" header, if present. The operation is limited to # ("gzip", "deflate"), as is in the ``webob.response.Response`` class. From 425f97dc8ce60a55f84b147c199b62437e4d378e Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Mon, 15 Jun 2026 11:38:32 -0400 Subject: [PATCH 60/63] FrrDriver Vtysh accepts --vty_socket argument Vtysh accepts passing different path to unix socket for communicating with frr via `--vty_socket` argument. This patch exposes this argument in the frr_driver. The goal is to use this argument when frrdriver is run inside a container where the unix path might be different than default. Related-Bug: #2144617 Assisted-by: Opus 4.6 model Change-Id: I34e2ecfe0f78f4cbc6326615745468761902e75e Signed-off-by: Miro Tomaska --- etc/neutron/rootwrap.d/rootwrap.filters | 6 ++--- .../agent/linux/evpn_router/frr/frr_driver.py | 3 ++- neutron/conf/agent/ovn/evpn/config.py | 5 ++++ .../linux/evpn_router/frr/test_frr_driver.py | 5 +++- .../linux/evpn_router/frr/test_frr_driver.py | 26 ++++++++++++++++--- tools/rootwrap/testing.filters | 6 ++--- 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/etc/neutron/rootwrap.d/rootwrap.filters b/etc/neutron/rootwrap.d/rootwrap.filters index 6bab79daaca..1c296ffeb06 100644 --- a/etc/neutron/rootwrap.d/rootwrap.filters +++ b/etc/neutron/rootwrap.d/rootwrap.filters @@ -57,9 +57,9 @@ conntrackd: CommandFilter, conntrackd, root conntrackd_env: EnvFilter, env, root, PROCESS_TAG=, conntrackd # FRR -vtysh_cmd: RegExpFilter, vtysh, root, vtysh, -c, .* -vtysh_dryrun: RegExpFilter, vtysh, root, vtysh, --dryrun, -f, .* -vtysh_apply: RegExpFilter, vtysh, root, vtysh, -f, .* +vtysh_cmd: RegExpFilter, vtysh, root, vtysh, --vty_socket, /[a-z/]+, -c, .* +vtysh_dryrun: RegExpFilter, vtysh, root, vtysh, --vty_socket, /[a-z/]+, --dryrun, -f, .* +vtysh_apply: RegExpFilter, vtysh, root, vtysh, --vty_socket, /[a-z/]+, -f, .* # OPEN VSWITCH ovs-ofctl: CommandFilter, ovs-ofctl, root diff --git a/neutron/agent/linux/evpn_router/frr/frr_driver.py b/neutron/agent/linux/evpn_router/frr/frr_driver.py index a19e6cb275a..9a899151b68 100644 --- a/neutron/agent/linux/evpn_router/frr/frr_driver.py +++ b/neutron/agent/linux/evpn_router/frr/frr_driver.py @@ -18,6 +18,7 @@ import jinja2 from neutron_lib import exceptions +from oslo_config import cfg from oslo_log import log as logging from oslo_serialization import jsonutils @@ -153,7 +154,7 @@ class FrrVtyshExecutor: @property def _vtysh_base_cmd(self) -> list[str]: - return ['vtysh'] + return ['vtysh', '--vty_socket', cfg.CONF.ovn_evpn.frr_vty_socket] def _execute_vtysh(self, vtysh_args: list[str]) -> str: """Execute any vtysh command args and return stdout.""" diff --git a/neutron/conf/agent/ovn/evpn/config.py b/neutron/conf/agent/ovn/evpn/config.py index b9ce6d78eb3..4c5732d690c 100644 --- a/neutron/conf/agent/ovn/evpn/config.py +++ b/neutron/conf/agent/ovn/evpn/config.py @@ -30,6 +30,11 @@ 'child_vxlan_port', default=49152, help=_('UDP port for the child VxLAN device used by EVPN')), + cfg.StrOpt( + 'frr_vty_socket', + default='/run/frr', + help=_('Path to the vtysh socket directory. This is passed ' + 'as --vty_socket to the vtysh command.')), ] 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 8db607a1ce0..c7cbe11430e 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 @@ -24,6 +24,7 @@ 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.conf.agent.ovn.evpn import config as evpn_conf from neutron.tests.common import net_helpers from neutron.tests.functional import base from neutron_lib import exceptions @@ -43,7 +44,7 @@ def __init__(self, namespace): @property def _vtysh_base_cmd(self) -> list[str]: - return ['vtysh', '-N', self._namespace] + return super()._vtysh_base_cmd + ['-N', self._namespace] def _is_centos9_frr85(): @@ -268,6 +269,7 @@ class TestFrrVtyshDriverConfiguration(base.BaseSudoTestCase): def setUp(self): super().setUp() + evpn_conf.register_opts() self.namespace = self.useFixture(net_helpers.NamespaceFixture()).name self.frr_fixture = self.useFixture( net_helpers.FrrFixture(namespace=self.namespace)) @@ -379,6 +381,7 @@ class TestFrrVtyshDriverOperation(base.BaseSudoTestCase): def setUp(self): super().setUp() + evpn_conf.register_opts() self.vtep_ip_a = '10.0.0.1' self.vtep_ip_b = '10.0.0.2' 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 c1120b101d7..222c27727a0 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 @@ -15,11 +15,14 @@ from unittest import mock +from neutron_lib import exceptions +from oslo_config import cfg + 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.conf.agent.ovn.evpn import config as evpn_conf from neutron.tests import base -from neutron_lib import exceptions def _build_test_evpn_router_config(vni): @@ -84,8 +87,11 @@ def test_delete_bgp_router_cmds(self): class TestFrrVtyshExecutor(base.BaseTestCase): + VTY_SOCKET = '/run/frr' + def setUp(self): super().setUp() + evpn_conf.register_opts() self.execute = mock.patch.object( frr_driver.linux_utils, 'execute').start() self.executor = frr_driver.FrrVtyshExecutor() @@ -98,7 +104,7 @@ def test_execute_cli_cmd(self): self.assertEqual("BGP summary output", out) self.execute.assert_called_once_with( - ['vtysh', '-c', mock_cmd], + ['vtysh', '--vty_socket', self.VTY_SOCKET, '-c', mock_cmd], run_as_root=True, ) @@ -127,7 +133,9 @@ def test_execute_cmds_calls_dryrun_then_apply(self): 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) + self.assertEqual( + ['vtysh', '--vty_socket', self.VTY_SOCKET, + '-c', 'write memory'], write_mem_cmd) def test_execute_cmds_raises_dryrun_error(self): mock_cmds = "bad config" @@ -160,6 +168,18 @@ def _dryrun_ok_apply_fail(cmd, **_kwargs): mock_cmds, ) + def test_execute_cli_cmd_custom_vty_socket(self): + custom_path = '/custom/frr/socket' + cfg.CONF.set_override('frr_vty_socket', custom_path, group='ovn_evpn') + self.execute.return_value = "output" + + self.executor.execute_cli_cmd('show version') + + self.execute.assert_called_once_with( + ['vtysh', '--vty_socket', custom_path, '-c', 'show version'], + run_as_root=True, + ) + class TestFrrVtyshDriver(base.BaseTestCase): diff --git a/tools/rootwrap/testing.filters b/tools/rootwrap/testing.filters index 01a611dbf42..94b2d1614a6 100644 --- a/tools/rootwrap/testing.filters +++ b/tools/rootwrap/testing.filters @@ -65,6 +65,6 @@ frr_init_start_bash: RegExpFilter, bash, root, bash, -c, /usr/lib(exec)?/frr/frr frr_init_restart_bash: RegExpFilter, bash, root, bash, -c, /usr/lib(exec)?/frr/frrinit\.sh restart [a-z]+-[0-9a-f-]+ > /dev/null 2>&1 frr_init_stop: RegExpFilter, /usr/lib/frr/frrinit.sh, root, /usr/lib/frr/frrinit.sh, stop, [a-z]+-[0-9a-f-]+ frr_init_stop_libexec: RegExpFilter, /usr/libexec/frr/frrinit.sh, root, /usr/libexec/frr/frrinit.sh, stop, [a-z]+-[0-9a-f-]+ -vtysh_cmd: RegExpFilter, vtysh, root, vtysh, -N, [a-z]+-[0-9a-f-]+, -c, .* -vtysh_dryrun: RegExpFilter, vtysh, root, vtysh, -N, [a-z]+-[0-9a-f-]+, --dryrun, -f, .* -vtysh_apply: RegExpFilter, vtysh, root, vtysh, -N, [a-z]+-[0-9a-f-]+, -f, .* \ No newline at end of file +vtysh_cmd: RegExpFilter, vtysh, root, vtysh, --vty_socket, /[a-z/]+, -N, [a-z]+-[0-9a-f-]+, -c, .* +vtysh_dryrun: RegExpFilter, vtysh, root, vtysh, --vty_socket, /[a-z/]+, -N, [a-z]+-[0-9a-f-]+, --dryrun, -f, .* +vtysh_apply: RegExpFilter, vtysh, root, vtysh, --vty_socket, /[a-z/]+, -N, [a-z]+-[0-9a-f-]+, -f, .* \ No newline at end of file From 91abb5e720e1c515dbfbe9b2313fbdff92b2abbf Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Wed, 17 Jun 2026 18:45:25 -0500 Subject: [PATCH 61/63] Only set the maintenance worker lock on the worker Other code passes the MaintenanceWorker as the trigger to from_server() like the ovn-db-sync-util and only the real MaintenanceWorker itself should call set_lock() before connecting. Closes-Bug: #2156979 Signed-off-by: Terry Wilson Change-Id: I74145ef1407856b7ec75de87daa2270b452b6c70 --- neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py | 3 +++ .../ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index ef0a7ae9e16..a35aa0cc4ee 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -429,6 +429,9 @@ def post_fork_initialize(self, resource, event, trigger, payload=None): if worker_class == wsgi.WorkerService: self._setup_hash_ring() + if worker_class == worker.MaintenanceWorker: + worker_class.lock_name = ovn_const.MAINTENANCE_NB_IDL_LOCK_NAME + # Initialize singleton agent cache and keep a copy. self._agent_cache = n_agent.AgentCache(self) self.nb_ovn, self.sb_ovn = impl_idl_ovn.get_ovn_idls(self, trigger) 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 5e5a862e486..1ce74affbe5 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,7 +245,10 @@ 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) + try: + idl_.set_lock(worker_class.lock_name) + except AttributeError: + pass else: idl_ = ovsdb_monitor.OvnNbIdl.from_server(*args, driver=driver) conn = connection.Connection(idl_, timeout=cfg.get_ovn_ovsdb_timeout()) From a8856b7c6f6ccab8352d3de37c0b108ae33db790 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Sun, 12 Apr 2026 13:17:42 +0200 Subject: [PATCH 62/63] Add security-groups-default-statefulness API extension Introduce a new API extension that allows configuring the default value of the ``stateful`` attribute for new security groups, on a per-project or system-wide basis. The new resource supports full CRUD operations with corresponding RBAC policy rules. The API definition ``security-groups-default-statefulness`` is provided in [1] and was released in neutron-lib 4.0.0. [1]https://review.opendev.org/c/openstack/neutron-lib/+/984354 Closes-Bug: #2146803 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I1c773a445a65414dbb3df325a3a61b94d050efc6 --- neutron/agent/securitygroups_rpc.py | 3 + neutron/common/ovn/extensions.py | 3 +- neutron/conf/policies/__init__.py | 2 + .../security_groups_default_statefulness.py | 93 +++++++++ ...dd_security_groups_default_statefulness.py | 45 ++++ .../alembic_migrations/versions/EXPAND_HEAD | 2 +- .../security_groups_default_statefulness.py | 38 ++++ .../security_groups_default_statefulness.py | 116 +++++++++++ neutron/db/securitygroups_db.py | 9 +- .../security_groups_default_statefulness.py | 42 ++++ .../security_groups_default_statefulness.py | 35 ++++ neutron/plugins/ml2/plugin.py | 3 + .../unit/agent/test_securitygroups_rpc.py | 7 +- ...st_security_groups_default_statefulness.py | 192 ++++++++++++++++++ ...st_security_groups_default_statefulness.py | 144 +++++++++++++ neutron/tests/unit/objects/test_objects.py | 1 + ...st_security_groups_default_statefulness.py | 32 +++ ...default-statefulness-a7e2e9ec4ba8490f.yaml | 12 ++ 18 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 neutron/conf/policies/security_groups_default_statefulness.py create mode 100644 neutron/db/migration/alembic_migrations/versions/2026.2/expand/a1b2c3d4e5f6_add_security_groups_default_statefulness.py create mode 100644 neutron/db/models/security_groups_default_statefulness.py create mode 100644 neutron/db/security_groups_default_statefulness.py create mode 100644 neutron/extensions/security_groups_default_statefulness.py create mode 100644 neutron/objects/security_groups_default_statefulness.py create mode 100644 neutron/tests/unit/conf/policies/test_security_groups_default_statefulness.py create mode 100644 neutron/tests/unit/db/test_security_groups_default_statefulness.py create mode 100644 neutron/tests/unit/objects/test_security_groups_default_statefulness.py create mode 100644 releasenotes/notes/security-groups-default-statefulness-a7e2e9ec4ba8490f.yaml diff --git a/neutron/agent/securitygroups_rpc.py b/neutron/agent/securitygroups_rpc.py index ce9fd0cb76a..8876e9873f2 100644 --- a/neutron/agent/securitygroups_rpc.py +++ b/neutron/agent/securitygroups_rpc.py @@ -19,6 +19,8 @@ from neutron_lib.api.definitions import aap_reject_multicast from neutron_lib.api.definitions import rbac_address_groups as rbac_ag_apidef from neutron_lib.api.definitions import rbac_security_groups as rbac_sg_apidef +from neutron_lib.api.definitions import security_groups_default_statefulness \ + as sg_ds_def from neutron_lib.api.definitions import security_groups_normalized_cidr from neutron_lib.api.definitions import security_groups_remote_address_group \ as sgag_def @@ -66,6 +68,7 @@ def disable_security_group_extension_by_config(aliases): _disable_extension('address-group', aliases) _disable_extension(rbac_ag_apidef.ALIAS, aliases) _disable_extension(sg_rules_default_sg_def.ALIAS, aliases) + _disable_extension(sg_ds_def.ALIAS, aliases) def skip_if_noopfirewall_or_firewall_disabled(func): diff --git a/neutron/common/ovn/extensions.py b/neutron/common/ovn/extensions.py index 9a77669866f..99350507d1d 100644 --- a/neutron/common/ovn/extensions.py +++ b/neutron/common/ovn/extensions.py @@ -87,6 +87,7 @@ from neutron_lib.api.definitions import rbac_security_groups from neutron_lib.api.definitions import router_availability_zone as raz_def from neutron_lib.api.definitions import router_enable_snat +from neutron_lib.api.definitions import security_groups_default_statefulness from neutron_lib.api.definitions import security_groups_normalized_cidr from neutron_lib.api.definitions import security_groups_remote_address_group from neutron_lib.api.definitions import \ @@ -113,7 +114,6 @@ from neutron.extensions import quotasv2_detail from neutron.extensions import security_groups_default_rules - # NOTE(russellb) This remains in its own file (vs constants.py) because we want # to be able to easily import it and export the info without any dependencies # on external imports. @@ -199,6 +199,7 @@ 'standard-attr-revisions', 'security-group', security_groups_default_rules.ALIAS, + security_groups_default_statefulness.ALIAS, security_groups_normalized_cidr.ALIAS, security_groups_remote_address_group.ALIAS, security_groups_rules_belongs_to_default_sg.ALIAS, diff --git a/neutron/conf/policies/__init__.py b/neutron/conf/policies/__init__.py index b4ca60d9031..06d94a8212e 100644 --- a/neutron/conf/policies/__init__.py +++ b/neutron/conf/policies/__init__.py @@ -43,6 +43,7 @@ from neutron.conf.policies import rbac from neutron.conf.policies import router from neutron.conf.policies import security_group +from neutron.conf.policies import security_groups_default_statefulness from neutron.conf.policies import segment from neutron.conf.policies import service_type from neutron.conf.policies import subnet @@ -81,6 +82,7 @@ def list_rules(): rbac.list_rules(), router.list_rules(), security_group.list_rules(), + security_groups_default_statefulness.list_rules(), segment.list_rules(), service_type.list_rules(), subnet.list_rules(), diff --git a/neutron/conf/policies/security_groups_default_statefulness.py b/neutron/conf/policies/security_groups_default_statefulness.py new file mode 100644 index 00000000000..d779597c3fe --- /dev/null +++ b/neutron/conf/policies/security_groups_default_statefulness.py @@ -0,0 +1,93 @@ +# 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 + + +COLLECTION_PATH = '/security-groups-default-statefulness' +RESOURCE_PATH = '/security-groups-default-statefulness/{id}' + + +rules = [ + policy.DocumentedRuleDefault( + name='create_security_groups_default_statefulness', + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, + scope_types=['project'], + description=( + 'Create a default statefulness setting for security groups. ' + 'System-wide settings (no project_id) always require admin ' + 'privileges. Per-project settings could be relaxed via ' + 'policy override to allow creation by project owners.'), + operations=[ + { + 'method': 'POST', + 'path': COLLECTION_PATH, + }, + ], + ), + policy.DocumentedRuleDefault( + name='get_security_groups_default_statefulness', + check_str=lib_rules.ADMIN_OR_PROJECT_READER, + scope_types=['project'], + description=( + 'Get default statefulness settings for security groups. ' + 'Admins can list all settings; project readers can see ' + 'the setting for their own project.'), + operations=[ + { + 'method': 'GET', + 'path': COLLECTION_PATH, + }, + { + 'method': 'GET', + 'path': RESOURCE_PATH, + }, + ], + ), + policy.DocumentedRuleDefault( + name='update_security_groups_default_statefulness', + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, + scope_types=['project'], + description=( + 'Update a default statefulness setting for security groups. ' + 'System-wide settings always require admin privileges. ' + 'Per-project settings could be relaxed via policy override ' + 'to allow update by project owners.'), + operations=[ + { + 'method': 'PUT', + 'path': RESOURCE_PATH, + }, + ], + ), + policy.DocumentedRuleDefault( + name='delete_security_groups_default_statefulness', + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, + scope_types=['project'], + description=( + 'Delete a default statefulness setting for security groups. ' + 'System-wide settings always require admin privileges. ' + 'Per-project settings could be relaxed via policy override ' + 'to allow deletion by project owners.'), + operations=[ + { + 'method': 'DELETE', + 'path': RESOURCE_PATH, + }, + ], + ), +] + + +def list_rules(): + return rules diff --git a/neutron/db/migration/alembic_migrations/versions/2026.2/expand/a1b2c3d4e5f6_add_security_groups_default_statefulness.py b/neutron/db/migration/alembic_migrations/versions/2026.2/expand/a1b2c3d4e5f6_add_security_groups_default_statefulness.py new file mode 100644 index 00000000000..21e9208845f --- /dev/null +++ b/neutron/db/migration/alembic_migrations/versions/2026.2/expand/a1b2c3d4e5f6_add_security_groups_default_statefulness.py @@ -0,0 +1,45 @@ +# Copyright 2026 OpenStack Foundation +# +# 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. +# + +"""Add security groups default statefulness table + +Revision ID: a1b2c3d4e5f6 +Revises: a00aa97899c0 +Create Date: 2026-05-12 10:00:00.000000 + +""" + +from neutron_lib.db import constants as db_const +import sqlalchemy as sa + +from neutron.db import migration + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'a00aa97899c0' + + +def upgrade(): + migration.create_table_if_not_exists( + 'security_groups_default_statefulness', + sa.Column('id', sa.String(length=db_const.UUID_FIELD_SIZE), + primary_key=True), + sa.Column('project_id', + sa.String(length=db_const.PROJECT_ID_FIELD_SIZE), + nullable=True, unique=True), + sa.Column('stateful', sa.Boolean(), nullable=False, + server_default=sa.sql.true()), + ) diff --git a/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD b/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD index 04ef4dffa8c..11498c03ccc 100644 --- a/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD +++ b/neutron/db/migration/alembic_migrations/versions/EXPAND_HEAD @@ -1 +1 @@ -a00aa97899c0 +a1b2c3d4e5f6 diff --git a/neutron/db/models/security_groups_default_statefulness.py b/neutron/db/models/security_groups_default_statefulness.py new file mode 100644 index 00000000000..4c0183beb2b --- /dev/null +++ b/neutron/db/models/security_groups_default_statefulness.py @@ -0,0 +1,38 @@ +# Copyright (c) 2026 Red Hat, Inc. +# +# 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.api.definitions import \ + security_groups_default_statefulness as apidef +from neutron_lib.db import constants as db_const +from neutron_lib.db import model_base +import sqlalchemy as sa +from sqlalchemy import sql + + +class SecurityGroupDefaultStatefulness(model_base.BASEV2, + model_base.HasId): + __tablename__ = 'security_groups_default_statefulness' + + project_id = sa.Column( + sa.String(db_const.PROJECT_ID_FIELD_SIZE), + nullable=True, + unique=True) + stateful = sa.Column( + sa.Boolean, + default=True, + server_default=sql.true(), + nullable=False) + api_collections = [apidef.COLLECTION_NAME] + collection_resource_map = { + apidef.COLLECTION_NAME: apidef.RESOURCE_NAME} diff --git a/neutron/db/security_groups_default_statefulness.py b/neutron/db/security_groups_default_statefulness.py new file mode 100644 index 00000000000..6b33d65fd86 --- /dev/null +++ b/neutron/db/security_groups_default_statefulness.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026 Red Hat, Inc. +# +# 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.api.definitions import \ + security_groups_default_statefulness as apidef +from neutron_lib.db import utils as db_utils +from neutron_lib import exceptions as n_exc + +from neutron._i18n import _ +from neutron.objects import base as base_obj +from neutron.objects import security_groups_default_statefulness as sg_ds_obj + + +class SecurityGroupDefaultStatefulnessNotFound(n_exc.NotFound): + message = _("Security group default statefulness %(id)s could not " + "be found.") + + +class SecurityGroupDefaultStatefulnessAlreadyExists(n_exc.Conflict): + message = _("A security group default statefulness setting already " + "exists for project '%(project_id)s'.") + + +class SecurityGroupDefaultStatefulnessMixin: + """Mixin class for security group default statefulness CRUD.""" + + @staticmethod + def _make_sg_default_statefulness_dict(sg_ds, fields=None): + res = {'id': sg_ds['id'], + 'project_id': sg_ds['project_id'], + 'stateful': sg_ds['stateful']} + return db_utils.resource_fields(res, fields) + + def _get_sg_default_statefulness(self, context, id): + obj = sg_ds_obj.SecurityGroupDefaultStatefulness.get_object( + context, id=id) + if obj is None: + raise SecurityGroupDefaultStatefulnessNotFound(id=id) + return obj + + def create_security_groups_default_statefulness( + self, context, security_groups_default_statefulness): + fields = security_groups_default_statefulness[ + apidef.RESOURCE_NAME] + project_id = fields.get('project_id') + existing = sg_ds_obj.SecurityGroupDefaultStatefulness.get_object( + context.elevated(), project_id=project_id) + if existing: + raise SecurityGroupDefaultStatefulnessAlreadyExists( + project_id=project_id or 'system-wide') + + sg_ds = sg_ds_obj.SecurityGroupDefaultStatefulness( + context, + project_id=project_id, + stateful=fields['stateful']) + sg_ds.create() + return self._make_sg_default_statefulness_dict(sg_ds) + + def update_security_groups_default_statefulness( + self, context, id, security_groups_default_statefulness): + fields = security_groups_default_statefulness[ + apidef.RESOURCE_NAME] + sg_ds = self._get_sg_default_statefulness(context, id) + sg_ds.update_fields(fields) + sg_ds.update() + return self._make_sg_default_statefulness_dict(sg_ds) + + def get_security_groups_default_statefulness( + self, context, id=None, filters=None, fields=None, + sorts=None, limit=None, marker=None, page_reverse=False): + if id is not None: + sg_ds = self._get_sg_default_statefulness(context, id) + return self._make_sg_default_statefulness_dict(sg_ds, fields) + + filters = filters or {} + pager = base_obj.Pager(sorts, limit, page_reverse, marker) + objs = sg_ds_obj.SecurityGroupDefaultStatefulness.get_objects( + context, _pager=pager, **filters) + return [ + self._make_sg_default_statefulness_dict(obj, fields) + for obj in objs + ] + + def delete_security_groups_default_statefulness(self, context, id): + sg_ds = self._get_sg_default_statefulness(context, id) + sg_ds.delete() + + def get_default_stateful_for_project(self, context, project_id): + """Return the effective default 'stateful' value for a project. + + Looks for a project-specific setting first, then a system-wide + setting. Returns True (the built-in default) if neither exists. + """ + obj = sg_ds_obj.SecurityGroupDefaultStatefulness.get_object( + context, project_id=project_id) + if obj: + return obj.stateful + + obj = sg_ds_obj.SecurityGroupDefaultStatefulness.get_object( + context, project_id=None) + if obj: + return obj.stateful + + # Return the default static value defined for this API, that is 'True'. + return True diff --git a/neutron/db/securitygroups_db.py b/neutron/db/securitygroups_db.py index 3a38bd0cc13..bb61ba47ec6 100644 --- a/neutron/db/securitygroups_db.py +++ b/neutron/db/securitygroups_db.py @@ -39,6 +39,7 @@ from neutron.db import address_group_db as ag_db from neutron.db.models import securitygroup as sg_models from neutron.db import rbac_db_mixin as rbac_mixin +from neutron.db import security_groups_default_statefulness as sg_stateful from neutron.extensions import security_groups_default_rules as \ ext_sg_default_rules from neutron.extensions import securitygroup as ext_sg @@ -60,9 +61,10 @@ class SecurityGroupDbMixin( ext_sg.SecurityGroupPluginBase, ext_sg_default_rules.SecurityGroupDefaultRulesPluginBase, - rbac_mixin.RbacPluginMixin): + rbac_mixin.RbacPluginMixin, + sg_stateful.SecurityGroupDefaultStatefulnessMixin, +): """Mixin class to add security group to db_base_plugin_v2.""" - __native_bulk_support = True def create_security_group_bulk(self, context, security_groups): @@ -100,6 +102,9 @@ def create_security_group(self, context, security_group, default_sg=False): project_id = s['project_id'] stateful = s.get('stateful', True) + if stateful is constants.ATTR_NOT_SPECIFIED: + stateful = self.get_default_stateful_for_project(context, + project_id) if default_sg: existing_def_sg_id = self._get_default_sg_id(context, project_id) diff --git a/neutron/extensions/security_groups_default_statefulness.py b/neutron/extensions/security_groups_default_statefulness.py new file mode 100644 index 00000000000..f5385f1c192 --- /dev/null +++ b/neutron/extensions/security_groups_default_statefulness.py @@ -0,0 +1,42 @@ +# 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.api.definitions import \ + security_groups_default_statefulness as apidef +from neutron_lib.api import extensions as api_extensions +from neutron_lib.plugins import directory + +from neutron.api import extensions +from neutron.api.v2 import base + + +class Security_groups_default_statefulness( + api_extensions.APIExtensionDescriptor): + + api_definition = apidef + + @classmethod + def get_resources(cls): + plugin = directory.get_plugin() + collection_name = apidef.COLLECTION_NAME.replace('_', '-') + params = apidef.RESOURCE_ATTRIBUTE_MAP.get( + apidef.COLLECTION_NAME, dict()) + controller = base.create_resource( + apidef.COLLECTION_NAME, + apidef.RESOURCE_NAME, + plugin, params, + allow_pagination=True, + allow_sorting=True) + + ex = extensions.ResourceExtension(collection_name, controller, + attr_map=params) + return [ex] diff --git a/neutron/objects/security_groups_default_statefulness.py b/neutron/objects/security_groups_default_statefulness.py new file mode 100644 index 00000000000..be67e5e576a --- /dev/null +++ b/neutron/objects/security_groups_default_statefulness.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026 Red Hat, Inc. +# +# 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.objects import common_types +from oslo_versionedobjects import fields as obj_fields + +from neutron.db.models import security_groups_default_statefulness as models +from neutron.objects import base + + +@base.NeutronObjectRegistry.register +class SecurityGroupDefaultStatefulness(base.NeutronDbObject): + # Version 1.0: Initial version + VERSION = '1.0' + + db_model = models.SecurityGroupDefaultStatefulness + + fields = { + 'id': common_types.UUIDField(), + 'project_id': obj_fields.StringField(nullable=True), + 'stateful': obj_fields.BooleanField(default=True), + } + + fields_no_update = ['project_id'] diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index 72def961eb4..237bcfcc27c 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -62,6 +62,8 @@ from neutron_lib.api.definitions import rbac_address_scope from neutron_lib.api.definitions import rbac_security_groups as rbac_sg_apidef from neutron_lib.api.definitions import rbac_subnetpool +from neutron_lib.api.definitions import security_groups_default_statefulness \ + as sg_ds_def from neutron_lib.api.definitions import security_groups_normalized_cidr from neutron_lib.api.definitions import security_groups_port_filtering from neutron_lib.api.definitions import security_groups_remote_address_group @@ -260,6 +262,7 @@ class Ml2Plugin(db_base_plugin_v2.NeutronDbPluginV2, sg_rules_default_sg.ALIAS, subnet_ext_net_def.ALIAS, qinq_apidef.ALIAS, + sg_ds_def.ALIAS, ] # List of agent types for which all binding_failed ports should try to be diff --git a/neutron/tests/unit/agent/test_securitygroups_rpc.py b/neutron/tests/unit/agent/test_securitygroups_rpc.py index 749f678974a..3590f717fca 100644 --- a/neutron/tests/unit/agent/test_securitygroups_rpc.py +++ b/neutron/tests/unit/agent/test_securitygroups_rpc.py @@ -19,6 +19,8 @@ import netaddr from neutron_lib.api.definitions import allowedaddresspairs as addr_apidef +from neutron_lib.api.definitions import \ + security_groups_default_statefulness as sg_ds_def from neutron_lib import constants as const from neutron_lib import context from neutron_lib.plugins import directory @@ -3454,7 +3456,10 @@ class TestSecurityGroupExtensionControl(base.BaseTestCase): def test_disable_security_group_extension_by_config(self): set_enable_security_groups(False) exp_aliases = ['dummy1', 'dummy2'] - ext_aliases = ['dummy1', 'security-group', 'dummy2'] + ext_aliases = [ + 'dummy1', 'security-group', 'dummy2', + sg_ds_def.ALIAS, + ] sg_rpc.disable_security_group_extension_by_config(ext_aliases) self.assertEqual(ext_aliases, exp_aliases) diff --git a/neutron/tests/unit/conf/policies/test_security_groups_default_statefulness.py b/neutron/tests/unit/conf/policies/test_security_groups_default_statefulness.py new file mode 100644 index 00000000000..88e7e3822b3 --- /dev/null +++ b/neutron/tests/unit/conf/policies/test_security_groups_default_statefulness.py @@ -0,0 +1,192 @@ +# 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 SecurityGroupDefaultStatefulnessAPITestCase(base.PolicyBaseTestCase): + + def setUp(self): + super().setUp() + self.target = {'project_id': self.project_id} + + +class SystemAdminTests(SecurityGroupDefaultStatefulnessAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.system_admin_ctx + + def test_create_security_groups_default_statefulness(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, + 'create_security_groups_default_statefulness', + self.target) + + def test_get_security_groups_default_statefulness(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, + 'get_security_groups_default_statefulness', + self.target) + + def test_update_security_groups_default_statefulness(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, + 'update_security_groups_default_statefulness', + self.target) + + def test_delete_security_groups_default_statefulness(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, + 'delete_security_groups_default_statefulness', + self.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(SecurityGroupDefaultStatefulnessAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.project_admin_ctx + + def test_create_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'create_security_groups_default_statefulness', + self.target)) + + def test_get_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'get_security_groups_default_statefulness', + self.target)) + + def test_update_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'update_security_groups_default_statefulness', + self.target)) + + def test_delete_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'delete_security_groups_default_statefulness', + self.target)) + + +class ProjectManagerTests(SecurityGroupDefaultStatefulnessAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.project_manager_ctx + + def test_create_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'create_security_groups_default_statefulness', + self.target)) + + def test_get_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'get_security_groups_default_statefulness', + self.target)) + + def test_update_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'update_security_groups_default_statefulness', + self.target)) + + def test_delete_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'delete_security_groups_default_statefulness', + self.target)) + + +class ProjectMemberTests(SecurityGroupDefaultStatefulnessAPITestCase): + + def setUp(self): + super().setUp() + self.context = self.project_member_ctx + + def test_create_security_groups_default_statefulness(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, + 'create_security_groups_default_statefulness', + self.target) + + def test_get_security_groups_default_statefulness(self): + self.assertTrue( + policy.enforce( + self.context, + 'get_security_groups_default_statefulness', + self.target)) + + def test_update_security_groups_default_statefulness(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, + 'update_security_groups_default_statefulness', + self.target) + + def test_delete_security_groups_default_statefulness(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, + 'delete_security_groups_default_statefulness', + self.target) + + +class ProjectReaderTests(ProjectMemberTests): + + def setUp(self): + super().setUp() + self.context = self.project_reader_ctx diff --git a/neutron/tests/unit/db/test_security_groups_default_statefulness.py b/neutron/tests/unit/db/test_security_groups_default_statefulness.py new file mode 100644 index 00000000000..16423959c3c --- /dev/null +++ b/neutron/tests/unit/db/test_security_groups_default_statefulness.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 Red Hat, Inc. +# +# 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.db import security_groups_default_statefulness as sg_ds_db +from neutron.tests.unit import testlib_api + + +DB_PLUGIN_KLASS = 'neutron.db.db_base_plugin_v2.NeutronDbPluginV2' + + +class SecurityGroupDefaultStatefulnessMixinImpl( + sg_ds_db.SecurityGroupDefaultStatefulnessMixin): + pass + + +class SecurityGroupDefaultStatefulnessDbTestCase(testlib_api.SqlTestCase): + + def setUp(self): + super().setUp() + self.setup_coreplugin(core_plugin=DB_PLUGIN_KLASS) + self.ctx = context.get_admin_context() + self.mixin = SecurityGroupDefaultStatefulnessMixinImpl() + + def _make_body(self, project_id=None, stateful=True): + return {'security_groups_default_statefulness': { + 'project_id': project_id, + 'stateful': stateful}} + + def test_create_sg_default_statefulness(self): + body = self._make_body(project_id='project1', stateful=False) + result = self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + self.assertFalse(result['stateful']) + self.assertEqual('project1', result['project_id']) + self.assertIn('id', result) + + def test_create_sg_default_statefulness_system_wide(self): + body = self._make_body(project_id=None, stateful=False) + result = self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + self.assertFalse(result['stateful']) + self.assertIsNone(result['project_id']) + + def test_create_sg_default_statefulness_duplicate_project(self): + body = self._make_body(project_id='project1', stateful=False) + self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + self.assertRaises( + sg_ds_db.SecurityGroupDefaultStatefulnessAlreadyExists, + self.mixin.create_security_groups_default_statefulness, + self.ctx, body) + + def test_get_sg_default_statefulness(self): + body = self._make_body(project_id='project1', stateful=False) + created = self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + result = self.mixin.get_security_groups_default_statefulness( + self.ctx, created['id']) + self.assertEqual(created['id'], result['id']) + self.assertFalse(result['stateful']) + + def test_get_sg_default_statefulness_not_found(self): + self.assertRaises( + sg_ds_db.SecurityGroupDefaultStatefulnessNotFound, + self.mixin.get_security_groups_default_statefulness, + self.ctx, 'non-existent-id') + + def test_list_sg_default_statefulness(self): + body1 = self._make_body(project_id='project1', stateful=False) + body2 = self._make_body(project_id='project2', stateful=True) + self.mixin.create_security_groups_default_statefulness( + self.ctx, body1) + self.mixin.create_security_groups_default_statefulness( + self.ctx, body2) + results = self.mixin.get_security_groups_default_statefulness( + self.ctx) + self.assertEqual(2, len(results)) + + def test_update_sg_default_statefulness(self): + body = self._make_body(project_id='project1', stateful=False) + created = self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + update_body = {'security_groups_default_statefulness': { + 'stateful': True}} + result = self.mixin.update_security_groups_default_statefulness( + self.ctx, created['id'], update_body) + self.assertTrue(result['stateful']) + self.assertEqual(created['id'], result['id']) + + def test_delete_sg_default_statefulness(self): + body = self._make_body(project_id='project1', stateful=False) + created = self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + self.mixin.delete_security_groups_default_statefulness( + self.ctx, created['id']) + self.assertRaises( + sg_ds_db.SecurityGroupDefaultStatefulnessNotFound, + self.mixin.get_security_groups_default_statefulness, + self.ctx, created['id']) + + def test_get_default_stateful_for_project_specific(self): + body = self._make_body(project_id='project1', stateful=False) + self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + result = self.mixin.get_default_stateful_for_project( + self.ctx, 'project1') + self.assertFalse(result) + + def test_get_default_stateful_for_project_system_wide(self): + body = self._make_body(project_id=None, stateful=False) + self.mixin.create_security_groups_default_statefulness( + self.ctx, body) + result = self.mixin.get_default_stateful_for_project( + self.ctx, 'any-project') + self.assertFalse(result) + + def test_get_default_stateful_project_overrides_system(self): + system_body = self._make_body(project_id=None, stateful=False) + project_body = self._make_body(project_id='project1', stateful=True) + self.mixin.create_security_groups_default_statefulness( + self.ctx, system_body) + self.mixin.create_security_groups_default_statefulness( + self.ctx, project_body) + result = self.mixin.get_default_stateful_for_project( + self.ctx, 'project1') + self.assertTrue(result) + + def test_get_default_stateful_no_config(self): + result = self.mixin.get_default_stateful_for_project( + self.ctx, 'project1') + self.assertTrue(result) diff --git a/neutron/tests/unit/objects/test_objects.py b/neutron/tests/unit/objects/test_objects.py index a5f1e4da5eb..29a7d7c0da0 100644 --- a/neutron/tests/unit/objects/test_objects.py +++ b/neutron/tests/unit/objects/test_objects.py @@ -117,6 +117,7 @@ 'RouterRoute': '1.0-07fc5337c801fb8c6ccfbcc5afb45907', 'SecurityGroup': '1.6-7eb8e44c327512e7bb1759ab41ede44b', 'SecurityGroupDefaultRule': '1.0-d498fd4993b6732f3f266c4b7e292e22', + 'SecurityGroupDefaultStatefulness': '1.0-499b42fe3496654e05c7aa104138c80e', 'SecurityGroupPortBinding': '1.0-6879d5c0af80396ef5a72934b6a6ef20', 'SecurityGroupRBAC': '1.1-be82ed54376b85ee4f963d479ac48c91', 'SecurityGroupRule': '1.3-60cdd6434e35979d2b280ed28a9598d3', diff --git a/neutron/tests/unit/objects/test_security_groups_default_statefulness.py b/neutron/tests/unit/objects/test_security_groups_default_statefulness.py new file mode 100644 index 00000000000..e6a7698a47d --- /dev/null +++ b/neutron/tests/unit/objects/test_security_groups_default_statefulness.py @@ -0,0 +1,32 @@ +# Copyright (c) 2026 Red Hat, Inc. +# +# 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.objects import security_groups_default_statefulness +from neutron.tests.unit.objects import test_base +from neutron.tests.unit import testlib_api + + +class SecurityGroupDefaultStatefulnessIfaceObjectTestCase( + test_base.BaseObjectIfaceTestCase): + + _test_class = ( + security_groups_default_statefulness.SecurityGroupDefaultStatefulness) + + +class SecurityGroupDefaultStatefulnessDbObjectTestCase( + test_base.BaseDbObjectTestCase, + testlib_api.SqlTestCase): + + _test_class = ( + security_groups_default_statefulness.SecurityGroupDefaultStatefulness) diff --git a/releasenotes/notes/security-groups-default-statefulness-a7e2e9ec4ba8490f.yaml b/releasenotes/notes/security-groups-default-statefulness-a7e2e9ec4ba8490f.yaml new file mode 100644 index 00000000000..06751241018 --- /dev/null +++ b/releasenotes/notes/security-groups-default-statefulness-a7e2e9ec4ba8490f.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + Added a new ``security-groups-default-statefulness`` API extension that + allows configuring the default value of the ``stateful`` attribute for + new security groups, on a per-project or system-wide basis. By default, + only the administrator user can can create, update and delete the default + statefulness settings through the new + ``security_groups_default_statefulness`` resource; non-admin users can + show and list the registers. This is useful for deployments where + stateless security groups are preferred by default (e.g. DPDK + deployments). From 953495bb402e2d853453a54dd72fcdc959f4673e Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 18 Jun 2026 14:51:43 +0200 Subject: [PATCH 63/63] Delete ``FloatingIPDNS`` row on FIP disassociation When a VM with a floating IP is deleted, the port deletion triggers ``disassociate_floatingips`` which calls ``_process_dns_floatingip_delete``. This method deleted the DNS records from Designate but did not remove the ``FloatingIPDNS`` DB row (``floatingipdnses`` table). The stale row caused failures when the same FIP was later associated with a new VM: the external DNS cleanup attempted to delete the old name which no longer existed in Designate, resulting in a ``DuplicateRecordSet`` error. The new VM's DNS records were then never cleaned on its deletion. Delete the ``FloatingIPDNS`` row after removing the records from the external DNS service. This is safe for both callers of ``_process_dns_floatingip_delete``: on FIP deletion the row would be cascade-deleted anyway, and on FIP disassociation it prevents stale data from persisting. Closes-Bug: #2130405 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: I0d7f722ae1ef8d474112b40c9ab78ed2d1a417b2 --- neutron/db/dns_db.py | 1 + neutron/tests/unit/db/test_dns_db.py | 100 +++++++++++++++++++++++ neutron/tests/unit/extensions/test_l3.py | 49 +++++++++++ 3 files changed, 150 insertions(+) create mode 100644 neutron/tests/unit/db/test_dns_db.py diff --git a/neutron/db/dns_db.py b/neutron/db/dns_db.py index 2dd98bbcae8..2c4a7848a53 100644 --- a/neutron/db/dns_db.py +++ b/neutron/db/dns_db.py @@ -186,6 +186,7 @@ def _process_dns_floatingip_delete(self, context, floatingip_data): context, dns_data_db['published_dns_domain'], dns_data_db['published_dns_name'], [floatingip_data['floating_ip_address']]) + dns_data_db.delete() def _validate_floatingip_dns(self, dns_name, dns_domain): if dns_domain and not dns_name: diff --git a/neutron/tests/unit/db/test_dns_db.py b/neutron/tests/unit/db/test_dns_db.py new file mode 100644 index 00000000000..1befbabe6b7 --- /dev/null +++ b/neutron/tests/unit/db/test_dns_db.py @@ -0,0 +1,100 @@ +# Copyright 2026 Red Hat, Inc. +# +# 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 oslo_utils import uuidutils + +from neutron.db import dns_db +from neutron.db.dns_db import extensions as dns_extensions +from neutron.db.dns_db import fip_obj +from neutron.tests import base + +FAKE_FIP_ID = uuidutils.generate_uuid() +FAKE_FIP_ADDRESS = '198.51.100.10' +FAKE_DNS_NAME = 'myvm' +FAKE_DNS_DOMAIN = 'example.com.' + + +class TestDNSDbMixin(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.mixin = dns_db.DNSDbMixin() + self.mixin._core_plugin = mock.Mock() + self.mixin._dns_driver = mock.Mock() + self.mixin._delete_floatingip_from_external_dns_service = mock.Mock() + self.context = mock.Mock() + self.floatingip_data = { + 'id': FAKE_FIP_ID, + 'floating_ip_address': FAKE_FIP_ADDRESS, + } + self.mock_ext_supported = mock.patch.object( + dns_extensions, 'is_extension_supported', + return_value=True).start() + self.mock_get_object = mock.patch.object( + fip_obj.FloatingIPDNS, 'get_object').start() + + def _get_mock_dns_data_db(self): + mock_dns_data_db = mock.Mock() + mock_dns_data_db.__getitem__ = mock.Mock(side_effect={ + 'published_dns_domain': FAKE_DNS_DOMAIN, + 'published_dns_name': FAKE_DNS_NAME, + }.__getitem__) + return mock_dns_data_db + + def test_process_dns_floatingip_delete(self): + mock_dns_data_db = self._get_mock_dns_data_db() + self.mock_get_object.return_value = mock_dns_data_db + + self.mixin._process_dns_floatingip_delete( + self.context, self.floatingip_data) + + self.mock_get_object.assert_called_once_with( + self.context, floatingip_id=FAKE_FIP_ID) + self.mixin._delete_floatingip_from_external_dns_service.\ + assert_called_once_with( + self.context, FAKE_DNS_DOMAIN, FAKE_DNS_NAME, + [FAKE_FIP_ADDRESS]) + mock_dns_data_db.delete.assert_called_once() + + def test_process_dns_floatingip_delete_no_dns_data(self): + self.mock_get_object.return_value = None + + self.mixin._process_dns_floatingip_delete( + self.context, self.floatingip_data) + + self.mixin._delete_floatingip_from_external_dns_service.\ + assert_not_called() + + def test_process_dns_floatingip_delete_no_dns_extension(self): + self.mock_ext_supported.return_value = False + + self.mixin._process_dns_floatingip_delete( + self.context, self.floatingip_data) + + self.mock_get_object.assert_not_called() + self.mixin._delete_floatingip_from_external_dns_service.\ + assert_not_called() + + def test_process_dns_floatingip_delete_ext_dns_service_error(self): + """The FloatingIPDNS DB row is deleted even if Designate call fails.""" + mock_dns_data_db = self._get_mock_dns_data_db() + self.mock_get_object.return_value = mock_dns_data_db + + self.mixin._process_dns_floatingip_delete( + self.context, self.floatingip_data) + + mock_dns_data_db.delete.assert_called_once() diff --git a/neutron/tests/unit/extensions/test_l3.py b/neutron/tests/unit/extensions/test_l3.py index 61e168fd70e..40fd36958d7 100644 --- a/neutron/tests/unit/extensions/test_l3.py +++ b/neutron/tests/unit/extensions/test_l3.py @@ -59,6 +59,7 @@ from neutron.db.models import l3 as l3_models from neutron.db import models_v2 from neutron.extensions import l3 +from neutron.objects import floatingip as fip_obj from neutron.objects import network as network_obj from neutron.services.revisions import revision_plugin from neutron.tests import base @@ -4822,6 +4823,54 @@ def test_floatingip_disassociate_port(self, mock_args): self.mock_admin_client.recordsets.delete.assert_called_with( in_addr_zone_name, in_addr_name) + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_disassociate_port_deletes_dns_db_row(self, mock_args): + """Verify FloatingIPDNS row is deleted on FIP disassociation.""" + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns( + net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME, assoc_port=True) as flip: + fake_recordset = {'id': '', + 'records': [flip['floating_ip_address']]} + self.mock_client.recordsets.list.return_value = [fake_recordset] + + admin_ctx = context.get_admin_context() + dns_data = fip_obj.FloatingIPDNS.get_object( + admin_ctx, floatingip_id=flip['id']) + self.assertIsNotNone(dns_data) + + data = {'floatingip': {'port_id': None}} + req = self.new_update_request('floatingips', data, flip['id']) + res = req.get_response(self._api_for_resource('floatingip')) + self.assertEqual(200, res.status_code) + + dns_data = fip_obj.FloatingIPDNS.get_object( + admin_ctx, floatingip_id=flip['id']) + self.assertIsNone(dns_data) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_port_delete_deletes_dns_db_row(self, mock_args): + """Verify FloatingIPDNS row is deleted when port is deleted""" + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns( + net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME, assoc_port=True) as flip: + fake_recordset = {'id': '', + 'records': [flip['floating_ip_address']]} + self.mock_client.recordsets.list.return_value = [fake_recordset] + + admin_ctx = context.get_admin_context() + dns_data = fip_obj.FloatingIPDNS.get_object( + admin_ctx, floatingip_id=flip['id']) + self.assertIsNotNone(dns_data) + + port_id = flip['port_id'] + self._delete('ports', port_id) + + dns_data = fip_obj.FloatingIPDNS.get_object( + admin_ctx, floatingip_id=flip['id']) + self.assertIsNone(dns_data) + @mock.patch(MOCK_PATH, **mock_config) def test_floatingip_delete(self, mock_args): cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN)