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-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..223b0bab637 100644 --- a/doc/source/admin/config-wsgi.rst +++ b/doc/source/admin/config-wsgi.rst @@ -58,6 +58,19 @@ Start neutron-api: .. end +uWSGI Python API +~~~~~~~~~~~~~~~~ + +When Neutron API workers run under uWSGI with the ``python3`` plugin, the +server injects a Python ``uwsgi`` module into each worker process at runtime. +This module is not a Neutron dependency and cannot be installed separately +with ``pip``; it exists only inside uWSGI-managed workers. + +Neutron uses this module (via ``neutron.common.wsgi_utils``) to read uWSGI +configuration options such as ``start-time`` and to obtain the worker ID +(``uwsgi.worker_id()``). For the full list of available functions, see the +`uWSGI API documentation `_. + Start Neutron RPC server ------------------------ @@ -167,5 +180,7 @@ in processing agents heartbeats. .. note:: ML2/OVN uses the ``[uwsgi]start-time = %t`` parameter to create the OVN hash ring registers during the initialization process. This value is populated - by the uWSGi process with the start time. For more information, check + by the uWSGi process with the start time and read via the runtime ``uwsgi`` + Python module (see the uWSGI Python API subsection above). For more + information, check `Configuring uWSGI `_. 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/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..a19e6cb275a --- /dev/null +++ b/neutron/agent/linux/evpn_router/frr/frr_driver.py @@ -0,0 +1,277 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import tempfile +import typing + +import jinja2 +from neutron_lib import exceptions +from oslo_log import log as logging +from oslo_serialization import jsonutils + +from neutron.agent.linux.evpn_router.frr import exceptions as frr_exceptions +from neutron.agent.linux.evpn_router.frr import templates as frr_tmpl +from neutron.agent.linux.evpn_router import interface +from neutron.agent.linux import utils as linux_utils + + +LOG = logging.getLogger(__name__) + + +class FrrCommandBuilder: + + def __init__(self): + self._env = jinja2.Environment( + loader=jinja2.DictLoader(frr_tmpl.TMPL_MAP), + autoescape=True, + trim_blocks=True, + lstrip_blocks=True, + ) + + def _render_template( + self, template_name: frr_tmpl.TmplName, + context: dict[str, typing.Any]) -> str: + try: + template = self._env.get_template(str(template_name)) + return template.render(context) + except Exception as err: + raise frr_exceptions.FrrTemplateRenderError( + "Failed to render FRR template context:\n%s" % context, + step=str(template_name), + cause=err, + ) from err + + def _build_base_bgp_context( + self, config: interface.EVPNRouterConfig, + peer_interface: str) -> dict[str, str]: + bgp_router_context = { + 'asn': config.asn, + 'bgp_router_id': config.bgp_router_id, + 'peer_interface': peer_interface, + } + bgp_af_context = { + 'peer_interface': peer_interface, + } + return { + frr_tmpl.TmplName.BGP_ROUTER_CONFIG: self._render_template( + frr_tmpl.TmplName.BGP_ROUTER_CONFIG, + bgp_router_context, + ), + frr_tmpl.TmplName.BGP_AF_IPV4_UNICAST: self._render_template( + frr_tmpl.TmplName.BGP_AF_IPV4_UNICAST, + bgp_af_context, + ), + frr_tmpl.TmplName.BGP_AF_IPV6_UNICAST: self._render_template( + frr_tmpl.TmplName.BGP_AF_IPV6_UNICAST, + bgp_af_context, + ), + frr_tmpl.TmplName.BGP_AF_L2VPN_EVPN: self._render_template( + frr_tmpl.TmplName.BGP_AF_L2VPN_EVPN, + bgp_af_context, + ), + } + + def _build_evpn_context( + self, config: interface.EVPNRouterConfig) -> dict[str, typing.Any]: + evpn_router_context = { + 'asn': config.asn, + 'bgp_router_id': config.bgp_router_id, + 'vrf_name': config.vrf_name, + } + return { + 'vrf_name': config.vrf_name, + 'vni': config.vni, + frr_tmpl.TmplName.EVPN_ROUTER_CONFIG: self._render_template( + frr_tmpl.TmplName.EVPN_ROUTER_CONFIG, + evpn_router_context, + ), + frr_tmpl.TmplName.EVPN_AF_IPV4_UNICAST: self._render_template( + frr_tmpl.TmplName.EVPN_AF_IPV4_UNICAST, + {}, + ), + frr_tmpl.TmplName.EVPN_AF_IPV6_UNICAST: self._render_template( + frr_tmpl.TmplName.EVPN_AF_IPV6_UNICAST, + {}, + ), + frr_tmpl.TmplName.EVPN_AF_L2VPN_EVPN: self._render_template( + frr_tmpl.TmplName.EVPN_AF_L2VPN_EVPN, + {}, + ), + } + + def _build_delete_evpn_context( + self, config: interface.EVPNRouterConfig) -> dict[str, typing.Any]: + return { + 'vrf_name': config.vrf_name, + 'vni': config.vni, + 'asn': config.asn, + } + + def _build_delete_bgp_context( + self, asn: int) -> dict[str, typing.Any]: + return { + 'asn': asn, + } + + def add_bgp_router_cmds(self, config: interface.EVPNRouterConfig, + peer_interface: str) -> str: + context = self._build_base_bgp_context( + config=config, peer_interface=peer_interface) + return self._render_template(frr_tmpl.TmplName.ADD_BGP_ROUTER, context) + + def add_evpn_router_cmds( + self, config: interface.EVPNRouterConfig) -> str: + context = self._build_evpn_context(config=config) + return self._render_template( + frr_tmpl.TmplName.ADD_EVPN_ROUTER, context) + + def delete_evpn_router_cmds( + self, config: interface.EVPNRouterConfig) -> str: + context = self._build_delete_evpn_context(config=config) + return self._render_template( + frr_tmpl.TmplName.DEL_EVPN_ROUTER, context) + + def delete_bgp_router_cmds( + self, config: interface.EVPNRouterConfig) -> str: + context = self._build_delete_bgp_context(config.asn) + return self._render_template(frr_tmpl.TmplName.DEL_BGP_ROUTER, context) + + +class FrrVtyshExecutor: + + @property + def _vtysh_base_cmd(self) -> list[str]: + return ['vtysh'] + + def _execute_vtysh(self, vtysh_args: list[str]) -> str: + """Execute any vtysh command args and return stdout.""" + cmd = self._vtysh_base_cmd + vtysh_args + return typing.cast(str, linux_utils.execute(cmd, run_as_root=True)) + + def execute_cli_cmd(self, cmd_string: str) -> str: + """Execute single vtysh CLI command (e.g. show).""" + try: + return self._execute_vtysh(['-c', cmd_string]) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrApplyError( + "Failed to execute vtysh command:\n%s" % cmd_string, + step='execute_cli', + cause=err, + ) from err + + def execute_cmds(self, cmd_string: str) -> None: + with tempfile.NamedTemporaryFile( + mode='w+', delete=True, suffix=".cmd") as f: + f.write(cmd_string) + f.flush() + temp_path = f.name + try: + self._execute_vtysh(['--dryrun', '-f', temp_path]) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrDryrunError( + "FRR syntatic validity failed for " + "command:\n%s" % cmd_string, + step='dryrun', + cause=err, + ) from err + try: + self._execute_vtysh(['-f', temp_path]) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrApplyError( + "Failed to apply FRR configuration:\n%s\n" + "via vtysh" % cmd_string, + step='apply', + cause=err, + ) from err + 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/nl_constants.py b/neutron/agent/linux/nl_constants.py new file mode 100644 index 00000000000..d7d640f366a --- /dev/null +++ b/neutron/agent/linux/nl_constants.py @@ -0,0 +1,21 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +RTM_NEWLINK = 'RTM_NEWLINK' +RTM_DELLINK = 'RTM_DELLINK' + +IP_LINK_ADD = 'add' +IP_LINK_DEL = 'del' +IP_LINK_SET = 'set' diff --git a/neutron/agent/linux/svd.py b/neutron/agent/linux/svd.py index 8560a60f620..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/ovn/extensions/evpn/__init__.py b/neutron/agent/ovn/extensions/evpn/__init__.py index 5143fdaf1e6..aa6bd216f4a 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,17 +13,36 @@ # 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): @@ -32,16 +51,48 @@ def __init__(self): self._evpn_fsm = None self.nl_dispatcher = None + def _get_evpn_config(self): + ext_ids = self.agent_api.ovs_idl.db_get( + 'Open_vSwitch', '.', 'external_ids').execute() + local_ip = ext_ids['ovn-evpn-local-ip'] + vxlan_port = ext_ids['ovn-evpn-vxlan-ports'] + vxlan_parent = 'vxlan_sys_%s' % vxlan_port + dstport = CONF.ovn_evpn.child_vxlan_port + mac = net_lib.get_random_mac(CONF.base_mac.split(':')) + self.cfg = EvpnConfig(local_ip=local_ip, dstport=dstport, + vxlan_parent=vxlan_parent, mac=mac, + br_mtu=evpn_const.EVPN_BR_MTU) + LOG.debug("EVPN config: local_ip %s vxlan_parent %s " + "child vxlan port %d SVD MAC %s", + self.cfg.local_ip, self.cfg.vxlan_parent, + self.cfg.dstport, self.cfg.mac) + def start(self): super().start() - self._evpn_fsm = fsm.EvpnFSM() + self._get_evpn_config() + + privileged_svd.register_vxlan_vnifilter() + br_evpn = '%s%d' % (evpn_const.EVPN_LB_NAME_PREFIX, 0) + vxlan_evpn = '%s%d' % (evpn_const.EVPN_VXLAN_IFNAME, 0) + self.svd = svd.EvpnSvd(br_evpn=br_evpn, vxlan_evpn=vxlan_evpn) + try: + self.svd.create(local_ip=self.cfg.local_ip, + mac=self.cfg.mac, + vxlan_parent=self.cfg.vxlan_parent, + dstport=self.cfg.dstport, br_mtu=self.cfg.br_mtu) + except linux_svd.SvdDeviceAlreadyExists: + LOG.warning("SVD already exists, reusing") + driver = fsm_frr_driver.FsmFrrVtyshDriver( + peer_interface=CONF.ovn_evpn.bgp_local_interface, + bgp_router_id=self.cfg.local_ip) + self._evpn_fsm = fsm.EvpnFSM(self.svd, self.cfg, driver) vrf_handler = netlink_monitor.VrfHandler(self._evpn_fsm) self.nl_dispatcher = nl_dispatcher.NetlinkDispatcher( rtnl.RTMGRP_LINK) self.nl_dispatcher.register_handler( - evpn_const.EVPN_RTM_NEWLINK, vrf_handler.handle_newlink) + nl_const.RTM_NEWLINK, vrf_handler.handle_newlink) self.nl_dispatcher.register_handler( - evpn_const.EVPN_RTM_DELLINK, vrf_handler.handle_dellink) + nl_const.RTM_DELLINK, vrf_handler.handle_dellink) self.nl_dispatcher.register_replay_callbacks( on_start=vrf_handler.replay_start, on_end=vrf_handler.replay_end) diff --git a/neutron/agent/ovn/extensions/evpn/constants.py b/neutron/agent/ovn/extensions/evpn/constants.py index bac44820f05..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..ab9aa4665c2 100644 --- a/neutron/agent/ovn/extensions/evpn/events.py +++ b/neutron/agent/ovn/extensions/evpn/events.py @@ -36,7 +36,8 @@ class EVPNPortBindingEvent(EVPNAgentEvent): def match_fn(self, event, row, old): return (ovn_const.LR_OPTIONS_DR_VRF_NAME in row.options and - svc_const.EVPN_LRP_VNI_EXT_ID_KEY in row.external_ids) + svc_const.EVPN_LRP_VNI_EXT_ID_KEY in row.external_ids and + svc_const.EVPN_LRP_VLAN_EXT_ID_KEY in row.external_ids) class PortBindingLrpEvpnCreateEvent(EVPNPortBindingEvent): @@ -51,14 +52,20 @@ def match_fn(self, event, row, old): except ValueError: LOG.error("Invalid VNI in Port_Binding %s", row.logical_port) return False + try: + int(row.external_ids[svc_const.EVPN_LRP_VLAN_EXT_ID_KEY]) + except ValueError: + LOG.error("Invalid VLAN in Port_Binding %s", row.logical_port) + return False return True def run(self, event, row, old): vrf = row.options[ovn_const.LR_OPTIONS_DR_VRF_NAME] vni = int(row.external_ids[svc_const.EVPN_LRP_VNI_EXT_ID_KEY]) + vid = int(row.external_ids[svc_const.EVPN_LRP_VLAN_EXT_ID_KEY]) try: self.fsm.advance(evpn_fsm.EvpnFSM.FSM_EVENT_PORT_BINDING_CREATE, - vrf, mac=row.mac[0], vni=vni) + vrf, mac=row.mac[0], vni=vni, vid=vid) except evpn_exc.FSMIllegalTransition: LOG.error("Unexpected FSM transition for VRF %s on %s", vrf, row.logical_port) diff --git a/neutron/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..1d340baf641 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,64 @@ class EvpnFSM: # (Current state, Event):(New state, transition callback) TRANSITIONS = { (Evpn.INIT, FSM_EVENT_PORT_BINDING_CREATE): - (Evpn.WAITING_FOR_VRF_UP, "_set_mac_vni"), + (Evpn.WAITING_FOR_ROUTER, "_set_evpn_bridge"), (Evpn.INIT, FSM_EVENT_VRF_CREATE): - (Evpn.WAITING_FOR_MAC_VNI, "_set_vrf_up"), - (Evpn.WAITING_FOR_VRF_UP, FSM_EVENT_VRF_CREATE): - (Evpn.ADVERTISING, "_set_vrf_up_and_advertise"), - (Evpn.WAITING_FOR_MAC_VNI, FSM_EVENT_PORT_BINDING_CREATE): - (Evpn.ADVERTISING, "_set_mac_vni_and_advertise"), + (Evpn.WAITING_FOR_BRIDGE, "_set_evpn_router"), + (Evpn.WAITING_FOR_ROUTER, FSM_EVENT_VRF_CREATE): + (Evpn.ADVERTISING, "_set_evpn_router_and_advertise"), + (Evpn.WAITING_FOR_BRIDGE, FSM_EVENT_PORT_BINDING_CREATE): + (Evpn.ADVERTISING, "_set_evpn_bridge_and_advertise"), (Evpn.ADVERTISING, FSM_EVENT_PORT_BINDING_DELETE): - (Evpn.WAITING_FOR_MAC_VNI, "_unset_mac_vni"), + (Evpn.WAITING_FOR_BRIDGE, "_unset_evpn_bridge_and_unadvertise"), (Evpn.ADVERTISING, FSM_EVENT_VRF_DELETE): - (Evpn.WAITING_FOR_VRF_UP, "_unset_vrf_up"), - (Evpn.WAITING_FOR_MAC_VNI, FSM_EVENT_VRF_DELETE): + (Evpn.WAITING_FOR_ROUTER, "_unset_evpn_router_and_unadvertise"), + (Evpn.WAITING_FOR_BRIDGE, FSM_EVENT_VRF_DELETE): (Evpn.DESTROY, "_destroy"), - (Evpn.WAITING_FOR_VRF_UP, FSM_EVENT_PORT_BINDING_DELETE): + (Evpn.WAITING_FOR_ROUTER, FSM_EVENT_PORT_BINDING_DELETE): (Evpn.DESTROY, "_destroy"), } - def __init__(self): + def __init__(self, svd, config, frr_driver): self.instances = {} # vrf -> Evpn + self._svd = svd + self._cfg = config + self._driver = frr_driver - def _set_mac_vni(self, evpn, mac, vni): + def _set_evpn_bridge(self, evpn, mac, vni, vid): evpn.mac = mac evpn.vni = vni + evpn.vid = vid - def _unset_mac_vni(self, evpn): + def _unset_evpn_bridge_and_unadvertise(self, evpn): + self._unadvertise(evpn) evpn.mac = None evpn.vni = None + evpn.vid = None - def _set_vrf_up(self, evpn): + def _set_evpn_router(self, evpn): evpn.vrf_up = True - def _unset_vrf_up(self, evpn): + def _unset_evpn_router_and_unadvertise(self, evpn): + self._unadvertise(evpn) evpn.vrf_up = False def _advertise(self, evpn): - 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/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/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/conf/agent/ovn/evpn/__init__.py b/neutron/conf/agent/ovn/evpn/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/conf/agent/ovn/evpn/config.py b/neutron/conf/agent/ovn/evpn/config.py new file mode 100644 index 00000000000..b9ce6d78eb3 --- /dev/null +++ b/neutron/conf/agent/ovn/evpn/config.py @@ -0,0 +1,37 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg + +from neutron._i18n import _ + + +EVPN_OPTS = [ + cfg.IntOpt( + 'bgp_as', + help=_('BGP Autonomous System number for EVPN')), + cfg.StrOpt( + 'bgp_local_interface', + help=_('The local interface name (e.g. eth2) on which to establish ' + 'BGP peer session')), + cfg.IntOpt( + 'child_vxlan_port', + default=49152, + help=_('UDP port for the child VxLAN device used by EVPN')), +] + + +def register_opts(): + cfg.CONF.register_opts(EVPN_OPTS, group='ovn_evpn') diff --git a/neutron/conf/agent/ovn/ovn_neutron_agent/config.py b/neutron/conf/agent/ovn/ovn_neutron_agent/config.py index 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..b4ca60d9031 100644 --- a/neutron/conf/policies/__init__.py +++ b/neutron/conf/policies/__init__.py @@ -37,6 +37,7 @@ from neutron.conf.policies import network_segment_range from neutron.conf.policies import port from neutron.conf.policies import port_bindings +from neutron.conf.policies import pvlan from neutron.conf.policies import qos from neutron.conf.policies import quotas from neutron.conf.policies import rbac @@ -74,6 +75,7 @@ def list_rules(): network_segment_range.list_rules(), port_bindings.list_rules(), port.list_rules(), + pvlan.list_rules(), qos.list_rules(), quotas.list_rules(), rbac.list_rules(), diff --git a/neutron/conf/policies/address_group.py b/neutron/conf/policies/address_group.py index c6ac4ae0d0d..32cf50c8e7e 100644 --- a/neutron/conf/policies/address_group.py +++ b/neutron/conf/policies/address_group.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - AG_COLLECTION_PATH = '/address-groups' AG_RESOURCE_PATH = '/address-groups/{id}' @@ -34,7 +33,7 @@ ), policy.DocumentedRuleDefault( name='create_address_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create an address group', operations=[ { @@ -52,7 +51,7 @@ policy.DocumentedRuleDefault( name='get_address_group', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared_address_groups'), description='Get an address group', operations=[ @@ -76,7 +75,7 @@ ), policy.DocumentedRuleDefault( name='update_address_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update an address group', operations=[ { @@ -93,7 +92,7 @@ ), policy.DocumentedRuleDefault( name='delete_address_group', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete an address group', operations=[ { @@ -110,7 +109,7 @@ ), policy.DocumentedRuleDefault( name='add_addresses', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Add addresses to an address group', operations=[ { @@ -127,7 +126,7 @@ ), policy.DocumentedRuleDefault( name='remove_addresses', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Remove addresses from an address group', operations=[ { diff --git a/neutron/conf/policies/address_scope.py b/neutron/conf/policies/address_scope.py index a376376a905..b2bfc0e493f 100644 --- a/neutron/conf/policies/address_scope.py +++ b/neutron/conf/policies/address_scope.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/address-scopes' RESOURCE_PATH = '/address-scopes/{id}' @@ -32,7 +31,7 @@ ), policy.DocumentedRuleDefault( name='create_address_scope', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create an address scope', operations=[ { @@ -49,7 +48,7 @@ ), policy.DocumentedRuleDefault( name='create_address_scope:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create a shared address scope', operations=[ { @@ -67,8 +66,8 @@ policy.DocumentedRuleDefault( name='get_address_scope', check_str=neutron_policy.policy_or( - base.ADMIN, - base.PROJECT_READER, + lib_rules.ADMIN, + lib_rules.PROJECT_READER, 'rule:shared_address_scopes'), description='Get an address scope', operations=[ @@ -92,7 +91,7 @@ ), policy.DocumentedRuleDefault( name='update_address_scope', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update an address scope', operations=[ { @@ -109,7 +108,7 @@ ), policy.DocumentedRuleDefault( name='update_address_scope:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update ``shared`` attribute of an address scope', operations=[ { @@ -126,7 +125,7 @@ ), policy.DocumentedRuleDefault( name='delete_address_scope', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete an address scope', operations=[ { diff --git a/neutron/conf/policies/agent.py b/neutron/conf/policies/agent.py index 5cd40265226..1a44d2cb60f 100644 --- a/neutron/conf/policies/agent.py +++ b/neutron/conf/policies/agent.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/agents' RESOURCE_PATH = '/agents/{id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create an agent', operations=[ { @@ -38,7 +37,7 @@ ), policy.DocumentedRuleDefault( name='get_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Get an agent', operations=[ { @@ -59,7 +58,7 @@ ), policy.DocumentedRuleDefault( name='update_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update an agent', operations=[ { @@ -76,7 +75,7 @@ ), policy.DocumentedRuleDefault( name='delete_agent', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Delete an agent', operations=[ { @@ -93,7 +92,7 @@ ), policy.DocumentedRuleDefault( name='create_dhcp-network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Add a network to a DHCP agent', operations=[ { @@ -110,7 +109,7 @@ ), policy.DocumentedRuleDefault( name='get_dhcp-networks', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List networks on a DHCP agent', operations=[ { @@ -127,7 +126,7 @@ ), policy.DocumentedRuleDefault( name='delete_dhcp-network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Remove a network from a DHCP agent', operations=[ { @@ -144,7 +143,7 @@ ), policy.DocumentedRuleDefault( name='create_l3-router', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Add a router to an L3 agent', operations=[ { @@ -161,7 +160,7 @@ ), policy.DocumentedRuleDefault( name='get_l3-routers', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List routers on an L3 agent', operations=[ { @@ -178,7 +177,7 @@ ), policy.DocumentedRuleDefault( name='update_l3-router', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update a router in an L3 agent', operations=[ { @@ -195,7 +194,7 @@ ), policy.DocumentedRuleDefault( name='delete_l3-router', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Remove a router from an L3 agent', operations=[ { @@ -212,7 +211,7 @@ ), policy.DocumentedRuleDefault( name='get_dhcp-agents', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List DHCP agents hosting a network', operations=[ { @@ -229,7 +228,7 @@ ), policy.DocumentedRuleDefault( name='get_l3-agents', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='List L3 agents hosting a router', operations=[ { diff --git a/neutron/conf/policies/auto_allocated_topology.py b/neutron/conf/policies/auto_allocated_topology.py index 00977a0c4e1..e6c044b924c 100644 --- a/neutron/conf/policies/auto_allocated_topology.py +++ b/neutron/conf/policies/auto_allocated_topology.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - RESOURCE_PATH = '/auto-allocated-topology/{project_id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_auto_allocated_topology', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description="Get a project's auto-allocated topology", operations=[ { @@ -43,7 +42,7 @@ ), policy.DocumentedRuleDefault( name='delete_auto_allocated_topology', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description="Delete a project's auto-allocated topology", operations=[ { diff --git a/neutron/conf/policies/base.py b/neutron/conf/policies/base.py index 63765c80154..5f861718e22 100644 --- a/neutron/conf/policies/base.py +++ b/neutron/conf/policies/base.py @@ -11,65 +11,13 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -# This role is used only for communication between services, it shouldn't be -# used by human users -SERVICE = 'rule:service_api' - -# For completion of the phase 1 -# https://governance.openstack.org/tc/goals/selected/consistent-and-secure-rbac.html#phase-1 -# there is now ADMIN role -ADMIN = "rule:admin_only" - -# This check string is the primary use case for the project's manager who is -# more privileged user then typical MEMBER of the project. -PROJECT_MANAGER = 'role:manager and project_id:%(project_id)s' - -# This check string is the primary use case for typical end-users, who are -# working with resources that belong to a project (e.g., creating ports and -# routers). -PROJECT_MEMBER = 'role:member and project_id:%(project_id)s' - -# This check string should only be used to protect read-only project-specific -# resources. It should not be used to protect APIs that make writable changes -# (e.g., updating a router or deleting a port). -PROJECT_READER = 'role:reader and project_id:%(project_id)s' - -# The following are common composite check strings that are useful for -# protecting APIs designed to operate with multiple scopes (e.g., -# an administrator should be able to delete any router in the deployment, a -# project member should only be able to delete routers in their project). -ADMIN_OR_SERVICE = ( - '(' + ADMIN + ') or (' + SERVICE + ')') -ADMIN_OR_PROJECT_MANAGER = ( - '(' + ADMIN + ') or (' + PROJECT_MANAGER + ')') -ADMIN_OR_PROJECT_MEMBER = ( - '(' + ADMIN + ') or (' + PROJECT_MEMBER + ')') -ADMIN_OR_PROJECT_READER = ( - '(' + ADMIN + ') or (' + PROJECT_READER + ')') - # Additional rules needed in Neutron RULE_NET_OWNER = 'rule:network_owner' -RULE_PARENT_OWNER = 'rule:ext_parent_owner' RULE_SG_OWNER = 'rule:sg_owner' -# In some cases we need to check owner of the parent resource, it's like that -# for example for QoS rules (check owner of QoS policy rule belongs to) or -# Floating IP port forwarding (check owner of FIP which PF is using). It's like -# that becasue those resources (QOS rules, FIP PFs) don't have project_id -# attribute at all and they belongs to the same project as parent resource (QoS -# policy, FIP). -PARENT_OWNER_MANAGER = 'role:manager and ' + RULE_PARENT_OWNER -PARENT_OWNER_MEMBER = 'role:member and ' + RULE_PARENT_OWNER -PARENT_OWNER_READER = 'role:reader and ' + RULE_PARENT_OWNER -ADMIN_OR_PARENT_OWNER_MANAGER = ( - '(' + ADMIN + ') or (' + PARENT_OWNER_MANAGER + ')') -ADMIN_OR_PARENT_OWNER_MEMBER = ( - '(' + ADMIN + ') or (' + PARENT_OWNER_MEMBER + ')') -ADMIN_OR_PARENT_OWNER_READER = ( - '(' + ADMIN + ') or (' + PARENT_OWNER_READER + ')') - # Those rules related to the network owner are very similar (almost the same) # as parent owner defined above. The only reason why they are kept here is that # in case of some resources like ports or subnets neutron have got policies @@ -80,9 +28,9 @@ NET_OWNER_MEMBER = 'role:member and ' + RULE_NET_OWNER NET_OWNER_READER = 'role:reader and ' + RULE_NET_OWNER ADMIN_OR_NET_OWNER_MEMBER = ( - '(' + ADMIN + ') or (' + NET_OWNER_MEMBER + ')') + '(' + lib_rules.ADMIN + ') or (' + NET_OWNER_MEMBER + ')') ADMIN_OR_NET_OWNER_READER = ( - '(' + ADMIN + ') or (' + NET_OWNER_READER + ')') + '(' + lib_rules.ADMIN + ') or (' + NET_OWNER_READER + ')') # Those rules for the SG owner are needed for the policies related to the # Security Group rules and are very similar to the parent owner rules defined @@ -94,9 +42,9 @@ SG_OWNER_MEMBER = 'role:member and ' + RULE_SG_OWNER SG_OWNER_READER = 'role:reader and ' + RULE_SG_OWNER ADMIN_OR_SG_OWNER_MEMBER = ( - '(' + ADMIN + ') or (' + SG_OWNER_MEMBER + ')') + '(' + lib_rules.ADMIN + ') or (' + SG_OWNER_MEMBER + ')') ADMIN_OR_SG_OWNER_READER = ( - '(' + ADMIN + ') or (' + SG_OWNER_READER + ')') + '(' + lib_rules.ADMIN + ') or (' + SG_OWNER_READER + ')') rules = [ policy.RuleDefault( diff --git a/neutron/conf/policies/default_security_group_rules.py b/neutron/conf/policies/default_security_group_rules.py index 23824f1e6da..8145fa633ec 100644 --- a/neutron/conf/policies/default_security_group_rules.py +++ b/neutron/conf/policies/default_security_group_rules.py @@ -11,10 +11,9 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = ( "The default security group rules API supports " "system scope and default roles.") @@ -27,7 +26,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_default_security_group_rule', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a templated of the security group rule', operations=[ @@ -69,7 +68,7 @@ ), policy.DocumentedRuleDefault( name='delete_default_security_group_rule', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete a templated of the security group rule', operations=[ diff --git a/neutron/conf/policies/evpn.py b/neutron/conf/policies/evpn.py index 5e23950a181..d41ac99537a 100644 --- a/neutron/conf/policies/evpn.py +++ b/neutron/conf/policies/evpn.py @@ -10,10 +10,9 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/routers' RESOURCE_PATH = '/routers/{id}' @@ -29,14 +28,14 @@ rules = [ policy.DocumentedRuleDefault( name='create_router:evpn_vni', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``evpn_vni`` attribute when creating a router', operations=ACTION_POST, ), policy.DocumentedRuleDefault( name='get_router:evpn_vni', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get ``evpn_vni`` attribute of a router', operations=ACTION_GET, diff --git a/neutron/conf/policies/flavor.py b/neutron/conf/policies/flavor.py index 6540d10aba7..2b43c3e4e70 100644 --- a/neutron/conf/policies/flavor.py +++ b/neutron/conf/policies/flavor.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - FLAVOR_COLLECTION_PATH = '/flavors' FLAVOR_RESOURCE_PATH = '/flavors/{id}' @@ -31,7 +30,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_flavor', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create a flavor', operations=[ { @@ -73,7 +72,7 @@ ), policy.DocumentedRuleDefault( name='update_flavor', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update a flavor', operations=[ { @@ -90,7 +89,7 @@ ), policy.DocumentedRuleDefault( name='delete_flavor', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Delete a flavor', operations=[ { @@ -108,7 +107,7 @@ policy.DocumentedRuleDefault( name='create_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Create a service profile', operations=[ { @@ -125,7 +124,7 @@ ), policy.DocumentedRuleDefault( name='get_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Get a service profile', operations=[ { @@ -146,7 +145,7 @@ ), policy.DocumentedRuleDefault( name='update_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Update a service profile', operations=[ { @@ -163,7 +162,7 @@ ), policy.DocumentedRuleDefault( name='delete_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Delete a service profile', operations=[ { @@ -181,7 +180,7 @@ policy.RuleDefault( name='get_flavor_service_profile', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description=( 'Get a flavor associated with a given service profiles. ' 'There is no corresponding GET operations in API currently. ' @@ -196,7 +195,7 @@ ), policy.DocumentedRuleDefault( name='create_flavor_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Associate a flavor with a service profile', operations=[ { @@ -213,7 +212,7 @@ ), policy.DocumentedRuleDefault( name='delete_flavor_service_profile', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, description='Disassociate a flavor with a service profile', operations=[ { diff --git a/neutron/conf/policies/floatingip.py b/neutron/conf/policies/floatingip.py index e2cad4a005b..c4519181a0f 100644 --- a/neutron/conf/policies/floatingip.py +++ b/neutron/conf/policies/floatingip.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/floatingips' RESOURCE_PATH = '/floatingips/{id}' @@ -44,7 +43,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_floatingip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create a floating IP', operations=[ { @@ -61,7 +60,7 @@ ), policy.DocumentedRuleDefault( name='create_floatingip:floating_ip_address', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, description='Create a floating IP with a specific IP address', operations=[ { @@ -78,19 +77,19 @@ ), policy.DocumentedRuleDefault( name='create_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create the floating IP tags', operations=ACTION_POST_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='create_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_floatingip', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get a floating IP', operations=[ { @@ -111,20 +110,20 @@ ), policy.DocumentedRuleDefault( name='get_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get the floating IP tags', operations=ACTION_GET_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='get_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_floatingip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update a floating IP', operations=[ { @@ -141,20 +140,20 @@ ), policy.DocumentedRuleDefault( name='update_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update the floating IP tags', operations=ACTION_PUT_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='update_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_floatingip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete a floating IP', operations=[ { @@ -171,13 +170,13 @@ ), policy.DocumentedRuleDefault( name='delete_floatingip:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete the floating IP tags', operations=ACTION_DELETE_TAGS, scope_types=['project'], deprecated_rule=policy.DeprecatedRule( name='delete_floatingips_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), diff --git a/neutron/conf/policies/floatingip_pools.py b/neutron/conf/policies/floatingip_pools.py index ecea2030b3e..f71c8e25fef 100644 --- a/neutron/conf/policies/floatingip_pools.py +++ b/neutron/conf/policies/floatingip_pools.py @@ -11,18 +11,17 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = ( "The Floating IP Pool API now supports system scope and default roles.") rules = [ policy.DocumentedRuleDefault( name='get_floatingip_pool', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get floating IP pools', operations=[ { diff --git a/neutron/conf/policies/floatingip_port_forwarding.py b/neutron/conf/policies/floatingip_port_forwarding.py index 685cb45174e..f523499daf3 100644 --- a/neutron/conf/policies/floatingip_port_forwarding.py +++ b/neutron/conf/policies/floatingip_port_forwarding.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The floating IP port forwarding API now supports system scope and default @@ -30,7 +29,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a floating IP port forwarding', operations=[ @@ -47,7 +46,7 @@ ), policy.DocumentedRuleDefault( name='get_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a floating IP port forwarding', operations=[ @@ -68,7 +67,7 @@ ), policy.DocumentedRuleDefault( name='update_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Update a floating IP port forwarding', operations=[ @@ -85,7 +84,7 @@ ), policy.DocumentedRuleDefault( name='delete_floatingip_port_forwarding', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a floating IP port forwarding', operations=[ diff --git a/neutron/conf/policies/l3_conntrack_helper.py b/neutron/conf/policies/l3_conntrack_helper.py index 58e2c8df5af..7912e4693e4 100644 --- a/neutron/conf/policies/l3_conntrack_helper.py +++ b/neutron/conf/policies/l3_conntrack_helper.py @@ -12,11 +12,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The router conntrack API now supports system scope and default roles. @@ -30,7 +29,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a router conntrack helper', operations=[ @@ -47,7 +46,7 @@ ), policy.DocumentedRuleDefault( name='get_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a router conntrack helper', operations=[ @@ -68,7 +67,7 @@ ), policy.DocumentedRuleDefault( name='update_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Update a router conntrack helper', operations=[ @@ -85,7 +84,7 @@ ), policy.DocumentedRuleDefault( name='delete_router_conntrack_helper', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a router conntrack helper', operations=[ diff --git a/neutron/conf/policies/local_ip.py b/neutron/conf/policies/local_ip.py index 40773d67ca0..9e722fc656c 100644 --- a/neutron/conf/policies/local_ip.py +++ b/neutron/conf/policies/local_ip.py @@ -12,11 +12,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/local-ips' RESOURCE_PATH = '/local-ips/{id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_local_ip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create a Local IP', operations=[ { @@ -43,7 +42,7 @@ ), policy.DocumentedRuleDefault( name='get_local_ip', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get a Local IP', operations=[ { @@ -64,7 +63,7 @@ ), policy.DocumentedRuleDefault( name='update_local_ip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update a Local IP', operations=[ { @@ -81,7 +80,7 @@ ), policy.DocumentedRuleDefault( name='delete_local_ip', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete a Local IP', operations=[ { diff --git a/neutron/conf/policies/local_ip_association.py b/neutron/conf/policies/local_ip_association.py index e223445a4d1..db35bfe31bd 100644 --- a/neutron/conf/policies/local_ip_association.py +++ b/neutron/conf/policies/local_ip_association.py @@ -12,11 +12,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/local_ips/{local_ip_id}/port_associations' RESOURCE_PATH = ('/local_ips/{local_ip_id}' '/port_associations/{fixed_port_id}') @@ -27,7 +26,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_local_ip_port_association', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a Local IP port association', operations=[ @@ -44,7 +43,7 @@ ), policy.DocumentedRuleDefault( name='get_local_ip_port_association', - check_str=base.ADMIN_OR_PARENT_OWNER_READER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a Local IP port association', operations=[ @@ -65,7 +64,7 @@ ), policy.DocumentedRuleDefault( name='delete_local_ip_port_association', - check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, + check_str=lib_rules.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a Local IP port association', operations=[ diff --git a/neutron/conf/policies/logging.py b/neutron/conf/policies/logging.py index 7b7f37d51ae..1fdcd78757f 100644 --- a/neutron/conf/policies/logging.py +++ b/neutron/conf/policies/logging.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The logging API now supports project scope and default roles. @@ -28,7 +27,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_loggable_resource', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Get loggable resources', operations=[ @@ -45,7 +44,7 @@ ), policy.DocumentedRuleDefault( name='create_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create a network log', operations=[ @@ -62,7 +61,7 @@ ), policy.DocumentedRuleDefault( name='get_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Get a network log', operations=[ @@ -83,7 +82,7 @@ ), policy.DocumentedRuleDefault( name='update_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Update a network log', operations=[ @@ -100,7 +99,7 @@ ), policy.DocumentedRuleDefault( name='delete_log', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete a network log', operations=[ diff --git a/neutron/conf/policies/metering.py b/neutron/conf/policies/metering.py index 899b9b127ba..542f55184f5 100644 --- a/neutron/conf/policies/metering.py +++ b/neutron/conf/policies/metering.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The metering API now supports system scope and default roles. """ @@ -30,7 +29,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_metering_label', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create a metering label', operations=[ @@ -47,7 +46,7 @@ ), policy.DocumentedRuleDefault( name='get_metering_label', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get a metering label', operations=[ @@ -68,7 +67,7 @@ ), policy.DocumentedRuleDefault( name='delete_metering_label', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete a metering label', operations=[ @@ -85,7 +84,7 @@ ), policy.DocumentedRuleDefault( name='create_metering_label_rule', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Create a metering label rule', operations=[ @@ -102,7 +101,7 @@ ), policy.DocumentedRuleDefault( name='get_metering_label_rule', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, scope_types=['project'], description='Get a metering label rule', operations=[ @@ -123,7 +122,7 @@ ), policy.DocumentedRuleDefault( name='delete_metering_label_rule', - check_str=base.ADMIN_OR_PROJECT_MANAGER, + check_str=lib_rules.ADMIN_OR_PROJECT_MANAGER, scope_types=['project'], description='Delete a metering label rule', operations=[ diff --git a/neutron/conf/policies/ndp_proxy.py b/neutron/conf/policies/ndp_proxy.py index 30c46f3c983..725cebf4a2e 100644 --- a/neutron/conf/policies/ndp_proxy.py +++ b/neutron/conf/policies/ndp_proxy.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - COLLECTION_PATH = '/ndp_proxies' RESOURCE_PATH = '/ndp_proxies/{id}' @@ -26,7 +25,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Create a ndp proxy', operations=[ { @@ -43,7 +42,7 @@ ), policy.DocumentedRuleDefault( name='get_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, description='Get a ndp proxy', operations=[ { @@ -64,7 +63,7 @@ ), policy.DocumentedRuleDefault( name='update_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Update a ndp proxy', operations=[ { @@ -81,7 +80,7 @@ ), policy.DocumentedRuleDefault( name='delete_ndp_proxy', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, description='Delete a ndp proxy', operations=[ { diff --git a/neutron/conf/policies/network.py b/neutron/conf/policies/network.py index b988e40192f..0348e881e15 100644 --- a/neutron/conf/policies/network.py +++ b/neutron/conf/policies/network.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The network API now supports system scope and default roles. """ @@ -63,7 +62,7 @@ policy.DocumentedRuleDefault( name='create_network', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create a network', operations=ACTION_POST, @@ -75,7 +74,7 @@ ), policy.DocumentedRuleDefault( name='create_network:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a shared network', operations=ACTION_POST, @@ -87,7 +86,7 @@ ), policy.DocumentedRuleDefault( name='create_network:router:external', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create an external network', operations=ACTION_POST, @@ -99,7 +98,7 @@ ), policy.DocumentedRuleDefault( name='create_network:is_default', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``is_default`` attribute when creating a network', operations=ACTION_POST, @@ -111,7 +110,7 @@ ), policy.DocumentedRuleDefault( name='create_network:port_security_enabled', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description=( 'Specify ``port_security_enabled`` ' @@ -126,7 +125,7 @@ ), policy.DocumentedRuleDefault( name='create_network:segments', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Specify ``segments`` attribute when creating a network', operations=ACTION_POST, @@ -138,7 +137,7 @@ ), policy.DocumentedRuleDefault( name='create_network:provider:network_type', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``provider:network_type`` ' @@ -153,7 +152,7 @@ ), policy.DocumentedRuleDefault( name='create_network:provider:physical_network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``provider:physical_network`` ' @@ -168,7 +167,7 @@ ), policy.DocumentedRuleDefault( name='create_network:provider:segmentation_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``provider:segmentation_id`` when creating a network' @@ -182,13 +181,13 @@ ), policy.DocumentedRuleDefault( name='create_network:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Create the network tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_networks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), @@ -196,8 +195,8 @@ policy.DocumentedRuleDefault( name='get_network', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, - base.SERVICE, + lib_rules.ADMIN_OR_PROJECT_READER, + lib_rules.SERVICE, 'rule:shared', 'rule:external', neutron_policy.RULE_ADVSVC @@ -217,7 +216,7 @@ ), policy.DocumentedRuleDefault( name='get_network:segments', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``segments`` attribute of a network', operations=ACTION_GET, @@ -229,7 +228,7 @@ ), policy.DocumentedRuleDefault( name='get_network:provider:network_type', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``provider:network_type`` attribute of a network', operations=ACTION_GET, @@ -241,7 +240,7 @@ ), policy.DocumentedRuleDefault( name='get_network:provider:physical_network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``provider:physical_network`` attribute of a network', operations=ACTION_GET, @@ -253,7 +252,7 @@ ), policy.DocumentedRuleDefault( name='get_network:provider:segmentation_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``provider:segmentation_id`` attribute of a network', operations=ACTION_GET, @@ -266,7 +265,7 @@ policy.DocumentedRuleDefault( name='get_network:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, + lib_rules.ADMIN_OR_PROJECT_READER, 'rule:shared', 'rule:external', neutron_policy.RULE_ADVSVC @@ -276,14 +275,14 @@ operations=ACTION_GET_TAGS, deprecated_rule=policy.DeprecatedRule( name='get_networks_tags', - check_str=base.ADMIN_OR_PROJECT_READER, + check_str=lib_rules.ADMIN_OR_PROJECT_READER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_network', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update a network', operations=ACTION_PUT, @@ -295,7 +294,7 @@ ), policy.DocumentedRuleDefault( name='update_network:segments', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``segments`` attribute of a network', operations=ACTION_PUT, @@ -307,7 +306,7 @@ ), policy.DocumentedRuleDefault( name='update_network:shared', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``shared`` attribute of a network', operations=ACTION_PUT, @@ -319,7 +318,7 @@ ), policy.DocumentedRuleDefault( name='update_network:provider:network_type', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``provider:network_type`` attribute of a network', operations=ACTION_PUT, @@ -331,7 +330,7 @@ ), policy.DocumentedRuleDefault( name='update_network:provider:physical_network', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Update ``provider:physical_network`` ' @@ -346,7 +345,7 @@ ), policy.DocumentedRuleDefault( name='update_network:provider:segmentation_id', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Update ``provider:segmentation_id`` ' @@ -361,7 +360,7 @@ ), policy.DocumentedRuleDefault( name='update_network:router:external', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``router:external`` attribute of a network', operations=ACTION_PUT, @@ -373,7 +372,7 @@ ), policy.DocumentedRuleDefault( name='update_network:is_default', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``is_default`` attribute of a network', operations=ACTION_PUT, @@ -385,7 +384,7 @@ ), policy.DocumentedRuleDefault( name='update_network:port_security_enabled', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update ``port_security_enabled`` attribute of a network', operations=ACTION_PUT, @@ -397,20 +396,20 @@ ), policy.DocumentedRuleDefault( name='update_network:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Update the network tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_networks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_network', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete a network', operations=ACTION_DELETE, @@ -423,13 +422,13 @@ policy.DocumentedRuleDefault( # This should be just "update_network:tags" probably name='delete_network:tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, scope_types=['project'], description='Delete the network tags', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_networks_tags', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=lib_rules.ADMIN_OR_PROJECT_MEMBER, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), diff --git a/neutron/conf/policies/network_ip_availability.py b/neutron/conf/policies/network_ip_availability.py index 6b84b006a68..3faa622f25b 100644 --- a/neutron/conf/policies/network_ip_availability.py +++ b/neutron/conf/policies/network_ip_availability.py @@ -11,11 +11,10 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The network IP availability API now support project scope and default roles. """ @@ -24,7 +23,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_network_ip_availability', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get network IP availability', operations=[ diff --git a/neutron/conf/policies/network_segment_range.py b/neutron/conf/policies/network_segment_range.py index 06be8fec863..b7e501ce378 100644 --- a/neutron/conf/policies/network_segment_range.py +++ b/neutron/conf/policies/network_segment_range.py @@ -14,11 +14,10 @@ # from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy -from neutron.conf.policies import base - DEPRECATED_REASON = """ The network segment range API now supports project scope and default roles. """ @@ -48,7 +47,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create a network segment range', operations=[ @@ -65,20 +64,20 @@ ), policy.DocumentedRuleDefault( name='create_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Create the network segment range tags', operations=ACTION_POST_TAGS, deprecated_rule=policy.DeprecatedRule( name='create_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='get_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get a network segment range', operations=[ @@ -99,20 +98,20 @@ ), policy.DocumentedRuleDefault( name='get_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get the network segment range tags', operations=ACTION_GET_TAGS, deprecated_rule=policy.DeprecatedRule( name='get_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='update_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update a network segment range', operations=[ @@ -129,20 +128,20 @@ ), policy.DocumentedRuleDefault( name='update_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update the network segment range tags', operations=ACTION_PUT_TAGS, deprecated_rule=policy.DeprecatedRule( name='update_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), policy.DocumentedRuleDefault( name='delete_network_segment_range', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete a network segment range', operations=[ @@ -159,13 +158,13 @@ ), policy.DocumentedRuleDefault( name='delete_network_segment_range:tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Delete the network segment range tags', operations=ACTION_DELETE_TAGS, deprecated_rule=policy.DeprecatedRule( name='delete_network_segment_ranges_tags', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") ), diff --git a/neutron/conf/policies/port.py b/neutron/conf/policies/port.py index 747805f6b9e..5bd8ff56539 100644 --- a/neutron/conf/policies/port.py +++ b/neutron/conf/policies/port.py @@ -11,6 +11,7 @@ # under the License. from neutron_lib import policy as neutron_policy +from neutron_lib.policy import rules as lib_rules from oslo_log import versionutils from oslo_policy import policy @@ -70,8 +71,8 @@ policy.DocumentedRuleDefault( name='create_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description='Create a port', operations=ACTION_POST, @@ -84,8 +85,8 @@ policy.DocumentedRuleDefault( name='create_port:device_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description='Specify ``device_id`` attribute when creating a port', operations=ACTION_POST, @@ -99,7 +100,7 @@ name='create_port:device_owner', check_str=neutron_policy.policy_or( 'not rule:network_device', - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -117,7 +118,7 @@ policy.DocumentedRuleDefault( name='create_port:mac_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER), scope_types=['project'], description='Specify ``mac_address`` attribute when creating a port', @@ -133,7 +134,7 @@ policy.DocumentedRuleDefault( name='create_port:fixed_ips', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, 'rule:shared'), scope_types=['project'], @@ -151,7 +152,7 @@ policy.DocumentedRuleDefault( name='create_port:fixed_ips:ip_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER), scope_types=['project'], description='Specify IP address in ``fixed_ips`` when creating a port', @@ -167,7 +168,7 @@ policy.DocumentedRuleDefault( name='create_port:fixed_ips:subnet_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, 'rule:shared'), scope_types=['project'], @@ -185,7 +186,7 @@ policy.DocumentedRuleDefault( name='create_port:port_security_enabled', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER), scope_types=['project'], description=( @@ -203,7 +204,7 @@ ), policy.DocumentedRuleDefault( name='create_port:binding:host_id', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description=( 'Specify ``binding:host_id`` ' @@ -218,7 +219,7 @@ ), policy.DocumentedRuleDefault( name='create_port:binding:profile', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description=( 'Specify ``binding:profile`` attribute ' @@ -234,8 +235,8 @@ policy.DocumentedRuleDefault( name='create_port:binding:vnic_type', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``binding:vnic_type`` ' @@ -252,7 +253,7 @@ name='create_port:allowed_address_pairs', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``allowed_address_pairs`` ' @@ -269,7 +270,7 @@ name='create_port:allowed_address_pairs:mac_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``mac_address` of `allowed_address_pairs`` ' @@ -286,7 +287,7 @@ name='create_port:allowed_address_pairs:ip_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Specify ``ip_address`` of ``allowed_address_pairs`` ' @@ -301,7 +302,7 @@ ), policy.DocumentedRuleDefault( name='create_port:hints', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``hints`` attribute when creating a port' @@ -310,7 +311,7 @@ ), policy.DocumentedRuleDefault( name='create_port:trusted', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description=( 'Specify ``trusted`` attribute when creating a port' @@ -320,7 +321,7 @@ policy.DocumentedRuleDefault( name='create_port:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), scope_types=['project'], @@ -329,7 +330,7 @@ deprecated_rule=policy.DeprecatedRule( name='create_ports_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), deprecated_reason="Name of the rule is changed.", @@ -339,9 +340,9 @@ policy.DocumentedRuleDefault( name='get_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_READER, - base.PROJECT_READER + lib_rules.PROJECT_READER ), scope_types=['project'], description='Get a port', @@ -356,7 +357,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:vif_type', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:vif_type`` attribute of a port', operations=ACTION_GET, @@ -368,7 +369,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:vif_details', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:vif_details`` attribute of a port', operations=ACTION_GET, @@ -380,7 +381,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:host_id', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:host_id`` attribute of a port', operations=ACTION_GET, @@ -392,7 +393,7 @@ ), policy.DocumentedRuleDefault( name='get_port:binding:profile', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get ``binding:profile`` attribute of a port', operations=ACTION_GET, @@ -404,7 +405,7 @@ ), policy.DocumentedRuleDefault( name='get_port:resource_request', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``resource_request`` attribute of a port', operations=ACTION_GET, @@ -416,14 +417,14 @@ ), policy.DocumentedRuleDefault( name='get_port:hints', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``hints`` attribute of a port', operations=ACTION_GET, ), policy.DocumentedRuleDefault( name='get_port:trusted', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Get ``trusted`` attribute of a port', operations=ACTION_GET, @@ -433,7 +434,7 @@ check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, base.ADMIN_OR_NET_OWNER_READER, - base.PROJECT_READER + lib_rules.PROJECT_READER ), scope_types=['project'], description='Get the port tags', @@ -443,7 +444,7 @@ check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, base.ADMIN_OR_NET_OWNER_READER, - base.PROJECT_READER + lib_rules.PROJECT_READER ), deprecated_reason="Name of the rule is changed.", deprecated_since="2025.1") @@ -454,8 +455,8 @@ policy.DocumentedRuleDefault( name='update_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, - base.PROJECT_MEMBER, + lib_rules.ADMIN_OR_SERVICE, + lib_rules.PROJECT_MEMBER, ), scope_types=['project'], description='Update a port', @@ -471,8 +472,8 @@ policy.DocumentedRuleDefault( name='update_port:device_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.SERVICE), + lib_rules.ADMIN_OR_PROJECT_MEMBER, + lib_rules.SERVICE), scope_types=['project'], description='Update ``device_id`` attribute of a port', operations=ACTION_PUT, @@ -486,7 +487,7 @@ name='update_port:device_owner', check_str=neutron_policy.policy_or( 'not rule:network_device', - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, ), scope_types=['project'], @@ -504,7 +505,7 @@ policy.DocumentedRuleDefault( name='update_port:mac_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MANAGER, ), scope_types=['project'], @@ -521,7 +522,7 @@ policy.DocumentedRuleDefault( name='update_port:fixed_ips', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -538,7 +539,7 @@ policy.DocumentedRuleDefault( name='update_port:fixed_ips:ip_address', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -558,7 +559,7 @@ policy.DocumentedRuleDefault( name='update_port:fixed_ips:subnet_id', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, 'rule:shared' ), @@ -580,7 +581,7 @@ policy.DocumentedRuleDefault( name='update_port:port_security_enabled', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER ), scope_types=['project'], @@ -596,7 +597,7 @@ ), policy.DocumentedRuleDefault( name='update_port:binding:host_id', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Update ``binding:host_id`` attribute of a port', operations=ACTION_PUT, @@ -608,7 +609,7 @@ ), policy.DocumentedRuleDefault( name='update_port:binding:profile', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Update ``binding:profile`` attribute of a port', operations=ACTION_PUT, @@ -621,8 +622,8 @@ policy.DocumentedRuleDefault( name='update_port:binding:vnic_type', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, - base.PROJECT_MEMBER, + lib_rules.ADMIN_OR_SERVICE, + lib_rules.PROJECT_MEMBER, ), scope_types=['project'], description='Update ``binding:vnic_type`` attribute of a port', @@ -639,7 +640,7 @@ name='update_port:allowed_address_pairs', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description='Update ``allowed_address_pairs`` attribute of a port', operations=ACTION_PUT, @@ -653,7 +654,7 @@ name='update_port:allowed_address_pairs:mac_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Update ``mac_address`` of ``allowed_address_pairs`` ' @@ -670,7 +671,7 @@ name='update_port:allowed_address_pairs:ip_address', check_str=neutron_policy.policy_or( base.ADMIN_OR_NET_OWNER_MEMBER, - base.SERVICE), + lib_rules.SERVICE), scope_types=['project'], description=( 'Update ``ip_address`` of ``allowed_address_pairs`` ' @@ -686,7 +687,7 @@ policy.DocumentedRuleDefault( name='update_port:data_plane_status', check_str=neutron_policy.policy_or( - base.ADMIN, + lib_rules.ADMIN, 'role:data_plane_integrator'), scope_types=['project'], description='Update ``data_plane_status`` attribute of a port', @@ -699,14 +700,14 @@ ), policy.DocumentedRuleDefault( name='update_port:hints', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``hints`` attribute of a port', operations=ACTION_PUT, ), policy.DocumentedRuleDefault( name='update_port:trusted', - check_str=base.ADMIN, + check_str=lib_rules.ADMIN, scope_types=['project'], description='Update ``trusted`` attribute of a port', operations=ACTION_PUT, @@ -714,7 +715,7 @@ policy.DocumentedRuleDefault( name='update_port:tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), scope_types=['project'], @@ -723,7 +724,7 @@ deprecated_rule=policy.DeprecatedRule( name='update_ports_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, + lib_rules.ADMIN_OR_PROJECT_MEMBER, neutron_policy.RULE_ADVSVC ), deprecated_reason="Name of the rule is changed.", @@ -733,9 +734,9 @@ policy.DocumentedRuleDefault( name='delete_port', check_str=neutron_policy.policy_or( - base.ADMIN_OR_SERVICE, + lib_rules.ADMIN_OR_SERVICE, base.NET_OWNER_MEMBER, - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, ), scope_types=['project'], description='Delete a port', @@ -752,7 +753,7 @@ name='delete_port:tags', check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER ), scope_types=['project'], @@ -762,7 +763,7 @@ name='delete_ports_tags', check_str=neutron_policy.policy_or( neutron_policy.RULE_ADVSVC, - base.PROJECT_MEMBER, + lib_rules.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER ), deprecated_reason="Name of the rule is changed.", diff --git a/neutron/conf/policies/port_bindings.py b/neutron/conf/policies/port_bindings.py index 74ae80ea771..5a5f5463816 100644 --- a/neutron/conf/policies/port_bindings.py +++ b/neutron/conf/policies/port_bindings.py @@ -10,10 +10,9 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib.policy import rules as lib_rules from oslo_policy import policy -from neutron.conf.policies import base - BINDING_PATH = '/ports/{port_id}/bindings/' ACTIVATE_BINDING_PATH = '/ports/{port_id}/bindings/{host}' @@ -22,7 +21,7 @@ rules = [ policy.DocumentedRuleDefault( name='get_port_binding', - check_str=base.ADMIN_OR_SERVICE, + check_str=lib_rules.ADMIN_OR_SERVICE, scope_types=['project'], description='Get port binding information', operations=[ @@ -34,7 +33,7 @@ ), policy.DocumentedRuleDefault( name='create_port_binding', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Create port binding on the host', operations=[ @@ -46,7 +45,7 @@ ), policy.DocumentedRuleDefault( name='delete_port_binding', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Delete port binding on the host', operations=[ @@ -58,7 +57,7 @@ ), policy.DocumentedRuleDefault( name='activate', - check_str=base.SERVICE, + check_str=lib_rules.SERVICE, scope_types=['project'], description='Activate port binding on the host', operations=[ diff --git a/neutron/conf/policies/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/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/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/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/rangeallocator.py b/neutron/db/rangeallocator.py new file mode 100644 index 00000000000..654193fd806 --- /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 = super()._make_params( + min_val, max_val, scope_val, allocation_id) + params['rand_val'] = _random.random() # noqa: S311 + return params 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/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py index 3916b2b01e9..5f546087637 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py @@ -20,13 +20,15 @@ ** OVS agent https://wiki.openstack.org/wiki/Ovs-flow-logic """ -import netaddr +import sys +import netaddr from neutron_lib import constants as lib_consts from neutron_lib.plugins.ml2 import ovs_constants as constants from os_ken.lib.packet import ether_types from os_ken.lib.packet import icmpv6 from os_ken.lib.packet import in_proto +from oslo_config import cfg from oslo_log import log as logging from neutron.plugins.ml2.common import constants as comm_consts @@ -52,7 +54,7 @@ class OVSIntegrationBridge(ovs_bridge.OVSAgentBridge, of_tables = constants.INT_BR_ALL_TABLES def setup_default_table(self, enable_openflow_dhcp=False, - enable_dhcpv6=False): + enable_dhcpv6=False, enable_dns_forwarder=False): (_dp, ofp, ofpp) = self._get_dp() self.setup_canary_table() self.install_goto(dest_table_id=PACKET_RATE_LIMIT) @@ -79,6 +81,9 @@ def setup_default_table(self, enable_openflow_dhcp=False, table_id=constants.LOCAL_EGRESS_TABLE) self.install_goto(dest_table_id=PACKET_RATE_LIMIT, table_id=constants.LOCAL_IP_TABLE) + # DNS Forwarder defaults + if enable_dns_forwarder: + self.init_dns_forwarder() def init_dhcp(self, enable_openflow_dhcp=False, enable_dhcpv6=False): if not enable_openflow_dhcp: @@ -172,6 +177,48 @@ def check_canary_table(self): return constants.OVS_DEAD return constants.OVS_NORMAL if flows else constants.OVS_RESTARTED + def init_dns_forwarder(self): + """Initialize DNS Forwarder flows.""" + for ip_port in cfg.CONF.DNS_FORWARDER.client_dns_server_ports: + try: + ip_part, port_part = ip_port.rsplit(':', 1) + ip = ip_part.replace('[', '').replace(']', '') + port = int(port_part) + netaddr_ip = netaddr.IPAddress(ip) + except (ValueError, netaddr.AddrFormatError): + LOG.error( + "Invalid client_dns_server_ports config: %s", ip_port + ) + sys.exit(1) + (_dp, ofp, ofpp) = self._get_dp() + if netaddr_ip.version == lib_consts.IP_VERSION_6: + match = ofpp.OFPMatch( + eth_type=ether_types.ETH_TYPE_IPV6, + ip_proto=in_proto.IPPROTO_UDP, + ipv6_dst=ip, + udp_dst=port + ) + else: + match = ofpp.OFPMatch( + eth_type=ether_types.ETH_TYPE_IP, + ip_proto=in_proto.IPPROTO_UDP, + ipv4_dst=ip, + udp_dst=port + ) + + actions = [ + ofpp.OFPActionOutput(ofp.OFPP_CONTROLLER, 0), + ] + instructions = [ + ofpp.OFPInstructionActions(ofp.OFPIT_APPLY_ACTIONS, actions), + ] + self.install_instructions( + table_id=constants.TRANSIENT_TABLE, + priority=102, + instructions=instructions, + match=match + ) + @staticmethod def _local_vlan_match(_ofp, ofpp, port, vlan_vid): return ofpp.OFPMatch(in_port=port, vlan_vid=vlan_vid) diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py index 53f73f080b3..6b255169d55 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 @@ -1489,7 +1490,15 @@ def port_unbound(self, vif_id, net_uuid=None): if not lvm.vif_ports: self.reclaim_local_vlan(net_uuid, lvm.segmentation_id) + @staticmethod + def _has_valid_ofport(port): + return port.ofport and port.ofport != ovs_lib.INVALID_OFPORT + def port_alive(self, port, log_errors=True): + if not self._has_valid_ofport(port): + LOG.warning("port_alive skipped for port %s: invalid ofport %s", + port.port_name, port.ofport) + return cur_tag = self.int_br.db_get_val("Port", port.port_name, "tag", log_errors=log_errors) # Port normal vlan tag is set correctly, remove the drop flows @@ -1505,6 +1514,10 @@ def port_dead(self, port, log_errors=True): :param port: an ovs_lib.VifPort object. ''' + if not self._has_valid_ofport(port): + LOG.warning("port_dead skipped for port %s: invalid ofport %s", + port.port_name, port.ofport) + return # Don't kill a port if it's already dead cur_tag = self.int_br.db_get_val("Port", port.port_name, "tag", log_errors=log_errors) @@ -1536,7 +1549,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 +1871,11 @@ def _process_port(port, ports, ancillary_ports): iface_id = self.int_br.portid_from_external_ids( port['external_ids']) if iface_id: - if port['ofport'] == ovs_lib.UNASSIGNED_OFPORT: - LOG.debug("Port %s not ready yet on the bridge", - iface_id) + if port['ofport'] in (ovs_lib.UNASSIGNED_OFPORT, + ovs_lib.INVALID_OFPORT): + LOG.debug("Port %s not ready yet on the bridge " + "(ofport=%s)", + iface_id, port['ofport']) ports_not_ready_yet.add(port['name']) return # check if port belongs to ancillary bridge @@ -1964,19 +1981,12 @@ def treat_vif_port(self, vif_port, port_id, network_id, network_type, physical_network, segmentation_id, admin_state_up, fixed_ips, device_owner, provisioning_needed): port_needs_binding = True - if not vif_port.ofport: - # Log an error if the VIF port has no ofport, which indicates - # that the port might not be able to transmit traffic. - LOG.error("VIF port: %s has no ofport and might not " - "be able to transmit.", vif_port.vif_id) - elif vif_port.ofport == ovs_lib.INVALID_OFPORT: - # When the ofport is set to INVALID_OFPORT, it indicates that - # the port is in a transitional state and has not yet been fully - # configured. - LOG.info("VIF port: %s is in a transitional state and has not " - "yet been assigned a valid ofport. This is expected " - "during port initialization. (ofport=%s)", - vif_port.vif_id, vif_port.ofport) + if not self._has_valid_ofport(vif_port): + LOG.warning("VIF port: %s has no valid ofport (ofport=%s), " + "skipping OF operations; the port will be " + "retried on the next iteration.", + vif_port.vif_id, vif_port.ofport) + return False if admin_state_up: port_needs_binding = self.port_bound( vif_port, network_id, network_type, diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py index c0d526e51cd..47b9934123e 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/api.py @@ -26,6 +26,10 @@ def create_lswitch_port(self, lport_name, lswitch_name, may_exist=True, **columns): """Create a command to add an OVN logical switch port + NOTE: Setting a tag sets the LSP tag_request column and northd + will eventually set the tag column. tag cannot be set and immediately + read. + :param lport_name: The name of the lport :type lport_name: string :param lswitch_name: The name of the lswitch the lport is created on @@ -44,6 +48,10 @@ def set_lswitch_port(self, lport_name, external_ids_update=None, if_exists=True, **columns): """Create a command to set OVN logical switch port fields + NOTE: Setting a tag sets the LSP tag_request column and northd + will eventually set the tag column. tag cannot be set and immediately + read. + :param lport_name: The name of the lport :type lport_name: string :param external_ids_update: Dictionary of keys to be updated diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py index 026473538aa..8fe3ea65ea2 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py @@ -197,6 +197,16 @@ def run_idl(self, txn): hcg.delete() +def _tag_column_to_tag_request(columns): + # Setting tag directly is verboten, if it is set convert it to a + # tag_request if there isn't one, otherwise ignore it + tag = columns.pop('tag', None) + if tag is not None and 'tag_request' not in columns: + LOG.debug("Converting tag %s to a tag_request", tag) + columns['tag_request'] = tag + return columns + + class AddLSwitchPortCommand(command.BaseCommand): def __init__(self, api, lport, lswitch, may_exist, network_id=None, **columns): @@ -205,7 +215,7 @@ def __init__(self, api, lport, lswitch, may_exist, network_id=None, self.lswitch = lswitch self.may_exist = may_exist self.network_uuid = uuid.UUID(str(network_id)) if network_id else None - self.columns = columns + self.columns = _tag_column_to_tag_request(columns) def run_idl(self, txn): try: @@ -235,7 +245,6 @@ def run_idl(self, txn): port = txn.insert(self.api._tables['Logical_Switch_Port']) port.name = self.lport - port.tag = self.columns.pop('tag', []) or [] dhcpv4_options = self.columns.pop('dhcpv4_options', []) if isinstance(dhcpv4_options, list): port.dhcpv4_options = dhcpv4_options @@ -269,7 +278,7 @@ def __init__(self, api, lport, external_ids_update, if_exists, **columns): super().__init__(api) self.lport = lport self.external_ids_update = external_ids_update - self.columns = columns + self.columns = _tag_column_to_tag_request(columns) self.if_exists = if_exists def run_idl(self, txn): diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py index 6d94c12aa47..5e5a862e486 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py @@ -245,6 +245,7 @@ def from_worker(cls, worker_class, driver=None): args = (cls.connection_string, cls.schema_helper) if worker_class == worker.MaintenanceWorker: idl_ = ovsdb_monitor.BaseOvnIdl.from_server(*args) + idl_.set_lock(ovn_const.MAINTENANCE_NB_IDL_LOCK_NAME) else: idl_ = ovsdb_monitor.OvnNbIdl.from_server(*args, driver=driver) conn = connection.Connection(idl_, timeout=cfg.get_ovn_ovsdb_timeout()) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index faddf15adc0..e8b87223216 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -51,8 +51,6 @@ INCONSISTENCY_TYPE_CREATE_UPDATE = 'create/update' INCONSISTENCY_TYPE_DELETE = 'delete' -# TODO(bpetermann): move MAINTENANCE_NB_IDL_LOCK_NAME to neutron-lib -MAINTENANCE_NB_IDL_LOCK_NAME = "ovn_db_inconsistencies_periodics" def has_lock_periodic(*args, periodic_run_limit=0, **kwargs): @@ -229,7 +227,6 @@ def __init__(self, ovn_client): self._nb_idl = self._ovn_client._nb_idl self._sb_idl = self._ovn_client._sb_idl self._idl = self._nb_idl.idl - self._idl.set_lock(MAINTENANCE_NB_IDL_LOCK_NAME) super().__init__(ovn_client) self._resources_func_map = { @@ -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: 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..998f68013a5 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: @@ -1416,19 +1416,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 +1624,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: @@ -2185,7 +2191,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 +2400,8 @@ def update_network(self, context, network, original_network=None): tag = [] if tag is None else tag lport_name = utils.ovn_provnet_port_name(segment['id']) txn.add(self._nb_idl.set_lswitch_port(lport_name=lport_name, - tag=tag, if_exists=True)) + tag_request=tag, + if_exists=True)) self._qos_driver.update_network(context, txn, network, original_network) diff --git a/neutron/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/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/pvlan/pvlan_plugin.py b/neutron/services/pvlan/pvlan_plugin.py index 4460eb9f0fe..f5bc119e5c0 100644 --- a/neutron/services/pvlan/pvlan_plugin.py +++ b/neutron/services/pvlan/pvlan_plugin.py @@ -146,6 +146,10 @@ def pvlan_network_update(self, resource, event, trigger, payload=None): context, network_id=network_id, pvlan=enable_pvlan ).create() + # Update the desired state for a correct PUT response. + if payload and payload.desired_state: + payload.desired_state[pvlan_const.PVLAN] = enable_pvlan + def _pvlan_port_driver_update(self, resource, event, trigger, payload=None, **kwargs): """Call the driver after the port is created, updated or deleted.""" @@ -287,6 +291,12 @@ def _pvlan_port_update(self, payload=None, port=None, network=None, pvlan_type=pvlan_type, pvlan_community=pvlan_community, ).create() + + # Update the desired state for a correct PUT response. + if payload and payload.desired_state: + payload.desired_state[pvlan_const.PVLAN_TYPE] = pvlan_type + payload.desired_state[pvlan_const.PVLAN_COMMUNITY] = ( + pvlan_community) return True def _check_port_security(self, network, port_data): diff --git a/neutron/tests/common/net_helpers.py b/neutron/tests/common/net_helpers.py index 77a6b8d0296..b566002e18b 100644 --- a/neutron/tests/common/net_helpers.py +++ b/neutron/tests/common/net_helpers.py @@ -1109,7 +1109,10 @@ class FrrFixture(fixtures.Fixture): FRR_CONF_DIR_BASE = '/etc/frr' FRR_STATE_DIR_BASE = '/var/run/frr' - FRRINIT = '/usr/lib/frr/frrinit.sh' + FRRINIT_PATHS = ['/usr/lib/frr/frrinit.sh', + '/usr/libexec/frr/frrinit.sh'] + FRRINIT = next((p for p in FRRINIT_PATHS if os.path.isfile(p)), + FRRINIT_PATHS[0]) DAEMONS_CONF = ( 'zebra=yes\n' @@ -1151,10 +1154,9 @@ def __init__(self, namespace): self._state_dir = os.path.join(self.FRR_STATE_DIR_BASE, namespace) def _setUp(self): - self.addCleanup(self._stop_frr) - self.addCleanup(self._remove_config) + self.addCleanup(self._cleanup_frr) self._create_config() - self._start_frr() + self.start_frr() @staticmethod def _write_file(path, content): @@ -1185,23 +1187,34 @@ def _create_config(self): ['chown', '-R', 'frr:frr', self._conf_dir], run_as_root=True) - def _start_frr(self): + def start_frr(self): utils.execute( [self.FRRINIT, 'start', self.namespace], run_as_root=True) - def _stop_frr(self): + def stop_frr(self): + utils.execute( + [self.FRRINIT, 'stop', self.namespace], + run_as_root=True) + + def restart_frr(self): + utils.execute( + [self.FRRINIT, 'restart', self.namespace], + run_as_root=True) + + def _cleanup_frr(self): + # NOTE: frrinit.sh returns 0 when stopping an already-stopped + # service, so this is safe even if a test stopped FRR earlier. + # However, stop must be called before config directories are + # removed. try: - utils.execute( - [self.FRRINIT, 'stop', self.namespace], - run_as_root=True) + self.stop_frr() except RuntimeError: LOG.error("Failed to stop FRR in namespace %s", self.namespace) - def _remove_config(self): - for dir in (self._conf_dir, self._state_dir): + for pathspace_dir in (self._conf_dir, self._state_dir): try: utils.execute( - ['rm', '-rf', dir], run_as_root=True) + ['rm', '-rf', pathspace_dir], run_as_root=True) except RuntimeError: - LOG.error("Failed to remove %s", dir) + LOG.error("Failed to remove %s", pathspace_dir) diff --git a/neutron/tests/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/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..b4a0f913c71 --- /dev/null +++ b/neutron/tests/functional/agent/linux/evpn_router/frr/test_frr_driver.py @@ -0,0 +1,497 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from pyroute2.netlink import rtnl + +from neutron.agent.linux.evpn_router.frr import exceptions as frr_exceptions +from neutron.agent.linux.evpn_router.frr import frr_driver +from neutron.agent.linux.evpn_router import interface +from neutron.agent.linux import ip_lib +from neutron.agent.linux import utils as linux_utils +from neutron.common import utils as common_utils +from neutron.tests.common import net_helpers +from neutron.tests.functional import base +from neutron_lib import exceptions + + +class FrrVtyshExecutorNamespaced(frr_driver.FrrVtyshExecutor): + """Namespaced vtysh executor for testing. + + Do not add any logic here — this subclass exists only to run + vtysh in a network namespace so that functional tests exercise + the production FrrVtyshExecutor code paths unchanged. + """ + + def __init__(self, namespace): + self._namespace = namespace + + @property + def _vtysh_base_cmd(self) -> list[str]: + return ['vtysh', '-N', self._namespace] + + +class NamespacedVRFHandler(interface.EVPNRouterVrfHandler): + """VRF handler that creates VRFs and required linux interfaces for + a FRR service. + """ + # TODO(mtomaska): Replace subprocess ip commands with ip_lib (pyroute2). + # ip_lib already supports: device exists, create/delete interface, + # set up/down, set master, add IP address, create VXLAN. + # Missing from ip_lib: addrgenmode, bridge_slave neigh_suppress/learning, + # VXLAN nolearning. + # For now, to avoid mixing two different approaches, all operations + # use subprocess ip commands. + + def __init__(self, namespace, vtep_ip=None, dstport=4789): + self._namespace = namespace + self._vtep_ip = vtep_ip + self._dstport = dstport + + def _ns_exec(self, cmd, **kwargs): + return linux_utils.execute( + ['ip', 'netns', 'exec', self._namespace] + cmd, + run_as_root=True, **kwargs) + + def _vni(self, vrf_name): + return int(vrf_name.split('-')[-1]) + + def _bridge_name(self, vni): + return 'br-%d' % vni + + def _vxlan_name(self, vni): + return 'vxlan-%d' % vni + + def _device_exists(self, dev_name): + _out, std_err = self._ns_exec( + ['ip', 'link', 'show', dev_name], + check_exit_code=False, + return_stderr=True, + log_fail_as_error=False) + return not str(std_err).strip() + + def _delete_device(self, dev_name, step): + try: + _out, std_err = self._ns_exec( + ['ip', 'link', 'del', dev_name], + check_exit_code=False, + return_stderr=True, + log_fail_as_error=False) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to delete %s" % dev_name, + step=step, + cause=err, + ) from err + std_err = str(std_err).strip() + ok = std_err in ('', 'Cannot find device "%s"' % dev_name) + if not ok: + raise frr_exceptions.FrrVrfError( + "Failed to delete %s: %s" % (dev_name, std_err), + step=step, + ) + + def _ensure_vrf_created(self, vrf_name): + if self._device_exists(vrf_name): + return + + # NOTE: For simplicity, the routing table ID is the same as the VNI + table_id = self._vni(vrf_name) + try: + self._ns_exec( + ['ip', 'link', 'add', vrf_name, + 'type', 'vrf', 'table', str(table_id)]) + self._ns_exec( + ['ip', 'link', 'set', vrf_name, 'up']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to create VRF: %s" % vrf_name, + step='ensure_vrf_exists', + cause=err, + ) from err + + def _ensure_bridge_created(self, vrf_name): + vni = self._vni(vrf_name) + br_name = self._bridge_name(vni) + if self._device_exists(br_name): + return + + try: + self._ns_exec( + ['ip', 'link', 'add', br_name, 'type', 'bridge']) + self._ns_exec( + ['ip', 'link', 'set', br_name, + 'master', vrf_name, 'addrgenmode', 'none']) + self._ns_exec( + ['ip', 'link', 'set', br_name, 'up']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to create bridge: %s" % br_name, + step='ensure_bridge_exists', + cause=err, + ) from err + + def _ensure_vxlan_created(self, vrf_name): + vni = self._vni(vrf_name) + vxlan_name = self._vxlan_name(vni) + br_name = self._bridge_name(vni) + if self._device_exists(vxlan_name): + return + + try: + self._ns_exec( + ['ip', 'link', 'add', vxlan_name, + 'type', 'vxlan', 'local', self._vtep_ip, + 'dstport', str(self._dstport), + 'id', str(vni), 'nolearning']) + self._ns_exec( + ['ip', 'link', 'set', vxlan_name, + 'master', br_name, 'addrgenmode', 'none']) + self._ns_exec( + ['ip', 'link', 'set', vxlan_name, + 'type', 'bridge_slave', + 'neigh_suppress', 'on', 'learning', 'off']) + self._ns_exec( + ['ip', 'link', 'set', vxlan_name, 'up']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to create VXLAN: %s" % vxlan_name, + step='ensure_vxlan_exists', + cause=err, + ) from err + + def _set_vtep_ip_on_lo(self): + out = self._ns_exec( + ['ip', 'addr', 'show', 'dev', 'lo']) + if self._vtep_ip in str(out): + return + try: + self._ns_exec( + ['ip', 'addr', 'add', '%s/32' % self._vtep_ip, 'dev', 'lo']) + except exceptions.ProcessExecutionError as err: + raise frr_exceptions.FrrVrfError( + "Failed to set VTEP IP on lo", + step='set_vtep_ip_on_lo', + cause=err, + ) from err + + def ensure_vrf_exists(self, vrf_name): + self._ensure_vrf_created(vrf_name) + if self._vtep_ip: + self._set_vtep_ip_on_lo() + self._ensure_bridge_created(vrf_name) + self._ensure_vxlan_created(vrf_name) + + def ensure_vrf_deleted(self, vrf_name): + if self._vtep_ip: + vni = self._vni(vrf_name) + self._delete_device( + self._vxlan_name(vni), step='ensure_vxlan_deleted') + self._delete_device( + self._bridge_name(vni), step='ensure_bridge_deleted') + self._delete_device(vrf_name, step='ensure_vrf_deleted') + + +def make_evpn_config(vni, bgp_router_id='10.0.0.1', vrf_name_prefix='vrf-'): + return interface.EVPNRouterConfig( + asn=65000, + bgp_router_id=bgp_router_id, + vrf_name=vrf_name_prefix + str(vni), + vni=vni, + ) + + +def add_blackhole_routes(namespace, cidrs, table_id): + """Add blackhole routes to simulate ovn-controller route advertisment.""" + for cidr in cidrs: + ip_lib.add_ip_route(namespace, cidr, table=table_id, + type=rtnl.rt_type['blackhole'], scope=0) + + +def assert_routes(namespace, table_id, present=None, absent=None, + ip_version=4, timeout=5): + def _check(): + routes = ip_lib.list_ip_routes(namespace, ip_version, table=table_id) + found = {r['cidr'] for r in routes} + if present and not present.issubset(found): + return False + if absent and absent.intersection(found): + return False + return True + + details = [] + if present: + details.append("expected present: %s" % present) + if absent: + details.append("expected absent: %s" % absent) + common_utils.wait_until_true( + _check, timeout=timeout, sleep=1, + exception=RuntimeError( + "Routes did not converge in VRF table %s (%s)" + % (table_id, ', '.join(details)))) + + +class TestFrrVtyshDriverConfiguration(base.BaseSudoTestCase): + + def setUp(self): + super().setUp() + self.namespace = self.useFixture(net_helpers.NamespaceFixture()).name + self.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() + + 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) + + 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_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..bfd6fdd61e7 100644 --- a/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py +++ b/neutron/tests/functional/agent/ovn/extensions/evpn/test_events.py @@ -17,10 +17,10 @@ import testtools -from neutron.agent.ovn.extensions.evpn import constants as evpn_const from neutron.agent.ovn.extensions.evpn import events as evpn_events from neutron.agent.ovn.extensions.evpn import exceptions as evpn_exc from neutron.agent.ovn.extensions.evpn import fsm as evpn_fsm +from neutron.agent.ovn.extensions.evpn import utils as evpn_utils from neutron.common.ovn import constants as ovn_const from neutron.common import utils as common_utils from neutron.services.bgp import ovn as bgp_ovn @@ -41,7 +41,8 @@ def setUp(self): finally: bgp_ovn.OvnSbIdl.tables = bgp_ovn.OVN_SB_TABLES self.mock_evpn_ext = mock.Mock() - self.real_fsm = evpn_fsm.EvpnFSM() + self.real_fsm = evpn_fsm.EvpnFSM(mock.Mock(), mock.Mock(), + mock.Mock()) self.mock_evpn_ext._evpn_fsm = mock.Mock(wraps=self.real_fsm) self.sb_api.idl.notify_handler.watch_event( evpn_events.PortBindingLrpEvpnCreateEvent( @@ -50,13 +51,13 @@ def setUp(self): evpn_events.PortBindingLrpEvpnDeleteEvent( self.mock_evpn_ext._evpn_fsm)) - def _create_evpn_lrp(self, vni, mac): + def _create_evpn_lrp(self, vni, mac, vlan=100): lr_name = f'lr-evpn-{vni}' ls_name = f'ls-evpn-{vni}' lrp_name = f'lrp-to-evpn-{vni}' lsp_name = f'lsp-to-evpn-{vni}' lr = self.nb_api.lr_add(lr_name).execute(check_error=True) - vrf = evpn_const.EVPN_VRF_PREFIX + str(lr.uuid)[:12] + vrf = evpn_utils.evpn_vrf_name(lr.uuid) with self.nb_api.transaction(check_error=True) as txn: txn.add(self.nb_api.db_set( 'Logical_Router', lr_name, @@ -70,6 +71,7 @@ def _create_evpn_lrp(self, vni, mac): lr_name, lrp_name, mac, [], external_ids={ svc_const.EVPN_LRP_VNI_EXT_ID_KEY: str(vni), + svc_const.EVPN_LRP_VLAN_EXT_ID_KEY: str(vlan), }, options={ 'dynamic-routing-maintain-vrf': 'true', @@ -81,7 +83,8 @@ def _create_evpn_lrp(self, vni, mac): return vrf def _create_lrp_without_evpn_match(self, vni, mac, - set_vrf=True, set_vni=True): + set_vrf=True, set_vni=True, + set_vlan=True): lr_name = f'lr-no-evpn-{vni}' ls_name = f'ls-no-evpn-{vni}' lrp_name = f'lrp-no-evpn-{vni}' @@ -90,10 +93,12 @@ def _create_lrp_without_evpn_match(self, vni, mac, options = {'dynamic-routing': 'true', 'chassis': 'fake-chassis'} if set_vrf: options[ovn_const.LR_OPTIONS_DR_VRF_NAME] = ( - evpn_const.EVPN_VRF_PREFIX + str(lr.uuid)[:12]) + evpn_utils.evpn_vrf_name(lr.uuid)) external_ids = {} if set_vni: external_ids[svc_const.EVPN_LRP_VNI_EXT_ID_KEY] = str(vni) + 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)) @@ -123,14 +128,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.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 +151,16 @@ def test_create_event_not_triggered_missing_vni(self): with testtools.ExpectedException(AssertionError): self._wait_for_advance(timeout=2) + def test_create_event_not_triggered_missing_vlan(self): + self._create_lrp_without_evpn_match(10003, 'aa:bb:cc:dd:ee:ff', + set_vlan=False) + with testtools.ExpectedException(AssertionError): + self._wait_for_advance(timeout=2) + def test_create_event_illegal_fsm_transition(self): self.mock_evpn_ext._evpn_fsm.advance.side_effect = \ evpn_exc.FSMIllegalTransition("forced bad state") - self._create_evpn_lrp(10003, 'aa:bb:cc:dd:ee:ff') + self._create_evpn_lrp(10004, 'aa:bb:cc:dd:ee:ff') self._wait_for_advance() diff --git a/neutron/tests/functional/agent/ovn/extensions/test_evpn.py b/neutron/tests/functional/agent/ovn/extensions/test_evpn.py index 9cfd4ecccb2..f146ff13343 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 @@ -35,19 +44,20 @@ def _safe_delete(name): pass def test_vrf_handler_lifecycle(self): - vrf_handler = netlink_monitor.VrfHandler(fsm.EvpnFSM()) + vrf_handler = netlink_monitor.VrfHandler( + fsm.EvpnFSM(svd=None, config=None, frr_driver=None)) 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 +68,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 +91,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 +103,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.svd, config=self.cfg, + frr_driver=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..66b16da3959 100644 --- a/neutron/tests/functional/base.py +++ b/neutron/tests/functional/base.py @@ -117,6 +117,10 @@ def _collect_ovn_process_logs(self): self._copy_file(src_filename, dst_filename) +def get_unique_name(prefix="test"): + return f"{prefix}_{uuidutils.generate_uuid()}" + + def config_decorator(method_to_decorate, config_tuples): def wrapper(*args, **kwargs): method_to_decorate(*args, **kwargs) diff --git a/neutron/tests/functional/db/test_rangeallocator.py b/neutron/tests/functional/db/test_rangeallocator.py new file mode 100644 index 00000000000..20e8b42b224 --- /dev/null +++ b/neutron/tests/functional/db/test_rangeallocator.py @@ -0,0 +1,302 @@ +# 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__ + self.allocator = rangeallocator.RangeAllocator( + table=self.table, + value_col_name='vni', + scope_col_name='physnet', + scope_param_type=sa.String, + exception_class=evpn_exc.EVPNNoVniAvailable, + ) + + def _allocate(self, min_vni=1, max_vni=100, physnet=_PHYSNET): + with db_api.CONTEXT_WRITER.using(self.ctx): + return self.allocator.allocate( + self.ctx, min_vni, max_vni, physnet) + + def _insert(self, vni, physnet=_PHYSNET): + """Directly insert a VNI to set up a specific allocation state.""" + with db_api.CONTEXT_WRITER.using(self.ctx): + result = self.ctx.session.execute( + self.table.insert().values(vni=vni, physnet=physnet)) + return result.inserted_primary_key[0] + + def _delete(self, allocation_id): + with db_api.CONTEXT_WRITER.using(self.ctx): + self.ctx.session.execute( + self.table.delete().where(self.table.c.id == allocation_id)) + + def _all_vnis(self, physnet=_PHYSNET): + with db_api.CONTEXT_READER.using(self.ctx): + rows = self.ctx.session.execute( + sa.select(self.table.c.vni) + .where(self.table.c.physnet == physnet) + .order_by(self.table.c.vni) + ).fetchall() + return [r.vni for r in rows] + + +class TestRangeAllocator(TestRangeAllocatorBase): + def test_allocate_from_empty_gets_min(self): + alloc_id, vni = self._allocate(min_vni=10, max_vni=100) + self.assertEqual(10, vni) + 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 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/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/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/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/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..c1120b101d7 --- /dev/null +++ b/neutron/tests/unit/agent/linux/evpn_router/frr/test_frr_driver.py @@ -0,0 +1,283 @@ +# Copyright 2026 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from neutron.agent.linux.evpn_router.frr import exceptions as frr_exceptions +from neutron.agent.linux.evpn_router.frr import frr_driver +from neutron.agent.linux.evpn_router import interface +from neutron.tests import base +from neutron_lib import exceptions + + +def _build_test_evpn_router_config(vni): + return interface.EVPNRouterConfig( + asn=65000, + bgp_router_id='10.0.0.1', + vrf_name=f'vrf-{vni}', + vni=vni, + ) + + +class TestFrrCommandBuilder(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.builder = frr_driver.FrrCommandBuilder() + + def test_add_bgp_router_cmds(self): + config = _build_test_evpn_router_config(100) + peer_iface = 'eth1' + result = self.builder.add_bgp_router_cmds(config, peer_iface) + + self.assertIn( + 'router bgp %d' % config.asn, result) + self.assertIn( + 'bgp router-id %s' % config.bgp_router_id, result) + self.assertIn( + 'neighbor %s interface remote-as internal' + % peer_iface, result) + self.assertIn('address-family ipv4 unicast', result) + self.assertIn('address-family ipv6 unicast', result) + self.assertIn('address-family l2vpn evpn', result) + self.assertIn('advertise-all-vni', result) + + def test_add_evpn_router_cmds(self): + config = _build_test_evpn_router_config(100) + result = self.builder.add_evpn_router_cmds(config) + + self.assertIn('vrf %s' % config.vrf_name, result) + self.assertIn('vni %d' % config.vni, result) + self.assertIn('address-family ipv4 unicast', result) + self.assertIn('address-family ipv6 unicast', result) + self.assertIn('address-family l2vpn evpn', result) + self.assertIn('redistribute kernel', result) + + def test_delete_evpn_router_cmds(self): + config = _build_test_evpn_router_config(100) + result = self.builder.delete_evpn_router_cmds(config) + + self.assertIn('no vni %d' % config.vni, result) + self.assertIn( + 'no router bgp %d vrf %s' + % (config.asn, config.vrf_name), result) + self.assertIn('no vrf %s' % config.vrf_name, result) + + def test_delete_bgp_router_cmds(self): + config = _build_test_evpn_router_config(100) + result = self.builder.delete_bgp_router_cmds(config) + + self.assertIn('no router bgp %d' % config.asn, result) + + +class TestFrrVtyshExecutor(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.execute = mock.patch.object( + frr_driver.linux_utils, 'execute').start() + self.executor = frr_driver.FrrVtyshExecutor() + + def test_execute_cli_cmd(self): + self.execute.return_value = "BGP summary output" + mock_cmd = 'show me something' + + out = self.executor.execute_cli_cmd(mock_cmd) + + self.assertEqual("BGP summary output", out) + self.execute.assert_called_once_with( + ['vtysh', '-c', mock_cmd], + run_as_root=True, + ) + + def test_execute_cli_cmd_raises_on_failure(self): + self.execute.side_effect = exceptions.ProcessExecutionError( + 'vtysh failure', returncode=1) + + self.assertRaises( + frr_exceptions.FrrApplyError, + self.executor.execute_cli_cmd, + "show bgp summary", + ) + + def test_execute_cmds_calls_dryrun_then_apply(self): + mock_cmds = "some config" + self.executor.execute_cmds(mock_cmds) + + calls = self.execute.call_args_list + self.assertEqual(3, len(calls)) + dryrun_cmd = calls[0][0][0] + apply_cmd = calls[1][0][0] + write_mem_cmd = calls[2][0][0] + self.assertEqual('vtysh', dryrun_cmd[0]) + self.assertIn('--dryrun', dryrun_cmd) + self.assertIn('-f', dryrun_cmd) + self.assertEqual('vtysh', apply_cmd[0]) + self.assertIn('-f', apply_cmd) + self.assertNotIn('--dryrun', apply_cmd) + self.assertEqual(['vtysh', '-c', 'write memory'], write_mem_cmd) + + def test_execute_cmds_raises_dryrun_error(self): + mock_cmds = "bad config" + self.execute.side_effect = exceptions.ProcessExecutionError( + 'syntax error', returncode=1) + + self.assertRaises( + frr_exceptions.FrrDryrunError, + self.executor.execute_cmds, + mock_cmds, + ) + self.execute.assert_called_once() + dryrun_cmd = self.execute.call_args[0][0] + self.assertIn('--dryrun', dryrun_cmd) + + def test_execute_cmds_raises_apply_error(self): + mock_cmds = "config syntactically correct, but apply failed" + + def _dryrun_ok_apply_fail(cmd, **_kwargs): + if '--dryrun' in cmd: + return '' + raise exceptions.ProcessExecutionError( + 'apply failed', returncode=1) + + self.execute.side_effect = _dryrun_ok_apply_fail + + self.assertRaises( + frr_exceptions.FrrApplyError, + self.executor.execute_cmds, + mock_cmds, + ) + + +class TestFrrVtyshDriver(base.BaseTestCase): + + def setUp(self): + super().setUp() + self.vrf_handler = mock.Mock(spec=interface.EVPNRouterVrfHandler) + self.driver = frr_driver.FrrVtyshDriver( + 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_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..8aa41ff2407 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.evpn_fsm = fsm.EvpnFSM() + self.mock_svd = mock.Mock() + self.mock_config = mock.Mock() + self.mock_driver = mock.Mock() + self.evpn_fsm = fsm.EvpnFSM(self.mock_svd, + self.mock_config, self.mock_driver) + self.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..e288f2e8442 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,61 @@ 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 = fsm.EvpnFSM(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 +105,18 @@ def test_handle_dellink_evpn_vrf(self): self.assertNotIn(vrf, self._evpn_fsm.instances) def test_handle_dellink_unknown_vrf(self): - vrf = 'vr0a1b2c3d-fff' + vrf = 'vr0a1b2c3d-ffff' self.handler._known_vrfs.add(vrf) evpn = fsm.Evpn(vrf) evpn.vrf_up = True - evpn.state = fsm.Evpn.WAITING_FOR_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 +131,7 @@ def test_ignores_wrong_length(self): self.assertEqual(set(), self.handler._known_vrfs) def test_ignores_no_linkinfo(self): - no_linkinfo_msg = _make_nlmsg('vr0a1b2c3d-fff') + no_linkinfo_msg = _make_nlmsg('vr0a1b2c3d-ffff') self.handler.handle_newlink(no_linkinfo_msg) self.assertEqual(set(), self.handler._known_vrfs) @@ -115,7 +151,9 @@ def test_parse_evpn_vrf_raises_unknown_vrf_for_wrong_length(self): self.handler._parse_evpn_vrf, msg) def test_multiple_vrfs(self): - vrf1, vrf2, vrf3 = 'vr0a1b2c3d-ddd', 'vr0a1b2c3d-eee', 'vr0a1b2c3d-fff' + vrf1 = 'vr0a1b2c3d-dddd' + vrf2 = 'vr0a1b2c3d-eeee' + vrf3 = 'vr0a1b2c3d-ffff' self.handler.handle_newlink(_make_vrf_msg(vrf1)) self.handler.handle_newlink(_make_vrf_msg(vrf2)) self.handler.handle_newlink(_make_vrf_msg(vrf3)) @@ -124,7 +162,9 @@ def test_multiple_vrfs(self): self.assertEqual({vrf1, vrf3}, self.handler._known_vrfs) def test_replay_removes_stale_vrfs(self): - vrf1, vrf2, vrf3 = 'vr0a1b2c3d-ddd', 'vr0a1b2c3d-eee', 'vr0a1b2c3d-fff' + vrf1 = 'vr0a1b2c3d-dddd' + vrf2 = 'vr0a1b2c3d-eeee' + vrf3 = 'vr0a1b2c3d-ffff' self.handler.handle_newlink(_make_vrf_msg(vrf1)) self.handler.handle_newlink(_make_vrf_msg(vrf2)) self.handler.handle_newlink(_make_vrf_msg(vrf3)) @@ -135,7 +175,7 @@ def test_replay_removes_stale_vrfs(self): self.assertEqual({vrf1, vrf3}, self.handler._known_vrfs) def test_replay_adds_new_vrfs(self): - vrf1, vrf2 = 'vr0a1b2c3d-ddd', 'vr0a1b2c3d-eee' + vrf1, vrf2 = 'vr0a1b2c3d-dddd', 'vr0a1b2c3d-eeee' self.handler._known_vrfs = {vrf1} self.handler.replay_start() self.handler.handle_newlink(_make_vrf_msg(vrf1)) 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/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_evpn_db.py b/neutron/tests/unit/db/test_evpn_db.py index fe6cce6798f..44c01b06b01 100644 --- a/neutron/tests/unit/db/test_evpn_db.py +++ b/neutron/tests/unit/db/test_evpn_db.py @@ -145,6 +145,37 @@ def test_router_interface_add_without_advertise_host(self): ).one_or_none() self.assertIsNone(evpn_net) + def test_router_create_auto_vni(self): + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as router: + vni = router['router'][evpn_apidef.EVPN_VNI] + self.assertIsNotNone(vni) + self.assertGreater(vni, 0) + + def test_router_create_auto_vni_twice_distinct(self): + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as r1, \ + self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as r2: + self.assertNotEqual(r1['router'][evpn_apidef.EVPN_VNI], + r2['router'][evpn_apidef.EVPN_VNI]) + + def test_router_create_auto_vni_reuses_freed_slot(self): + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as router: + first_vni = router['router'][evpn_apidef.EVPN_VNI] + self._delete('routers', router['router']['id']) + + with self.router(as_admin=True, + arg_list=(evpn_apidef.EVPN_VNI,), + evpn_vni=0) as router: + self.assertEqual(first_vni, + router['router'][evpn_apidef.EVPN_VNI]) + def test_router_interface_remove_cleans_evpn_network(self): with self.router(as_admin=True, arg_list=(evpn_apidef.EVPN_VNI,), diff --git a/neutron/tests/unit/db/test_vni_vlan_allocator.py b/neutron/tests/unit/db/test_vni_vlan_allocator.py new file mode 100644 index 00000000000..99ab940d0d8 --- /dev/null +++ b/neutron/tests/unit/db/test_vni_vlan_allocator.py @@ -0,0 +1,151 @@ +# Copyright 2026 Red Hat, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from neutron_lib import context +from neutron_lib.db import api as db_api + +from neutron.db.models import vxlan_vlan_allocations as alloc_models +from neutron.db import vni_vlan_allocator +from neutron.services.evpn import exceptions as evpn_exc +from neutron.tests.unit import testlib_api + +load_tests = testlib_api.module_load_tests + +_PHYSNET = 'test-physnet' +_OTHER_PHYSNET = 'other-physnet' +_MIN_VNI = 1 +_MAX_VNI = 100 +_MIN_VLAN = 1 +_MAX_VLAN = 50 + + +class TestVNIVLANAllocator(testlib_api.SqlTestCase): + + def setUp(self): + super().setUp() + self.ctx = context.Context( + user_id=None, project_id=None, is_admin=True, overwrite=False) + self.allocator = vni_vlan_allocator.VNIVLANAllocator( + vni_exhausted_exc=evpn_exc.EVPNNoVniAvailable, + vlan_exhausted_exc=evpn_exc.EVPNNoVlanAvailable, + vni_in_use_exc=evpn_exc.EVPNVNIInUse, + ) + + def _allocate(self, min_vni=_MIN_VNI, max_vni=_MAX_VNI, + min_vlan=_MIN_VLAN, max_vlan=_MAX_VLAN, + physnet=_PHYSNET): + with db_api.CONTEXT_WRITER.using(self.ctx): + return self.allocator.allocate( + self.ctx, min_vni, max_vni, min_vlan, max_vlan, physnet) + + def _allocate_specific(self, vni, min_vlan=_MIN_VLAN, max_vlan=_MAX_VLAN, + physnet=_PHYSNET): + with db_api.CONTEXT_WRITER.using(self.ctx): + return self.allocator.allocate_specific_vni( + self.ctx, vni, min_vlan, max_vlan, physnet) + + def _deallocate(self, mapping_id): + with db_api.CONTEXT_WRITER.using(self.ctx): + self.allocator.deallocate(self.ctx, mapping_id) + + def _get_mapping(self, mapping_id): + with db_api.CONTEXT_READER.using(self.ctx): + return self.ctx.session.query( + alloc_models.VNIVLANMapping + ).filter_by(id=mapping_id).one_or_none() + + def _count_vni_allocations(self, physnet=_PHYSNET): + with db_api.CONTEXT_READER.using(self.ctx): + return self.ctx.session.query( + alloc_models.VNIAllocation + ).filter_by(physnet=physnet).count() + + def _count_vlan_allocations(self, physnet=_PHYSNET): + with db_api.CONTEXT_READER.using(self.ctx): + return self.ctx.session.query( + alloc_models.VLANAllocation + ).filter_by(physnet=physnet).count() + + def test_allocate_returns_valid_tuple(self): + mapping_id, vni, vlan_id = self._allocate() + self.assertIsNotNone(mapping_id) + self.assertGreaterEqual(vni, _MIN_VNI) + self.assertLessEqual(vni, _MAX_VNI) + self.assertGreaterEqual(vlan_id, _MIN_VLAN) + self.assertLessEqual(vlan_id, _MAX_VLAN) + + def test_allocate_creates_mapping_row(self): + mapping_id, vni, vlan_id = self._allocate() + mapping = self._get_mapping(mapping_id) + self.assertIsNotNone(mapping) + self.assertEqual(vni, mapping.vni_allocation.vni) + self.assertEqual(vlan_id, mapping.vlan_allocation.vlan_id) + + def test_allocate_sequential_distinct(self): + _, vni1, vlan1 = self._allocate() + _, vni2, vlan2 = self._allocate() + self.assertNotEqual(vni1, vni2) + self.assertNotEqual(vlan1, vlan2) + + def test_allocate_specific_vni_uses_requested_value(self): + mapping_id, vni, vlan_id = self._allocate_specific(42) + self.assertEqual(42, vni) + self.assertIsNotNone(mapping_id) + self.assertGreaterEqual(vlan_id, _MIN_VLAN) + + def test_allocate_specific_vni_duplicate_raises(self): + self._allocate_specific(42) + self.assertRaises( + evpn_exc.EVPNVNIInUse, self._allocate_specific, 42) + + def test_allocate_vni_exhausted_raises(self): + self._allocate(min_vni=1, max_vni=1) + self.assertRaises( + evpn_exc.EVPNNoVniAvailable, + self._allocate, min_vni=1, max_vni=1) + + def test_allocate_vlan_exhausted_raises(self): + self._allocate(min_vlan=1, max_vlan=1) + self.assertRaises( + evpn_exc.EVPNNoVlanAvailable, + self._allocate, min_vlan=1, max_vlan=1) + + def test_deallocate_removes_all_rows(self): + mapping_id, _, _ = self._allocate() + self._deallocate(mapping_id) + + self.assertIsNone(self._get_mapping(mapping_id)) + self.assertEqual(0, self._count_vni_allocations()) + self.assertEqual(0, self._count_vlan_allocations()) + + def test_deallocate_nonexistent_is_safe(self): + self._deallocate(99999) + + def test_allocate_scoped_by_physnet(self): + mapping_a, vni_a, _ = self._allocate(min_vni=1, max_vni=1, + physnet=_PHYSNET) + mapping_b, vni_b, _ = self._allocate(min_vni=1, max_vni=1, + physnet=_OTHER_PHYSNET) + self.assertEqual(vni_a, vni_b) + self.assertNotEqual(mapping_a, mapping_b) + + def test_deallocate_then_reallocate_reuses_slot(self): + mapping_id, vni, vlan = self._allocate(min_vni=1, max_vni=1, + min_vlan=1, max_vlan=1) + self._deallocate(mapping_id) + _mapping_id2, vni2, vlan2 = self._allocate(min_vni=1, max_vni=1, + min_vlan=1, max_vlan=1) + self.assertEqual(vni, vni2) + self.assertEqual(vlan, vlan2) 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..894ca0317e2 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py +++ b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py @@ -412,6 +412,59 @@ def test_port_dead_with_port_already_dead(self): def test_port_dead_with_valid_tag(self): self._test_port_dead(cur_tag=1) + def test_port_dead_invalid_ofport_unassigned(self): + port = mock.Mock() + port.ofport = ovs_lib.UNASSIGNED_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_dead(port) + int_br.set_db_attribute.assert_not_called() + int_br.drop_port.assert_not_called() + + def test_port_dead_invalid_ofport_negative(self): + port = mock.Mock() + port.ofport = ovs_lib.INVALID_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_dead(port) + int_br.set_db_attribute.assert_not_called() + int_br.drop_port.assert_not_called() + + def test_port_alive_invalid_ofport_unassigned(self): + port = mock.Mock() + port.ofport = ovs_lib.UNASSIGNED_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_alive(port) + int_br.db_get_val.assert_not_called() + int_br.uninstall_flows.assert_not_called() + + def test_port_alive_invalid_ofport_negative(self): + port = mock.Mock() + port.ofport = ovs_lib.INVALID_OFPORT + port.port_name = 'tap1234' + with mock.patch.object(self.agent, 'int_br') as int_br: + self.agent.port_alive(port) + int_br.db_get_val.assert_not_called() + int_br.uninstall_flows.assert_not_called() + + def test_treat_vif_port_invalid_ofport_returns_false(self): + for ofport in (ovs_lib.UNASSIGNED_OFPORT, ovs_lib.INVALID_OFPORT, 0): + vif_port = mock.Mock() + vif_port.ofport = ofport + vif_port.vif_id = 'test-port-id' + with mock.patch.object( + self.agent, 'port_bound' + ) as port_bound, mock.patch.object( + self.agent, 'port_alive' + ) as port_alive: + result = self.agent.treat_vif_port( + vif_port, 'port-id', 'net-id', 'vxlan', + None, 100, True, [], 'compute:nova', False) + self.assertFalse(result) + port_bound.assert_not_called() + port_alive.assert_not_called() + def mock_scan_ports(self, vif_port_set=None, registered_ports=None, updated_ports=None, port_tags_dict=None, sync=False): if port_tags_dict is None: # Because empty dicts evaluate as False. @@ -1153,7 +1206,7 @@ def test_treat_vif_port_shut_down_port(self): "iface-id": "407a79e0-e0be-4b7d-92a6-513b2161011b", "vif_mac": "fa:16:3e:68:46:7b", "port_name": "qr-407a79e0-e0", - "ofport": -1, + "ofport": 10, "bridge_name": "br-int"}) with mock.patch.object( self.agent.plugin_rpc, 'update_device_down' diff --git a/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..336edc36161 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -1033,7 +1033,6 @@ def test_create_network_create_localnet_port_physical_network_type(self): ovn_const.LSP_OPTIONS_MCAST_FLOOD: ovs_conf.get_igmp_flood(), ovn_const.LSP_OPTIONS_LOCALNET_LEARN_FDB: 'false'}, - tag=2, tag_request=2, type='localnet') @@ -3700,7 +3699,7 @@ def test_update_network_segmentation_id(self): # Assert the tag was changed in the OVN database expected_call = mock.call( lport_name=ovn_utils.ovn_provnet_port_name(segment['id']), - tag=new_vlan_tag, if_exists=True) + tag_request=new_vlan_tag, if_exists=True) self.nb_ovn.set_lswitch_port.assert_has_calls([expected_call]) @mock.patch.object(wsgi_utils, 'get_api_worker_id', return_value=1) @@ -4027,7 +4026,6 @@ def test_create_segment_create_localnet_port(self): ovn_const.LSP_OPTIONS_MCAST_FLOOD: ovs_conf.get_igmp_flood(), ovn_const.LSP_OPTIONS_LOCALNET_LEARN_FDB: 'false'}, - tag=200, tag_request=200, type='localnet') ovn_nb_api.create_lswitch_port.reset_mock() @@ -4048,7 +4046,6 @@ def test_create_segment_create_localnet_port(self): ovn_const.LSP_OPTIONS_MCAST_FLOOD: ovs_conf.get_igmp_flood(), ovn_const.LSP_OPTIONS_LOCALNET_LEARN_FDB: 'false'}, - tag=300, tag_request=300, type='localnet') segments = segments_db.get_network_segments( @@ -5474,8 +5471,7 @@ def test_delete_virtual_port_parent(self): 'type': ovn_const.LSP_TYPE_VIRTUAL, 'options': {ovn_const.LSP_OPTIONS_VIRTUAL_PARENTS_KEY: parent['id']}}) - self.nb_idl.ls_get.return_value.execute.return_value = ( - mock.Mock(ports=[fake_row])) + self.nb_idl.lookup.return_value = mock.Mock(ports=[fake_row]) self.mech_driver._ovn_client.delete_port(self.context, parent['id']) self.nb_idl.unset_lswitch_port_to_virtual_type.assert_called_once_with( 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/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/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/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/deploy_rootwrap.sh b/tools/deploy_rootwrap.sh index c7f41c3d807..105fb3f8ec1 100755 --- a/tools/deploy_rootwrap.sh +++ b/tools/deploy_rootwrap.sh @@ -63,6 +63,6 @@ if [[ "$OS_SUDO_TESTING" = "1" ]]; then sed -i 's/use_syslog=False/use_syslog=True/g' ${dst_conf} sed -i 's/syslog_log_level=ERROR/syslog_log_level=DEBUG/g' ${dst_conf} sed -i 's/daemon_timeout=600/daemon_timeout=7800/g' ${dst_conf} - cp -p ${neutron_path}/neutron/tests/contrib/testing.filters \ + cp -p ${neutron_path}/tools/rootwrap/testing.filters \ ${filters_path}/ fi diff --git a/neutron/tests/contrib/testing.filters b/tools/rootwrap/testing.filters similarity index 100% rename from neutron/tests/contrib/testing.filters rename to tools/rootwrap/testing.filters diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index aa702808aa2..0187f86b78c 100644 --- a/zuul.d/base.yaml +++ b/zuul.d/base.yaml @@ -51,6 +51,7 @@ Q_BUILD_OVS_FROM_GIT: True MEMORY_TRACKER: True INSTALL_OVN: True + ENABLE_FRR: True devstack_services: # Ignore any default set by devstack. Emit a "disable_all_services". base: false diff --git a/zuul.d/job-templates.yaml b/zuul.d/job-templates.yaml index a48e0144864..3b1f4277175 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -119,6 +119,7 @@ - neutron-tempest-plugin-ovn-with-ovn-metadata-agent - neutron-ovn-grenade-multinode-ovn-metadata-agent - ironic-tempest-ovn-uefi-ipmi-pxe + - neutron-ovn-bgp-tempest-multinode experimental: jobs: *neutron-periodic-jobs diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index dc36066aac5..e5200629622 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -66,14 +66,6 @@ - ^zuul.d/rally.yaml - ^zuul.d/tempest-multinode.yaml - ^zuul.d/tempest-singlenode.yaml - - neutron-ovn-bgp-tempest-multinode: - # neutron-ovn-bgp-tempest-multinode inherits irrelevant-files from - # its parent job neutron-tempest-plugin-ovn, defined in - # neutron-tempest-plugin, which are identical to this file's - # ovn-irrelevant-files - voting: false - attempts: 1 - gate: jobs: - neutron-functional