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/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/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/lib/frr b/devstack/lib/frr index 2e8792d741f..a82a393992c 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/ @@ -69,4 +81,3 @@ function cleanup_frr { # Clean the FRRt configuration dir sudo rm -rf $FRR_CONF_DIR } - 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..dcebc7e9971 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -19,8 +19,10 @@ 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 +source $LIBDIR/dns_forwarder_ovs_ext # source the OVS/OVN compilation helper methods source $TOP_DIR/lib/neutron_plugins/ovs_source @@ -72,6 +74,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 @@ -85,6 +90,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/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-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 diff --git a/doc/source/admin/config-wsgi.rst b/doc/source/admin/config-wsgi.rst index 4a16d3b5d32..596241d679e 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 ------------------------ @@ -140,10 +153,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. @@ -167,5 +181,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 `_. diff --git a/etc/neutron/rootwrap.d/rootwrap.filters b/etc/neutron/rootwrap.d/rootwrap.filters index df8021288cc..1c296ffeb06 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, --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 ovsdb-client: CommandFilter, ovsdb-client, root 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/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/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/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/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/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..9a899151b68 --- /dev/null +++ b/neutron/agent/linux/evpn_router/frr/frr_driver.py @@ -0,0 +1,278 @@ +# 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_config import cfg +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', '--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.""" + 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 + self.execute_cli_cmd('write memory') + + +class FrrVtyshDriver(interface.EVPNRouterDriver): + + def __init__(self, peer_interface: str, + vrf_handler: interface.EVPNRouterVrfHandler, + 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/agent/linux/ip_conntrack.py b/neutron/agent/linux/ip_conntrack.py index ae77fb93638..bc1229db787 100644 --- a/neutron/agent/linux/ip_conntrack.py +++ b/neutron/agent/linux/ip_conntrack.py @@ -142,12 +142,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/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/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/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/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/linux/svd.py b/neutron/agent/linux/svd.py index 8560a60f620..148f45d6569 100644 --- a/neutron/agent/linux/svd.py +++ b/neutron/agent/linux/svd.py @@ -17,79 +17,108 @@ 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 - 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 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, br_mtu): 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, br_mtu) 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/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/agent/ovn/extensions/evpn/__init__.py b/neutron/agent/ovn/extensions/evpn/__init__.py index 5143fdaf1e6..299f678fa66 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,40 +13,91 @@ # 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 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 +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): def __init__(self): super().__init__() - self._evpn_fsm = None + self._evpn_fsm = fsm.EvpnFSM() 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") + driver = fsm_frr_driver.FsmFrrVtyshDriver( + peer_interface=CONF.ovn_evpn.bgp_local_interface, + bgp_router_id=self.cfg.local_ip) + 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) 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) self.nl_dispatcher.start() LOG.info("NetlinkDispatcher started as part of EVPN extension") + super().start() @property def name(self): @@ -66,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/constants.py b/neutron/agent/ovn/extensions/evpn/constants.py index bac44820f05..3b7bd82ba97 100644 --- a/neutron/agent/ovn/extensions/evpn/constants.py +++ b/neutron/agent/ovn/extensions/evpn/constants.py @@ -15,13 +15,8 @@ EVPN_EXT_NAME = 'EVPN agent extension' 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/events.py b/neutron/agent/ovn/extensions/evpn/events.py index 301ff9a48a9..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,15 +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 - svc_const.EVPN_LRP_VNI_EXT_ID_KEY in row.external_ids) + 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: @@ -51,14 +62,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/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/fsm.py b/neutron/agent/ovn/extensions/evpn/fsm.py index 8740163031b..5932f0d3f43 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,69 @@ 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): self.instances = {} # vrf -> Evpn + self._svd = None + self._cfg = None + self._driver = None - def _set_mac_vni(self, evpn, mac, vni): + def setup(self, svd, config, frr_driver): + self._svd = svd + self._cfg = config + self._driver = frr_driver + + 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): - 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) + self._svd.add_vni(evpn.vni, evpn.vid, evpn.vrf, evpn.mac, + self._cfg.br_mtu) + self._driver.create_router(evpn.vrf, evpn.vni) + LOG.debug("EVPN: advertised %s", evpn) + + def _unadvertise(self, evpn): + 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) 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/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/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/agent/ovn/extensions/evpn/svd.py b/neutron/agent/ovn/extensions/evpn/svd.py new file mode 100644 index 00000000000..f8b860f4b66 --- /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, 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, br_mtu) + 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/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/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/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/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/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/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. 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/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/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/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/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/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/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/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..4c5732d690c --- /dev/null +++ b/neutron/conf/agent/ovn/evpn/config.py @@ -0,0 +1,42 @@ +# 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')), + 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.')), +] + + +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 6c0462878d2..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 @@ -46,7 +47,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', evpn_conf.EVPN_OPTS), ] @@ -54,6 +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') + evpn_conf.register_opts() def get_root_helper(conf): diff --git a/neutron/conf/common.py b/neutron/conf/common.py index 64f2af3585d..42efcd58e17 100644 --- a/neutron/conf/common.py +++ b/neutron/conf/common.py @@ -110,13 +110,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 " 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/conf/policies/__init__.py b/neutron/conf/policies/__init__.py index 25508e610ca..06d94a8212e 100644 --- a/neutron/conf/policies/__init__.py +++ b/neutron/conf/policies/__init__.py @@ -37,11 +37,13 @@ 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 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 @@ -74,11 +76,13 @@ 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(), 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/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..b991d9a0e8e 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,10 @@ policy.DocumentedRuleDefault( name='create_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_SERVICE, + base.NET_OWNER_MEMBER, + 'rule:shared', + ), scope_types=['project'], description='Create a port', operations=ACTION_POST, @@ -84,8 +87,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 +102,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 +120,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 +136,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 +154,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 +170,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 +188,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 +206,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 +221,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 +237,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 +255,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 +272,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 +289,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 +304,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 +313,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 +323,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 +332,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 +342,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 +359,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 +371,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 +383,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 +395,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 +407,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 +419,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 +436,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 +446,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 +457,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 +474,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 +489,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 +507,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 +524,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 +541,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 +561,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 +583,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 +599,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 +611,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 +624,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 +642,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 +656,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 +673,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 +689,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 +702,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 +717,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 +726,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 +736,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 +755,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 +765,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/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/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..b7241bf4518 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,36 +310,36 @@ ), 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'), - operations=ACTION_POST, + operations=ACTION_PUT, ), 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'), - operations=ACTION_POST, + operations=ACTION_PUT, ), 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/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/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=[ 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/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/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/db/evpn_db.py b/neutron/db/evpn_db.py index def8197fe13..a2395f1dc93 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() @@ -199,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 @@ -212,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/db/l3_db.py b/neutron/db/l3_db.py index 184bfdfc887..38d8bbc47ab 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): 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/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.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/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/rangeallocator.py b/neutron/db/rangeallocator.py new file mode 100644 index 00000000000..01fd8275ee0 --- /dev/null +++ b/neutron/db/rangeallocator.py @@ -0,0 +1,248 @@ +# 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. + +import random as _random + +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. + + 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, + 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_source() + 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_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( + (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() + + @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 no value is + available. Lets DBDuplicateEntry propagate for retry handling by + the caller. + """ + allocation_id = uuidutils.generate_uuid() + params = self._make_params(min_val, max_val, scope_val, 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) + + +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 = RangeAllocator._make_params( + min_val, max_val, scope_val, allocation_id) + params['rand_val'] = _random.random() # noqa: S311 + return params 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 5712c114b46..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) @@ -783,7 +788,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/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/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/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/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/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 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/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/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/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/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.") 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/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/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/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 53f73f080b3..cd8fcbde6e3 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 @@ -517,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} @@ -1489,7 +1492,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 @@ -1505,6 +1516,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) @@ -1536,7 +1551,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.''' @@ -1856,9 +1873,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 @@ -1964,19 +1983,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, @@ -2296,7 +2308,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) @@ -2625,7 +2637,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 @@ -2756,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/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..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) @@ -1245,7 +1248,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/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..34b464445e0 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,19 @@ 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 + # Defensively handle unchecked tag_request=None + if columns.get('tag_request') is None: + columns['tag_request'] = [] + return columns + + class AddLSwitchPortCommand(command.BaseCommand): def __init__(self, api, lport, lswitch, may_exist, network_id=None, **columns): @@ -205,7 +218,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 +248,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 +281,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/impl_idl_ovn.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py index 6d94c12aa47..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,6 +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) + 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()) 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..9aef798da2b 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 @@ -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 = { @@ -293,7 +290,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: @@ -1412,6 +1409,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 +1424,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 +1435,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/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 17a013c3f29..3ce124983d7 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 @@ -320,16 +320,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 ' @@ -884,10 +881,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: @@ -1272,7 +1272,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) @@ -1416,19 +1419,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', {}) @@ -1619,7 +1627,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: @@ -2159,6 +2168,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') @@ -2185,7 +2195,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 +2404,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) @@ -2694,7 +2704,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..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 @@ -2679,7 +2682,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 +2889,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/privileged/agent/linux/svd.py b/neutron/privileged/agent/linux/svd.py index 02724ce8213..8b68029b9ac 100644 --- a/neutron/privileged/agent/linux/svd.py +++ b/neutron/privileged/agent/linux/svd.py @@ -22,7 +22,7 @@ from pyroute2.netlink.rtnl import ifinfmsg from pyroute2.netlink.rtnl.ifinfmsg.plugins import vxlan -from neutron.agent.ovn.extensions.evpn import constants as evpn_const +from neutron.agent.linux import nl_constants as nl_const from neutron import privileged from neutron.privileged.agent.linux import ip_lib as priv_ip_lib @@ -70,6 +70,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. @@ -157,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 @@ -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,16 +206,16 @@ 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, - mtu=evpn_const.EVPN_BR_MTU) + ipr.link(nl_const.IP_LINK_SET, index=br_idx, + mtu=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, 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] @@ -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,14 +264,12 @@ 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') + mtu=br_mtu, state='up') _set_addrgenmode_none(ipr, svi_idx) LOG.debug("SVD %s/%s: added VLAN %d -> VNI %d, SVI %s", @@ -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/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/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/services/evpn/commands.py b/neutron/services/evpn/commands.py new file mode 100644 index 00000000000..a1b98b2fa47 --- /dev/null +++ b/neutron/services/evpn/commands.py @@ -0,0 +1,208 @@ +# 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], + } + + +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. + + 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, 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() + + 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), + } + + 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: + ovn_nb_commands.LrpAddCommand( + self.api, self.lrouter_name, lrp_name, mac, + networks=[], + options=options, + 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} + 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..37158bed905 100644 --- a/neutron/services/evpn/constants.py +++ b/neutron/services/evpn/constants.py @@ -14,6 +14,8 @@ # 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' +EVPN_HCG_NAME_PREFIX = 'evpn-hcg-' 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..336b52c302c 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__) @@ -48,9 +50,26 @@ class EVPNPlugin(service_base.ServicePluginBase): def __init__(self): super().__init__() - self._evpn_db = evpn_db.EVPNVNIDbHelper() + self._evpn_db = evpn_db.EVPNDbHelper() + self._ovn_mech_driver = None LOG.info("Starting EVPN service plugin") + @property + 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 + + @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" @@ -93,6 +112,28 @@ 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) + 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, gw_chassis)) + + 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 +148,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 +193,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/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/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/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/net_helpers.py b/neutron/tests/common/net_helpers.py index 77a6b8d0296..367e63af1fa 100644 --- a/neutron/tests/common/net_helpers.py +++ b/neutron/tests/common/net_helpers.py @@ -1109,7 +1109,11 @@ class FrrFixture(fixtures.Fixture): FRR_CONF_DIR_BASE = '/etc/frr' FRR_STATE_DIR_BASE = '/var/run/frr' - FRRINIT = '/usr/lib/frr/frrinit.sh' + 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)), + FRRINIT_PATHS[0]) DAEMONS_CONF = ( 'zebra=yes\n' @@ -1149,12 +1153,12 @@ 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._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): @@ -1164,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'), @@ -1179,29 +1186,46 @@ 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}) + 'log_file': '%s/frr.log' % self._log_dir}) + + 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( - ['chown', '-R', 'frr:frr', self._conf_dir], + ['bash', '-c', + '%s %s %s > /dev/null 2>&1' + % (self.FRRINIT, frr_action, + self.namespace)], run_as_root=True) - def _start_frr(self): + def start_frr(self): + self._excute_with_std_discard('start') + + def stop_frr(self): utils.execute( - [self.FRRINIT, 'start', self.namespace], + [self.FRRINIT, 'stop', self.namespace], run_as_root=True) - def _stop_frr(self): + def restart_frr(self): + self._excute_with_std_discard('restart') + + 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/common/test_db_base_plugin_v2.py b/neutron/tests/common/test_db_base_plugin_v2.py index 1f2e239428a..00c99e72f5e 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', @@ -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/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/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/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/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/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..c7cbe11430e --- /dev/null +++ b/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py @@ -0,0 +1,524 @@ +# 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 platform +import unittest + +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.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 +from oslo_serialization import jsonutils + + +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 super()._vtysh_base_cmd + ['-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. + """ + # 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() + evpn_conf.register_opts() + self.namespace = self.useFixture(net_helpers.NamespaceFixture()).name + self.frr_fixture = 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_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) + + 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() + evpn_conf.register_opts() + + 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.frr_fixture_a = 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) + + @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 + 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) + + 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/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/agent/linux/test_svd.py b/neutron/tests/functional/agent/linux/test_svd.py new file mode 100644 index 00000000000..4ae6f315b05 --- /dev/null +++ b/neutron/tests/functional/agent/linux/test_svd.py @@ -0,0 +1,248 @@ +# 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' + BR_MTU = 1500 + + @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, + br_mtu=self.BR_MTU) + 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, + br_mtu=self.BR_MTU) + 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, + br_mtu=self.BR_MTU) + + 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.BR_MTU) + 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.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) + + 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.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, + 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, self.BR_MTU) + + 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, 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)) + + 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, self.BR_MTU) + + 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/bgp/test_commands.py b/neutron/tests/functional/agent/ovn/extensions/bgp/test_commands.py index 6ed178b3012..11e347fa444 100644 --- a/neutron/tests/functional/agent/ovn/extensions/bgp/test_commands.py +++ b/neutron/tests/functional/agent/ovn/extensions/bgp/test_commands.py @@ -13,8 +13,6 @@ # 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.agent.ovn.extensions.bgp import exceptions from neutron.common.ovn import constants as ovn_const @@ -22,11 +20,11 @@ from neutron.services.bgp import helpers 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..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,56 +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 constants as evpn_const -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() - 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)) - - def _create_evpn_lrp(self, vni, mac): + 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_const.EVPN_VRF_PREFIX + str(lr.uuid)[:12] + 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)) @@ -70,10 +204,13 @@ 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', - })) + # 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', @@ -81,19 +218,22 @@ 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}' 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_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) + 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)) @@ -101,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})) @@ -113,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')) @@ -123,14 +265,16 @@ class PortBindingLrpEvpnCreateEventTestCase(BaseEvpnEventsTestCase): 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_VRF_UP, instance.state) + 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) + 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', @@ -144,10 +288,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 = \ + self.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() @@ -158,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 9cfd4ecccb2..824521d3608 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 @@ -13,15 +13,24 @@ # 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 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.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 from neutron.tests.functional import base as functional_base @@ -39,15 +48,15 @@ def test_vrf_handler_lifecycle(self): 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 = 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) @@ -58,7 +67,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 = 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( @@ -81,7 +90,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' @@ -91,3 +102,105 @@ 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._evpn_fsm.setup(self.svd, self.cfg, mock.Mock()) + + 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/functional/base.py b/neutron/tests/functional/base.py index f4b55a504c4..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 @@ -117,6 +118,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) @@ -221,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/db/test_rangeallocator.py b/neutron/tests/functional/db/test_rangeallocator.py new file mode 100644 index 00000000000..7d8226b463d --- /dev/null +++ b/neutron/tests/functional/db/test_rangeallocator.py @@ -0,0 +1,315 @@ +# 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 TestRangeAllocatorBase(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__ + + 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] + + +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) + 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 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 + # 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) + + +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. + + 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) + + +class TestRandomRangeAllocator(TestRangeAllocatorBase): + """Tests for RangeAllocator with strategy=RANDOM. + + Runs against SQLite by default. + 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) + 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. + """ diff --git a/neutron/tests/functional/pecan_wsgi/test_hooks.py b/neutron/tests/functional/pecan_wsgi/test_hooks.py index f3d5382f9c0..d7158ae6b6b 100644 --- a/neutron/tests/functional/pecan_wsgi/test_hooks.py +++ b/neutron/tests/functional/pecan_wsgi/test_hooks.py @@ -27,26 +27,11 @@ 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 -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): @@ -320,6 +305,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', 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/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/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/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 fb85c6446b5..d653bc840e4 100644 --- a/neutron/tests/functional/services/bgp/test_commands.py +++ b/neutron/tests/functional/services/bgp/test_commands.py @@ -28,11 +28,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..855a94cd1ea --- /dev/null +++ b/neutron/tests/functional/services/evpn/test_commands.py @@ -0,0 +1,342 @@ +# 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 as bgp_base + + +class CreateEVPNRouterCommandTestCase(bgp_base.BaseBgpTestCase): + + 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 _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): + 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_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() + + 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) + 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( + 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_base.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_base.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/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/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/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/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/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/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..222c27727a0 --- /dev/null +++ b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py @@ -0,0 +1,303 @@ +# 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_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 + + +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): + + 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() + + 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', '--vty_socket', self.VTY_SOCKET, '-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(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', '--vty_socket', self.VTY_SOCKET, + '-c', 'write memory'], write_mem_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, + ) + + 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): + + def setUp(self): + super().setUp() + self.vrf_handler = mock.Mock(spec=interface.EVPNRouterVrfHandler) + self.driver = frr_driver.FrrVtyshDriver( + peer_interface='peer_iface', + vrf_handler=self.vrf_handler) + 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/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_firewall.py b/neutron/tests/unit/agent/linux/test_iptables_firewall.py index 65b36d753a2..b2e9bb8e53d 100644 --- a/neutron/tests/unit/agent/linux/test_iptables_firewall.py +++ b/neutron/tests/unit/agent/linux/test_iptables_firewall.py @@ -1482,15 +1482,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 = [ @@ -1574,7 +1574,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): @@ -1637,8 +1637,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(): @@ -1931,7 +1931,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): 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/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): 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], 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..0e0b3b12524 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,60 @@ 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.mock_svd = mock.Mock() + self.mock_config = mock.Mock() + self.mock_driver = mock.Mock() 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 - @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) + self.mock_driver.create_router.assert_called_once_with(vrf, 10) - @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) + self.mock_driver.create_router.assert_called_once_with(vrf, 10) - 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 +83,15 @@ 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) + self.mock_driver.delete_router.assert_called_once_with(vrf, 10) - 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 +101,8 @@ 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) + 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.""" @@ -101,7 +118,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 +131,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 +140,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 +166,63 @@ 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) + 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' + 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) + 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' 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 +236,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 +244,15 @@ 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) + self.mock_driver.delete_router.assert_called_once_with(vrf, 10) 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} @@ -187,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 d83a08440cd..05689ac9f3f 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.evpn import config as evpn_conf +from neutron.privileged.agent.linux import svd as privileged_svd from neutron.tests import base @@ -36,32 +43,62 @@ 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() + evpn_conf.register_opts() + 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.setup(mock.Mock(), mock.Mock(), mock.Mock()) 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 - 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) @@ -69,18 +106,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_MAC_VNI + 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) @@ -95,7 +132,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) @@ -115,7 +152,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)) @@ -124,7 +163,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)) @@ -135,7 +176,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)) 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/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/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/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/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) 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/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/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_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/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_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/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/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) 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) 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/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/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/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_neutron_agent.py b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py index ffb7ee8a932..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 @@ -412,6 +430,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 +1224,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/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/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..681aacf7924 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() @@ -395,6 +400,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() @@ -564,6 +717,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 e05d85e5fe2..7e3a4ed6d1a 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') @@ -2841,6 +2840,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') @@ -3700,7 +3710,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 +4037,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 +4057,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( @@ -5474,8 +5482,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( 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/evpn/test_plugin.py b/neutron/tests/unit/services/evpn/test_plugin.py index 36e0cdf65de..9ccbbacc6a7 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,19 @@ 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() + 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): self.assertEqual( @@ -75,16 +85,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 +110,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 +141,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 +167,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, \ 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) 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]) 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( 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/pyproject.toml b/pyproject.toml index 013033faebd..c5e25427f82 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. 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. 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``. 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. 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. 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. 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). 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. diff --git a/requirements.txt b/requirements.txt index 1e04e6a9cd8..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 @@ -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 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/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() 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 65% rename from neutron/tests/contrib/testing.filters rename to tools/rootwrap/testing.filters index 2ea075d691d..94b2d1614a6 100644 --- a/neutron/tests/contrib/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, --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 diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index aa702808aa2..90081fcf115 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 @@ -58,7 +59,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 @@ -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 aea640f96ca..bb7af6ef65f 100644 --- a/zuul.d/grenade.yaml +++ b/zuul.d/grenade.yaml @@ -50,7 +50,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 @@ -109,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 @@ -131,7 +124,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 @@ -310,7 +302,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 @@ -434,13 +425,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 a48e0144864..4c7c5fefb06 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -116,9 +116,8 @@ - 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: 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 diff --git a/zuul.d/rally.yaml b/zuul.d/rally.yaml index caeb5212073..b04af01836d 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 @@ -189,7 +188,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 4a18c850c7f..43f28d571ec 100644 --- a/zuul.d/tempest-multinode.yaml +++ b/zuul.d/tempest-multinode.yaml @@ -93,7 +93,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 @@ -221,7 +220,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: @@ -311,7 +309,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 @@ -458,7 +455,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 b9dabd736d4..ef89c34b7aa 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: @@ -108,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 @@ -162,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 @@ -182,7 +169,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 @@ -309,7 +295,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 @@ -394,7 +379,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 @@ -504,7 +488,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: @@ -764,7 +747,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 @@ -834,7 +816,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