Version in base suite: 26.0.0-9 Version in overlay suite: 26.0.0-9+deb13u1 Base version: neutron_26.0.0-9+deb13u1 Target version: neutron_26.0.3-0+deb13u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/n/neutron/neutron_26.0.0-9+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/n/neutron/neutron_26.0.3-0+deb13u2.dsc debian/changelog | 16 debian/control | 1 debian/neutron-api-uwsgi.ini | 5 debian/patches/Add_state_reporting_back_to_metadata_agents.patch | 314 --- debian/patches/Fix_LoopingCallBase_argument_issue.patch | 40 debian/patches/OSSA-2026-021_Fix_port_RBAC_policies_to_require_network_ownership.patch | 986 ++++++++++ debian/patches/series | 3 doc/source/admin/config-qos-min-bw.rst | 12 doc/source/admin/config-qos.rst | 92 doc/source/admin/ovn/l3_scheduler.rst | 10 doc/source/admin/ovn/troubleshooting.rst | 15 doc/source/ovn/index.rst | 1 doc/source/ovn/ovn_agent.rst | 2 doc/source/ovn/virtual_ips.rst | 193 + neutron/agent/common/placement_report.py | 27 neutron/agent/dhcp/agent.py | 22 neutron/agent/linux/openvswitch_firewall/firewall.py | 2 neutron/agent/metadata/agent.py | 145 - neutron/agent/metadata/proxy_base.py | 9 neutron/agent/ovn/agent/ovn_neutron_agent.py | 30 neutron/agent/ovn/extensions/metadata.py | 4 neutron/agent/ovn/metadata/agent.py | 17 neutron/agent/ovn/metadata/server_socket.py | 141 - neutron/agent/ovsdb/native/helpers.py | 2 neutron/cmd/eventlet/agents/metadata.py | 24 neutron/common/loopingcall.py | 211 ++ neutron/common/metadata.py | 144 + neutron/common/ovn/acl.py | 17 neutron/common/ovn/constants.py | 7 neutron/common/ovn/extensions.py | 2 neutron/common/ovn/utils.py | 14 neutron/common/utils.py | 7 neutron/common/wsgi_utils.py | 5 neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py | 10 neutron/conf/policies/l3_conntrack_helper.py | 16 neutron/conf/policies/local_ip_association.py | 12 neutron/db/allowedaddresspairs_db.py | 16 neutron/db/db_base_plugin_v2.py | 35 neutron/db/external_net_db.py | 11 neutron/db/l3_hamode_db.py | 3 neutron/ipam/requests.py | 36 neutron/ipam/subnet_alloc.py | 18 neutron/objects/subnet.py | 16 neutron/pecan_wsgi/hooks/policy_enforcement.py | 9 neutron/plugins/ml2/drivers/helpers.py | 10 neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/base_oskenapp.py | 5 neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_tun.py | 88 neutron/plugins/ml2/drivers/openvswitch/agent/ovs_dvr_neutron_agent.py | 2 neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py | 2 neutron/plugins/ml2/drivers/ovn/agent/neutron_agent.py | 24 neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py | 59 neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py | 34 neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py | 60 neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/qos.py | 49 neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py | 20 neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py | 86 neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 71 neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py | 131 + neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py | 9 neutron/plugins/ml2/drivers/type_tunnel.py | 44 neutron/plugins/ml2/drivers/type_vlan.py | 47 neutron/plugins/ml2/managers.py | 24 neutron/plugins/ml2/plugin.py | 37 neutron/privileged/agent/ovsdb/native/helpers.py | 33 neutron/services/auto_allocate/db.py | 6 neutron/services/externaldns/drivers/designate/driver.py | 8 neutron/services/logapi/common/sg_callback.py | 5 neutron/services/logapi/drivers/manager.py | 10 neutron/services/logapi/drivers/ovn/driver.py | 84 neutron/services/ovn_l3/plugin.py | 4 neutron/services/trunk/drivers/ovn/trunk_driver.py | 48 neutron/services/trunk/plugin.py | 10 neutron/tests/common/test_db_base_plugin_v2.py | 47 neutron/tests/fullstack/test_agent_bandwidth_report.py | 9 neutron/tests/fullstack/test_l3_agent.py | 5 neutron/tests/functional/agent/l3/test_dvr_router.py | 2 neutron/tests/functional/agent/ovn/agent/test_ovn_neutron_agent.py | 38 neutron/tests/functional/agent/ovn/metadata/test_metadata_agent.py | 13 neutron/tests/functional/agent/ovsdb/native/test_connection.py | 15 neutron/tests/functional/agent/ovsdb/native/test_helpers.py | 60 neutron/tests/functional/agent/test_dhcp_agent.py | 40 neutron/tests/functional/agent/test_l2_ovs_agent.py | 9 neutron/tests/functional/base.py | 7 neutron/tests/functional/pecan_wsgi/test_hooks.py | 6 neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py | 31 neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py | 72 neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl.py | 51 neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py | 155 + neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py | 162 + neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py | 289 ++ neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py | 27 neutron/tests/functional/plugins/ml2/drivers/test_type_tunnel.py | 92 neutron/tests/functional/services/ovn_l3/test_plugin.py | 2 neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py | 54 neutron/tests/unit/agent/dhcp/test_agent.py | 3 neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py | 43 neutron/tests/unit/agent/metadata/test_agent.py | 18 neutron/tests/unit/common/ovn/test_acl.py | 6 neutron/tests/unit/common/ovn/test_utils.py | 38 neutron/tests/unit/conf/policies/test_l3_conntrack_helper.py | 23 neutron/tests/unit/conf/policies/test_local_ip_association.py | 21 neutron/tests/unit/conf/policies/test_port.py | 331 ++- neutron/tests/unit/db/test_l3_db.py | 5 neutron/tests/unit/db/test_l3_hamode_db.py | 23 neutron/tests/unit/ipam/test_subnet_alloc.py | 31 neutron/tests/unit/plugins/ml2/drivers/base_type_tunnel.py | 16 neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_tun.py | 158 - neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py | 11 neutron/tests/unit/plugins/ml2/drivers/ovn/agent/test_neutron_agent.py | 131 + neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py | 56 neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py | 12 neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py | 33 neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py | 39 neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py | 49 neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py | 300 ++- neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py | 72 neutron/tests/unit/plugins/ml2/drivers/test_type_vlan.py | 8 neutron/tests/unit/plugins/ml2/extensions/test_dns_domain_keywords.py | 8 neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py | 8 neutron/tests/unit/plugins/ml2/test_plugin.py | 60 neutron/tests/unit/services/auto_allocate/test_db.py | 107 + neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py | 25 neutron/tests/unit/services/logapi/drivers/ovn/test_driver.py | 45 neutron/tests/unit/services/ovn_l3/test_plugin.py | 18 neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py | 69 neutron/tests/unit/services/trunk/test_plugin.py | 27 releasenotes/notes/block-metadata-port-IP-address-to-be-used-as-virtual-ip-by-ovn-driver-0d46fed7652fea7a.yaml | 8 releasenotes/notes/do-not-sync-OVN-ACLs-which-do-not-belongs-to-the-neutron-f0758ac56f8dd2d7.yaml | 9 releasenotes/notes/ovn-acl-with-address-set-89d4a6b6614b52c4.yaml | 10 releasenotes/notes/ovn-db-sync-gw-agent-cd049668511ac730.yaml | 7 releasenotes/notes/ovn-placement-delete-resource-provider-72c09b7df7238984.yaml | 7 releasenotes/notes/ovn-placement-init-config-6198f572e1dadcba.yaml | 8 releasenotes/notes/ovn-qos-fip-rule-priority-16ad3908790dfa7d.yaml | 8 releasenotes/notes/ovn-qos-max-bw-physical-networks-843dfce4a60fc38f.yaml | 7 tox.ini | 17 zuul.d/job-templates.yaml | 11 zuul.d/tempest-singlenode.yaml | 6 137 files changed, 5400 insertions(+), 1390 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp5gesdqyq/neutron_26.0.0-9+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp5gesdqyq/neutron_26.0.3-0+deb13u2.dsc: no acceptable signature found diff -Nru neutron-26.0.0/debian/changelog neutron-26.0.3/debian/changelog --- neutron-26.0.0/debian/changelog 2026-05-29 06:24:56.000000000 +0000 +++ neutron-26.0.3/debian/changelog 2026-06-05 09:00:14.000000000 +0000 @@ -1,3 +1,19 @@ +neutron (2:26.0.3-0+deb13u2) trixie-security; urgency=medium + + * New upstream point release. + * Removed patches applied upstream: + - Add_state_reporting_back_to_metadata_agents.patch + - Fix_LoopingCallBase_argument_issue.patch + * Add start-time=%t in neutron-api-uwsgi.ini. + * Add haproxy as runtime depends of neutron-ovn-agent. Thanks to Sakirnth + Nagarasa for the report (Closes: #1135272). + * CVE-2026-50266 / OSSA-2026-021: Neutron port RBAC policy bypass allows + project managers to set trusted device owners on shared networks. Added + upstream patch: Fix port RBAC policies to require network ownership + (Closes: #1138844). + + -- Thomas Goirand Fri, 05 Jun 2026 11:00:14 +0200 + neutron (2:26.0.0-9+deb13u1) trixie; urgency=medium * OSSA-2026-016: Neutron tagging policy bypass allows project readers to diff -Nru neutron-26.0.0/debian/control neutron-26.0.3/debian/control --- neutron-26.0.0/debian/control 2026-05-29 06:24:56.000000000 +0000 +++ neutron-26.0.3/debian/control 2026-06-05 09:00:14.000000000 +0000 @@ -307,6 +307,7 @@ Package: neutron-ovn-agent Architecture: all Depends: + haproxy, neutron-common (= ${source:Version}), sudo, ${misc:Depends}, diff -Nru neutron-26.0.0/debian/neutron-api-uwsgi.ini neutron-26.0.3/debian/neutron-api-uwsgi.ini --- neutron-26.0.0/debian/neutron-api-uwsgi.ini 2026-05-29 06:24:56.000000000 +0000 +++ neutron-26.0.3/debian/neutron-api-uwsgi.ini 2026-06-05 09:00:14.000000000 +0000 @@ -73,6 +73,11 @@ chdir = /var/lib/neutron wsgi-file = /usr/bin/neutron-api +# Provide unix time in seconds to have persistent value for all workers +# See https://docs.openstack.org/releasenotes/neutron/2025.1.html +# (search for "start-time"). +start-time=%t + # This is controled by the init script using the --http-socket # or using the --https thing. https will be activated if a file # /etc/neutron/ssl/private/*.pem is found. In both case, port 9292 diff -Nru neutron-26.0.0/debian/patches/Add_state_reporting_back_to_metadata_agents.patch neutron-26.0.3/debian/patches/Add_state_reporting_back_to_metadata_agents.patch --- neutron-26.0.0/debian/patches/Add_state_reporting_back_to_metadata_agents.patch 2026-05-29 06:24:56.000000000 +0000 +++ neutron-26.0.3/debian/patches/Add_state_reporting_back_to_metadata_agents.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,314 +0,0 @@ -Author: Brian Haley -Date: Wed, 11 Jun 2025 15:43:19 -0400 -Description: Add state reporting back to metadata agents - The call to initialize state reporting was removed - in [0], which resulted in the metadata-agent for - ML2/OVS being reported as Not Alive (XXX) when - 'openstack network agent list' is run, even though - it is running properly. - . - The second part of this fix is that we need force the - metadata agents to run using the oslo.service threading - backend, not the eventlet one. Otherwise the background - thread sending RPC updates to neutron-server will never - start. - . - This also needs to be backported to stable/2025.1 as the - original change was merged there, but since oslo.service - 4.2.0 does not support the threading backend, we will - need to implement private versions of LoopingCallBase - and FixedIntervalLoopingCall there. - . - [0] https://review.opendev.org/c/openstack/neutron/+/942916 - . -Bug: https://launchpad.net/bugs/2112492 -Change-Id: I4399b6aca1984003e0b564552cc1907425241b9d -Origin: upstream, https://review.opendev.org/c/openstack/neutron/+/952561 -Last-Update: 2025-08-13 - -diff --git a/neutron/agent/metadata/agent.py b/neutron/agent/metadata/agent.py -index 3448851..a6232fe 100644 ---- a/neutron/agent/metadata/agent.py -+++ b/neutron/agent/metadata/agent.py -@@ -22,7 +22,6 @@ - from neutron_lib import context - from oslo_config import cfg - from oslo_log import log as logging --from oslo_service import loopingcall - from oslo_utils import encodeutils - import requests - import webob -@@ -34,6 +33,7 @@ - from neutron.agent.metadata import proxy_base - from neutron.agent import rpc as agent_rpc - from neutron.common import ipv6_utils -+from neutron.common import loopingcall - from neutron.common import utils as common_utils - - -@@ -335,4 +335,5 @@ - self._server = socketserver.ThreadingUnixStreamServer( - file_socket, MetadataProxyHandler) - MetadataProxyHandler._conf = self.conf -+ self._init_state_reporting() - self._server.serve_forever() -diff --git a/neutron/common/loopingcall.py b/neutron/common/loopingcall.py -new file mode 100644 -index 0000000..fa8a647 ---- /dev/null -+++ b/neutron/common/loopingcall.py -@@ -0,0 +1,211 @@ -+# Copyright (C) 2025 Red Hat, Inc. -+# -+# Licensed under the Apache License, Version 2.0 (the "License"); -+# you may not use this file except in compliance with the License. -+# You may obtain a copy of the License at -+# -+# http://www.apache.org/licenses/LICENSE-2.0 -+# -+# Unless required by applicable law or agreed to in writing, software -+# distributed under the License is distributed on an "AS IS" BASIS, -+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or -+# implied. -+# See the License for the specific language governing permissions and -+# limitations under the License. -+# NOTE(ralonsoh): this is the implementation of ``FixedIntervalLoopingCall`` -+# and all needed resources done in ``oslo.service`` 4.2.0, in the ``threading`` -+# backend. Because this code is not going to be backported or available in -+# 2025.1. -+ -+import sys -+import threading -+ -+import futurist -+from oslo_log import log as logging -+from oslo_utils import reflection -+from oslo_utils import timeutils -+ -+from neutron._i18n import _ -+ -+ -+LOG = logging.getLogger(__name__) -+ -+ -+class FutureEvent: -+ """A simple event object that can carry a result or an exception.""" -+ -+ def __init__(self): -+ self._event = threading.Event() -+ self._result = None -+ self._exc_info = None -+ -+ def send(self, result): -+ self._result = result -+ self._event.set() -+ -+ def send_exception(self, exc_type, exc_value, tb): -+ self._exc_info = (exc_type, exc_value, tb) -+ self._event.set() -+ -+ def wait(self, timeout=None): -+ flag = self._event.wait(timeout) -+ -+ if not flag: -+ raise RuntimeError(_('Timed out waiting for event')) -+ -+ if self._exc_info: -+ exc_type, exc_value, tb = self._exc_info -+ raise exc_value.with_traceback(tb) -+ return self._result -+ -+ -+class LoopingCallDone(Exception): -+ """Exception to break out and stop a LoopingCallBase. -+ -+ The function passed to a looping call may raise this exception to -+ break out of the loop normally. An optional return value may be -+ provided; this value will be returned by LoopingCallBase.wait(). -+ """ -+ -+ def __init__(self, retvalue=True): -+ """:param retvalue: Value that LoopingCallBase.wait() should return.""" -+ self.retvalue = retvalue -+ -+ -+def _safe_wrapper(f, kind, func_name): -+ """Wrapper that calls the wrapped function and logs errors as needed.""" -+ -+ def func(*args, **kwargs): -+ try: -+ return f(*args, **kwargs) -+ except LoopingCallDone: -+ raise # Let the outer handler process this -+ except Exception: -+ LOG.error('%(kind)s %(func_name)r failed', -+ {'kind': kind, 'func_name': func_name}, -+ exc_info=True) -+ return 0 -+ -+ return func -+ -+ -+class LoopingCallBase: -+ KIND = _("Unknown looping call") -+ RUN_ONLY_ONE_MESSAGE = _( -+ "A looping call can only run one function at a time") -+ -+ def __init__(self, *args, f=None, **kwargs): -+ self.args = args -+ self.kwargs = kwargs -+ self.f = f -+ self._future = None -+ self.done = None -+ self._abort = threading.Event() # When set, the loop stops -+ -+ @property -+ def _running(self): -+ return not self._abort.is_set() -+ -+ def stop(self): -+ if self._running: -+ self._abort.set() -+ -+ def wait(self): -+ """Wait for the looping call to complete and return its result.""" -+ return self.done.wait() -+ -+ def _on_done(self, future): -+ self._future = None -+ -+ def _sleep(self, timeout): -+ # Instead of eventlet.sleep, we wait on the abort event for timeout -+ # seconds. -+ self._abort.wait(timeout) -+ -+ def _start(self, idle_for, initial_delay=None, stop_on_exception=True): -+ """Start the looping call. -+ -+ :param idle_for: Callable taking two arguments (last result, -+ elapsed time) and returning how long to idle. -+ :param initial_delay: Delay (in seconds) before starting the -+ loop. -+ :param stop_on_exception: Whether to stop on exception. -+ :returns: A FutureEvent instance. -+ """ -+ -+ if self._future is not None: -+ raise RuntimeError(self.RUN_ONLY_ONE_MESSAGE) -+ -+ self.done = FutureEvent() -+ self._abort.clear() -+ -+ def _run_loop(): -+ kind = self.KIND -+ func_name = reflection.get_callable_name(self.f) -+ func = self.f if stop_on_exception else _safe_wrapper(self.f, kind, -+ func_name) -+ if initial_delay: -+ self._sleep(initial_delay) -+ try: -+ watch = timeutils.StopWatch() -+ -+ while self._running: -+ watch.restart() -+ result = func(*self.args, **self.kwargs) -+ watch.stop() -+ -+ if not self._running: -+ break -+ -+ idle = idle_for(result, watch.elapsed()) -+ LOG.debug( -+ '%(kind)s %(func_name)r sleeping for %(idle).02f' -+ ' seconds', -+ {'func_name': func_name, 'idle': idle, 'kind': kind}) -+ self._sleep(idle) -+ except LoopingCallDone as e: -+ self.done.send(e.retvalue) -+ except Exception: -+ exc_info = sys.exc_info() -+ try: -+ LOG.error('%(kind)s %(func_name)r failed', -+ {'kind': kind, 'func_name': func_name}, -+ exc_info=exc_info) -+ self.done.send_exception(*exc_info) -+ finally: -+ del exc_info -+ return -+ else: -+ self.done.send(True) -+ -+ # Use futurist's ThreadPoolExecutor to run the loop in a background -+ # thread. -+ executor = futurist.ThreadPoolExecutor(max_workers=1) -+ self._future = executor.submit(_run_loop) -+ self._future.add_done_callback(self._on_done) -+ return self.done -+ -+ # NOTE: _elapsed() is a thin wrapper for StopWatch.elapsed() -+ def _elapsed(self, watch): -+ return watch.elapsed() -+ -+ -+class FixedIntervalLoopingCall(LoopingCallBase): -+ """A fixed interval looping call.""" -+ RUN_ONLY_ONE_MESSAGE = _( -+ "A fixed interval looping call can only run one function at a time") -+ KIND = _('Fixed interval looping call') -+ -+ def start(self, interval, initial_delay=None, stop_on_exception=True): -+ def _idle_for(result, elapsed): -+ delay = round(elapsed - interval, 2) -+ if delay > 0: -+ func_name = reflection.get_callable_name(self.f) -+ LOG.warning( -+ 'Function %(func_name)r run outlasted interval by' -+ ' %(delay).2f sec', -+ {'func_name': func_name, 'delay': delay}) -+ return -delay if delay < 0 else 0 -+ -+ return self._start(_idle_for, initial_delay=initial_delay, -+ stop_on_exception=stop_on_exception) -diff --git a/neutron/tests/unit/agent/metadata/test_agent.py b/neutron/tests/unit/agent/metadata/test_agent.py -index b7cad50..83343f3 100644 ---- a/neutron/tests/unit/agent/metadata/test_agent.py -+++ b/neutron/tests/unit/agent/metadata/test_agent.py -@@ -12,6 +12,7 @@ - # License for the specific language governing permissions and limitations - # under the License. - -+import socketserver - from unittest import mock - - import ddt -@@ -393,7 +394,7 @@ - self.cfg_p = mock.patch.object(agent, 'cfg') - self.cfg = self.cfg_p.start() - looping_call_p = mock.patch( -- 'oslo_service.loopingcall.FixedIntervalLoopingCall') -+ 'neutron.common.loopingcall.FixedIntervalLoopingCall') - self.looping_mock = looping_call_p.start() - self.cfg.CONF.metadata_proxy_socket = '/the/path' - self.cfg.CONF.metadata_workers = 0 -@@ -435,6 +436,21 @@ - agent.UnixDomainMetadataProxy(mock.Mock()) - unlink.assert_called_once_with('/the/path') - -+ @mock.patch.object(agent, 'MetadataProxyHandler') -+ @mock.patch.object(socketserver, 'ThreadingUnixStreamServer') -+ @mock.patch.object(fileutils, 'ensure_tree') -+ def test_run(self, ensure_dir, server, handler): -+ p = agent.UnixDomainMetadataProxy(self.cfg.CONF) -+ p.run() -+ -+ ensure_dir.assert_called_once_with('/the', mode=0o755) -+ server.assert_has_calls([ -+ mock.call('/the/path', mock.ANY), -+ mock.call().serve_forever()]) -+ self.looping_mock.assert_called_once_with(p._report_state) -+ self.looping_mock.return_value.start.assert_called_once_with( -+ interval=mock.ANY) -+ - def test_main(self): - with mock.patch.object(agent, 'UnixDomainMetadataProxy') as proxy: - with mock.patch.object(metadata_agent, 'config') as config: diff -Nru neutron-26.0.0/debian/patches/Fix_LoopingCallBase_argument_issue.patch neutron-26.0.3/debian/patches/Fix_LoopingCallBase_argument_issue.patch --- neutron-26.0.0/debian/patches/Fix_LoopingCallBase_argument_issue.patch 2026-05-29 06:24:56.000000000 +0000 +++ neutron-26.0.3/debian/patches/Fix_LoopingCallBase_argument_issue.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,40 +0,0 @@ -Author: Brian Haley -Date: Sun, 22 Jun 2025 16:23:25 -0400 -Description: Fix LoopingCallBase argument issue - I changed the argument order for LoopingCallBase based - on a pep8 failure, but it led to the code not working. - Changed caller to correctly specify the argument using - the 'f' keyword to fix the issue. - . -Bug: https://launchpad.net/bugs/2112492 -Change-Id: Iceac2354a669939435c1ed0d32294abc56462f98 -Signed-off-by: Brian Haley -Origin: upstream, https://review.opendev.org/c/openstack/neutron/+/953064 -Last-Update: 2025-07-01 - -Index: neutron/neutron/agent/metadata/agent.py -=================================================================== ---- neutron.orig/neutron/agent/metadata/agent.py -+++ neutron/neutron/agent/metadata/agent.py -@@ -306,7 +306,7 @@ class UnixDomainMetadataProxy(proxy_base - report_interval = cfg.CONF.AGENT.report_interval - if report_interval: - self.heartbeat = loopingcall.FixedIntervalLoopingCall( -- self._report_state) -+ f=self._report_state) - self.heartbeat.start(interval=report_interval) - - def _report_state(self): -Index: neutron/neutron/tests/unit/agent/metadata/test_agent.py -=================================================================== ---- neutron.orig/neutron/tests/unit/agent/metadata/test_agent.py -+++ neutron/neutron/tests/unit/agent/metadata/test_agent.py -@@ -447,7 +447,7 @@ class TestUnixDomainMetadataProxy(base.B - server.assert_has_calls([ - mock.call('/the/path', mock.ANY), - mock.call().serve_forever()]) -- self.looping_mock.assert_called_once_with(p._report_state) -+ self.looping_mock.assert_called_once_with(f=p._report_state) - self.looping_mock.return_value.start.assert_called_once_with( - interval=mock.ANY) - diff -Nru neutron-26.0.0/debian/patches/OSSA-2026-021_Fix_port_RBAC_policies_to_require_network_ownership.patch neutron-26.0.3/debian/patches/OSSA-2026-021_Fix_port_RBAC_policies_to_require_network_ownership.patch --- neutron-26.0.0/debian/patches/OSSA-2026-021_Fix_port_RBAC_policies_to_require_network_ownership.patch 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/debian/patches/OSSA-2026-021_Fix_port_RBAC_policies_to_require_network_ownership.patch 2026-06-05 09:00:14.000000000 +0000 @@ -0,0 +1,986 @@ +Author: Rodolfo Alonso Hernandez +Date: Wed, 27 May 2026 12:33:51 +0200 +Description: Fix port RBAC policies to require network ownership + Several default port policies that require network ownership incorrectly + included PROJECT_MANAGER. That rule checks the port project_id, not + network ownership, so any project manager could perform those actions + on shared/RBAC networks where they do not own the network. + . + Remove PROJECT_MANAGER from the affected create/update port policies + and rely on NET_OWNER_MEMBER or ADMIN_OR_NET_OWNER_MEMBER instead. + Project managers who own the network remain authorized through the + default Keystone role implication chain (manager implies member). + . + Conflicts: + neutron/tests/unit/conf/policies/test_port.py + . +Bug: https://launchpad.net/bugs/2152115 +Bug-Debian: https://bugs.debian.org/1138844 +Assisted-By: Claude Composer 2.5 +Signed-off-by: Rodolfo Alonso Hernandez +Change-Id: I4e258d28cdf72adcc13fc9d03749256c65881c45 +Origin: upstream, https://review.opendev.org/c/openstack/neutron/+/991523 +Last-Update: 2026-06-05 + +Index: neutron/neutron/conf/policies/base.py +=================================================================== +--- neutron.orig/neutron/conf/policies/base.py ++++ neutron/neutron/conf/policies/base.py +@@ -76,6 +76,7 @@ ADMIN_OR_PARENT_OWNER_READER = ( + # related to the "network owner" and network isn't really parent of the subnet + # or port. Because of that, using parent owner in those cases may be + # missleading for users so it's better to keep also "network owner" rules. ++NET_OWNER_MANAGER = 'role:manager and ' + RULE_NET_OWNER + NET_OWNER_MEMBER = 'role:member and ' + RULE_NET_OWNER + NET_OWNER_READER = 'role:reader and ' + RULE_NET_OWNER + ADMIN_OR_NET_OWNER_MEMBER = ( +Index: neutron/neutron/conf/policies/port.py +=================================================================== +--- neutron.orig/neutron/conf/policies/port.py ++++ neutron/neutron/conf/policies/port.py +@@ -86,7 +86,6 @@ rules = [ + check_str=neutron_policy.policy_or( + 'not rule:network_device', + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER + ), + scope_types=['project'], +@@ -105,7 +104,6 @@ rules = [ + name='create_port:mac_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER), + scope_types=['project'], + description='Specify ``mac_address`` attribute when creating a port', +@@ -122,7 +120,6 @@ rules = [ + name='create_port:fixed_ips', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER, + 'rule:shared'), + scope_types=['project'], +@@ -141,7 +138,6 @@ rules = [ + name='create_port:fixed_ips:ip_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER), + scope_types=['project'], + description='Specify IP address in ``fixed_ips`` when creating a port', +@@ -158,7 +154,6 @@ rules = [ + name='create_port:fixed_ips:subnet_id', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER, + 'rule:shared'), + scope_types=['project'], +@@ -177,7 +172,6 @@ rules = [ + name='create_port:port_security_enabled', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER), + scope_types=['project'], + description=( +@@ -244,7 +238,7 @@ rules = [ + name='create_port:allowed_address_pairs', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_NET_OWNER_MEMBER, +- base.PROJECT_MANAGER), ++ base.SERVICE), + scope_types=['project'], + description=( + 'Specify ``allowed_address_pairs`` ' +@@ -261,7 +255,7 @@ rules = [ + name='create_port:allowed_address_pairs:mac_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_NET_OWNER_MEMBER, +- base.PROJECT_MANAGER), ++ base.SERVICE), + scope_types=['project'], + description=( + 'Specify ``mac_address` of `allowed_address_pairs`` ' +@@ -278,7 +272,7 @@ rules = [ + name='create_port:allowed_address_pairs:ip_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_NET_OWNER_MEMBER, +- base.PROJECT_MANAGER), ++ base.SERVICE), + scope_types=['project'], + description=( + 'Specify ``ip_address`` of ``allowed_address_pairs`` ' +@@ -465,7 +459,6 @@ rules = [ + check_str=neutron_policy.policy_or( + 'not rule:network_device', + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER, + ), + scope_types=['project'], +@@ -484,7 +477,7 @@ rules = [ + name='update_port:mac_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER ++ base.NET_OWNER_MANAGER, + ), + scope_types=['project'], + description='Update ``mac_address`` attribute of a port', +@@ -501,7 +494,6 @@ rules = [ + name='update_port:fixed_ips', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER + ), + scope_types=['project'], +@@ -519,7 +511,6 @@ rules = [ + name='update_port:fixed_ips:ip_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER + ), + scope_types=['project'], +@@ -540,7 +531,6 @@ rules = [ + name='update_port:fixed_ips:subnet_id', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER, + 'rule:shared' + ), +@@ -563,7 +553,6 @@ rules = [ + name='update_port:port_security_enabled', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_SERVICE, +- base.PROJECT_MANAGER, + base.NET_OWNER_MEMBER + ), + scope_types=['project'], +@@ -622,7 +611,7 @@ rules = [ + name='update_port:allowed_address_pairs', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_NET_OWNER_MEMBER, +- base.PROJECT_MANAGER), ++ base.SERVICE), + scope_types=['project'], + description='Update ``allowed_address_pairs`` attribute of a port', + operations=ACTION_PUT, +@@ -636,7 +625,7 @@ rules = [ + name='update_port:allowed_address_pairs:mac_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_NET_OWNER_MEMBER, +- base.PROJECT_MANAGER), ++ base.SERVICE), + scope_types=['project'], + description=( + 'Update ``mac_address`` of ``allowed_address_pairs`` ' +@@ -653,7 +642,7 @@ rules = [ + name='update_port:allowed_address_pairs:ip_address', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_NET_OWNER_MEMBER, +- base.PROJECT_MANAGER), ++ base.SERVICE), + scope_types=['project'], + description=( + 'Update ``ip_address`` of ``allowed_address_pairs`` ' +Index: neutron/neutron/tests/unit/conf/policies/test_port.py +=================================================================== +--- neutron.orig/neutron/tests/unit/conf/policies/test_port.py ++++ neutron/neutron/tests/unit/conf/policies/test_port.py +@@ -45,6 +45,12 @@ class PortAPITestCase(base.PolicyBaseTes + 'project_id': self.alt_project_id, + 'network_id': self.alt_network['id'], + 'ext_parent_network_id': self.alt_network['id']} ++ # This port belongs to "project_id", but the network belongs to ++ # "alt_project_id". ++ self.target_net_alt_target = { ++ 'project_id': self.project_id, ++ 'network_id': self.alt_network['id'], ++ 'ext_parent_network_id': self.alt_network['id']} + + networks = { + self.network['id']: self.network, +@@ -60,6 +66,33 @@ class PortAPITestCase(base.PolicyBaseTes + 'neutron_lib.plugins.directory.get_plugin', + return_value=self.plugin_mock).start() + ++ def _assert_network_owner_policy(self, action, target=None, ++ alt_target=None, ++ target_net_alt_target=None): ++ target = target if target is not None else self.target ++ alt_target = alt_target if alt_target is not None else self.alt_target ++ if target_net_alt_target is None: ++ target_net_alt_target = self.target_net_alt_target ++ self.assertTrue(policy.enforce(self.context, action, target)) ++ self.assertRaises( ++ base_policy.PolicyNotAuthorized, ++ policy.enforce, self.context, action, alt_target) ++ self.assertRaises( ++ base_policy.PolicyNotAuthorized, ++ policy.enforce, self.context, action, target_net_alt_target) ++ ++ def _assert_denied_network_owner_policy(self, action, target=None, ++ alt_target=None, ++ target_net_alt_target=None): ++ target = target if target is not None else self.target ++ alt_target = alt_target if alt_target is not None else self.alt_target ++ if target_net_alt_target is None: ++ target_net_alt_target = self.target_net_alt_target ++ for test_target in (target, alt_target, target_net_alt_target): ++ self.assertRaises( ++ base_policy.PolicyNotAuthorized, ++ policy.enforce, self.context, action, test_target) ++ + + class SystemAdminTests(PortAPITestCase): + +@@ -827,57 +860,26 @@ class ProjectManagerTests(AdminTests): + target['device_owner'] = 'network:test' + alt_target = self.alt_target.copy() + alt_target['device_owner'] = 'network:test' +- self.assertTrue( +- policy.enforce(self.context, 'create_port:device_owner', target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:device_owner', +- alt_target) ++ target_net_alt_target = self.target_net_alt_target.copy() ++ target_net_alt_target['device_owner'] = 'network:test' ++ self._assert_network_owner_policy( ++ 'create_port:device_owner', target, alt_target, ++ target_net_alt_target) + + def test_create_port_with_mac_address(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:mac_address', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:mac_address', +- self.alt_target) ++ self._assert_network_owner_policy('create_port:mac_address') + + def test_create_port_with_fixed_ips(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:fixed_ips', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips', +- self.alt_target) ++ self._assert_network_owner_policy('create_port:fixed_ips') + + def test_create_port_with_fixed_ips_and_ip_address(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:fixed_ips:ip_address', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips:ip_address', +- self.alt_target) ++ self._assert_network_owner_policy('create_port:fixed_ips:ip_address') + + def test_create_port_with_fixed_ips_and_subnet_id(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:fixed_ips:subnet_id', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips:subnet_id', +- self.alt_target) ++ self._assert_network_owner_policy('create_port:fixed_ips:subnet_id') + + def test_create_port_with_port_security_enabled(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:port_security_enabled', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:port_security_enabled', +- self.alt_target) ++ self._assert_network_owner_policy('create_port:port_security_enabled') + + def test_create_port_with_binding_host_id(self): + self.assertRaises( +@@ -909,36 +911,15 @@ class ProjectManagerTests(AdminTests): + self.alt_target) + + def test_create_port_with_allowed_address_pairs(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:allowed_address_pairs', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs', +- self.alt_target) ++ self._assert_network_owner_policy('create_port:allowed_address_pairs') + + def test_create_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:allowed_address_pairs:mac_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:mac_address', +- self.alt_target) ++ self._assert_network_owner_policy( ++ 'create_port:allowed_address_pairs:mac_address') + + def test_create_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'create_port:allowed_address_pairs:ip_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:ip_address', +- self.alt_target) ++ self._assert_network_owner_policy( ++ 'create_port:allowed_address_pairs:ip_address') + + def test_create_port_with_hints(self): + self.assertRaises( +@@ -1067,57 +1048,26 @@ class ProjectManagerTests(AdminTests): + target['device_owner'] = 'network:test' + alt_target = self.alt_target.copy() + alt_target['device_owner'] = 'network:test' +- self.assertTrue( +- policy.enforce(self.context, 'update_port:device_owner', target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:device_owner', +- alt_target) ++ target_net_alt_target = self.target_net_alt_target.copy() ++ target_net_alt_target['device_owner'] = 'network:test' ++ self._assert_network_owner_policy( ++ 'update_port:device_owner', target, alt_target, ++ target_net_alt_target) + + def test_update_port_with_mac_address(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'update_port:mac_address', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:mac_address', +- self.alt_target) ++ self._assert_network_owner_policy('update_port:mac_address') + + def test_update_port_with_fixed_ips(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'update_port:fixed_ips', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:fixed_ips', +- self.alt_target) ++ self._assert_network_owner_policy('update_port:fixed_ips') + + def test_update_port_with_fixed_ips_and_ip_address(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'update_port:fixed_ips:ip_address', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:fixed_ips:ip_address', +- self.alt_target) ++ self._assert_network_owner_policy('update_port:fixed_ips:ip_address') + + def test_update_port_with_fixed_ips_and_subnet_id(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'update_port:fixed_ips:subnet_id', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:fixed_ips:subnet_id', +- self.alt_target) ++ self._assert_network_owner_policy('update_port:fixed_ips:subnet_id') + + def test_update_port_with_port_security_enabled(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'update_port:port_security_enabled', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:port_security_enabled', +- self.alt_target) ++ self._assert_network_owner_policy('update_port:port_security_enabled') + + def test_update_port_with_binding_host_id(self): + self.assertRaises( +@@ -1149,36 +1099,16 @@ class ProjectManagerTests(AdminTests): + self.alt_target) + + def test_update_port_with_allowed_address_pairs(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'update_port:allowed_address_pairs', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs', +- self.alt_target) ++ self._assert_network_owner_policy( ++ 'update_port:allowed_address_pairs:mac_address') + + def test_update_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'update_port:allowed_address_pairs:mac_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:mac_address', +- self.alt_target) ++ self._assert_network_owner_policy( ++ 'update_port:allowed_address_pairs:mac_address') + + def test_update_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertTrue( +- policy.enforce(self.context, +- 'update_port:allowed_address_pairs:ip_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:ip_address', +- self.alt_target) ++ self._assert_network_owner_policy( ++ 'update_port:allowed_address_pairs:ip_address') + + def test_update_port_data_plane_status(self): + self.assertRaises( +@@ -1231,183 +1161,8 @@ class ProjectMemberTests(ProjectManagerT + super().setUp() + self.context = self.project_member_ctx + +- def test_create_port_with_device_owner(self): +- target = self.target.copy() +- target['device_owner'] = 'network:test' +- alt_target = self.alt_target.copy() +- alt_target['device_owner'] = 'network:test' +- self.assertTrue( +- policy.enforce(self.context, 'create_port:device_owner', target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:device_owner', +- alt_target) +- +- def test_create_port_with_mac_address(self): +- self.assertTrue( +- policy.enforce(self.context, 'create_port:mac_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:mac_address', +- self.alt_target) +- +- def test_create_port_with_fixed_ips(self): +- self.assertTrue( +- policy.enforce(self.context, 'create_port:fixed_ips', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips', +- self.alt_target) +- +- def test_create_port_with_fixed_ips_and_ip_address(self): +- self.assertTrue( +- policy.enforce(self.context, 'create_port:fixed_ips:ip_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips:ip_address', +- self.alt_target) +- +- def test_create_port_with_fixed_ips_and_subnet_id(self): +- self.assertTrue( +- policy.enforce(self.context, 'create_port:fixed_ips:subnet_id', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips:subnet_id', +- self.alt_target) +- +- def test_create_port_with_port_security_enabled(self): +- self.assertTrue( +- policy.enforce(self.context, 'create_port:port_security_enabled', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:port_security_enabled', +- self.alt_target) +- +- def test_create_port_with_allowed_address_pairs(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'create_port:allowed_address_pairs', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs', +- self.alt_target) +- +- def test_create_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'create_port:allowed_address_pairs:mac_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:mac_address', +- self.alt_target) +- +- def test_create_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'create_port:allowed_address_pairs:ip_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:ip_address', +- self.alt_target) +- +- def test_update_port_with_device_owner(self): +- target = self.target.copy() +- target['device_owner'] = 'network:test' +- alt_target = self.alt_target.copy() +- alt_target['device_owner'] = 'network:test' +- self.assertTrue( +- policy.enforce(self.context, 'update_port:device_owner', target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:device_owner', +- alt_target) +- + def test_update_port_with_mac_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:mac_address', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:mac_address', +- self.alt_target) +- +- def test_update_port_with_fixed_ips(self): +- self.assertTrue( +- policy.enforce(self.context, 'update_port:fixed_ips', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:fixed_ips', +- self.alt_target) +- +- def test_update_port_with_fixed_ips_and_ip_address(self): +- self.assertTrue( +- policy.enforce(self.context, 'update_port:fixed_ips', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:fixed_ips:ip_address', +- self.alt_target) +- +- def test_update_port_with_fixed_ips_and_subnet_id(self): +- self.assertTrue( +- policy.enforce(self.context, 'update_port:fixed_ips', self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:fixed_ips:subnet_id', +- self.alt_target) +- +- def test_update_port_with_port_security_enabled(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'update_port:port_security_enabled', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:port_security_enabled', +- self.alt_target) +- +- def test_update_port_with_allowed_address_pairs(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'update_port:allowed_address_pairs', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs', +- self.alt_target) +- +- def test_update_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'update_port:allowed_address_pairs:mac_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:mac_address', +- self.alt_target) +- +- def test_update_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertTrue( +- policy.enforce( +- self.context, 'update_port:allowed_address_pairs:ip_address', +- self.target)) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:ip_address', +- self.alt_target) ++ self._assert_denied_network_owner_policy('update_port:mac_address') + + + class ProjectReaderTests(ProjectMemberTests): +@@ -1429,90 +1184,37 @@ class ProjectReaderTests(ProjectMemberTe + target['device_owner'] = 'network:test' + alt_target = self.alt_target.copy() + alt_target['device_owner'] = 'network:test' +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:device_owner', +- target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:device_owner', +- alt_target) ++ target_net_alt_target = self.target_net_alt_target.copy() ++ target_net_alt_target['device_owner'] = 'network:test' ++ self._assert_denied_network_owner_policy( ++ 'create_port:device_owner', target, alt_target, ++ target_net_alt_target) + + def test_create_port_with_mac_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:mac_address', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:mac_address', +- self.alt_target) ++ self._assert_denied_network_owner_policy('create_port:mac_address') + + def test_create_port_with_fixed_ips(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips', +- self.alt_target) ++ self._assert_denied_network_owner_policy('create_port:fixed_ips') + + def test_create_port_with_fixed_ips_and_subnet_id(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips:subnet_id', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:fixed_ips:subnet_id', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'create_port:fixed_ips:subnet_id') + + def test_create_port_with_port_security_enabled(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:port_security_enabled', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'create_port:port_security_enabled', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'create_port:port_security_enabled') + + def test_create_port_with_allowed_address_pairs(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'create_port:allowed_address_pairs') + + def test_create_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:mac_address', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:mac_address', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'create_port:allowed_address_pairs:mac_address') + + def test_create_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:ip_address', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:ip_address', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'create_port:fixed_ips:ip_address') + + def test_create_port_with_fixed_ips_and_ip_address(self): + self.assertRaises( +@@ -1555,96 +1257,41 @@ class ProjectReaderTests(ProjectMemberTe + target['device_owner'] = 'network:test' + alt_target = self.alt_target.copy() + alt_target['device_owner'] = 'network:test' +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:device_owner', +- target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:device_owner', +- alt_target) ++ target_net_alt_target = self.target_net_alt_target.copy() ++ target_net_alt_target['device_owner'] = 'network:test' ++ self._assert_denied_network_owner_policy( ++ 'update_port:device_owner', target, alt_target, ++ target_net_alt_target) ++ ++ def test_update_port_with_mac_address(self): ++ self._assert_denied_network_owner_policy('update_port:mac_address') + + def test_update_port_with_port_security_enabled(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:port_security_enabled', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, self.context, 'update_port:port_security_enabled', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'update_port:port_security_enabled') + + def test_update_port_with_allowed_address_pairs(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'update_port:allowed_address_pairs') + + def test_update_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:mac_address', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:mac_address', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'update_port:allowed_address_pairs:mac_address') + + def test_update_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:ip_address', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:ip_address', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'update_port:allowed_address_pairs:ip_address') + + def test_update_port_with_fixed_ips(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:fixed_ips', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:fixed_ips', +- self.alt_target) ++ self._assert_denied_network_owner_policy('update_port:fixed_ips') + + def test_update_port_with_fixed_ips_and_ip_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:fixed_ips:ip_address', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:fixed_ips:ip_address', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'update_port:fixed_ips:ip_address') + + def test_update_port_with_fixed_ips_and_subnet_id(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:fixed_ips:subnet_id', +- self.target) +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:fixed_ips:subnet_id', +- self.alt_target) ++ self._assert_denied_network_owner_policy( ++ 'update_port:fixed_ips:subnet_id') + + def test_update_port_with_binding_vnic_type(self): + self.assertRaises( +@@ -1730,25 +1377,22 @@ class ServiceRoleTests(PortAPITestCase): + 'create_port:binding:vnic_type', self.target)) + + def test_create_port_with_allowed_address_pairs(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs', +- self.target) ++ self.assertTrue( ++ policy.enforce( ++ self.context, 'create_port:allowed_address_pairs', ++ self.target)) + + def test_create_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:mac_address', +- self.alt_target) ++ self.assertTrue( ++ policy.enforce( ++ self.context, 'create_port:allowed_address_pairs:mac_address', ++ self.alt_target)) + + def test_create_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'create_port:allowed_address_pairs:ip_address', +- self.target) ++ self.assertTrue( ++ policy.enforce( ++ self.context, 'create_port:allowed_address_pairs:ip_address', ++ self.target)) + + def test_create_port_tags(self): + self.assertRaises( +@@ -1838,25 +1482,22 @@ class ServiceRoleTests(PortAPITestCase): + self.context, 'update_port:binding:vnic_type', self.target)) + + def test_update_port_with_allowed_address_pairs(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs', +- self.target) ++ self.assertTrue( ++ policy.enforce( ++ self.context, 'update_port:allowed_address_pairs', ++ self.target)) + + def test_update_port_with_allowed_address_pairs_and_mac_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:mac_address', +- self.target) ++ self.assertTrue( ++ policy.enforce( ++ self.context, 'update_port:allowed_address_pairs:mac_address', ++ self.target)) + + def test_update_port_with_allowed_address_pairs_and_ip_address(self): +- self.assertRaises( +- base_policy.PolicyNotAuthorized, +- policy.enforce, +- self.context, 'update_port:allowed_address_pairs:ip_address', +- self.target) ++ self.assertTrue( ++ policy.enforce( ++ self.context, 'update_port:allowed_address_pairs:ip_address', ++ self.target)) + + def test_update_port_data_plane_status(self): + self.assertRaises( +Index: neutron/releasenotes/notes/fix-port-device-owner-rbac-network-owner-manager-a4b8c2d1e3f56789.yaml +=================================================================== +--- /dev/null ++++ neutron/releasenotes/notes/fix-port-device-owner-rbac-network-owner-manager-a4b8c2d1e3f56789.yaml +@@ -0,0 +1,31 @@ ++--- ++security: ++ - | ++ Fixed overly permissive default RBAC policies for several port actions ++ that require network ownership. Those policies previously included ++ ``PROJECT_MANAGER``, which allowed any project manager to perform the ++ action even when not owning the network (for example, on shared/RBAC ++ networks). They now rely on ``NET_OWNER_MEMBER`` or ++ ``ADMIN_OR_NET_OWNER_MEMBER`` only. Project managers who own the ++ network remain authorized through the default Keystone role implication ++ chain (``manager`` implies ``member``). ++ ++ Affected actions: ``create_port:device_owner``, ++ ``update_port:device_owner``, ``create_port:mac_address``, ++ ``update_port:mac_address``, ``create_port:fixed_ips``, ++ ``create_port:fixed_ips:ip_address``, ``create_port:fixed_ips:subnet_id``, ++ ``update_port:fixed_ips``, ``update_port:fixed_ips:ip_address``, ++ ``update_port:fixed_ips:subnet_id``, ``create_port:port_security_enabled``, ++ ``update_port:port_security_enabled``, ++ ``create_port:allowed_address_pairs``, ++ ``create_port:allowed_address_pairs:mac_address``, ++ ``create_port:allowed_address_pairs:ip_address``, ++ ``update_port:allowed_address_pairs``, ++ ``update_port:allowed_address_pairs:mac_address``, and ++ ``update_port:allowed_address_pairs:ip_address``. ++upgrade: ++ - | ++ Default RBAC policies for the port actions listed above no longer ++ include ``PROJECT_MANAGER``. Deployments with customized policy files ++ should review and update those rules if they rely on the previous ++ behaviour. diff -Nru neutron-26.0.0/debian/patches/series neutron-26.0.3/debian/patches/series --- neutron-26.0.0/debian/patches/series 2026-05-29 06:24:56.000000000 +0000 +++ neutron-26.0.3/debian/patches/series 2026-06-05 09:00:14.000000000 +0000 @@ -1,4 +1,3 @@ fix-path-of-healthcheck_disable.patch -Add_state_reporting_back_to_metadata_agents.patch -Fix_LoopingCallBase_argument_issue.patch OSSA-2026-016_Fix_plural_policy_names_in_tagging_controller_and_floatingip_policy.patch +OSSA-2026-021_Fix_port_RBAC_policies_to_require_network_ownership.patch diff -Nru neutron-26.0.0/doc/source/admin/config-qos-min-bw.rst neutron-26.0.3/doc/source/admin/config-qos-min-bw.rst --- neutron-26.0.0/doc/source/admin/config-qos-min-bw.rst 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/doc/source/admin/config-qos-min-bw.rst 2026-04-15 04:51:52.000000000 +0000 @@ -325,13 +325,11 @@ This information is retrieved from the OVN SB database during the Neutron server initialization and when the "Chassis" registers are updated. -During the Neutron server initialization, a ``MaintenanceWorker`` thread will -call ``OvnSbSynchronizer.do_sync``, that will call -``OVNClientPlacementExtension.read_initial_chassis_config``. This method lists -all chassis and builds the resource provider information needed by Placement. -This information is stored in the "Chassis" registers, in -"external_ids:ovn-cms-options", with the same format as retrieved from the -local "Open_vSwitch" registers from each chassis. +The initial Placement configuration is retrieved when the Neutron API receives +a "Chassis" create event, that happens when the IDL is connected to the +database server. When a creation event is received, the Neutron API reads the +configuration, builds a ``PlacementState`` instance and sends it to the +Placement API. The second method to update the Placement information is when a "Chassis" registers is updated. The ``OVNClientPlacementExtension`` extension registers diff -Nru neutron-26.0.0/doc/source/admin/config-qos.rst neutron-26.0.3/doc/source/admin/config-qos.rst --- neutron-26.0.0/doc/source/admin/config-qos.rst 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/doc/source/admin/config-qos.rst 2026-04-15 04:51:52.000000000 +0000 @@ -55,15 +55,15 @@ .. table:: **Networking back ends, supported rules, and traffic direction** - ==================== ============================= ======================= =================== + ==================== ============================= ======================= ====================== Rule \\ back end Open vSwitch SR-IOV OVN - ==================== ============================= ======================= =================== + ==================== ============================= ======================= ====================== Bandwidth limit Egress \\ Ingress Egress (1) Egress \\ Ingress Packet rate limit Egress \\ Ingress - - - Minimum bandwidth Egress \\ Ingress (2) Egress \\ Ingress (2) - + Minimum bandwidth Egress \\ Ingress (2) Egress \\ Ingress (2) Egress \\ Ingress (2) Minimum packet rate - - - DSCP marking Egress - Egress - ==================== ============================= ======================= =================== + ==================== ============================= ======================= ====================== .. note:: @@ -74,12 +74,12 @@ .. table:: **Neutron backends, supported directions and enforcement types for Minimum Bandwidth rule** - ============================ ==================== ==================== ===== + ============================ ==================== ==================== ==================== Enforcement type \ Backend Open vSwitch SR-IOV OVN - ============================ ==================== ==================== ===== - Dataplane Egress (3) Egress (1) - - Placement Egress/Ingress (2) Egress/Ingress (2) - - ============================ ==================== ==================== ===== + ============================ ==================== ==================== ==================== + Dataplane Egress (3) Egress (1) Egress (4) + Placement Egress/Ingress (2) Egress/Ingress (2) Egress/Ingress (4) + ============================ ==================== ==================== ==================== .. note:: @@ -88,6 +88,7 @@ (3) Open vSwitch minimum bandwidth support is only implemented for egress direction and only for networks without tunneled traffic (only VLAN and flat network types). + (4) Since Zed .. note:: The SR-IOV agent does not support dataplane enforcement for ports with ``direct-physical`` vnic_type. However since Yoga the Placement @@ -137,11 +138,20 @@ L3 QoS support ~~~~~~~~~~~~~~ -The Neutron L3 services have implemented their own QoS extensions. Currently -only bandwidth limit QoS is provided. This is the L3 QoS extension list: +The Neutron L3 services have implemented their own QoS extensions. It is +possible to apply QoS policies to the floating IPs and to the routers; in the +last one (routers), the QoS policy will be applied on the gateway port. -* Floating IP bandwidth limit: the rate limit is applied per floating IP - address independently. +The rule support depends on the ML2 backend used. + + +ML2/OVS +------- + +The ML2/OVS L3 QoS supports only rate limit rules: + +* Floating IP bandwidth limit: rate limit is applied per floating IP address + independently. * Gateway IP bandwidth limit: the rate limit is applied in the router namespace gateway port (or in the SNAT namespace in case of DVR edge router). The rate @@ -149,7 +159,42 @@ will be limited. This rate limit does not apply to the floating IP traffic. -L3 services that provide QoS extensions: +ML2/OVN +------- + +The ML2/OVN L3 QoS supports both rate limit and DSCP rules. Both floating IP +and gateway port QoS policies are applied using the QoS metering rules. + +* Floating IP: the traffic should match the gateway port and the floating IP + address. The port can be centralized or distributed. + +* Gateway port: the traffic should match the gateway chassis port. + + +Because both floating IP and router can have QoS policies and both QoS policies +will match the same traffic, the floating IP policy has a higher priority and +will match this one only. In case of having port policies, that will apply to +the virtual machine private port, the private port rule will be applied first. +This is the QoS rules precedence and result: + +* Rate limit rules: if both private port QoS and router/floating IP rules are + applied, because both will be executed, the minimum rate limit value will + apply. + +* DSCP rules: if both private port QoS and router/floating IP rules are + applied, the router/floating IP DSCP mark will be applied on the egress + packet. + + +.. note:: + + In case of having both router and floating IP QoS policies, the floating IP + QoS policy has precedence always over the router QoS one. See `[OVN] Change + the OVN QoS rule priority for floating IPs `_. + + +L3 services that provide QoS extensions +--------------------------------------- * L3 router: implements the rate limit using `Linux TC `_. @@ -161,16 +206,31 @@ The following table shows the L3 service, the QoS supported extension, and traffic directions (from the VM point of view) for **bandwidth limiting**. -.. table:: **L3 service, supported extension, and traffic direction** +.. table:: **L3 service, supported extension and traffic direction for + bandwidth limiting** ==================== =================== =================== - Rule \\ L3 service L3 router OVN L3 + L3 service L3 router OVN L3 ==================== =================== =================== Floating IP Egress \\ Ingress Egress \\ Ingress Gateway IP Egress \\ Ingress Egress \\ Ingress ==================== =================== =================== +The following table shows the L3 service, the QoS supported extension, and +traffic directions (from the VM point of view) for **DSCP marking**. + +.. table:: **L3 service, supported extension and traffic direction for + DSCP marking** + + ==================== =========== ======== + L3 service L3 router OVN L3 + ==================== =========== ======== + Floating IP - Egress + Gateway IP - Egress + ==================== =========== ======== + + Configuration ~~~~~~~~~~~~~ diff -Nru neutron-26.0.0/doc/source/admin/ovn/l3_scheduler.rst neutron-26.0.3/doc/source/admin/ovn/l3_scheduler.rst --- neutron-26.0.0/doc/source/admin/ovn/l3_scheduler.rst 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/doc/source/admin/ovn/l3_scheduler.rst 2026-04-15 04:51:52.000000000 +0000 @@ -79,6 +79,16 @@ is changed, the ``Logical_Router_Port`` is bound to the new ``Chassis`` and could break any active sessions. +.. note:: + + Neutron does not support adding or modifying the ``Gateway_Chassis`` + registers with the "ovn-nbctl lrp-set-gateway-chassis" or the + "ovn-nbctl set" commands. Operators should not use these commands to + modify the ``Gateway_Chassis`` registers because Neutron will not be able + to re-schedule the corresponding ``Logical_Router_Port`` properly. + +.. end + Availability Zones (AZ) distribution ------------------------------------ diff -Nru neutron-26.0.0/doc/source/admin/ovn/troubleshooting.rst neutron-26.0.3/doc/source/admin/ovn/troubleshooting.rst --- neutron-26.0.0/doc/source/admin/ovn/troubleshooting.rst 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/doc/source/admin/ovn/troubleshooting.rst 2026-04-15 04:51:52.000000000 +0000 @@ -44,6 +44,8 @@ If you are using VM's as compute nodes make sure that you either lower the MTU size on the virtual interface or enable fragmentation on it. +.. _duplicated_ovn_agents: + Duplicated or deleted OVN agents -------------------------------- @@ -118,3 +120,16 @@ administrator should manually delete the orphaned OVN Southbound database register. Neutron will receive this event and will delete the associated OVN agents. + +Recovering from an OVN Chassis crash +------------------------------------ + +If the "ovn-controller" process, running on an OVN Chassis host, is killed +without being gracefully stopped or the host running the "ovn-controller" +process crashed, the corresponding "Chassis" and "Chassis_Private" registers +are not deleted. Before restarting the "ovn-controller" or "ovn-controller" +host after the crash, the administrator should follow the procedure described +in the :ref:`Duplicated or deleted OVN agents` section, +and manually delete stale "Chassis" and "Chassis_Private" registers. Neutron +will be notified of the deletion and will remove the corresponding +"Gateway_Chassis" registers in the OVN Northbound database. diff -Nru neutron-26.0.0/doc/source/ovn/index.rst neutron-26.0.3/doc/source/ovn/index.rst --- neutron-26.0.0/doc/source/ovn/index.rst 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/doc/source/ovn/index.rst 2026-04-15 04:51:52.000000000 +0000 @@ -13,3 +13,4 @@ ml2ovn_trace.rst faq/index.rst ovn_agent.rst + virtual_ips.rst diff -Nru neutron-26.0.0/doc/source/ovn/ovn_agent.rst neutron-26.0.3/doc/source/ovn/ovn_agent.rst --- neutron-26.0.0/doc/source/ovn/ovn_agent.rst 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/doc/source/ovn/ovn_agent.rst 2026-04-15 04:51:52.000000000 +0000 @@ -38,7 +38,7 @@ .. code-block:: console - [DEFAULT] + [agent] extensions = metadata diff -Nru neutron-26.0.0/doc/source/ovn/virtual_ips.rst neutron-26.0.3/doc/source/ovn/virtual_ips.rst --- neutron-26.0.0/doc/source/ovn/virtual_ips.rst 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/doc/source/ovn/virtual_ips.rst 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,193 @@ +.. _ovn_virtual_ips: + +Virtual IPs +=========== + +It is common practice to create an unbound port in a Neutron network to +allocate (reserve) an IP address that will be used as a Virtual IP (VIP) +by other ports in the same network. Such IP addresses are then added as +``allowed_address_pairs`` to the ports used by Virtual Machines. + +Applications, such as keepalived, running inside these Virtual Machines can +then configure the VIP on one of the VMs and move it between VMs dynamically. + +Implementation in OVN +~~~~~~~~~~~~~~~~~~~~~ + +For Virtual IP addresses to work properly in the OVN backend, Neutron needs to +mark the ``Logical Switch Port`` corresponding to the port with the Virtual IP +as ``virtual``. Neutron does this for ports that are unbound and have a fixed +IP address that is also configured in the ``allowed_address_pairs`` of any +other port in the same network. + +Limitations +~~~~~~~~~~~ + +* In the case when a Virtual IP address is going to be used in Virtual Machines + and configured as ``allowed_address_pairs``, it is necessary to also create + such an unbound port in Neutron in order to: + + * reserve that IP address for that use case so that it will not be later + allocated for another port in the same network as the fixed IP, + * let OVN know that this IP and ``Logical Switch Port`` is ``virtual`` so + that OVN can configure it accordingly. + +* A port created in Neutron in order to allocate virtual IP address has to be + ``unbound``, it can not be attached directly to any Virtual Machine. + +* Because of how Virtual IP addresses are implemented in the ML2/OVN backend, + the Virtual IP address must be set in the ``allowed_address_pairs`` of the VM + port as a single IP address (/32 for IPv4 or /128 for IPv6). + Setting a larger CIDR as ``allowed_address_pairs``, even if it contains + the Virtual IP address, will not mark the ``Logical Switch Port`` + corresponding to the port with that IP address as ``virtual``. + +* Another limitation is that setting an IP address that belongs to the + distributed metadata port in the same network as ``allowed_address_pairs`` is + not allowed. + + +Usage example +~~~~~~~~~~~~~ + +To use a Virtual IP address in Neutron, you need to create an unbound port in a +Neutron network and add the Virtual IP address to the ``allowed_address_pairs`` +of the port(s) that belong to the Virtual Machine(s). + +* Create an unbound port in the Neutron network to allocate the Virtual IP + address: + + .. code-block:: console + + $ openstack port create --network private virtual-ip-port + +-------------------------+-----------------------------------------------------------------------------------------------------+ + | Field | Value | + +-------------------------+-----------------------------------------------------------------------------------------------------+ + | admin_state_up | UP | + | allowed_address_pairs | | + | binding_host_id | | + | binding_profile | | + | binding_vif_details | | + | binding_vif_type | unbound | + | binding_vnic_type | normal | + | created_at | 2025-11-28T14:39:06Z | + | data_plane_status | None | + | description | | + | device_id | | + | device_owner | | + | device_profile | None | + | dns_assignment | | + | dns_domain | None | + | dns_name | None | + | extra_dhcp_opts | | + | fixed_ips | ip_address='10.0.0.20', subnet_id='866305cc-26db-48d7-8471-cbd267321b8b' | + | | ip_address='fde7:7c8e:8883:0:f816:3eff:feb6:559f', subnet_id='b8b0a413-6229-4c64-9d6e-65906a33b056' | + | hardware_offload_type | None | + | hints | | + | id | 3f078d1b-2f6e-41d8-99d7-70bc801f3979 | + | ip_allocation | None | + | mac_address | fa:16:3e:b6:55:9f | + | name | virtual-ip-port | + | network_id | c8e5e81c-d318-43f6-a45e-056f22a518e6 | + | numa_affinity_policy | None | + | port_security_enabled | True | + | project_id | b7907ac4c9794e5787a8d6bac0e5b80b | + | propagate_uplink_status | None | + | resource_request | None | + | revision_number | 1 | + | qos_network_policy_id | None | + | qos_policy_id | None | + | security_group_ids | 876d4c44-e2fd-48fc-bbd4-4bd295676a0e | + | status | DOWN | + | tags | | + | trunk_details | None | + | trusted | None | + | updated_at | 2025-11-28T14:39:06Z | + +-------------------------+-----------------------------------------------------------------------------------------------------+ + +* Create a Virtual Machine + + .. code-block:: console + + openstack server create --flavor m1.micro --image cirros-0.5.1-x86_64-disk --network private virtual-machine + +-------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------+ + | Field | Value | + +-------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------+ + | OS-DCF:diskConfig | MANUAL | + | OS-EXT-AZ:availability_zone | None | + | OS-EXT-SRV-ATTR:host | None | + | OS-EXT-SRV-ATTR:hostname | virtual-machine | + | OS-EXT-SRV-ATTR:hypervisor_hostname | None | + | OS-EXT-SRV-ATTR:instance_name | None | + | OS-EXT-SRV-ATTR:kernel_id | None | + | OS-EXT-SRV-ATTR:launch_index | None | + | OS-EXT-SRV-ATTR:ramdisk_id | None | + | OS-EXT-SRV-ATTR:reservation_id | None | + | OS-EXT-SRV-ATTR:root_device_name | None | + | OS-EXT-SRV-ATTR:user_data | None | + | OS-EXT-STS:power_state | N/A | + | OS-EXT-STS:task_state | scheduling | + | OS-EXT-STS:vm_state | building | + | OS-SRV-USG:launched_at | None | + | OS-SRV-USG:terminated_at | None | + | accessIPv4 | None | + | accessIPv6 | None | + | addresses | N/A | + | adminPass | QNkLbpeZ72LF | + | config_drive | None | + | created | 2025-11-28T14:41:22Z | + | description | None | + | flavor | description=, disk='1', ephemeral='0', extra_specs.hw_rng:allowed='True', id='m1.micro', is_disabled=, is_public='True', location=, name='m1.micro', | + | | original_name='m1.micro', ram='256', rxtx_factor=, swap='0', vcpus='1' | + | hostId | None | + | host_status | None | + | id | d2573702-b79c-46a3-bd7a-d8aa50341082 | + | image | cirros-0.5.1-x86_64-disk (7b920c82-0879-4526-9ee8-7e3b77e7fe28) | + | key_name | None | + | locked | None | + | locked_reason | None | + | name | virtual-machine | + | pinned_availability_zone | None | + | progress | None | + | project_id | b7907ac4c9794e5787a8d6bac0e5b80b | + | properties | None | + | scheduler_hints | | + | security_groups | name='default' | + | server_groups | None | + | status | BUILD | + | tags | | + | trusted_image_certificates | None | + | updated | 2025-11-28T14:41:22Z | + | user_id | d46c7955bea644c9a45e5d95bb462e29 | + | volumes_attached | | + +-------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------+ + + +* List ports of the Virtual Machine + + .. code-block:: console + + $ openstack port list --device-id d2573702-b79c-46a3-bd7a-d8aa50341082 + +--------------------------------------+------+-------------------+-----------------------------------------------------------------------------------------------------+--------+ + | ID | Name | MAC Address | Fixed IP Addresses | Status | + +--------------------------------------+------+-------------------+-----------------------------------------------------------------------------------------------------+--------+ + | 692c7f41-0497-4d4c-9766-3d71ffd229df | | fa:16:3e:b6:44:9a | ip_address='10.0.0.30', subnet_id='866305cc-26db-48d7-8471-cbd267321b8b' | ACTIVE | + | | | | ip_address='fde7:7c8e:8883:0:f816:3eff:feb6:449a', subnet_id='b8b0a413-6229-4c64-9d6e-65906a33b056' | | + +--------------------------------------+------+-------------------+-----------------------------------------------------------------------------------------------------+--------+ + +* Set the Virtual IP address as an allowed address pair to the port of the + Virtual Machine + + .. code-block:: console + + $ openstack port set --allowed-address ip-address=10.0.0.20 692c7f41-0497-4d4c-9766-3d71ffd229df + + +After these steps, the Virtual IP address will be available on the port of the +Virtual Machine. + +If a CIDR such as ``10.0.0.0/24`` is set in the ``allowed_address_pairs`` +instead of the IP address ``10.0.0.20``, then the ``Logical Switch Port`` +related to the port with IP address ``10.0.0.20`` would +not be marked as a Virtual IP address due to the limitations mentioned above. + diff -Nru neutron-26.0.0/neutron/agent/common/placement_report.py neutron-26.0.3/neutron/agent/common/placement_report.py --- neutron-26.0.0/neutron/agent/common/placement_report.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/common/placement_report.py 2026-04-15 04:51:52.000000000 +0000 @@ -92,11 +92,14 @@ hypervisor_rps, device_mappings, supported_vnic_types, - client): + client, + rp_deleted=None, + ): self._rp_bandwidths = rp_bandwidths self._rp_inventory_defaults = rp_inventory_defaults self._rp_pp = rp_pkt_processing self._rp_pp_inventory_defaults = rp_pkt_processing_inventory_defaults + self._rp_deleted = rp_deleted self._driver_uuid_namespace = driver_uuid_namespace self._agent_type = agent_type self._hypervisor_rps = hypervisor_rps @@ -176,14 +179,26 @@ 'parent_provider_uuid': agent_rp_uuid})) return rps + def _deferred_delete_device_rps(self): + rps = [] + if not self._rp_deleted: + return rps + + for device in self._rp_deleted: + hypervisor = self._hypervisor_rps[device] + rp_uuid = place_utils.device_resource_provider_uuid( + self._driver_uuid_namespace, + hypervisor['name'], + device) + rps.append( + DeferredCall(self._client.delete_resource_provider, rp_uuid)) + return rps + def deferred_create_resource_providers(self): agent_rps = self._deferred_create_agent_rps() device_rps = self._deferred_create_device_rps() - - rps = [] - rps.extend(agent_rps) - rps.extend(device_rps) - return rps + deleted_rps = self._deferred_delete_device_rps() + return agent_rps + device_rps + deleted_rps def _deferred_update_agent_rp_traits(self, traits_): agent_rp_traits = [] diff -Nru neutron-26.0.0/neutron/agent/dhcp/agent.py neutron-26.0.3/neutron/agent/dhcp/agent.py --- neutron-26.0.0/neutron/agent/dhcp/agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/dhcp/agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -24,6 +24,7 @@ from neutron_lib import constants from neutron_lib import context from neutron_lib import exceptions +from oslo_concurrency import lockutils from oslo_config import cfg from oslo_log import helpers as log_helpers from oslo_log import log as logging @@ -49,6 +50,8 @@ DEFAULT_PRIORITY = 255 +DHCP_PROCESS_GREENLET_MAX = 32 +DHCP_PROCESS_GREENLET_MIN = 8 DELETED_PORT_MAX_AGE = 86400 DHCP_READY_PORTS_SYNC_MAX = 64 @@ -132,7 +135,8 @@ self._process_monitor = external_process.ProcessMonitor( config=self.conf, resource_type='dhcp') - self._pool = eventlet.GreenPool(1) + self._pool_size = DHCP_PROCESS_GREENLET_MIN + self._pool = eventlet.GreenPool(size=self._pool_size) self._queue = queue.ResourceProcessingQueue() self._network_bulk_allocations = {} # Each dhcp-agent restart should trigger a restart of all @@ -444,6 +448,8 @@ # created before enabling dhcp can be updated. self.dhcp_ready_ports |= {p.id for p in network.ports} + self._resize_process_pool() + def disable_dhcp_helper(self, network_id): """Disable DHCP for a network known to the agent.""" network = self.cache.get_network_by_id(network_id) @@ -459,6 +465,8 @@ if self.call_driver('disable', network): self.cache.remove(network) + self._resize_process_pool() + def refresh_dhcp_helper(self, network_id): """Refresh or disable DHCP for a network depending on the current state of the network. @@ -572,6 +580,18 @@ return self.refresh_dhcp_helper(network.id) + @lockutils.synchronized('resize_greenpool') + def _resize_process_pool(self): + num_nets = len(self.cache.get_network_ids()) + pool_size = max([DHCP_PROCESS_GREENLET_MIN, + min([DHCP_PROCESS_GREENLET_MAX, num_nets])]) + if pool_size == self._pool_size: + return + LOG.info("Resizing dhcp processing queue green pool size to: %d", + pool_size) + self._pool.resize(pool_size) + self._pool_size = pool_size + def _process_loop(self): LOG.debug("Starting _process_loop") diff -Nru neutron-26.0.0/neutron/agent/linux/openvswitch_firewall/firewall.py neutron-26.0.3/neutron/agent/linux/openvswitch_firewall/firewall.py --- neutron-26.0.0/neutron/agent/linux/openvswitch_firewall/firewall.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/linux/openvswitch_firewall/firewall.py 2026-04-15 04:51:52.000000000 +0000 @@ -872,7 +872,7 @@ for sg_id in sg_to_delete: sec_group = self.sg_port_map.get_sg(sg_id) - if sec_group.members and sec_group.ports: + if sec_group.members or sec_group.ports: # sec_group is still in use continue diff -Nru neutron-26.0.0/neutron/agent/metadata/agent.py neutron-26.0.3/neutron/agent/metadata/agent.py --- neutron-26.0.0/neutron/agent/metadata/agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/metadata/agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -14,49 +14,25 @@ import io import socketserver -import urllib -import jinja2 from neutron_lib.agent import topics from neutron_lib import constants from neutron_lib import context from oslo_config import cfg from oslo_log import log as logging -from oslo_service import loopingcall -from oslo_utils import encodeutils -import requests import webob -from webob import exc as webob_exc from neutron._i18n import _ from neutron.agent.common import base_agent_rpc from neutron.agent.linux import utils as agent_utils from neutron.agent.metadata import proxy_base from neutron.agent import rpc as agent_rpc -from neutron.common import ipv6_utils -from neutron.common import utils as common_utils +from neutron.common import loopingcall +from neutron.common import metadata as common_metadata LOG = logging.getLogger(__name__) -RESPONSE = jinja2.Template("""HTTP/1.1 {{ http_code }} -Content-Type: text/plain; charset=UTF-8 -Connection: close -Content-Length: {{ len }} - - - - {{ title }} - - -

{{ body_title }}

- {{ body }}

- - - -""") -RESPONSE_LENGHT = 40 - class MetadataPluginAPI(base_agent_rpc.BasePluginApi): """Agent-side RPC for metadata agent-to-plugin interaction. @@ -78,98 +54,9 @@ version='1.0') -class MetadataProxyHandlerBaseSocketServer( - proxy_base.MetadataProxyHandlerBase): - @staticmethod - def _http_response(http_response, request): - _res = webob.Response( - body=http_response.content, - status=http_response.status_code, - content_type=http_response.headers['content-type'], - charset=http_response.encoding) - # NOTE(ralonsoh): there should be a better way to format the HTTP - # response, adding the HTTP version to the ``webob.Response`` - # output string. - out = request.http_version + ' ' + str(_res) - if (int(_res.headers['content-length']) == 0 and - _res.status_code == 200): - # Add 2 extra \r\n to the result. HAProxy is also expecting - # it even when the body is empty. - out += '\r\n\r\n' - return out.encode(http_response.encoding) - - def _proxy_request(self, instance_id, project_id, req): - headers = { - 'X-Forwarded-For': req.headers.get('X-Forwarded-For'), - 'X-Instance-ID': instance_id, - 'X-Tenant-ID': project_id, - 'X-Instance-ID-Signature': common_utils.sign_instance_id( - self.conf, instance_id) - } - - nova_host_port = ipv6_utils.valid_ipv6_url( - self.conf.nova_metadata_host, - self.conf.nova_metadata_port) - - url = urllib.parse.urlunsplit(( - self.conf.nova_metadata_protocol, - nova_host_port, - req.path_info, - req.query_string, - '')) - - disable_ssl_certificate_validation = self.conf.nova_metadata_insecure - if self.conf.auth_ca_cert and not disable_ssl_certificate_validation: - verify_cert = self.conf.auth_ca_cert - else: - verify_cert = not disable_ssl_certificate_validation - - client_cert = None - if self.conf.nova_client_cert and self.conf.nova_client_priv_key: - client_cert = (self.conf.nova_client_cert, - self.conf.nova_client_priv_key) - - try: - resp = requests.request(method=req.method, url=url, - headers=headers, - data=req.body, - cert=client_cert, - verify=verify_cert, - timeout=60) - except requests.ConnectionError: - msg = _('The remote metadata server is temporarily unavailable. ' - 'Please try again later.') - LOG.warning(msg) - title = '503 Service Unavailable' - length = RESPONSE_LENGHT + len(title) * 2 + len(msg) - reponse = RESPONSE.render(http_code=title, title=title, - body_title=title, body=title, len=length) - return encodeutils.to_utf8(reponse) - - if resp.status_code == 200: - return self._http_response(resp, req) - if resp.status_code == 403: - LOG.warning( - 'The remote metadata server responded with Forbidden. This ' - 'response usually occurs when shared secrets do not match.' - ) - # TODO(ralonsoh): add info in the returned HTTP message to the VM. - return self._http_response(resp, req) - if resp.status_code == 500: - msg = _( - 'Remote metadata server experienced an internal server error.' - ) - LOG.warning(msg) - # TODO(ralonsoh): add info in the returned HTTP message to the VM. - return self._http_response(resp, req) - if resp.status_code in (400, 404, 409, 502, 503, 504): - # TODO(ralonsoh): add info in the returned HTTP message to the VM. - return self._http_response(resp, req) - raise Exception(_('Unexpected response code: %s') % resp.status_code) - - -class MetadataProxyHandler(MetadataProxyHandlerBaseSocketServer, - socketserver.StreamRequestHandler): +class MetadataProxyHandler( + common_metadata.MetadataProxyHandlerBaseSocketServer, + socketserver.StreamRequestHandler): NETWORK_ID_HEADER = 'X-Neutron-Network-ID' ROUTER_ID_HEADER = 'X-Neutron-Router-ID' _conf = None @@ -191,9 +78,22 @@ res = self._proxy_request(instance_id, project_id, req) self.wfile.write(res) return - # TODO(ralonsoh): change this return to be a formatted Request - # and added to self.wfile - return webob_exc.HTTPNotFound() + + network_id, router_id = self._get_instance_id(req) + if network_id and router_id: + title = '400 Bad Request' + msg = _('Both network %s and router %s ' + 'defined.') % (network_id, router_id) + elif network_id: + title = '404 Not Found' + msg = _('Instance was not found on network %s.') % network_id + LOG.warning(msg) + else: + title = '404 Not Found' + msg = _('Instance was not found on router %s.') % router_id + LOG.warning(msg) + res = common_metadata.encode_http_reponse(title, title, msg) + self.wfile.write(res) except Exception as exc: LOG.exception('Error while receiving data.') raise exc @@ -306,7 +206,7 @@ report_interval = cfg.CONF.AGENT.report_interval if report_interval: self.heartbeat = loopingcall.FixedIntervalLoopingCall( - self._report_state) + f=self._report_state) self.heartbeat.start(interval=report_interval) def _report_state(self): @@ -335,4 +235,5 @@ self._server = socketserver.ThreadingUnixStreamServer( file_socket, MetadataProxyHandler) MetadataProxyHandler._conf = self.conf + self._init_state_reporting() self._server.serve_forever() diff -Nru neutron-26.0.0/neutron/agent/metadata/proxy_base.py neutron-26.0.3/neutron/agent/metadata/proxy_base.py --- neutron-26.0.0/neutron/agent/metadata/proxy_base.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/metadata/proxy_base.py 2026-04-15 04:51:52.000000000 +0000 @@ -91,11 +91,16 @@ explanation = str(msg) return webob.exc.HTTPInternalServerError(explanation=explanation) - def _get_instance_and_project_id(self, req, skip_cache=False): - forwarded_for = req.headers.get('X-Forwarded-For') + def _get_instance_id(self, req): + """Returns the network ID and the router ID from the request""" network_id = req.headers.get(self.NETWORK_ID_HEADER) router_id = (req.headers.get(self.ROUTER_ID_HEADER) if self.ROUTER_ID_HEADER else None) + return network_id, router_id + + def _get_instance_and_project_id(self, req, skip_cache=False): + forwarded_for = req.headers.get('X-Forwarded-For') + network_id, router_id = self._get_instance_id(req) # Only one should be given, drop since it could be spoofed if network_id and router_id: diff -Nru neutron-26.0.0/neutron/agent/ovn/agent/ovn_neutron_agent.py neutron-26.0.3/neutron/agent/ovn/agent/ovn_neutron_agent.py --- neutron-26.0.0/neutron/agent/ovn/agent/ovn_neutron_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/ovn/agent/ovn_neutron_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -58,9 +58,8 @@ self._chassis = None self._chassis_id = None self._ovn_bridge = None - self.ext_manager_api = ext_mgr.OVNAgentExtensionAPI() - self.ext_manager = ext_mgr.OVNAgentExtensionManager(self._conf) - self.ext_manager.initialize(None, 'ovn', self) + self.ext_manager_api = None + self.ext_manager = None def __getitem__(self, name): """Return the named extension objet from ``self.ext_manager``""" @@ -151,7 +150,30 @@ return ovsdb.MonitorAgentOvnSbIdl(tables, events, chassis=self.chassis).start() + def update_neutron_sb_cfg_key(self): + nb_cfg = self.sb_idl.db_get('Chassis_Private', + self.chassis, 'nb_cfg').execute() + external_ids = {ovn_const.OVN_AGENT_NEUTRON_SB_CFG_KEY: str(nb_cfg)} + self.sb_idl.db_set( + 'Chassis_Private', self.chassis, + ('external_ids', external_ids)).execute(check_error=True) + + def _initialize_ext_manager(self): + """Initialize the externsion manager and the extension manager API. + + This method must be called once, outside the ``__init__`` method and + at the beginning of the ``start`` method. + """ + if not self.ext_manager: + self.ext_manager_api = ext_mgr.OVNAgentExtensionAPI() + self.ext_manager = ext_mgr.OVNAgentExtensionManager(self._conf) + self.ext_manager.initialize(None, 'ovn', self) + def start(self): + # This must be the first operation in the `start` method. + self._initialize_ext_manager() + + # Extension manager configuration. self.ext_manager_api.ovs_idl = self._load_ovs_idl() self.load_config() # Before executing "_load_sb_idl", is is needed to execute @@ -159,6 +181,8 @@ self.ext_manager_api.sb_idl = self._load_sb_idl() self.ext_manager_api.nb_idl = self._load_nb_idl() self.ext_manager.start() + + self.update_neutron_sb_cfg_key() LOG.info('OVN Neutron Agent started') self.wait() diff -Nru neutron-26.0.0/neutron/agent/ovn/extensions/metadata.py neutron-26.0.3/neutron/agent/ovn/extensions/metadata.py --- neutron-26.0.0/neutron/agent/ovn/extensions/metadata.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/ovn/extensions/metadata.py 2026-04-15 04:51:52.000000000 +0000 @@ -161,6 +161,8 @@ Reload the configuration and sync the agent again. """ self.agent_api.load_config() + self._update_chassis_private_config() + self.agent_api.update_neutron_sb_cfg_key() self.sync() def start(self): @@ -177,6 +179,8 @@ # Register the agent with its corresponding Chassis self.register_metadata_agent() + self._update_chassis_private_config() + self.agent_api.update_neutron_sb_cfg_key() # Start the metadata server. proxy_thread = threading.Thread(target=self._proxy.wait) diff -Nru neutron-26.0.0/neutron/agent/ovn/metadata/agent.py neutron-26.0.3/neutron/agent/ovn/metadata/agent.py --- neutron-26.0.0/neutron/agent/ovn/metadata/agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/ovn/metadata/agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -431,6 +431,21 @@ 'Chassis_Private', self.chassis, ('external_ids', external_ids)).execute(check_error=True) + def _update_metadata_sb_cfg_key(self): + """Update the Chassis_Private nb_cfg information in external_ids + + This method should be called once the Metadata Agent has been + registered (method ``register_metadata_agent`` has been called) and + the corresponding Chassis_Private register has been created/updated + and chassis private config has been updated. + """ + nb_cfg = self.sb_idl.db_get('Chassis_Private', + self.chassis, 'nb_cfg').execute() + external_ids = {ovn_const.OVN_AGENT_METADATA_SB_CFG_KEY: str(nb_cfg)} + self.sb_idl.db_set( + 'Chassis_Private', self.chassis, + ('external_ids', external_ids)).execute(check_error=True) + @_sync_lock def resync(self): """Resync the agent. @@ -439,6 +454,7 @@ """ self._load_config() self._update_chassis_private_config() + self._update_metadata_sb_cfg_key() self.sync() def start(self): @@ -474,6 +490,7 @@ # Register the agent with its corresponding Chassis self.register_metadata_agent() self._update_chassis_private_config() + self._update_metadata_sb_cfg_key() self._proxy.wait() diff -Nru neutron-26.0.0/neutron/agent/ovn/metadata/server_socket.py neutron-26.0.3/neutron/agent/ovn/metadata/server_socket.py --- neutron-26.0.0/neutron/agent/ovn/metadata/server_socket.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/ovn/metadata/server_socket.py 2026-04-15 04:51:52.000000000 +0000 @@ -12,140 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. - import io import socketserver -import urllib -import jinja2 from oslo_config import cfg from oslo_log import log as logging -from oslo_utils import encodeutils -import requests import webob -from webob import exc as webob_exc from neutron._i18n import _ from neutron.agent.linux import utils as agent_utils from neutron.agent.metadata import proxy_base -from neutron.common import ipv6_utils +from neutron.common import metadata as common_metadata from neutron.common.ovn import constants as ovn_const -from neutron.common import utils as common_utils LOG = logging.getLogger(__name__) -RESPONSE = jinja2.Template("""HTTP/1.1 {{ http_code }} -Content-Type: text/plain; charset=UTF-8 -Connection: close -Content-Length: {{ len }} - - - - {{ title }} - - -

{{ body_title }}

- {{ body }}

- - - -""") -RESPONSE_LENGHT = 40 - - -class MetadataProxyHandlerBaseSocketServer( - proxy_base.MetadataProxyHandlerBase): - @staticmethod - def _http_response(http_response, request): - _res = webob.Response( - body=http_response.content, - status=http_response.status_code, - content_type=http_response.headers['content-type'], - charset=http_response.encoding) - # NOTE(ralonsoh): there should be a better way to format the HTTP - # response, adding the HTTP version to the ``webob.Response`` - # output string. - out = request.http_version + ' ' + str(_res) - if (int(_res.headers['content-length']) == 0 and - _res.status_code == 200): - # Add 2 extra \r\n to the result. HAProxy is also expecting - # it even when the body is empty. - out += '\r\n\r\n' - return out.encode(http_response.encoding) - - def _proxy_request(self, instance_id, project_id, req): - headers = { - 'X-Forwarded-For': req.headers.get('X-Forwarded-For'), - 'X-Instance-ID': instance_id, - 'X-Tenant-ID': project_id, - 'X-Instance-ID-Signature': common_utils.sign_instance_id( - self.conf, instance_id) - } - - nova_host_port = ipv6_utils.valid_ipv6_url( - self.conf.nova_metadata_host, - self.conf.nova_metadata_port) - - url = urllib.parse.urlunsplit(( - self.conf.nova_metadata_protocol, - nova_host_port, - req.path_info, - req.query_string, - '')) - - disable_ssl_certificate_validation = self.conf.nova_metadata_insecure - if self.conf.auth_ca_cert and not disable_ssl_certificate_validation: - verify_cert = self.conf.auth_ca_cert - else: - verify_cert = not disable_ssl_certificate_validation - - client_cert = None - if self.conf.nova_client_cert and self.conf.nova_client_priv_key: - client_cert = (self.conf.nova_client_cert, - self.conf.nova_client_priv_key) - - try: - resp = requests.request(method=req.method, url=url, - headers=headers, - data=req.body, - cert=client_cert, - verify=verify_cert, - timeout=60) - except requests.ConnectionError: - msg = _('The remote metadata server is temporarily unavailable. ' - 'Please try again later.') - LOG.warning(msg) - title = '503 Service Unavailable' - length = RESPONSE_LENGHT + len(title) * 2 + len(msg) - reponse = RESPONSE.render(http_code=title, title=title, - body_title=title, body=title, len=length) - return encodeutils.to_utf8(reponse) - - if resp.status_code == 200: - return self._http_response(resp, req) - if resp.status_code == 403: - LOG.warning( - 'The remote metadata server responded with Forbidden. This ' - 'response usually occurs when shared secrets do not match.' - ) - # TODO(ralonsoh): add info in the returned HTTP message to the VM. - return self._http_response(resp, req) - if resp.status_code == 500: - msg = _( - 'Remote metadata server experienced an internal server error.' - ) - LOG.warning(msg) - # TODO(ralonsoh): add info in the returned HTTP message to the VM. - return self._http_response(resp, req) - if resp.status_code in (400, 404, 409, 502, 503, 504): - # TODO(ralonsoh): add info in the returned HTTP message to the VM. - return self._http_response(resp, req) - raise Exception(_('Unexpected response code: %s') % resp.status_code) - -class MetadataProxyHandler(MetadataProxyHandlerBaseSocketServer, - socketserver.StreamRequestHandler): +class MetadataProxyHandler( + common_metadata.MetadataProxyHandlerBaseSocketServer, + socketserver.StreamRequestHandler): NETWORK_ID_HEADER = 'X-OVN-Network-ID' ROUTER_ID_HEADER = '' _conf = None @@ -167,9 +53,22 @@ res = self._proxy_request(instance_id, project_id, req) self.wfile.write(res) return - # TODO(ralonsoh): change this return to be a formatted Request - # and added to self.wfile - return webob_exc.HTTPNotFound() + + network_id, router_id = self._get_instance_id(req) + if network_id and router_id: + title = '400 Bad Request' + msg = _('Both network %s and router %s ' + 'defined.') % (network_id, router_id) + elif network_id: + title = '404 Not Found' + msg = _('Instance was not found on network %s.') % network_id + LOG.warning(msg) + else: + title = '404 Not Found' + msg = _('Instance was not found on router %s.') % router_id + LOG.warning(msg) + res = common_metadata.encode_http_reponse(title, title, msg) + self.wfile.write(res) except Exception as exc: LOG.exception('Error while receiving data.') raise exc diff -Nru neutron-26.0.0/neutron/agent/ovsdb/native/helpers.py neutron-26.0.3/neutron/agent/ovsdb/native/helpers.py --- neutron-26.0.0/neutron/agent/ovsdb/native/helpers.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/agent/ovsdb/native/helpers.py 2026-04-15 04:51:52.000000000 +0000 @@ -26,6 +26,6 @@ enable_connection_uri = functools.partial( priv_helpers.enable_connection_uri, - log_fail_as_error=False, check_exit_code=False, + log_fail_as_error=False, check_exit_code=True, timeout=cfg.CONF.OVS.ovsdb_timeout, inactivity_probe=cfg.CONF.OVS.of_inactivity_probe * 1000) diff -Nru neutron-26.0.0/neutron/cmd/eventlet/agents/metadata.py neutron-26.0.3/neutron/cmd/eventlet/agents/metadata.py --- neutron-26.0.0/neutron/cmd/eventlet/agents/metadata.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/cmd/eventlet/agents/metadata.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,24 +0,0 @@ -# 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 setproctitle - -from neutron.agent import metadata_agent -from neutron_lib import constants - - -def main(): - proctitle = "{} ({})".format( - constants.AGENT_PROCESS_METADATA, setproctitle.getproctitle()) - setproctitle.setproctitle(proctitle) - - metadata_agent.main() diff -Nru neutron-26.0.0/neutron/common/loopingcall.py neutron-26.0.3/neutron/common/loopingcall.py --- neutron-26.0.0/neutron/common/loopingcall.py 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/neutron/common/loopingcall.py 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,211 @@ +# Copyright (C) 2025 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# NOTE(ralonsoh): this is the implementation of ``FixedIntervalLoopingCall`` +# and all needed resources done in ``oslo.service`` 4.2.0, in the ``threading`` +# backend. Because this code is not going to be backported or available in +# 2025.1. + +import sys +import threading + +import futurist +from oslo_log import log as logging +from oslo_utils import reflection +from oslo_utils import timeutils + +from neutron._i18n import _ + + +LOG = logging.getLogger(__name__) + + +class FutureEvent: + """A simple event object that can carry a result or an exception.""" + + def __init__(self): + self._event = threading.Event() + self._result = None + self._exc_info = None + + def send(self, result): + self._result = result + self._event.set() + + def send_exception(self, exc_type, exc_value, tb): + self._exc_info = (exc_type, exc_value, tb) + self._event.set() + + def wait(self, timeout=None): + flag = self._event.wait(timeout) + + if not flag: + raise RuntimeError(_('Timed out waiting for event')) + + if self._exc_info: + exc_type, exc_value, tb = self._exc_info + raise exc_value.with_traceback(tb) + return self._result + + +class LoopingCallDone(Exception): + """Exception to break out and stop a LoopingCallBase. + + The function passed to a looping call may raise this exception to + break out of the loop normally. An optional return value may be + provided; this value will be returned by LoopingCallBase.wait(). + """ + + def __init__(self, retvalue=True): + """:param retvalue: Value that LoopingCallBase.wait() should return.""" + self.retvalue = retvalue + + +def _safe_wrapper(f, kind, func_name): + """Wrapper that calls the wrapped function and logs errors as needed.""" + + def func(*args, **kwargs): + try: + return f(*args, **kwargs) + except LoopingCallDone: + raise # Let the outer handler process this + except Exception: + LOG.error('%(kind)s %(func_name)r failed', + {'kind': kind, 'func_name': func_name}, + exc_info=True) + return 0 + + return func + + +class LoopingCallBase: + KIND = _("Unknown looping call") + RUN_ONLY_ONE_MESSAGE = _( + "A looping call can only run one function at a time") + + def __init__(self, *args, f=None, **kwargs): + self.args = args + self.kwargs = kwargs + self.f = f + self._future = None + self.done = None + self._abort = threading.Event() # When set, the loop stops + + @property + def _running(self): + return not self._abort.is_set() + + def stop(self): + if self._running: + self._abort.set() + + def wait(self): + """Wait for the looping call to complete and return its result.""" + return self.done.wait() + + def _on_done(self, future): + self._future = None + + def _sleep(self, timeout): + # Instead of eventlet.sleep, we wait on the abort event for timeout + # seconds. + self._abort.wait(timeout) + + def _start(self, idle_for, initial_delay=None, stop_on_exception=True): + """Start the looping call. + + :param idle_for: Callable taking two arguments (last result, + elapsed time) and returning how long to idle. + :param initial_delay: Delay (in seconds) before starting the + loop. + :param stop_on_exception: Whether to stop on exception. + :returns: A FutureEvent instance. + """ + + if self._future is not None: + raise RuntimeError(self.RUN_ONLY_ONE_MESSAGE) + + self.done = FutureEvent() + self._abort.clear() + + def _run_loop(): + kind = self.KIND + func_name = reflection.get_callable_name(self.f) + func = self.f if stop_on_exception else _safe_wrapper(self.f, kind, + func_name) + if initial_delay: + self._sleep(initial_delay) + try: + watch = timeutils.StopWatch() + + while self._running: + watch.restart() + result = func(*self.args, **self.kwargs) + watch.stop() + + if not self._running: + break + + idle = idle_for(result, watch.elapsed()) + LOG.debug( + '%(kind)s %(func_name)r sleeping for %(idle).02f' + ' seconds', + {'func_name': func_name, 'idle': idle, 'kind': kind}) + self._sleep(idle) + except LoopingCallDone as e: + self.done.send(e.retvalue) + except Exception: + exc_info = sys.exc_info() + try: + LOG.error('%(kind)s %(func_name)r failed', + {'kind': kind, 'func_name': func_name}, + exc_info=exc_info) + self.done.send_exception(*exc_info) + finally: + del exc_info + return + else: + self.done.send(True) + + # Use futurist's ThreadPoolExecutor to run the loop in a background + # thread. + executor = futurist.ThreadPoolExecutor(max_workers=1) + self._future = executor.submit(_run_loop) + self._future.add_done_callback(self._on_done) + return self.done + + # NOTE: _elapsed() is a thin wrapper for StopWatch.elapsed() + def _elapsed(self, watch): + return watch.elapsed() + + +class FixedIntervalLoopingCall(LoopingCallBase): + """A fixed interval looping call.""" + RUN_ONLY_ONE_MESSAGE = _( + "A fixed interval looping call can only run one function at a time") + KIND = _('Fixed interval looping call') + + def start(self, interval, initial_delay=None, stop_on_exception=True): + def _idle_for(result, elapsed): + delay = round(elapsed - interval, 2) + if delay > 0: + func_name = reflection.get_callable_name(self.f) + LOG.warning( + 'Function %(func_name)r run outlasted interval by' + ' %(delay).2f sec', + {'func_name': func_name, 'delay': delay}) + return -delay if delay < 0 else 0 + + return self._start(_idle_for, initial_delay=initial_delay, + stop_on_exception=stop_on_exception) diff -Nru neutron-26.0.0/neutron/common/metadata.py neutron-26.0.3/neutron/common/metadata.py --- neutron-26.0.0/neutron/common/metadata.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/common/metadata.py 2026-04-15 04:51:52.000000000 +0000 @@ -11,8 +11,21 @@ # License for the specific language governing permissions and limitations # under the License. +import abc +from urllib import parse + +import jinja2 from neutron_lib import constants from oslo_log import log as logging +from oslo_utils import encodeutils +import requests +import webob + +from neutron._i18n import _ +from neutron.agent.metadata import proxy_base +from neutron.common import ipv6_utils +from neutron.common import utils as common_utils + LOG = logging.getLogger(__name__) @@ -20,6 +33,11 @@ PROXY_SERVICE_NAME = 'haproxy' PROXY_SERVICE_CMD = 'haproxy' +CONTENT_ENCODERS = { + 'gzip': b'\x1f\x8b\x08', + 'deflate': b'\x1f\x8b\x08' +} + class InvalidUserOrGroupException(Exception): pass @@ -69,6 +87,24 @@ server metadata %(unix_socket_path)s """ # noqa: E501 line-length +RESPONSE = jinja2.Template("""HTTP/1.1 {{ http_code }} +Content-Type: text/plain; charset=UTF-8 +Connection: close +Content-Length: {{ len }} + + + + {{ title }} + + +

{{ body_title }}

+ {{ body }}

+ + + +""") +RESPONSE_LENGTH = 95 + def parse_ip_versions(ip_versions): if not set(ip_versions).issubset({constants.IP_VERSION_4, @@ -111,3 +147,111 @@ header_config_template) return FINAL_CONFIG_TEMPLATE % cfg_info + + +def encode_http_reponse(http_code, title, message): + """Return an encoded HTTP, providing the HTTP code, title and message""" + length = RESPONSE_LENGTH + len(title) * 2 + len(message) + reponse = RESPONSE.render(http_code=http_code, title=title, + body_title=title, body=message, len=length) + return encodeutils.to_utf8(reponse) + + +class MetadataProxyHandlerBaseSocketServer( + proxy_base.MetadataProxyHandlerBase, + metaclass=abc.ABCMeta): + @staticmethod + def _http_response(http_response, request): + headerlist = list(http_response.headers.items()) + # We detect if content is compressed by magic signature, + # when `content-encoding` is not present. + if not http_response.headers.get('content-encoding'): + if http_response.content[:3] == CONTENT_ENCODERS['gzip']: + headerlist.append(('content-encoding', 'gzip')) + + _res = webob.Response( + body=http_response.content, + status=http_response.status_code, + headerlist=headerlist) + # The content of the response is decoded depending on the + # "Context-Enconding" header, if present. The operation is limited to + # ("gzip", "deflate"), as is in the ``webob.response.Response`` class. + if _res.content_encoding in CONTENT_ENCODERS.keys(): + _res.decode_content() + + # NOTE(ralonsoh): there should be a better way to format the HTTP + # response, adding the HTTP version to the ``webob.Response`` + # output string. + out = request.http_version + ' ' + str(_res) + if (int(_res.headers['content-length']) == 0 and + _res.status_code == 200): + # Add 2 extra \r\n to the result. HAProxy is also expecting + # it even when the body is empty. + out += '\r\n\r\n' + return out.encode(http_response.encoding) + + def _proxy_request(self, instance_id, project_id, req): + headers = { + 'X-Forwarded-For': req.headers.get('X-Forwarded-For'), + 'X-Instance-ID': instance_id, + 'X-Tenant-ID': project_id, + 'X-Instance-ID-Signature': common_utils.sign_instance_id( + self.conf, instance_id) + } + + nova_host_port = ipv6_utils.valid_ipv6_url( + self.conf.nova_metadata_host, + self.conf.nova_metadata_port) + + url = parse.urlunsplit(( + self.conf.nova_metadata_protocol, + nova_host_port, + req.path_info, + req.query_string, + '')) + + disable_ssl_certificate_validation = self.conf.nova_metadata_insecure + if self.conf.auth_ca_cert and not disable_ssl_certificate_validation: + verify_cert = self.conf.auth_ca_cert + else: + verify_cert = not disable_ssl_certificate_validation + + client_cert = None + if self.conf.nova_client_cert and self.conf.nova_client_priv_key: + client_cert = (self.conf.nova_client_cert, + self.conf.nova_client_priv_key) + + try: + resp = requests.request(method=req.method, url=url, + headers=headers, + data=req.body, + cert=client_cert, + verify=verify_cert, + timeout=60) + except requests.ConnectionError: + msg = _('The remote metadata server is temporarily unavailable. ' + 'Please try again later.') + LOG.warning(msg) + title = '503 Service Unavailable' + return encode_http_reponse(title, title, msg) + + if resp.status_code == 200: + return self._http_response(resp, req) + if resp.status_code == 403: + LOG.warning( + 'The remote metadata server responded with Forbidden. This ' + 'response usually occurs when shared secrets do not match.' + ) + # TODO(ralonsoh): add info in the returned HTTP message to the VM. + return self._http_response(resp, req) + if resp.status_code == 500: + msg = _( + 'Remote metadata server experienced an internal server error.' + ) + LOG.warning(msg) + # TODO(ralonsoh): add info in the returned HTTP message to the VM. + return self._http_response(resp, req) + if resp.status_code in (400, 404, 409, 502, 503, 504): + # TODO(ralonsoh): add info in the returned HTTP message to the VM. + return self._http_response(resp, req) + raise Exception(_('Unexpected response code: %s') % resp.status_code) diff -Nru neutron-26.0.0/neutron/common/ovn/acl.py neutron-26.0.3/neutron/common/ovn/acl.py --- neutron-26.0.0/neutron/common/ovn/acl.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/common/ovn/acl.py 2026-04-15 04:51:52.000000000 +0000 @@ -148,7 +148,8 @@ "name": [], "severity": [], "direction": direction, - "match": '{} == @{} && ip'.format(p, pg_name)} + "match": '{} == @{} && ip'.format(p, pg_name), + "meter": []} acl_list.append(acl) return acl_list @@ -167,6 +168,7 @@ "severity": [], "direction": direction, "match": '{} == "{}" && ip'.format(p, port['id']), + "meter": [], "external_ids": {'neutron:lport': port['id']}} acl_list.append(acl) return acl_list @@ -187,6 +189,7 @@ "severity": [], "direction": dir_map[r['direction']], "match": match, + "meter": [], ovn_const.OVN_SG_RULE_EXT_ID_KEY: r['id']} return acl @@ -293,6 +296,15 @@ if not is_sg_enabled(): return + # It's possible to have a security group created on one controller and + # then a security group rule created on a different controller quickly + # enough that the second controller does not yet see that security group + # in its local cache of the OVN northbound database. Check if the port + # group is present or not in the idl's local copy of the database before + # creating the security group rule. + pg_name = utils.ovn_port_group_name(security_group_id) + ovn.check_for_row_by_value_and_retry('Port_Group', 'name', pg_name) + # Check if ACL log name and severity supported or not keep_name_severity = _acl_columns_name_severity_supported(ovn) @@ -300,8 +312,7 @@ stateful = is_sg_stateful(sg) acl = _add_sg_rule_acl_for_port_group( - utils.ovn_port_group_name(security_group_id), - stateful, security_group_rule) + pg_name, stateful, security_group_rule) # Remove ACL log name and severity if not supported if is_add_acl: if not keep_name_severity: diff -Nru neutron-26.0.0/neutron/common/ovn/constants.py neutron-26.0.3/neutron/common/ovn/constants.py --- neutron-26.0.0/neutron/common/ovn/constants.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/common/ovn/constants.py 2026-04-15 04:51:52.000000000 +0000 @@ -96,6 +96,11 @@ OVN_CONTROLLER_TYPES = (OVN_CONTROLLER_AGENT, OVN_CONTROLLER_GW_AGENT, ) +OVN_AGENT_TYPES = (OVN_CONTROLLER_AGENT, + OVN_CONTROLLER_GW_AGENT, + OVN_METADATA_AGENT, + OVN_NEUTRON_AGENT, + ) # OVN ACLs have priorities. The highest priority ACL that matches is the one # that takes effect. Our choice of priority numbers is arbitrary, but it @@ -254,7 +259,7 @@ ACL_EXPECTED_COLUMNS_NBDB = ( 'external_ids', 'direction', 'log', 'priority', - 'name', 'action', 'severity', 'match') + 'name', 'action', 'severity', 'match', 'meter') # Resource types TYPE_NETWORKS = 'networks' diff -Nru neutron-26.0.0/neutron/common/ovn/extensions.py neutron-26.0.3/neutron/common/ovn/extensions.py --- neutron-26.0.0/neutron/common/ovn/extensions.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/common/ovn/extensions.py 2026-04-15 04:51:52.000000000 +0000 @@ -70,6 +70,7 @@ from neutron_lib.api.definitions import qinq from neutron_lib.api.definitions import qos from neutron_lib.api.definitions import qos_bw_limit_direction +from neutron_lib.api.definitions import qos_bw_minimum_ingress from neutron_lib.api.definitions import qos_default from neutron_lib.api.definitions import qos_gateway_ip from neutron_lib.api.definitions import qos_rule_type_details @@ -171,6 +172,7 @@ qinq.ALIAS, qos.ALIAS, qos_bw_limit_direction.ALIAS, + qos_bw_minimum_ingress.ALIAS, qos_default.ALIAS, qos_rule_type_details.ALIAS, qos_rule_type_filter.ALIAS, diff -Nru neutron-26.0.0/neutron/common/ovn/utils.py neutron-26.0.3/neutron/common/ovn/utils.py --- neutron-26.0.0/neutron/common/ovn/utils.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/common/ovn/utils.py 2026-04-15 04:51:52.000000000 +0000 @@ -37,7 +37,6 @@ from oslo_serialization import jsonutils from oslo_utils import netutils from oslo_utils import strutils -from ovsdbapp.backend.ovs_idl import idlutils from ovsdbapp import constants as ovsdbapp_const from pecan import util as pecan_util import tenacity @@ -464,7 +463,9 @@ if pbp_param_set.vnic_type: if pbp_param_set.vnic_type != vnic_type: continue - if capabilities and pbp_param_set.capability not in capabilities: + if (capabilities and + pbp_param_set.capability is not None and + pbp_param_set.capability not in capabilities): continue param_set = pbp_param_set.param_set param_keys = param_set.keys() @@ -1114,11 +1115,10 @@ candidates = _filter_candidates_for_ha_chassis_group(hcg_info) # Try to get the HA Chassis Group or create if it doesn't exist - ha_ch_grp = ha_ch_grp_cmd = None - try: - ha_ch_grp = nb_idl.ha_chassis_group_get( - hcg_info.group_name).execute(check_error=True) - except idlutils.RowNotFound: + ha_ch_grp_cmd = None + ha_ch_grp = nb_idl.lookup('HA_Chassis_Group', hcg_info.group_name, + default=None) + if ha_ch_grp is None: ha_ch_grp_cmd = txn.add(nb_idl.ha_chassis_group_add( hcg_info.group_name, may_exist=True, external_ids=hcg_info.external_ids)) diff -Nru neutron-26.0.0/neutron/common/utils.py neutron-26.0.3/neutron/common/utils.py --- neutron-26.0.0/neutron/common/utils.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/common/utils.py 2026-04-15 04:51:52.000000000 +0000 @@ -1115,3 +1115,10 @@ for key, value in data.items(): result[key] = default if value is None else str(value) return result + + +def is_iterable_not_string(value): + """Return if a value is iterable but not a string type""" + return (isinstance(value, abc.Iterable) and + not isinstance(value, abc.ByteString) and + not isinstance(value, str)) diff -Nru neutron-26.0.0/neutron/common/wsgi_utils.py neutron-26.0.3/neutron/common/wsgi_utils.py --- neutron-26.0.0/neutron/common/wsgi_utils.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/common/wsgi_utils.py 2026-04-15 04:51:52.000000000 +0000 @@ -20,6 +20,9 @@ from neutron.common import utils +FIRST_WORKER_ID = 1 + + def get_start_time(default=None, current_time=False): """Return the 'start-time=%t' config varible in the WSGI config @@ -54,5 +57,5 @@ # pylint: disable=import-outside-toplevel import uwsgi return uwsgi.worker_id() - except ImportError: + except (ImportError, ModuleNotFoundError): return None diff -Nru neutron-26.0.0/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py neutron-26.0.3/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py --- neutron-26.0.0/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py 2026-04-15 04:51:52.000000000 +0000 @@ -171,10 +171,11 @@ "automatically set on each subnet upon creation and " "on all existing subnets when Neutron starts.\n" "An empty value for a DHCP option will cause that " - "option to be unset globally.\n" + "option to be unset globally. Multiple values should " + "be separated by semi-colon.\n" "EXAMPLES:\n" - "- ntp_server:1.2.3.4,wpad:1.2.3.5 - Set ntp_server " - "and wpad\n" + "- ntp_server:1.2.3.4,wpad:1.2.3.5;1.2.3.6 - Set " + "ntp_server and wpad\n" "- ntp_server:,wpad:1.2.3.5 - Unset ntp_server and " "set wpad\n" "See the ovn-nb(5) man page for available options.")), @@ -184,7 +185,8 @@ "automatically set on each subnet upon creation and " "on all existing subnets when Neutron starts.\n" "An empty value for a DHCPv6 option will cause that " - "option to be unset globally.\n" + "option to be unset globally. Multiple values should " + "be separated by semi-colon.\n" "See the ovn-nb(5) man page for available options.")), cfg.BoolOpt('ovn_emit_need_to_frag', default=True, diff -Nru neutron-26.0.0/neutron/conf/policies/l3_conntrack_helper.py neutron-26.0.3/neutron/conf/policies/l3_conntrack_helper.py --- neutron-26.0.0/neutron/conf/policies/l3_conntrack_helper.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/conf/policies/l3_conntrack_helper.py 2026-04-15 04:51:52.000000000 +0000 @@ -30,9 +30,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_router_conntrack_helper', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.RULE_PARENT_OWNER), + check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a router conntrack helper', operations=[ @@ -49,9 +47,7 @@ ), policy.DocumentedRuleDefault( name='get_router_conntrack_helper', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, - base.RULE_PARENT_OWNER), + check_str=base.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a router conntrack helper', operations=[ @@ -72,9 +68,7 @@ ), policy.DocumentedRuleDefault( name='update_router_conntrack_helper', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.RULE_PARENT_OWNER), + check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Update a router conntrack helper', operations=[ @@ -91,9 +85,7 @@ ), policy.DocumentedRuleDefault( name='delete_router_conntrack_helper', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.RULE_PARENT_OWNER), + check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a router conntrack helper', operations=[ diff -Nru neutron-26.0.0/neutron/conf/policies/local_ip_association.py neutron-26.0.3/neutron/conf/policies/local_ip_association.py --- neutron-26.0.0/neutron/conf/policies/local_ip_association.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/conf/policies/local_ip_association.py 2026-04-15 04:51:52.000000000 +0000 @@ -27,9 +27,7 @@ rules = [ policy.DocumentedRuleDefault( name='create_local_ip_port_association', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.RULE_PARENT_OWNER), + check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Create a Local IP port association', operations=[ @@ -46,9 +44,7 @@ ), policy.DocumentedRuleDefault( name='get_local_ip_port_association', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, - base.RULE_PARENT_OWNER), + check_str=base.ADMIN_OR_PARENT_OWNER_READER, scope_types=['project'], description='Get a Local IP port association', operations=[ @@ -69,9 +65,7 @@ ), policy.DocumentedRuleDefault( name='delete_local_ip_port_association', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_MEMBER, - base.RULE_PARENT_OWNER), + check_str=base.ADMIN_OR_PARENT_OWNER_MEMBER, scope_types=['project'], description='Delete a Local IP port association', operations=[ diff -Nru neutron-26.0.0/neutron/db/allowedaddresspairs_db.py neutron-26.0.3/neutron/db/allowedaddresspairs_db.py --- neutron-26.0.0/neutron/db/allowedaddresspairs_db.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/db/allowedaddresspairs_db.py 2026-04-15 04:51:52.000000000 +0000 @@ -17,6 +17,8 @@ from neutron_lib.api.definitions import allowedaddresspairs as addr_apidef from neutron_lib.api.definitions import port as port_def from neutron_lib.api import validators +from neutron_lib.callbacks import events +from neutron_lib.callbacks import registry from neutron_lib.db import api as db_api from neutron_lib.db import resource_extend from neutron_lib.db import utils as db_utils @@ -36,6 +38,20 @@ allowed_address_pairs): if not validators.is_attr_set(allowed_address_pairs): return [] + + desired_state = { + 'context': context, + 'network_id': port['network_id'], + 'allowed_address_pairs': allowed_address_pairs, + } + # TODO(slaweq): use constant from neutron_lib.callbacks.resources once + # it will be available and released + registry.publish( + 'allowed_address_pair', events.BEFORE_CREATE, self, + payload=events.DBEventPayload( + context, + resource_id=port['id'], + desired_state=desired_state)) try: with db_api.CONTEXT_WRITER.using(context): for address_pair in allowed_address_pairs: diff -Nru neutron-26.0.0/neutron/db/db_base_plugin_v2.py neutron-26.0.3/neutron/db/db_base_plugin_v2.py --- neutron-26.0.0/neutron/db/db_base_plugin_v2.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/db/db_base_plugin_v2.py 2026-04-15 04:51:52.000000000 +0000 @@ -21,6 +21,7 @@ from neutron_lib.api.definitions import ip_allocation as ipalloc_apidef from neutron_lib.api.definitions import port as port_def from neutron_lib.api.definitions import portbindings as portbindings_def +from neutron_lib.api.definitions import provider_net as pnet_def from neutron_lib.api.definitions import subnetpool as subnetpool_def from neutron_lib.api import validators from neutron_lib.callbacks import events @@ -57,6 +58,7 @@ from neutron.conf import experimental as c_exp from neutron.db import db_base_plugin_common from neutron.db import ipam_pluggable_backend +from neutron.db.models import segment as segment_db from neutron.db import models_v2 from neutron.db import rbac_db_mixin as rbac_mixin from neutron.db import rbac_db_models @@ -130,6 +132,33 @@ return conditions +def _network_result_filter_hook(query, filters): + # This filter matches the provider network attributes, defined in + # ``neutron_lib.api.definitions.provider_net.ATTRIBUTES``. + attr_to_field = { + pnet_def.NETWORK_TYPE: segment_db.NetworkSegment.network_type, + pnet_def.PHYSICAL_NETWORK: segment_db.NetworkSegment.physical_network, + pnet_def.SEGMENTATION_ID: segment_db.NetworkSegment.segmentation_id + } + + if any(attr for attr in pnet_def.ATTRIBUTES if attr in filters): + query = query.join( + segment_db.NetworkSegment, + segment_db.NetworkSegment.network_id == models_v2.Network.id) + for attr in pnet_def.ATTRIBUTES: + if attr not in filters: + continue + + value = filters[attr] + field = attr_to_field[attr] + if utils.is_iterable_not_string(value): + query = query.filter(field.in_(value)) + else: + query = query.filter(field == value) + + return query + + @registry.has_registry_receivers class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, neutron_plugin_base_v2.NeutronPluginBaseV2, @@ -164,6 +193,12 @@ query_hook=_port_query_hook, filter_hook=_port_filter_hook, result_filters=None) + model_query.register_hook( + models_v2.Network, + 'network', + query_hook=None, + filter_hook=None, + result_filters=_network_result_filter_hook) return super().__new__(cls, *args, **kwargs) @staticmethod diff -Nru neutron-26.0.0/neutron/db/external_net_db.py neutron-26.0.3/neutron/db/external_net_db.py --- neutron-26.0.0/neutron/db/external_net_db.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/db/external_net_db.py 2026-04-15 04:51:52.000000000 +0000 @@ -51,6 +51,15 @@ return query.filter(~models_v2.Network.external.has()) +def _subnet_result_filter_hook(query, filters): + vals = filters and filters.get(extnet_apidef.EXTERNAL, []) + if not vals: + return query + if vals[0]: + return query.filter(models_v2.Subnet.external.has()) + return query.filter(~models_v2.Subnet.external.has()) + + @resource_extend.has_resource_extenders @registry.has_registry_receivers class External_net_db_mixin: @@ -70,7 +79,7 @@ "external_subnet", query_hook=None, filter_hook=None, - result_filters=None, + result_filters=_subnet_result_filter_hook, rbac_actions=EXTERNAL_NETWORK_RBAC_ACTIONS, ) return super().__new__(cls, *args, **kwargs) diff -Nru neutron-26.0.0/neutron/db/l3_hamode_db.py neutron-26.0.3/neutron/db/l3_hamode_db.py --- neutron-26.0.0/neutron/db/l3_hamode_db.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/db/l3_hamode_db.py 2026-04-15 04:51:52.000000000 +0000 @@ -615,7 +615,8 @@ # List agents where router is active and agent is dead # and agents where router is standby and agent is dead for binding in bindings: - if not (binding.agent.is_active and + if not (binding.agent and + binding.agent.is_active and binding.agent.admin_state_up): if binding.state == constants.HA_ROUTER_STATE_ACTIVE: router_active_agents_dead.append(binding.agent) diff -Nru neutron-26.0.0/neutron/ipam/requests.py neutron-26.0.3/neutron/ipam/requests.py --- neutron-26.0.0/neutron/ipam/requests.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/ipam/requests.py 2026-04-15 04:51:52.000000000 +0000 @@ -36,7 +36,8 @@ instantiated on its own. Rather, a subclass of this class should be used. """ def __init__(self, tenant_id, subnet_id, - gateway_ip=None, allocation_pools=None): + gateway_ip=None, allocation_pools=None, + set_gateway_ip=True): """Initialize and validate :param tenant_id: The tenant id who will own the subnet @@ -50,10 +51,16 @@ of this range if specifically requested. :type allocation_pools: A list of netaddr.IPRange. None if not specified. + :param set_gateway_ip: in case the ``gateway_ip`` value is not defined + (None), the IPAM module will set an IP address within the range of + the subnet CIDR. If ``set_gateway_ip`` is unset, no IP address will + be assigned. + :type set_gateway_ip: boolean """ self._tenant_id = tenant_id self._subnet_id = subnet_id self._gateway_ip = None + self._set_gateway_ip = set_gateway_ip self._allocation_pools = None if gateway_ip is not None: @@ -98,6 +105,10 @@ return self._gateway_ip @property + def set_gateway_ip(self): + return self._set_gateway_ip + + @property def allocation_pools(self): return self._allocation_pools @@ -144,7 +155,8 @@ constants.IPv6: '::'} def __init__(self, tenant_id, subnet_id, version, prefixlen, - gateway_ip=None, allocation_pools=None): + gateway_ip=None, allocation_pools=None, + set_gateway_ip=True): """Initialize AnySubnetRequest :param version: Either constants.IPv4 or constants.IPv6 @@ -156,7 +168,9 @@ tenant_id=tenant_id, subnet_id=subnet_id, gateway_ip=gateway_ip, - allocation_pools=allocation_pools) + allocation_pools=allocation_pools, + set_gateway_ip=set_gateway_ip, + ) net = netaddr.IPNetwork(self.WILDCARDS[version] + '/' + str(prefixlen)) self._validate_with_subnet(net) @@ -176,7 +190,8 @@ blueprints. """ def __init__(self, tenant_id, subnet_id, subnet_cidr, - gateway_ip=None, allocation_pools=None): + gateway_ip=None, allocation_pools=None, + set_gateway_ip=True): """Initialize SpecificSubnetRequest :param subnet: The subnet requested. Can be IPv4 or IPv6. However, @@ -188,7 +203,9 @@ tenant_id=tenant_id, subnet_id=subnet_id, gateway_ip=gateway_ip, - allocation_pools=allocation_pools) + allocation_pools=allocation_pools, + set_gateway_ip=set_gateway_ip, + ) self._subnet_cidr = netaddr.IPNetwork(subnet_cidr) self._validate_with_subnet(self._subnet_cidr) @@ -322,6 +339,7 @@ cidr = subnet.get('cidr') cidr = cidr if validators.is_attr_set(cidr) else None gateway_ip = subnet.get('gateway_ip') + set_gateway_ip = gateway_ip is not None gateway_ip = gateway_ip if validators.is_attr_set(gateway_ip) else None subnet_id = subnet.get('id', uuidutils.generate_uuid()) @@ -335,7 +353,9 @@ subnet['tenant_id'], subnet_id, common_utils.ip_version_from_int(subnetpool['ip_version']), - prefixlen) + prefixlen, + set_gateway_ip=set_gateway_ip, + ) alloc_pools = subnet.get('allocation_pools') alloc_pools = ( alloc_pools if validators.is_attr_set(alloc_pools) else None) @@ -352,4 +372,6 @@ subnet_id, cidr, gateway_ip=gateway_ip, - allocation_pools=alloc_pools) + allocation_pools=alloc_pools, + set_gateway_ip=set_gateway_ip, + ) diff -Nru neutron-26.0.0/neutron/ipam/subnet_alloc.py neutron-26.0.3/neutron/ipam/subnet_alloc.py --- neutron-26.0.0/neutron/ipam/subnet_alloc.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/ipam/subnet_alloc.py 2026-04-15 04:51:52.000000000 +0000 @@ -129,7 +129,7 @@ if request.prefixlen >= prefix.prefixlen: subnet = next(prefix.subnet(request.prefixlen)) gateway_ip = request.gateway_ip - if not gateway_ip: + if not gateway_ip and request.set_gateway_ip: gateway_ip = subnet.network + 1 pools = ipam_utils.generate_pools(subnet.cidr, gateway_ip) @@ -138,7 +138,9 @@ request.subnet_id, subnet.cidr, gateway_ip=gateway_ip, - allocation_pools=pools) + allocation_pools=pools, + set_gateway_ip=request.set_gateway_ip, + ) msg = _("Insufficient prefix space to allocate subnet size /%s") raise exceptions.SubnetAllocationError( reason=msg % str(request.prefixlen)) @@ -156,7 +158,9 @@ request.subnet_id, cidr, gateway_ip=request.gateway_ip, - allocation_pools=request.allocation_pools) + allocation_pools=request.allocation_pools, + set_gateway_ip=request.set_gateway_ip, + ) msg = _("Cannot allocate requested subnet from the available " "set of prefixes") raise exceptions.SubnetAllocationError(reason=msg) @@ -200,13 +204,17 @@ subnet_id, cidr, gateway_ip=None, - allocation_pools=None): + allocation_pools=None, + set_gateway_ip=True, + ): self._req = ipam_req.SpecificSubnetRequest( tenant_id, subnet_id, cidr, gateway_ip=gateway_ip, - allocation_pools=allocation_pools) + allocation_pools=allocation_pools, + set_gateway_ip=set_gateway_ip, + ) def allocate(self, address_request): raise NotImplementedError() diff -Nru neutron-26.0.0/neutron/objects/subnet.py neutron-26.0.3/neutron/objects/subnet.py --- neutron-26.0.0/neutron/objects/subnet.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/objects/subnet.py 2026-04-15 04:51:52.000000000 +0000 @@ -11,7 +11,6 @@ # under the License. import netaddr -from neutron_lib.api.definitions import external_net from neutron_lib.api import validators from neutron_lib import constants as const from neutron_lib.db import model_query @@ -319,21 +318,6 @@ setattr(self, 'external', external_network) self.obj_reset_changes(['external']) - @classmethod - def get_objects(cls, context, _pager=None, validate_filters=True, - fields=None, return_db_obj=False, **kwargs): - external = kwargs.pop(external_net.EXTERNAL, None) - if isinstance(external, list): - external = external[0] - subnets = super().get_objects( - context, _pager=_pager, validate_filters=validate_filters, - fields=fields, return_db_obj=return_db_obj, **kwargs) - - if external is not None: - return [subnet for subnet in subnets if - subnet.external == external] - return subnets - def from_db_object(self, db_obj): super().from_db_object(db_obj) self._load_dns_publish_fixed_ip(db_obj) diff -Nru neutron-26.0.0/neutron/pecan_wsgi/hooks/policy_enforcement.py neutron-26.0.3/neutron/pecan_wsgi/hooks/policy_enforcement.py --- neutron-26.0.0/neutron/pecan_wsgi/hooks/policy_enforcement.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/pecan_wsgi/hooks/policy_enforcement.py 2026-04-15 04:51:52.000000000 +0000 @@ -16,6 +16,7 @@ from neutron_lib import constants as const from oslo_log import log as logging from oslo_policy import policy as oslo_policy +from oslo_serialization import jsonutils from oslo_utils import excutils from pecan import hooks import webob @@ -190,6 +191,14 @@ # we have to set the status_code here to prevent the catch_errors # middleware from turning this into a 500. state.response.status_code = 404 + # replace the original body on NotFound body + error_message = { + 'type': 'HTTPNotFound', + 'message': 'The resource could not be found.', + 'detail': '' + } + state.response.text = jsonutils.dumps(error_message) + state.response.content_type = 'application/json' return if is_single: diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/helpers.py neutron-26.0.3/neutron/plugins/ml2/drivers/helpers.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/helpers.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/helpers.py 2026-04-15 04:51:52.000000000 +0000 @@ -15,7 +15,6 @@ import functools -from neutron_lib import context from neutron_lib.db import api as db_api from neutron_lib import exceptions from neutron_lib.plugins import constants as plugin_constants @@ -165,8 +164,7 @@ context.elevated()): LOG.debug(' - %s', srange) - @db_api.retry_db_errors - def _delete_expired_default_network_segment_ranges(self, start_time): - ns_range.NetworkSegmentRange.\ - delete_expired_default_network_segment_ranges( - context.get_admin_context(), self.get_type(), start_time) + def _delete_expired_default_network_segment_ranges(self, ctx, start_time): + (ns_range.NetworkSegmentRange. + delete_expired_default_network_segment_ranges( + ctx, self.get_type(), start_time)) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/base_oskenapp.py neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/base_oskenapp.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/base_oskenapp.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/base_oskenapp.py 2026-04-15 04:51:52.000000000 +0000 @@ -24,7 +24,10 @@ class BaseNeutronAgentOSKenApp(app_manager.OSKenApp): OFP_VERSIONS = [ofproto_v1_3.OFP_VERSION] - packet_in_handlers = [] + + def __init__(self): + super().__init__() + self.packet_in_handlers = [] def register_packet_in_handler(self, caller): self.packet_in_handlers.append(caller) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_tun.py neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_tun.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_tun.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_tun.py 2026-04-15 04:51:52.000000000 +0000 @@ -15,8 +15,11 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib import constants as lib_constants 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 neutron.plugins.ml2.drivers.openvswitch.agent.openflow.native \ import br_dvr_process @@ -33,6 +36,62 @@ dvr_process_next_table_id = constants.PATCH_LV_TO_TUN of_tables = constants.TUN_BR_ALL_TABLES + def _setup_learn_flows(self, ofpp, patch_int_ofport): + flow_specs = [ + ofpp.NXFlowSpecMatch(src=('vlan_tci', 0), + dst=('vlan_tci', 0), + n_bits=12), + ofpp.NXFlowSpecMatch(src=('eth_src', 0), + dst=('eth_dst', 0), + n_bits=48), + ofpp.NXFlowSpecLoad(src=0, + dst=('vlan_tci', 0), + n_bits=16), + ofpp.NXFlowSpecLoad(src=('tunnel_id', 0), + dst=('tunnel_id', 0), + n_bits=64), + ofpp.NXFlowSpecOutput(src=('in_port', 0), + dst='', + n_bits=32), + ] + actions = [ + ofpp.NXActionLearn(table_id=constants.UCAST_TO_TUN, + cookie=self.default_cookie, + priority=1, + hard_timeout=300, + specs=flow_specs), + ofpp.OFPActionOutput(patch_int_ofport, 0), + ] + + arp_match = ofpp.OFPMatch( + eth_type=ether_types.ETH_TYPE_ARP, + arp_tha=lib_constants.BROADCAST_MAC + ) + ipv6_ra_match = ofpp.OFPMatch( + eth_type=ether_types.ETH_TYPE_IPV6, + ip_proto=in_proto.IPPROTO_ICMPV6, + icmpv6_type=icmpv6.ND_ROUTER_ADVERT) # icmp_type=134 + ipv6_na_match = ofpp.OFPMatch( + eth_type=ether_types.ETH_TYPE_IPV6, + ip_proto=in_proto.IPPROTO_ICMPV6, + icmpv6_type=icmpv6.ND_NEIGHBOR_ADVERT) # icmp_type=136 + + self.install_apply_actions(table_id=constants.LEARN_FROM_TUN, + priority=2, + match=arp_match, + actions=actions) + self.install_apply_actions(table_id=constants.LEARN_FROM_TUN, + priority=2, + match=ipv6_ra_match, + actions=actions) + self.install_apply_actions(table_id=constants.LEARN_FROM_TUN, + priority=2, + match=ipv6_na_match, + actions=actions) + self.install_apply_actions(table_id=constants.LEARN_FROM_TUN, + priority=1, + actions=actions) + def setup_default_table( self, patch_int_ofport, arp_responder_enabled, dvr_enabled): (dp, ofp, ofpp) = self._get_dp() @@ -81,34 +140,7 @@ # dynamically set-up flows in UCAST_TO_TUN corresponding to remote mac # addresses (assumes that lvid has already been set by a previous flow) # Once remote mac addresses are learnt, output packet to patch_int - flow_specs = [ - ofpp.NXFlowSpecMatch(src=('vlan_tci', 0), - dst=('vlan_tci', 0), - n_bits=12), - ofpp.NXFlowSpecMatch(src=('eth_src', 0), - dst=('eth_dst', 0), - n_bits=48), - ofpp.NXFlowSpecLoad(src=0, - dst=('vlan_tci', 0), - n_bits=16), - ofpp.NXFlowSpecLoad(src=('tunnel_id', 0), - dst=('tunnel_id', 0), - n_bits=64), - ofpp.NXFlowSpecOutput(src=('in_port', 0), - dst='', - n_bits=32), - ] - actions = [ - ofpp.NXActionLearn(table_id=constants.UCAST_TO_TUN, - cookie=self.default_cookie, - priority=1, - hard_timeout=300, - specs=flow_specs), - ofpp.OFPActionOutput(patch_int_ofport, 0), - ] - self.install_apply_actions(table_id=constants.LEARN_FROM_TUN, - priority=1, - actions=actions) + self._setup_learn_flows(ofpp, patch_int_ofport) # Egress unicast will be handled in table UCAST_TO_TUN, where remote # mac addresses will be learned. For now, just add a default flow that diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_dvr_neutron_agent.py neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_dvr_neutron_agent.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_dvr_neutron_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_dvr_neutron_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -805,7 +805,7 @@ self.context, self.host, sub_uuid)) local_aap_macs = set() for lport in local_compute_ports: - if lport.id != port.vif_id: + if lport['id'] != port.vif_id: local_aap_macs.update({ aap["mac_address"] for aap in lport.get( "allowed_address_pairs", [])}) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -901,7 +901,7 @@ @profiler.trace("rpc") def tunnel_delete(self, context, **kwargs): - LOG.debug("tunnel_delete received") + LOG.debug("tunnel_delete received: %s", kwargs) if not self.enable_tunneling: return tunnel_ip = kwargs.get('tunnel_ip') diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/agent/neutron_agent.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/agent/neutron_agent.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/agent/neutron_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/agent/neutron_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -17,6 +17,7 @@ import datetime from oslo_config import cfg +from oslo_log import log as logging from oslo_utils import timeutils from neutron._i18n import _ @@ -25,8 +26,12 @@ from neutron.common import utils +LOG = logging.getLogger(__name__) + + class DeletedChassis: external_ids = {} + other_config = {} hostname = '("Chassis" register deleted)' name = '("Chassis" register deleted)' @@ -291,8 +296,25 @@ def get_agents(self, filters=None): filters = filters or {} agent_list = [] + type_errors = {} for agent in self: agent_dict = agent.as_dict() - if all(agent_dict[k] in v for k, v in filters.items()): + for k, v in filters.items(): + if isinstance(agent_dict[k], type(v)): + if agent_dict[k] != v: + break + else: + if utils.is_iterable_not_string(v): + if agent_dict[k] not in v: + break + else: + type_errors[k] = (type(agent_dict[k]), v) + break + else: agent_list.append(agent) + for field, (field_type, value) in type_errors.items(): + LOG.info(f'Value "{value}" {type(value)} does not ' + f'match the OVN related agent field "{field}" ' + f'with type {field_type}') + return agent_list diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -23,6 +23,7 @@ import types import uuid +import netaddr from neutron_lib.api.definitions import portbindings from neutron_lib.api.definitions import provider_net from neutron_lib.api.definitions import segment as segment_def @@ -305,6 +306,11 @@ registry.subscribe(self.delete_segment_provnet_port, resources.SEGMENT, events.AFTER_DELETE) + # TODO(slaweq): use constant from neutron_lib.callbacks.resources once + # it will be available and released + registry.subscribe(self._validate_allowed_address_pairs, + 'allowed_address_pair', + events.BEFORE_CREATE) # Handle security group/rule or address group notifications if self.sg_enabled: @@ -623,6 +629,57 @@ ) raise n_exc.InvalidInput(error_message=m) + def _validate_allowed_address_pairs(self, resource, event, trigger, + payload): + context = payload.desired_state['context'] + allowed_address_pairs = payload.desired_state['allowed_address_pairs'] + network_id = payload.desired_state['network_id'] + if not allowed_address_pairs: + return + + port_allowed_address_pairs_ip_addresses = [ + netaddr.IPNetwork(pair['ip_address']) + for pair in allowed_address_pairs] + + distributed_ports = self._plugin.get_ports( + context.elevated(), + filters={'device_owner': [const.DEVICE_OWNER_DISTRIBUTED], + 'network_id': [network_id]}) + if not distributed_ports: + return + + def _get_common_ips(ip_addresses, ip_networks): + common_ips = set() + for ip_address in ip_addresses: + if any(ip_address in ip_net for ip_net in ip_networks): + common_ips.add(str(ip_address)) + return common_ips + + # NOTE(slaweq): We can safely ignore any CIDR larger than /32 (for + # IPv4) or /128 (for IPv6) in the allowed_address_pairs, since such + # CIDRs cannot be set as a Virtual IP in OVN. + # Only /32 and /128 CIDRs are allowed to be set as Virtual IPs in OVN. + address_pairs_to_check = [ + ip_net for ip_net in port_allowed_address_pairs_ip_addresses + if ip_net.size == 1] + + for distributed_port in distributed_ports: + distributed_port_ip_addresses = [ + netaddr.IPAddress(fixed_ip['ip_address']) for fixed_ip in + distributed_port.get('fixed_ips', [])] + + common_ips = _get_common_ips( + distributed_port_ip_addresses, + address_pairs_to_check) + + if common_ips: + err_msg = ( + _("IP addresses '%s' already used by the '%s' port(s) in " + "the same network" % (";".join(common_ips), + const.DEVICE_OWNER_DISTRIBUTED)) + ) + raise n_exc.InvalidInput(error_message=err_msg) + def create_segment_provnet_port(self, resource, event, trigger, payload=None): segment = payload.latest_state @@ -1321,7 +1378,7 @@ if self._should_notify_nova(db_port): self._plugin.nova_notifier.record_port_status_changed( - db_port, const.PORT_STATUS_ACTIVE, const.PORT_STATUS_DOWN, + db_port, const.PORT_STATUS_DOWN, const.PORT_STATUS_ACTIVE, None) self._plugin.nova_notifier.send_port_status( None, None, db_port) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/commands.py 2026-04-15 04:51:52.000000000 +0000 @@ -635,6 +635,40 @@ _updatevalues_in_list(lswitch, 'acls', old_values=acls_to_del) +class DelACLBySGruleIDCommand(command.BaseCommand): + lookup_table = 'Port_Group' + + def __init__(self, api, sg_id, sg_rule_id, if_exists): + super().__init__(api) + self.sg_id = sg_id + self.sg_rule_id = sg_rule_id + self.if_exists = if_exists + + def run_idl(self, txn): + pg_name = utils.ovn_port_group_name(self.sg_id) + try: + port_group = idlutils.row_by_value( + self.api.idl, self.lookup_table, 'name', pg_name) + except idlutils.RowNotFound: + if self.if_exists: + return + msg = _('%(table)s %(name)s does not exist') % { + 'table': self.lookup_table, 'name': pg_name} + raise RuntimeError(msg) + + acls_to_del = None + acls = getattr(port_group, 'acls', []) + for acl in acls: + ext_ids = getattr(acl, 'external_ids', {}) + if (ext_ids.get(ovn_const.OVN_SG_RULE_EXT_ID_KEY) == + self.sg_rule_id): + acls_to_del = acl + break + if acls_to_del: + acls_to_del.delete() + _updatevalues_in_list(port_group, 'acls', old_values=[acls_to_del]) + + class AddStaticRouteCommand(command.BaseCommand): def __init__(self, api, lrouter, maintain_bfd=False, **columns): super().__init__(api) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py 2026-04-15 04:51:52.000000000 +0000 @@ -125,16 +125,19 @@ if self._driver._post_fork_event.is_set(): return self._driver._ovn_client.placement_extension + @property + def placement_extension_enabled(self): + return self.placement_extension and self.placement_extension.enabled + def match_fn(self, event, row, old=None): - # If the OVNMechanismDriver OVNClient has not been instantiated, the - # event is skipped. All chassis configurations are read during the - # OVN placement extension initialization. - if (not self.placement_extension or - not self.placement_extension.enabled): - return False if event == self.ROW_CREATE: return True - if event == self.ROW_UPDATE and old and hasattr(old, 'other_config'): + + # If the OVNMechanismDriver OVNClient has not been instantiated, the + # update event is skipped. + if not self.placement_extension_enabled: + return False + if old and hasattr(old, 'other_config'): row_bw = _parse_ovn_cms_options(row) old_bw = _parse_ovn_cms_options(old) if row_bw != old_bw: @@ -142,15 +145,24 @@ return False def run(self, event, row, old): + if event == self.ROW_CREATE: + # It is possible that a Chassis create event is received before + # the OVNMechanismDriver OVNClient has been instantiated. Wait for + # it and check the Placement extension. + self._driver._post_fork_event.wait() + if not self.placement_extension_enabled: + return + name2uuid = self.placement_extension.name2uuid() - state = self.placement_extension.build_placement_state(row, name2uuid) + state = self.placement_extension.build_placement_state(row, name2uuid, + chassis_old=old) if not state: return _send_deferred_batch(state) ch_config = dict_chassis_config(state) - LOG.debug('OVN chassis %(chassis)s Placement configuration modified: ' - '%(config)s', {'chassis': row.name, 'config': ch_config}) + LOG.info('OVN chassis %(chassis)s Placement configuration modified: ' + '%(config)s', {'chassis': row.name, 'config': ch_config}) @common_utils.SingletonDecorator @@ -207,6 +219,8 @@ name2uuid = self.name2uuid() for ch in self._driver._sb_idl.chassis_list().execute( check_error=True): + # TODO(ralonsoh): retrieve the OVN controller agent current RP + # information and delete any child RP not present in the chassis. state = self.build_placement_state(ch, name2uuid) if state: chassis[ch.name] = state @@ -228,7 +242,7 @@ msg = ', '.join(['Chassis {}: {}'.format( name, dict_chassis_config(state)) for (name, state) in chassis.items()]) or '(no info)' - LOG.debug('OVN chassis Placement initial configuration: %s', msg) + LOG.info('OVN chassis Placement initial configuration: %s', msg) return chassis def name2uuid(self, name=None): @@ -244,9 +258,22 @@ '(name:uuid):%s ', _name2uuid) return _name2uuid - def build_placement_state(self, chassis, name2uuid): + def build_placement_state(self, chassis, name2uuid, chassis_old=None): bridge_mappings = _parse_bridge_mappings(chassis) cms_options = _parse_ovn_cms_options(chassis) + try: + cms_options_old = _parse_ovn_cms_options(chassis_old) + except AttributeError: + cms_options_old = {} + + rp_new = set(cms_options.get(n_const.RP_BANDWIDTHS, {}).keys()) + rp_old = set(cms_options_old.get(n_const.RP_BANDWIDTHS, {}).keys()) + rp_deleted = rp_old - rp_new + rp_hyp_deleted = { + device: hyperv for device, hyperv in + cms_options_old.get(n_const.RP_HYPERVISORS, {}).items() if + device in rp_deleted} + LOG.debug('Building placement options for chassis %s: %s', chassis.name, cms_options) hypervisor_rps = {} @@ -257,7 +284,10 @@ # ovn-cms-options = # resource_provider_bandwidths=br-ex:100:200;rp_tunnelled:300:400 # resource_provider_hypervisors=br-ex:host1,rp_tunnelled:host1 - for device, hyperv in cms_options[n_const.RP_HYPERVISORS].items(): + rp_hypervisors = itertools.chain( + cms_options[n_const.RP_HYPERVISORS].items(), + rp_hyp_deleted.items()) + for device, hyperv in rp_hypervisors: try: hypervisor_rps[device] = {'name': hyperv, 'uuid': name2uuid[hyperv]} @@ -294,4 +324,6 @@ hypervisor_rps=hypervisor_rps, device_mappings=bridge_mappings, supported_vnic_types=self.supported_vnic_types, - client=self.placement_plugin._placement_client) + client=self.placement_plugin._placement_client, + rp_deleted=rp_deleted, + ) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/qos.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/qos.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/qos.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/qos.py 2026-04-15 04:51:52.000000000 +0000 @@ -33,6 +33,7 @@ LOG = logging.getLogger(__name__) OVN_QOS_DEFAULT_RULE_PRIORITY = 2002 +OVN_QOS_FIP_RULE_PRIORITY = 2003 _MIN_RATE = ovn_const.LSP_OPTIONS_QOS_MIN_RATE # NOTE(ralonsoh): this constant will be in neutron_lib.constants TYPE_PHYSICAL = (constants.TYPE_FLAT, constants.TYPE_VLAN) @@ -167,8 +168,11 @@ match = self._ovn_qos_rule_match(rules_direction, port_id, ip_address, resident_port) - ovn_qos_rule = {'switch': lswitch_name, 'direction': direction, - 'priority': OVN_QOS_DEFAULT_RULE_PRIORITY, + priority = (OVN_QOS_FIP_RULE_PRIORITY if fip_id else + OVN_QOS_DEFAULT_RULE_PRIORITY) + ovn_qos_rule = {'switch': lswitch_name, + 'direction': direction, + 'priority': priority, 'match': match} if not rules: @@ -205,7 +209,23 @@ return ovn_qos_rule - def _ovn_lsp_rule(self, rules): + def get_lsp_options_qos(self, port_id): + """Return the current LSP.options QoS fields, passing the port ID""" + qos_options = {} + lsp = self.nb_idl.lookup('Logical_Switch_Port', port_id, default=None) + if not lsp: + return {} + + for qos_key in (ovn_const.LSP_OPTIONS_QOS_MAX_RATE, + ovn_const.LSP_OPTIONS_QOS_BURST, + ovn_const.LSP_OPTIONS_QOS_MIN_RATE): + qos_value = lsp.options.get(qos_key) + if qos_value is not None: + qos_options[qos_key] = qos_value + return qos_options + + @staticmethod + def _ovn_lsp_rule(rules): """Generate the OVN LSP.options for physical network ports (egress) The Logical_Switch_Port options field is a dictionary that can contain @@ -328,14 +348,25 @@ _qos_rules = (copy.deepcopy(qos_rules) if qos_rules else self._qos_rules(admin_context, qos_policy_id)) for direction, rules in _qos_rules.items(): + min_bw = rules.get(qos_consts.RULE_TYPE_MINIMUM_BANDWIDTH) + # NOTE(ralonsoh): the QoS rules are defined in the LSP.options + # dictionary if (1) direction=egress, (2) the network is physical + # and (3) there are min-bw rules. Otherwise, the OVN QoS registers + # are used (OVN BW policer). if (network_type in TYPE_PHYSICAL and direction == constants.EGRESS_DIRECTION): - ovn_rule_lsp = self._ovn_lsp_rule(rules) - self._update_lsp_qos_options(txn, lsp, port_id, ovn_rule_lsp) - # In this particular case, the QoS rules should be defined in - # LSP.options. Only DSCP rule will create a QoS entry. - rules.pop(qos_consts.RULE_TYPE_BANDWIDTH_LIMIT, None) - rules.pop(qos_consts.RULE_TYPE_MINIMUM_BANDWIDTH, None) + if min_bw: + ovn_rule_lsp = self._ovn_lsp_rule(rules) + self._update_lsp_qos_options(txn, lsp, port_id, + ovn_rule_lsp) + # In this particular case, the QoS rules should be defined + # in LSP.options. Only DSCP rule will create a QoS entry. + rules.pop(qos_consts.RULE_TYPE_BANDWIDTH_LIMIT, None) + rules.pop(qos_consts.RULE_TYPE_MINIMUM_BANDWIDTH, None) + else: + # Clear the LSP.options QoS rules. + self._update_lsp_qos_options(txn, lsp, port_id, + self._ovn_lsp_rule({})) ovn_rule_qos = self._ovn_qos_rule(direction, rules, port_id, network_id) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py 2026-04-15 04:51:52.000000000 +0000 @@ -332,11 +332,17 @@ if ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY not in ( lrouter.external_ids): continue - lrports = {lrport.name.replace('lrp-', ''): lrport.networks - for lrport in getattr(lrouter, 'ports', [])} - sroutes = [{'destination': sroute.ip_prefix, - 'nexthop': sroute.nexthop} - for sroute in getattr(lrouter, 'static_routes', [])] + lrports = { + lrport.name.replace('lrp-', ''): lrport.networks + for lrport in getattr(lrouter, 'ports', []) + if ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY in lrport.external_ids + } + sroutes = [ + {'destination': route.ip_prefix, 'nexthop': route.nexthop} + for route in getattr(lrouter, 'static_routes', []) + if any(eid.startswith(constants.DEVICE_OWNER_NEUTRON_PREFIX) + for eid in route.external_ids) + ] dnat_and_snats = [] snat = [] @@ -483,6 +489,10 @@ def delete_acl(self, lswitch, lport, if_exists=True): return cmd.DelACLCommand(self, lswitch, lport, if_exists) + def delete_acl_by_sg_id(self, sg_id, sg_rule_id, if_exists=True): + """Removes an ACL register matching the security group rule ID""" + return cmd.DelACLBySGruleIDCommand(self, sg_id, sg_rule_id, if_exists) + def add_static_route(self, lrouter, maintain_bfd=False, **columns): return cmd.AddStaticRouteCommand(self, lrouter, maintain_bfd, **columns) diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py 2026-04-15 04:51:52.000000000 +0000 @@ -26,12 +26,14 @@ from neutron_lib import constants as n_const from neutron_lib import context as n_context from neutron_lib import exceptions as n_exc +from neutron_lib.exceptions import address_group as ag_exc from neutron_lib.exceptions import l3 as l3_exc from oslo_config import cfg from oslo_log import log from oslo_utils import strutils from oslo_utils import timeutils from ovsdbapp.backend.ovs_idl import event as row_event +from ovsdbapp.backend.ovs_idl import rowview from neutron.common.ovn import constants as ovn_const from neutron.common.ovn import utils @@ -42,6 +44,9 @@ from neutron.objects import network as network_obj from neutron.objects import ports as ports_obj from neutron.objects import router as router_obj +from neutron.objects import securitygroup as sg_obj +from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions import qos \ + as qos_extension from neutron import service from neutron.services.logapi.drivers.ovn import driver as log_driver @@ -530,6 +535,8 @@ cmds = [] for ls in self._nb_idl.ls_list().execute(check_error=True): + if ovn_const.OVN_NETWORK_NAME_EXT_ID_KEY not in ls.external_ids: + continue snooping = ls.other_config.get(ovn_const.MCAST_SNOOP) flood = ls.other_config.get(ovn_const.MCAST_FLOOD_UNREGISTERED) @@ -569,9 +576,11 @@ context = n_context.get_admin_context() with self._nb_idl.transaction(check_error=True) as txn: for port in external_ports: - network_id = port.external_ids[ - ovn_const.OVN_NETWORK_NAME_EXT_ID_KEY].replace( + network_id = port.external_ids.get( + ovn_const.OVN_NETWORK_NAME_EXT_ID_KEY, '').replace( ovn_const.OVN_NAME_PREFIX, '') + if not network_id: + continue ha_ch_grp, high_prio_ch = utils.sync_ha_chassis_group_network( context, self._nb_idl, self._sb_idl, port.name, network_id, txn) @@ -1002,6 +1011,9 @@ for seg in net_segments} cmds = [] for ls in self._nb_idl.ls_list().execute(check_error=True): + if ovn_const.OVN_NETWORK_NAME_EXT_ID_KEY not in ls.external_ids: + continue + if ovn_const.OVN_NETTYPE_EXT_ID_KEY not in ls.external_ids: net_id = utils.get_neutron_name(ls.name) external_ids = { @@ -1134,6 +1146,76 @@ raise periodics.NeverAgain() + # TODO(ralonsoh): to remove in E+4 cycle (2nd next SLURP release) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) + def update_qos_fip_rule_priority(self): + """The new QoS FIP rule priority is OVN_QOS_FIP_RULE_PRIORITY""" + cmds = [] + table = self._nb_idl.tables['QoS'] + for qos_rule in table.rows.values(): + qos_rule = rowview.RowView(qos_rule) + if qos_rule.external_ids.get(ovn_const.OVN_FIP_EXT_ID_KEY): + cmds.append(self._nb_idl.db_set( + 'QoS', qos_rule.uuid, + ('priority', qos_extension.OVN_QOS_FIP_RULE_PRIORITY))) + + if cmds: + with self._nb_idl.transaction(check_error=True) as txn: + for cmd in cmds: + txn.add(cmd) + + raise periodics.NeverAgain() + + # TODO(ralonsoh): to remove in G+4 (2028.1) cycle (2nd next SLURP release) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) + def update_security_group_with_address_group(self): + """Create all Address_Set and update the corresponding ACLs""" + # 1. List all Address Groups with missing Address_Set registers. + admin_context = n_context.get_admin_context() + ag_ids_missing_as = [] + for ag in self._ovn_client._plugin.get_address_groups(admin_context): + for ip_version in n_const.IP_ALLOWED_VERSIONS: + as_name = utils.ovn_ag_addrset_name( + ag['id'], 'ip' + str(ip_version)) + if not self._nb_idl.lookup('Address_Set', as_name, + default=None): + ag_ids_missing_as.append(ag['id']) + break + + # 2. Create the corresponding Address_Set (IPv4, IPv6) registers. + # The ``create_address_group`` method calls ``address_set_add`` with + # may_exist=True, so is safe if any of these registers already exists. + # This operation will also create the OVN revision number for each + # Address Group. + for ag_id in ag_ids_missing_as: + try: + _ag = self._ovn_client._plugin.get_address_group( + admin_context, ag_id) + except ag_exc.AddressGroupNotFound: + continue + self._ovn_client.create_address_group(admin_context, _ag) + + # 3. Update all the ACLs associated to the SG rules that have these + # Address Groups. + filter = {'remote_address_group_id': ag_ids_missing_as} + for sg_rule in sg_obj.SecurityGroupRule.get_objects( + admin_context, **filter): + # Delete the current ACL. + self._ovn_client._nb_idl.delete_acl_by_sg_id( + sg_rule['security_group_id'], sg_rule['id'], + if_exists=True).execute(check_error=True) + # Re-create the ACL including the Address Group (OVN Address_Set). + self._ovn_client.create_security_group_rule( + admin_context, sg_rule) + + raise periodics.NeverAgain() + class HashRingHealthCheckPeriodics: diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py 2026-04-15 04:51:52.000000000 +0000 @@ -267,7 +267,7 @@ wait=tenacity.wait_random(min=2, max=3), stop=tenacity.stop_after_attempt(3), reraise=True) - def _wait_for_port_bindings_host(self, context, port_id): + def _wait_for_active_port_bindings_host(self, context, port_id): db_port = ml2_db.get_port(context, port_id) # This is already checked previously but, just to stay on # the safe side in case the port is deleted mid-operation @@ -280,11 +280,18 @@ _('No port bindings information found for ' 'port %s') % port_id) - if not db_port.port_bindings[0].host: + active_binding = p_utils.get_port_binding_by_status_and_host( + db_port.port_bindings, const.ACTIVE) + if not active_binding: + raise RuntimeError( + _('No active port bindings information found for ' + 'port %s') % port_id) + + if not active_binding.host: raise RuntimeError( _('No hosting information found for port %s') % port_id) - return db_port + return active_binding def update_lsp_host_info(self, context, db_port, up=True): """Update the binding hosting information for the LSP. @@ -307,24 +314,29 @@ if up: if not port_up: LOG.warning('Logical_Switch_Port %s host information not ' - 'updated, the port state is down') + 'updated, the port state is down', db_port.id) return if not db_port.port_bindings: return - if not db_port.port_bindings[0].host: + # There could be more than one port binding present, we need + # to find the active one + active_binding = p_utils.get_port_binding_by_status_and_host( + db_port.port_bindings, const.ACTIVE) + + if not active_binding or not active_binding.host: # NOTE(lucasgomes): There might be a sync issue between # the moment that this port was fetched from the database # and the hosting information being set, retry a few times try: - db_port = self._wait_for_port_bindings_host( + active_binding = self._wait_for_active_port_bindings_host( context, db_port.id) except RuntimeError as e: LOG.warning(e) return - host = db_port.port_bindings[0].host + host = active_binding.host ext_ids = ('external_ids', {ovn_const.OVN_HOST_ID_EXT_ID_KEY: host}) cmd.append( @@ -333,7 +345,7 @@ else: if port_up: LOG.warning('Logical_Switch_Port %s host information not ' - 'removed, the port state is up') + 'removed, the port state is up', db_port.id) return cmd.append( @@ -497,8 +509,11 @@ if self.is_mcast_flood_broken and port_type not in ( 'vtep', ovn_const.LSP_TYPE_LOCALPORT, 'router'): options.update({ovn_const.LSP_OPTIONS_MCAST_FLOOD_REPORTS: 'true'}) - sg_ids = ' '.join(utils.get_lsp_security_groups(port)) + + lsp_options_qos = self._qos_driver.get_lsp_options_qos(port['id']) + options.update(lsp_options_qos) + return OvnPortInfo(port_type, options, addresses, port_security, parent_name, tag, dhcpv4_options, dhcpv6_options, cidrs.strip(), device_owner, sg_ids, @@ -1726,13 +1741,13 @@ LOG.debug("Router %s not found", port['device_id']) else: network_ids = {port['network_id'] for port in router_ports} - # If this method is called during a port creation, the port - # won't be present yet in the router ports list. - network_ids.add(port['network_id']) - networks = None if ovn_conf.is_ovn_emit_need_to_frag_enabled(): + # If this method is called during a port creation, the port + # won't be present yet in the router ports list. It is + # needed not to modify the ``network_ids`` set. + _network_ids = network_ids.union({port['network_id']}) networks = self._plugin.get_networks( - admin_context, filters={'id': network_ids}) + admin_context, filters={'id': _network_ids}) # Set the lower MTU of all networks connected to the router min_mtu = str(min(net['mtu'] for net in networks)) options[ovn_const.OVN_ROUTER_PORT_GW_MTU_OPTION] = min_mtu @@ -1744,7 +1759,7 @@ # If there are no VLAN type networks attached we need to # still make it centralized. enable_redirect = False - networks = networks or self._plugin.get_networks( + networks = self._plugin.get_networks( admin_context, filters={'id': network_ids}) if networks: enable_redirect = all( @@ -1836,7 +1851,8 @@ for gw_port in gw_ports: provider_net = self._plugin.get_network( context, gw_port['network_id']) - self.set_gateway_mtu(context, provider_net) + self.set_gateway_mtu(context, provider_net, txn=txn, + router_id=router_id) if _has_separate_snat_per_subnet(router): for fixed_ip in port['fixed_ips']: @@ -1982,7 +1998,8 @@ for gw_port in gw_ports: provider_net = self._plugin.get_network( context, gw_port['network_id']) - self.set_gateway_mtu(context, provider_net, txn=txn) + self.set_gateway_mtu(context, provider_net, txn=txn, + router_id=router_id) if _has_separate_snat_per_subnet(router): for sid in subnet_ids: @@ -2159,10 +2176,13 @@ db_rev.delete_revision( context, network_id, ovn_const.TYPE_NETWORKS) - def set_gateway_mtu(self, context, prov_net, txn=None): - ports = self._plugin.get_ports( - context, filters=dict(network_id=[prov_net['id']], - device_owner=[const.DEVICE_OWNER_ROUTER_GW])) + def set_gateway_mtu(self, context, prov_net, txn=None, + router_id=None): + _filters = {'network_id': [prov_net['id']], + 'device_owner': [const.DEVICE_OWNER_ROUTER_GW]} + if router_id: + _filters['device_id'] = [router_id] + ports = self._plugin.get_ports(context, filters=_filters) commands = [] for port in ports: lrp_name = utils.ovn_lrouter_port_name(port['id']) @@ -2272,7 +2292,7 @@ # make sure to use admin context as this is a external # network self.set_gateway_mtu(n_context.get_admin_context(), - network, txn) + network, txn=txn) self._check_network_changes_in_ha_chassis_groups( context, lswitch, lswitch_params, txn) @@ -2340,7 +2360,12 @@ # If the value is null (i.e. config ntp_server:), treat it as # a request to remove the option if value: - options[option] = value + # Example: ntp_server='{1.2.3.4, 1.2.3.5}'. A single value is + # also allowed but in shake of readability, it is printed as a + # single string. + _value = value.split(';') + options[option] = (_value[0] if len(_value) == 1 else + '{%s}' % ', '.join(_value)) else: try: del options[option] diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py 2026-04-15 04:51:52.000000000 +0000 @@ -35,6 +35,7 @@ from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions import qos \ as ovn_qos from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import ovn_client +from neutron.services.logapi.drivers.ovn import driver as log_driver from neutron.services.segments import db as segments_db @@ -82,6 +83,18 @@ self.segments_plugin = ( manager.NeutronManager.load_class_for_provider( 'neutron.service_plugins', 'segments')()) + self.log_plugin = directory.get_plugin(plugin_constants.LOG_API) + if not self.log_plugin: + self.log_plugin = ( + manager.NeutronManager.load_class_for_provider( + 'neutron.service_plugins', 'log')()) + directory.add_plugin(plugin_constants.LOG_API, self.log_plugin) + for driver in self.log_plugin.driver_manager.drivers: + if driver.name == "ovn": + self.ovn_log_driver = driver + if not hasattr(self, 'ovn_log_driver'): + self.ovn_log_driver = log_driver.OVNDriver() + self.log_plugin.driver_manager.register_driver(self.ovn_log_driver) def stop(self): if utils.is_ovn_l3(self.l3_plugin): @@ -183,7 +196,11 @@ ovn_pgs = set() port_groups = self.ovn_api.db_list_rows('Port_Group').execute() or [] for pg in port_groups: - ovn_pgs.add(pg.name) + # Default neutron "drop pg" does NOT have any external IDs, but + # we still want to manage it, so we match it on its name. + if (ovn_const.OVN_SG_EXT_ID_KEY in pg.external_ids or + pg.name == ovn_const.OVN_DROP_PORT_GROUP_NAME): + ovn_pgs.add(pg.name) add_pgs = neutron_pgs.difference(ovn_pgs) remove_pgs = ovn_pgs.difference(neutron_pgs) @@ -233,6 +250,9 @@ def _get_acls_from_port_groups(self): ovn_acls = [] + # Options and label columns are only present for OVN >= 22.03. + # Furthermore label is a randint so it cannot be compared with any + # expected neutron value. They are added later on the ACL addition. acl_columns = (self.ovn_api._tables['ACL'].columns.keys() & set(ovn_const.ACL_EXPECTED_COLUMNS_NBDB)) acl_columns.discard('external_ids') @@ -244,7 +264,21 @@ acl_string['port_group'] = pg.name if id_key in acl.external_ids: acl_string[id_key] = acl.external_ids[id_key] + elif pg.name != ovn_const.OVN_DROP_PORT_GROUP_NAME: + # If ACL is not associated with a security group rule, + # nor it belongs to the default neutron_pg_drop port group, + # it don't need to be synced. + continue + # This properties are present as lists of one item, + # converting them to string. + if acl_string['name']: + acl_string['name'] = acl_string['name'][0] + if acl_string['meter']: + acl_string['meter'] = acl_string['meter'][0] + if acl_string['severity']: + acl_string['severity'] = acl_string['severity'][0] ovn_acls.append(acl_string) + return ovn_acls def sync_acls(self, ctx): @@ -274,6 +308,10 @@ neutron_default_acls = acl_utils.add_acls_for_drop_port_group( ovn_const.OVN_DROP_PORT_GROUP_NAME) + # Add logging options + self.ovn_log_driver.add_logging_options_to_acls(neutron_acls, ctx) + self.ovn_log_driver.add_logging_options_to_acls(neutron_default_acls, + ctx) ovn_acls = self._get_acls_from_port_groups() # Sort the acls in the ovn database according to the security # group rule id for easy comparison in the future. @@ -304,17 +342,54 @@ o_index += 1 elif n_id == o_id: if any(item not in na.items() for item in oa.items()): + for item in oa.items(): + if item not in na.items(): + LOG.warning('Property %(item)s from OVN ACL not ' + 'found in Neutron ACL: %(n_acl)s', + {'item': item, + 'n_acl': na}) add_acls.append(na) remove_acls.append(oa) n_index += 1 o_index += 1 elif n_id > o_id: + LOG.warning('ACL should not be present in OVN, removing' + '%(acl)s', {'acl': oa}) remove_acls.append(oa) o_index += 1 else: + LOG.warning('ACL should be present in OVN but is not, adding:' + '%(acl)s', {'acl': na}) add_acls.append(na) n_index += 1 + # Check any ACLs we found to add against existing ACLs, ignoring the + # SG rule ID key. This eliminates any false-positives where the + # normalized cidr for two SG rules is the same value, since there + # will only be a single ACL that matches exactly with the SG rule ID. + if add_acls: + def copy_acl_rem_id_key(acl): + acl_copy = acl.copy() + del acl_copy[ovn_const.OVN_SG_RULE_EXT_ID_KEY] + return acl_copy + + add_rem_acls = [] + # Make a list of non-default rule ACLs (they have a security group + # rule id). See ovn_default_acls code/comment above for more info. + nd_ovn_acls = [copy_acl_rem_id_key(oa) for oa in ovn_acls + if ovn_const.OVN_SG_RULE_EXT_ID_KEY in oa] + # We must copy here since we need to keep the original + # 'add_acl' intact for removal + for add_acl in add_acls: + add_acl_copy = copy_acl_rem_id_key(add_acl) + if add_acl_copy in nd_ovn_acls: + add_rem_acls.append(add_acl) + + # Remove any of the false-positive ACLs + LOG.warning('False-positive ACLs to remove: (%s)', add_rem_acls) + for add_rem in add_rem_acls: + add_acls.remove(add_rem) + if n_index < neutron_num: # We didn't find the OVN ACLs matching the Neutron ACLs # in "ovn_acls" and we are just adding the pending Neutron ACLs. @@ -351,6 +426,22 @@ if (self.mode == ovn_const.OVN_DB_SYNC_MODE_REPAIR and (num_acls_to_add or num_acls_to_remove)): one_time_pg_resync = True + with self.ovn_api.transaction(check_error=True) as txn: + for aclr in ovn_acls: + LOG.warning('ACLs found in OVN NB DB but not in ' + 'Neutron for port group %s', + aclr['port_group']) + txn.add(self.ovn_api.pg_acl_del(aclr['port_group'], + aclr['direction'], + aclr['priority'], + aclr['match'])) + for aclr in ovn_acls_from_ls: + # Remove all the ACLs from any Logical Switch if they have + # any. Elements are (lswitch_name, list_of_acls). + if len(aclr[1]) > 0: + LOG.warning('Removing ACLs from OVN from Logical ' + 'Switch %s', aclr[0]) + txn.add(self.ovn_api.acl_del(aclr[0])) while True: try: with self.ovn_api.transaction(check_error=True) as txn: @@ -358,8 +449,18 @@ LOG.warning('ACL found in Neutron but not in ' 'OVN NB DB for port group %s', acla['port_group']) - txn.add(self.ovn_api.pg_acl_add( - **acla, may_exist=True)) + acl = txn.add(self.ovn_api.pg_acl_add(**acla, + may_exist=True)) + # We need to do this now since label should be + # random and not 0. We can use options as a way + # to see if label is supported or not. + if acla.get('log'): + self.ovn_log_driver.add_label_related(acla, + ctx) + txn.add(self.ovn_api.db_set('ACL', acl, + label=acla['label'], + options=acla['options'])) + except idlutils.RowNotFound as row_err: if row_err.msg.startswith("Cannot find Port_Group"): if one_time_pg_resync: @@ -377,23 +478,6 @@ raise break - with self.ovn_api.transaction(check_error=True) as txn: - for aclr in ovn_acls: - LOG.warning('ACLs found in OVN NB DB but not in ' - 'Neutron for port group %s', - aclr['port_group']) - txn.add(self.ovn_api.pg_acl_del(aclr['port_group'], - aclr['direction'], - aclr['priority'], - aclr['match'])) - for aclr in ovn_acls_from_ls: - # Remove all the ACLs from any Logical Switch if they have - # any. Elements are (lswitch_name, list_of_acls). - if len(aclr[1]) > 0: - LOG.warning('Removing ACLs from OVN from Logical ' - 'Switch %s', aclr[0]) - txn.add(self.ovn_api.acl_del(aclr[0])) - LOG.debug('OVN-NB Sync ACLs completed @ %s', str(datetime.now())) def _calculate_routes_differences(self, ovn_routes, db_routes): @@ -564,6 +648,8 @@ db_extends = {} db_router_ports = {} for router in self.l3_plugin.get_routers(ctx): + if not utils.is_ovn_provider_router(router): + continue db_routers[router['id']] = router db_extends[router['id']] = {} db_extends[router['id']]['routes'] = [] @@ -1351,9 +1437,6 @@ self.sync_hostname_and_physical_networks(ctx) if utils.is_ovn_l3(self.l3_plugin): self.l3_plugin.schedule_unhosted_gateways() - # NOTE(ralonsoh): this could be called using a resource event. - self.ovn_driver._ovn_client.placement_extension.\ - read_initial_chassis_config() LOG.debug("OVN-Southbound DB sync process completed @ %s", str(datetime.now())) @@ -1364,7 +1447,7 @@ host_phynets_map = self.ovn_api.get_chassis_hostname_and_physnets() current_hosts = set(host_phynets_map) previous_hosts = segments_db.get_hosts_mapped_with_segments( - ctx, include_agent_types={ovn_const.OVN_CONTROLLER_AGENT}) + ctx, include_agent_types=set(ovn_const.OVN_CONTROLLER_TYPES)) stale_hosts = previous_hosts - current_hosts for host in stale_hosts: diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py 2026-04-15 04:51:52.000000000 +0000 @@ -339,7 +339,8 @@ # On updates to Chassis_Private because the Chassis has been deleted, # don't update the AgentCache. We use chassis_private.chassis to return # data about the agent. - return event == self.ROW_CREATE or hasattr(old, 'nb_cfg') + return (event == self.ROW_CREATE or + (hasattr(old, 'nb_cfg') and row.chassis)) def run(self, event, row, old): n_agent.AgentCache().update(ovn_const.OVN_CONTROLLER_AGENT, row, @@ -693,8 +694,10 @@ self.event_name = 'HAChassisGroupRouterEvent' def match_fn(self, event, row, old): - if (ovn_const.OVN_ROUTER_ID_EXT_ID_KEY in row.external_ids or - hasattr(old, 'ha_chassis')): + if ovn_const.OVN_ROUTER_ID_EXT_ID_KEY not in row.external_ids: + # This is not a router "HA_Chassis_Group". + return False + if hasattr(old, 'ha_chassis'): # "HA_Chassis_Group" has been assigned to a router or there are # changes in the "ha_chassis" list. return True diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/type_tunnel.py neutron-26.0.3/neutron/plugins/ml2/drivers/type_tunnel.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/type_tunnel.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/type_tunnel.py 2026-04-15 04:51:52.000000000 +0000 @@ -127,7 +127,7 @@ # allocation during driver initialization, instead of using the # directory.get_plugin() method - the normal way used elsewhere to # check if a plugin is loaded. - self.sync_allocations() + self._sync_allocations() def _parse_tunnel_ranges(self, tunnel_ranges, current_range): for entry in tunnel_ranges: @@ -145,17 +145,15 @@ {'type': self.get_type(), 'range': current_range}) @db_api.retry_db_errors - def _populate_new_default_network_segment_ranges(self, start_time): - ctx = context.get_admin_context() - with db_api.CONTEXT_WRITER.using(ctx): - for tun_min, tun_max in self.tunnel_ranges: - range_obj.NetworkSegmentRange.new_default( - ctx, self.get_type(), None, tun_min, tun_max, start_time) + def _populate_new_default_network_segment_ranges(self, ctx, start_time): + for tun_min, tun_max in self.tunnel_ranges: + range_obj.NetworkSegmentRange.new_default( + ctx, self.get_type(), None, tun_min, tun_max, start_time) @db_api.retry_db_errors - def _get_network_segment_ranges_from_db(self): + def _get_network_segment_ranges_from_db(self, ctx=None): ranges = [] - ctx = context.get_admin_context() + ctx = ctx or context.get_admin_context() with db_api.CONTEXT_READER.using(ctx): range_objs = (range_obj.NetworkSegmentRange.get_objects( ctx, network_type=self.get_type())) @@ -164,21 +162,27 @@ return ranges + @db_api.retry_db_errors def initialize_network_segment_range_support(self, start_time): - self._delete_expired_default_network_segment_ranges(start_time) - self._populate_new_default_network_segment_ranges(start_time) - # Override self.tunnel_ranges with the network segment range - # information from DB and then do a sync_allocations since the - # segment range service plugin has not yet been loaded at this - # initialization time. - self.tunnel_ranges = self._get_network_segment_ranges_from_db() - self.sync_allocations() + admin_context = context.get_admin_context() + with db_api.CONTEXT_WRITER.using(admin_context): + self._delete_expired_default_network_segment_ranges( + admin_context, start_time) + self._populate_new_default_network_segment_ranges( + admin_context, start_time) + # Override self.tunnel_ranges with the network segment range + # information from DB and then do a sync_allocations since the + # segment range service plugin has not yet been loaded at this + # initialization time. + self.tunnel_ranges = self._get_network_segment_ranges_from_db( + ctx=admin_context) + self._sync_allocations(ctx=admin_context) def update_network_segment_range_allocations(self): - self.sync_allocations() + self._sync_allocations() @db_api.retry_db_errors - def sync_allocations(self): + def _sync_allocations(self, ctx=None): # determine current configured allocatable tunnel ids tunnel_ids = set() ranges = self.get_network_segment_ranges() @@ -187,7 +191,7 @@ tunnel_id_getter = operator.attrgetter(self.segmentation_key) tunnel_col = getattr(self.model, self.segmentation_key) - ctx = context.get_admin_context() + ctx = ctx or context.get_admin_context() with db_api.CONTEXT_WRITER.using(ctx): # Check if the allocations are updated: if the total number of # allocations for this tunnel type matches the allocations of the diff -Nru neutron-26.0.0/neutron/plugins/ml2/drivers/type_vlan.py neutron-26.0.3/neutron/plugins/ml2/drivers/type_vlan.py --- neutron-26.0.0/neutron/plugins/ml2/drivers/type_vlan.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/drivers/type_vlan.py 2026-04-15 04:51:52.000000000 +0000 @@ -56,16 +56,13 @@ self.model_segmentation_id = vlan_alloc_model.VlanAllocation.vlan_id self._parse_network_vlan_ranges() - @db_api.retry_db_errors - def _populate_new_default_network_segment_ranges(self, start_time): - ctx = context.get_admin_context() - with db_api.CONTEXT_WRITER.using(ctx): - for (physical_network, vlan_ranges) in ( - self.network_vlan_ranges.items()): - for vlan_min, vlan_max in vlan_ranges: - range_obj.NetworkSegmentRange.new_default( - ctx, self.get_type(), physical_network, vlan_min, - vlan_max, start_time) + def _populate_new_default_network_segment_ranges(self, ctx, start_time): + for (physical_network, vlan_ranges) in ( + self.network_vlan_ranges.items()): + for vlan_min, vlan_max in vlan_ranges: + range_obj.NetworkSegmentRange.new_default( + ctx, self.get_type(), physical_network, vlan_min, + vlan_max, start_time) def _parse_network_vlan_ranges(self): try: @@ -78,8 +75,8 @@ LOG.info("Network VLAN ranges: %s", self.network_vlan_ranges) @db_api.retry_db_errors - def _sync_vlan_allocations(self): - ctx = context.get_admin_context() + def _sync_vlan_allocations(self, ctx=None): + ctx = ctx or context.get_admin_context() with db_api.CONTEXT_WRITER.using(ctx): # VLAN ranges per physical network: # {phy1: [(1, 10), (30, 50)], ...} @@ -142,9 +139,9 @@ vlan_ids) @db_api.retry_db_errors - def _get_network_segment_ranges_from_db(self): + def _get_network_segment_ranges_from_db(self, ctx=None): ranges = {} - ctx = context.get_admin_context() + ctx = ctx or context.get_admin_context() with db_api.CONTEXT_READER.using(ctx): range_objs = (range_obj.NetworkSegmentRange.get_objects( ctx, network_type=self.get_type())) @@ -171,15 +168,21 @@ self._sync_vlan_allocations() LOG.info("VlanTypeDriver initialization complete") + @db_api.retry_db_errors def initialize_network_segment_range_support(self, start_time): - self._delete_expired_default_network_segment_ranges(start_time) - self._populate_new_default_network_segment_ranges(start_time) - # Override self.network_vlan_ranges with the network segment range - # information from DB and then do a sync_allocations since the - # segment range service plugin has not yet been loaded at this - # initialization time. - self.network_vlan_ranges = self._get_network_segment_ranges_from_db() - self._sync_vlan_allocations() + admin_context = context.get_admin_context() + with db_api.CONTEXT_WRITER.using(admin_context): + self._delete_expired_default_network_segment_ranges( + admin_context, start_time) + self._populate_new_default_network_segment_ranges( + admin_context, start_time) + # Override self.network_vlan_ranges with the network segment range + # information from DB and then do a sync_allocations since the + # segment range service plugin has not yet been loaded at this + # initialization time. + self.network_vlan_ranges = ( + self._get_network_segment_ranges_from_db(ctx=admin_context)) + self._sync_vlan_allocations(ctx=admin_context) def update_network_segment_range_allocations(self): self._sync_vlan_allocations() diff -Nru neutron-26.0.0/neutron/plugins/ml2/managers.py neutron-26.0.3/neutron/plugins/ml2/managers.py --- neutron-26.0.0/neutron/plugins/ml2/managers.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/managers.py 2026-04-15 04:51:52.000000000 +0000 @@ -32,6 +32,7 @@ import stevedore from neutron._i18n import _ +from neutron.common import wsgi_utils from neutron.conf.plugins.ml2 import config from neutron.db import segments_db from neutron.objects import ports @@ -121,10 +122,6 @@ self.is_partial_segment) return segments - def _match_segment(self, segment, filters): - return all(not filters.get(attr) or segment.get(attr) in filters[attr] - for attr in provider.ATTRIBUTES) - def _get_provider_segment(self, network): # TODO(manishg): Placeholder method # Code intended for operating on a provider segment should use @@ -134,18 +131,6 @@ # here we will do the job of extracting the segment information. return network - def network_matches_filters(self, network, filters): - if not filters: - return True - if any(validators.is_attr_set(network.get(attr)) - for attr in provider.ATTRIBUTES): - segments = [self._get_provider_segment(network)] - elif validators.is_attr_set(network.get(mpnet_apidef.SEGMENTS)): - segments = self._get_attribute(network, mpnet_apidef.SEGMENTS) - else: - return True - return any(self._match_segment(s, filters) for s in segments) - def _get_attribute(self, attrs, key): value = attrs.get(key) if value is constants.ATTR_NOT_SPECIFIED: @@ -205,6 +190,13 @@ driver.obj.initialize() def initialize_network_segment_range_support(self, start_time): + w_id = wsgi_utils.get_api_worker_id() + # NOTE(ralonsoh): ``get_api_worker_id`` returns None in case of using + # the eventlet API server (2025.1 is the last version that supports + # it). In that case, the initialization must be executed. + if w_id != wsgi_utils.FIRST_WORKER_ID and w_id is not None: + return + for network_type, driver in self.drivers.items(): if network_type in constants.NETWORK_SEGMENT_RANGE_TYPES: LOG.info("Initializing driver network segment range support " diff -Nru neutron-26.0.0/neutron/plugins/ml2/plugin.py neutron-26.0.3/neutron/plugins/ml2/plugin.py --- neutron-26.0.0/neutron/plugins/ml2/plugin.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/plugins/ml2/plugin.py 2026-04-15 04:51:52.000000000 +0000 @@ -469,12 +469,6 @@ if workers: self.add_workers(workers) - def _filter_nets_provider(self, context, networks, filters): - return [network - for network in networks - if self.type_manager.network_matches_filters(network, filters) - ] - def _check_mac_update_allowed(self, orig_port, port, binding): new_mac = port.get('mac_address') mac_change = (new_mac is not None and @@ -498,6 +492,34 @@ return True return False + @registry.receives(resources.AGENT, [events.AFTER_DELETE]) + def delete_agent_notified(self, resource, event, trigger, + payload=None): + context = payload.context + agent = payload.states[0] + if agent.binary != const.AGENT_PROCESS_OVS: + return + tunnel_id = payload.resource_id + tunnel_ip = agent.configurations.get('tunneling_ip') + tunnel_types = agent.configurations.get('tunnel_types') + if not tunnel_ip or not tunnel_types: + return + LOG.debug('Deleting tunnel id %s, and endpoints associated with ' + 'it (tunnel_ip: %s tunnel_types: %s)', + tunnel_id, tunnel_ip, tunnel_types) + for t_type in tunnel_types: + self.notifier.tunnel_delete( + context=context, + tunnel_ip=tunnel_ip, + tunnel_type=t_type) + try: + driver = self.type_manager.drivers.get(t_type) + except KeyError: + LOG.warning('Tunnel type %s is not registered, cannot ' + 'delete tunnel endpoint for it.', t_type) + else: + driver.obj.delete_endpoint(tunnel_ip) + @registry.receives(resources.AGENT, [events.AFTER_UPDATE]) def _retry_binding_revived_agents(self, resource, event, trigger, payload=None): @@ -1363,8 +1385,7 @@ net_data.append(self._make_network_dict(net, context=context)) self.type_manager.extend_networks_dict_provider(context, net_data) - nets = self._filter_nets_provider(context, net_data, filters) - return [db_utils.resource_fields(net, fields) for net in nets] + return [db_utils.resource_fields(net, fields) for net in net_data] def get_network_contexts(self, context, network_ids): """Return a map of network_id to NetworkContext for network_ids.""" diff -Nru neutron-26.0.0/neutron/privileged/agent/ovsdb/native/helpers.py neutron-26.0.3/neutron/privileged/agent/ovsdb/native/helpers.py --- neutron-26.0.0/neutron/privileged/agent/ovsdb/native/helpers.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/privileged/agent/ovsdb/native/helpers.py 2026-04-15 04:51:52.000000000 +0000 @@ -13,11 +13,15 @@ # under the License. from oslo_concurrency import processutils +from oslo_log import log as logging from oslo_utils import netutils from neutron import privileged +LOG = logging.getLogger(__name__) + + def _connection_to_manager_uri(conn_uri): proto, addr = conn_uri.split(':', 1) ip, port = netutils.parse_host_port(addr) @@ -45,5 +49,30 @@ '--', 'add', 'Open_vSwitch', '.', 'manager_options', '@manager'] if probe is not None: cmd += ['--', 'set', 'Manager', man_uri, 'inactivity_probe=%s' % probe] - return processutils.execute(*cmd, log_errors=log_fail_as_error, - check_exit_code=check_exit_code) + try: + processutils.execute(*cmd, log_errors=log_fail_as_error, + check_exit_code=check_exit_code) + except processutils.ProcessExecutionError as pe: + LOG.warning("OVS Manager creation failed, it might already " + "exist (stderr: %s).", pe.stderr) + if probe is None: + LOG.debug("No new value for inactivity_probe, re-creation of " + "OVS Manager is not necessary") + return + + # Try to fetch Manager table as it is already exists and see if + # inactivity_probe is already the desired value + cmd = ['ovs-vsctl', '--timeout=%d' % timeout, '--id=@manager', + '--', 'get', 'Manager', man_uri, 'inactivity_probe'] + in_probe = processutils.execute(*cmd, log_errors=log_fail_as_error, + check_exit_code=True) + if in_probe[0].strip() == str(probe): + LOG.info("OVS Manager is already created and inactivity_probe " + "is set to %s.", in_probe[0].strip()) + return in_probe + cmd = ['ovs-vsctl', '--timeout=%d' % timeout, '--', 'set', + 'Manager', man_uri, 'inactivity_probe=%s' % probe] + processutils.execute(*cmd, log_errors=log_fail_as_error, + check_exit_code=True) + LOG.info("OVS Manager was set with new inactivity_probe " + "value %s.", probe) diff -Nru neutron-26.0.0/neutron/services/auto_allocate/db.py neutron-26.0.3/neutron/services/auto_allocate/db.py --- neutron-26.0.0/neutron/services/auto_allocate/db.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/auto_allocate/db.py 2026-04-15 04:51:52.000000000 +0000 @@ -54,7 +54,11 @@ def _do_ensure_external_network_default_value_callback( context, request, orig, network): is_default = request.get(api_const.IS_DEFAULT) - is_external = request.get(external_net_apidef.EXTERNAL) + # the update request might not have external set to True, so + # verify by looking at the network + req_external = request.get(external_net_apidef.EXTERNAL) + net_external = network[external_net_apidef.EXTERNAL] + is_external = req_external is not None or net_external if is_default is None or not is_external: return if is_default: diff -Nru neutron-26.0.0/neutron/services/externaldns/drivers/designate/driver.py neutron-26.0.3/neutron/services/externaldns/drivers/designate/driver.py --- neutron-26.0.0/neutron/services/externaldns/drivers/designate/driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/externaldns/drivers/designate/driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -165,8 +165,6 @@ dns_domain, criterion={"name": "%s" % name}) except (d_exc.NotFound, d_exc.Forbidden): raise dns_exc.DNSDomainNotFound(dns_domain=dns_domain) - ids = [rec['id'] for rec in recordsets] - ips = [str(ip) for rec in recordsets for ip in rec['records']] - if set(ips) != set(records): - raise dns_exc.DuplicateRecordSet(dns_name=name) - return ids + return [rec['id'] for rec in recordsets + for ip in rec['records'] + if ip in records] diff -Nru neutron-26.0.0/neutron/services/logapi/common/sg_callback.py neutron-26.0.3/neutron/services/logapi/common/sg_callback.py --- neutron-26.0.0/neutron/services/logapi/common/sg_callback.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/logapi/common/sg_callback.py 2026-04-15 04:51:52.000000000 +0000 @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib import context as n_context from neutron_lib.services.logapi import constants as log_const from neutron.services.logapi.common import db_api @@ -23,14 +24,16 @@ def handle_event(self, resource, event, trigger, payload): context = payload.context + admin_context = n_context.get_admin_context() sg_rule = payload.latest_state if sg_rule: sg_id = sg_rule.get('security_group_id') else: sg_id = payload.resource_id + # Log resources can only be fetched from admin context. log_resources = db_api.get_logs_bound_sg( - context, sg_id=sg_id, project_id=context.project_id) + admin_context, sg_id=sg_id, project_id=context.project_id) if log_resources: self.resource_push_api( log_const.RESOURCE_UPDATE, context, log_resources) diff -Nru neutron-26.0.0/neutron/services/logapi/drivers/manager.py neutron-26.0.3/neutron/services/logapi/drivers/manager.py --- neutron-26.0.0/neutron/services/logapi/drivers/manager.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/logapi/drivers/manager.py 2026-04-15 04:51:52.000000000 +0000 @@ -68,14 +68,18 @@ self._drivers = set() self.rpc_required = False registry.publish(log_const.LOGGING_PLUGIN, events.AFTER_INIT, self) - - if self.rpc_required: - self.logging_rpc = server_rpc.LoggingApiNotification() + self._logging_rpc = None @property def drivers(self): return self._drivers + @property + def logging_rpc(self): + if self.rpc_required and not self._logging_rpc: + self._logging_rpc = server_rpc.LoggingApiNotification() + return self._logging_rpc + def register_driver(self, driver): """Register driver with logging plugin. diff -Nru neutron-26.0.0/neutron/services/logapi/drivers/ovn/driver.py neutron-26.0.3/neutron/services/logapi/drivers/ovn/driver.py --- neutron-26.0.0/neutron/services/logapi/drivers/ovn/driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/logapi/drivers/ovn/driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -158,6 +158,11 @@ for pg in pgs: meter_name = self.meter_name if pg["name"] != ovn_const.OVN_DROP_PORT_GROUP_NAME: + if ovn_const.OVN_SG_EXT_ID_KEY not in pg["external_ids"]: + LOG.info("Port Group %s is not part of any security " + "group, skipping its network log " + "setting...", pg["name"]) + continue sg = sg_obj.SecurityGroup.get_sg_by_id( context, pg["external_ids"][ovn_const.OVN_SG_EXT_ID_KEY]) if not sg: @@ -409,6 +414,85 @@ with self.ovn_nb.transaction(check_error=True) as ovn_txn: self._update_log_objs(context, ovn_txn, log_objs) + def add_logging_options_to_acls(self, neutron_acls, context): + log_objs = self._get_logs(context) + for log_obj in log_objs: + pgs = self._pgs_from_log_obj(context, log_obj) + actions_enabled = self._acl_actions_enabled(log_obj) + self._set_neutron_acls_log(pgs, context, actions_enabled, + utils.ovn_name(log_obj.id), + neutron_acls) + + # This function is a version of set_acls_log meant to change neutron + # defined acls, mostly thought for ovndbsync consistency check. + def _set_neutron_acls_log(self, pgs, context, actions_enabled, log_name, + neutron_acls): + acl_changes, acl_visits = 0, 0 + for pg in pgs: + meter_name = self.meter_name + if pg['name'] != ovn_const.OVN_DROP_PORT_GROUP_NAME: + if ovn_const.OVN_SG_EXT_ID_KEY not in pg["external_ids"]: + LOG.info("Port Group %s is not part of any security " + "group, skipping its network log " + "setting...", pg["name"]) + continue + sg = sg_obj.SecurityGroup.get_sg_by_id(context, + pg['external_ids'][ovn_const.OVN_SG_EXT_ID_KEY]) + if not sg: + LOG.warning("Port Group %s is missing a corresponding " + "security group, skipping its network log " + "setting...", pg["name"]) + continue + if not sg.stateful: + meter_name = meter_name + ("_stateless") + # We need to get the OVN ACL because UUID is not listed as a + # property on neutron defined ACLs (and it shouldn't), so we need + # to check which ACL is that UUID referring to, using match as + # differentiating value. + for acl in neutron_acls: + acl_visits += 1 + # skip acls used by a different network log + n_acl_name = acl['name'] + if n_acl_name and n_acl_name != log_name: + continue + action = acl['action'] in actions_enabled + acl['log'] = action + acl['meter'] = meter_name + acl['name'] = log_name + acl['severity'] = "info" + if acl.get('options'): + acl["options"] = {'log-related': "true"} + # label is not set because the actual number should not + # be compared or taken into account, we only need it to be + # different from 0. + acl_changes += 1 + LOG.info("Set %d (out of %d visited) Neutron ACLs for network log %s", + acl_changes, acl_visits, log_name) + + def _get_all_log_pgs(self, ctx): + """Get all Port Group names associated to a Log Object. + + :param log_plugin: Currently loaded log_plugging. + :param ctx: current running context information + """ + log_objs = self._get_logs(ctx) + log_pgs = [] + for log_obj in log_objs: + log_pgs.extend(self._pgs_from_log_obj(ctx, log_obj)) + return log_pgs + + def add_label_related(self, n_acl, ctx): + # Get acls to be able to check if label is present in OVN ACLs and + # also check old label value for ACL if it was already present. + acls = [acl for pg in self._get_all_log_pgs(ctx) for acl in pg["acls"]] + if not acls: + return + acl = self.ovn_nb.lookup("ACL", acls[0], default=None) + if not hasattr(acl, 'label'): + return + n_acl["label"] = secrets.SystemRandom().randrange(1, MAX_INT_LABEL) + n_acl["options"] = {'log-related': 'true'} + def register(plugin_driver): """Register the driver.""" diff -Nru neutron-26.0.0/neutron/services/ovn_l3/plugin.py neutron-26.0.3/neutron/services/ovn_l3/plugin.py --- neutron-26.0.0/neutron/services/ovn_l3/plugin.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/ovn_l3/plugin.py 2026-04-15 04:51:52.000000000 +0000 @@ -278,8 +278,8 @@ try: ports_impacted.append(utils.get_port_id_from_gwc_row(gwc)) except AttributeError: - # Malformed GWC format. - pass + LOG.warning('Malformed Gateway_Chassis register name ' + 'format: %s', gwc.name) port_physnet_dict = { k: v for k, v in port_physnet_dict.items() diff -Nru neutron-26.0.0/neutron/services/trunk/drivers/ovn/trunk_driver.py neutron-26.0.3/neutron/services/trunk/drivers/ovn/trunk_driver.py --- neutron-26.0.0/neutron/services/trunk/drivers/ovn/trunk_driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/trunk/drivers/ovn/trunk_driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -14,6 +14,7 @@ from neutron_lib.callbacks import events from neutron_lib.callbacks import registry from neutron_lib.callbacks import resources +from neutron_lib import constants from neutron_lib import context as n_context from neutron_lib.db import api as db_api from neutron_lib import exceptions as n_exc @@ -25,7 +26,9 @@ from neutron.db import db_base_plugin_common from neutron.db import ovn_revision_numbers_db as db_rev from neutron.objects import ports as port_obj +from neutron.objects import trunk as trunk_objects from neutron.services.trunk.drivers import base as trunk_base +from neutron.services.trunk import exceptions as trunk_exc SUPPORTED_INTERFACES = ( @@ -192,6 +195,46 @@ self._unset_sub_ports(subports) trunk.update(status=trunk_consts.TRUNK_ACTIVE_STATUS) + def port_updated(self, resource, event, trunk_plugin, payload): + '''Propagate trunk parent port ACTIVE to trunk ACTIVE + + During a live migration with a trunk the only way we found to update + the trunk to ACTIVE is to do this when the trunk's parent port gets + updated to ACTIVE. This is clearly suboptimal, because the trunk's + ACTIVE status should mean that all of its ports (parent and sub) are + active. But in ml2/ovn the parent port's binding is not cascaded to the + subports. Actually the subports' binding:host is left empty. This way + here we don't know anything about the subports' state changes during a + live migration. If we don't want to leave the trunk in DOWN this is + what we have. + + Please note that this affects trunk create as well. Because of this we + move ml2/ovn trunks to ACTIVE way early. But at least here we don't + affect other mechanism drivers and their corresponding trunk drivers. + + See also: + https://bugs.launchpad.net/neutron/+bug/1988549 + https://review.opendev.org/c/openstack/neutron/+/853779 + https://bugs.launchpad.net/neutron/+bug/2095152 + ''' + updated_port = payload.latest_state + trunk_details = updated_port.get('trunk_details') + # If no trunk_details, the port is not the parent of a trunk. + if not trunk_details: + return + + original_port = payload.states[0] + orig_status = original_port.get('status') + new_status = updated_port.get('status') + context = payload.context + trunk_id = trunk_details['trunk_id'] + if (new_status == constants.PORT_STATUS_ACTIVE and + new_status != orig_status): + trunk = trunk_objects.Trunk.get_object(context, id=trunk_id) + if trunk is None: + raise trunk_exc.TrunkNotFound(trunk_id=trunk_id) + trunk.update(status=trunk_consts.TRUNK_ACTIVE_STATUS) + class OVNTrunkDriver(trunk_base.DriverBase): @property @@ -222,6 +265,11 @@ resources.SUBPORTS, events.AFTER_DELETE) + registry.subscribe( + self._handler.port_updated, + resources.PORT, + events.AFTER_UPDATE) + @classmethod def create(cls, plugin_driver): cls.plugin_driver = plugin_driver diff -Nru neutron-26.0.0/neutron/services/trunk/plugin.py neutron-26.0.3/neutron/services/trunk/plugin.py --- neutron-26.0.0/neutron/services/trunk/plugin.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/services/trunk/plugin.py 2026-04-15 04:51:52.000000000 +0000 @@ -21,7 +21,6 @@ from neutron_lib.callbacks import events from neutron_lib.callbacks import registry from neutron_lib.callbacks import resources -from neutron_lib import constants as const from neutron_lib import context from neutron_lib.db import api as db_api from neutron_lib.db import resource_extend @@ -471,19 +470,12 @@ original_port = payload.states[0] orig_vif_type = original_port.get(portbindings.VIF_TYPE) new_vif_type = updated_port.get(portbindings.VIF_TYPE) - orig_status = original_port.get('status') - new_status = updated_port.get('status') vif_type_changed = orig_vif_type != new_vif_type - trunk_id = trunk_details['trunk_id'] if vif_type_changed and new_vif_type == portbindings.VIF_TYPE_UNBOUND: + trunk_id = trunk_details['trunk_id'] # NOTE(status_police) Trunk status goes to DOWN when the parent # port is unbound. This means there are no more physical resources # associated with the logical resource. self.update_trunk( context, trunk_id, {'trunk': {'status': constants.TRUNK_DOWN_STATUS}}) - elif new_status == const.PORT_STATUS_ACTIVE and \ - new_status != orig_status: - self.update_trunk( - context, trunk_id, - {'trunk': {'status': constants.TRUNK_ACTIVE_STATUS}}) diff -Nru neutron-26.0.0/neutron/tests/common/test_db_base_plugin_v2.py neutron-26.0.3/neutron/tests/common/test_db_base_plugin_v2.py --- neutron-26.0.0/neutron/tests/common/test_db_base_plugin_v2.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/common/test_db_base_plugin_v2.py 2026-04-15 04:51:52.000000000 +0000 @@ -660,7 +660,7 @@ as_admin=False, **kwargs): res = self._create_port(fmt, net_id, expected_res_status, is_admin=as_admin, **kwargs) - self._check_http_response(res) + self._check_http_response(res, expected_res_status) return self.deserialize(fmt, res) def _make_security_group(self, fmt, name=None, expected_res_status=None, @@ -6455,6 +6455,28 @@ self.assertEqual(subnet.prefixlen, int(sp['subnetpool']['default_prefixlen'])) + def test_allocate_any_subnet_with_default_prefixlen_no_gateway_ip(self): + with self.network() as network: + sp = self._test_create_subnetpool(['10.10.0.0/16'], + tenant_id=self._tenant_id, + name=self._POOL_NAME, + min_prefixlen='21') + + # Request any subnet allocation using default prefix + data = {'subnet': {'network_id': network['network']['id'], + 'subnetpool_id': sp['subnetpool']['id'], + 'ip_version': constants.IP_VERSION_4, + 'tenant_id': network['network']['tenant_id'], + 'gateway_ip': None, + }} + req = self.new_create_request('subnets', data) + res = self.deserialize(self.fmt, req.get_response(self.api)) + + subnet = netaddr.IPNetwork(res['subnet']['cidr']) + self.assertEqual(subnet.prefixlen, + int(sp['subnetpool']['default_prefixlen'])) + self.assertIsNone(res['subnet']['gateway_ip']) + def test_allocate_specific_subnet_with_mismatch_prefixlen(self): with self.network() as network: sp = self._test_create_subnetpool(['10.10.0.0/16'], @@ -6645,6 +6667,29 @@ res = req.get_response(self.api) self._check_http_response(res, 400) + def test_allocate_specific_subnet_no_gateway_ip(self): + with self.network() as network: + sp = self._test_create_subnetpool(['10.10.0.0/16'], + tenant_id=self._tenant_id, + name=self._POOL_NAME, + min_prefixlen='21') + + # Request a specific subnet allocation + data = {'subnet': {'network_id': network['network']['id'], + 'subnetpool_id': sp['subnetpool']['id'], + 'cidr': '10.10.1.0/24', + 'ip_version': constants.IP_VERSION_4, + 'tenant_id': network['network']['tenant_id'], + 'gateway_ip': None, + }} + req = self.new_create_request('subnets', data) + res = self.deserialize(self.fmt, req.get_response(self.api)) + + # Assert the allocated subnet CIDR is what we expect + subnet = netaddr.IPNetwork(res['subnet']['cidr']) + self.assertEqual(netaddr.IPNetwork('10.10.1.0/24'), subnet) + self.assertIsNone(res['subnet']['gateway_ip']) + def test_delete_subnetpool_existing_allocations(self): with self.network() as network: sp = self._test_create_subnetpool(['10.10.0.0/16'], diff -Nru neutron-26.0.0/neutron/tests/fullstack/test_agent_bandwidth_report.py neutron-26.0.3/neutron/tests/fullstack/test_agent_bandwidth_report.py --- neutron-26.0.0/neutron/tests/fullstack/test_agent_bandwidth_report.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/fullstack/test_agent_bandwidth_report.py 2026-04-15 04:51:52.000000000 +0000 @@ -206,7 +206,8 @@ mech_drivers=self.mech_drivers, report_bandwidths=True, has_placement=True, - placement_port=self.placement_port + placement_port=self.placement_port, + agent_down_time=10 ) env = environment.Environment(env_desc, host_desc) super().setUp(env) @@ -233,7 +234,7 @@ check_agent_synced = functools.partial(self._check_agent_synced) base.wait_until_true( predicate=check_agent_synced, - timeout=report_interval + 10, + timeout=report_interval + 20, sleep=1) self.environment.placement.process_fixture.stop() @@ -251,12 +252,12 @@ self._check_agent_not_synced) base.wait_until_true( predicate=check_agent_not_synced, - timeout=report_interval + 10, + timeout=report_interval + 20, sleep=1) self.environment.placement.process_fixture.start() check_agent_synced = functools.partial(self._check_agent_synced) base.wait_until_true( predicate=check_agent_synced, - timeout=report_interval + 10, + timeout=report_interval + 20, sleep=1) diff -Nru neutron-26.0.0/neutron/tests/fullstack/test_l3_agent.py neutron-26.0.3/neutron/tests/fullstack/test_l3_agent.py --- neutron-26.0.0/neutron/tests/fullstack/test_l3_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/fullstack/test_l3_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -566,8 +566,9 @@ netcat_udp.stop_processes() # With the default advert_int of 2s the keepalived master timeout is - # about 6s. Assert less than 90 lost packets (9 seconds) - threshold = 90 + # about 6s. Assert less than 90 lost packets (9 seconds) plus 30 to + # account for CI infrastructure variability + threshold = 120 lost = pinger.sent - pinger.received message = (f'Sent {pinger.sent} packets, received {pinger.received} ' diff -Nru neutron-26.0.0/neutron/tests/functional/agent/l3/test_dvr_router.py neutron-26.0.3/neutron/tests/functional/agent/l3/test_dvr_router.py --- neutron-26.0.0/neutron/tests/functional/agent/l3/test_dvr_router.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/agent/l3/test_dvr_router.py 2026-04-15 04:51:52.000000000 +0000 @@ -32,6 +32,7 @@ from neutron.agent.linux import ip_lib from neutron.agent.linux import iptables_manager from neutron.common import utils +from neutron.tests import base as test_base from neutron.tests.common import l3_test_common from neutron.tests.common import machine_fixtures from neutron.tests.common import net_helpers @@ -2180,6 +2181,7 @@ test_machine1.assert_no_ping(test_machine2.ip) test_machine2.assert_no_ping(test_machine1.ip) + @test_base.unstable_test('bug 2115026') def test_fip_connection_for_address_scope(self): self.agent.conf.agent_mode = 'dvr_snat' (machine_same_scope, machine_diff_scope, diff -Nru neutron-26.0.0/neutron/tests/functional/agent/ovn/agent/test_ovn_neutron_agent.py neutron-26.0.3/neutron/tests/functional/agent/ovn/agent/test_ovn_neutron_agent.py --- neutron-26.0.0/neutron/tests/functional/agent/ovn/agent/test_ovn_neutron_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/agent/ovn/agent/test_ovn_neutron_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -23,6 +23,7 @@ from neutron.agent.ovn.agent import ovsdb as agent_ovsdb from neutron.agent.ovn.metadata import agent as metadata_agent from neutron.agent.ovn.metadata import server_socket +from neutron.agent.ovsdb import impl_idl from neutron.common.ovn import constants as ovn_const from neutron.common import utils as n_utils from neutron.tests.common import net_helpers @@ -46,6 +47,7 @@ self.mock_chassis_name = mock.patch.object( agent_ovsdb, 'get_own_chassis_name', return_value=self.chassis_name).start() + self.ovs_idl_events = [] with mock.patch.object(metadata_agent.MetadataAgent, '_get_own_chassis_name', return_value=self.chassis_name): @@ -57,6 +59,21 @@ self.assertEqual(EXTENSION_NAMES.get(_ext), loaded_ext.name) self.assertTrue(loaded_ext.is_started) + def _create_ovs_idl(self, ovn_agent): + for extension in ovn_agent.ext_manager: + self.ovs_idl_events += extension.obj.ovs_idl_events + self.ovs_idl_events = [e(ovn_agent) for e in + set(self.ovs_idl_events)] + ovsdb = impl_idl.api_factory() + ovsdb.idl.notify_handler.watch_events(self.ovs_idl_events) + + ovn_agent.ext_manager_api.ovs_idl = ovsdb + return ovsdb + + def _clear_events_ovs_idl(self): + self.ovn_agent.ovs_idl.idl_monitor.notify_handler.unwatch_events( + self.ovs_idl_events) + def _start_ovn_neutron_agent(self): conf = self.useFixture(fixture_config.Config()).conf conf.set_override('extensions', ','.join(self.extensions), @@ -75,11 +92,21 @@ # Once eventlet is completely removed, this mock can be deleted. with mock.patch.object(ovn_neutron_agent.OVNNeutronAgent, 'wait'), \ mock.patch.object(server_socket.UnixDomainMetadataProxy, - 'wait'): + 'wait'), \ + mock.patch.object(ovn_neutron_agent.OVNNeutronAgent, + '_load_ovs_idl') as mock_load_ovs_idl: + agt._initialize_ext_manager() + mock_load_ovs_idl.return_value = self._create_ovs_idl(agt) agt.start() - self._check_loaded_and_started_extensions(agt) + external_ids = agt.sb_idl.db_get( + 'Chassis_Private', agt.chassis, 'external_ids').execute( + check_error=True) + self.assertEqual( + external_ids[ovn_const.OVN_AGENT_NEUTRON_SB_CFG_KEY], + '0') - self.addCleanup(agt.ext_manager_api.ovs_idl.ovsdb_connection.stop) + self._check_loaded_and_started_extensions(agt) + self.addCleanup(self._clear_events_ovs_idl) if agt.ext_manager_api.sb_idl: self.addCleanup(agt.ext_manager_api.sb_idl.ovsdb_connection.stop) if agt.ext_manager_api.nb_idl: @@ -128,7 +155,10 @@ # Check the metadata extension is registered. chassis_id = uuid.UUID(self.chassis_name) agent_id = uuid.uuid5(chassis_id, 'metadata_agent') - ext_ids = {ovn_const.OVN_AGENT_METADATA_ID_KEY: str(agent_id)} + ext_ids = {ovn_const.OVN_AGENT_METADATA_ID_KEY: str(agent_id), + ovn_const.OVN_AGENT_OVN_BRIDGE: 'br-int', + ovn_const.OVN_AGENT_NEUTRON_SB_CFG_KEY: '0', + } ch_private = self.sb_api.lookup('Chassis_Private', self.chassis_name) self.assertEqual(ext_ids, ch_private.external_ids) diff -Nru neutron-26.0.0/neutron/tests/functional/agent/ovn/metadata/test_metadata_agent.py neutron-26.0.3/neutron/tests/functional/agent/ovn/metadata/test_metadata_agent.py --- neutron-26.0.0/neutron/tests/functional/agent/ovn/metadata/test_metadata_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/agent/ovn/metadata/test_metadata_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -120,6 +120,9 @@ check_error=True) self.assertEqual(external_ids[ovn_const.OVN_AGENT_OVN_BRIDGE], self.OVN_BRIDGE) + self.assertEqual( + external_ids[ovn_const.OVN_AGENT_METADATA_SB_CFG_KEY], + '0') # Metadata agent will open connections to OVS and SB databases. # Close connections to them when the test ends, @@ -129,16 +132,6 @@ return agt def test_metadata_agent_healthcheck(self): - chassis_row = self.sb_api.db_find( - AGENT_CHASSIS_TABLE, - ('name', '=', self.chassis_name)).execute( - check_error=True)[0] - - # Assert that, prior to creating a resource the metadata agent - # didn't populate the external_ids from the Chassis - self.assertNotIn(ovn_const.OVN_AGENT_METADATA_SB_CFG_KEY, - chassis_row['external_ids']) - # Let's list the agents to force the nb_cfg to be bumped on NB # db, which will automatically increment the nb_cfg counter on # NB_Global and make ovn-controller copy it over to SB_Global. Upon diff -Nru neutron-26.0.0/neutron/tests/functional/agent/ovsdb/native/test_connection.py neutron-26.0.3/neutron/tests/functional/agent/ovsdb/native/test_connection.py --- neutron-26.0.0/neutron/tests/functional/agent/ovsdb/native/test_connection.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/agent/ovsdb/native/test_connection.py 2026-04-15 04:51:52.000000000 +0000 @@ -15,6 +15,7 @@ import threading +from oslo_utils import uuidutils from ovsdbapp import event from neutron.agent.common import ovs_lib @@ -25,7 +26,7 @@ event_name = 'WaitForBridgesEvent' ONETIME = True - def __init__(self, bridges, timeout=5): + def __init__(self, bridges, timeout=20): self.bridges_not_seen = set(bridges) self.timeout = timeout self.event = threading.Event() @@ -52,9 +53,17 @@ self.ovs.delete_bridge(bridge) def test_create_bridges(self): - bridges_to_monitor = ['br01', 'br02', 'br03'] - bridges_to_create = ['br01', 'br02', 'br03', 'br04', 'br05'] + bridges_to_create = [ + 'br_' + uuidutils.generate_uuid()[:12], + 'br_' + uuidutils.generate_uuid()[:12], + 'br_' + uuidutils.generate_uuid()[:12], + 'br_' + uuidutils.generate_uuid()[:12], + 'br_' + uuidutils.generate_uuid()[:12], + ] + bridges_to_monitor = bridges_to_create[:3] self.ovs = ovs_lib.BaseOVS() + self._delete_bridges(bridges_to_create) + self.ovs.ovsdb.idl_monitor.start_bridge_monitor(bridges_to_monitor) self.addCleanup(self._delete_bridges, bridges_to_create) event = WaitForBridgesEvent(bridges_to_monitor) diff -Nru neutron-26.0.0/neutron/tests/functional/agent/ovsdb/native/test_helpers.py neutron-26.0.3/neutron/tests/functional/agent/ovsdb/native/test_helpers.py --- neutron-26.0.0/neutron/tests/functional/agent/ovsdb/native/test_helpers.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/agent/ovsdb/native/test_helpers.py 2026-04-15 04:51:52.000000000 +0000 @@ -14,6 +14,7 @@ # under the License. from neutron_lib import constants as const +from ovsdbapp.backend.ovs_idl import event as idl_event from neutron.agent.common import ovs_lib from neutron.agent.ovsdb.native import helpers @@ -22,6 +23,23 @@ from neutron.tests.functional import base +class WaitOvsManagerEvent(idl_event.WaitEvent): + event_name = 'WaitOvsManagerEvent' + + def __init__(self, manager_target, inactivity_probe=None, event=None): + table = 'Manager' + events = (self.ROW_CREATE,) if not event else (event,) + conditions = (('target', '=', manager_target),) + super().__init__(events, table, conditions, timeout=10) + self.inactivity_probe = inactivity_probe + + def match_fn(self, event, row, old): + if (self.inactivity_probe is None or + self.inactivity_probe == row.inactivity_probe[0]): + return True + return False + + class EnableConnectionUriTestCase(base.BaseSudoTestCase): def test_add_manager_appends(self): @@ -39,10 +57,15 @@ manager_connections.append('ptcp:%s:127.0.0.1' % _port) for index, conn_uri in enumerate(ovsdb_cfg_connections): + target_event = WaitOvsManagerEvent(manager_connections[index]) + ovs.ovsdb.idl.notify_handler.watch_event(target_event) helpers.enable_connection_uri(conn_uri) manager_removal.append(ovs.ovsdb.remove_manager( manager_connections[index])) self.addCleanup(manager_removal[index].execute) + target_event.wait() + # This check is redundant, the ``target_event`` ensures the + # ``Manager`` register with the expected targer is created. self.assertIn(manager_connections[index], ovs.ovsdb.get_manager().execute()) @@ -51,3 +74,40 @@ for connection in manager_connections: self.assertNotIn(connection, ovs.ovsdb.get_manager().execute()) + + def test_add_manager_overwrites_existing_manager(self): + ovs = ovs_lib.BaseOVS() + + _port = self.useFixture(port.ExclusivePort( + const.PROTO_NAME_TCP, + start=net_helpers.OVS_MANAGER_TEST_PORT_FIRST, + end=net_helpers.OVS_MANAGER_TEST_PORT_LAST)).port + ovsdb_cfg_connection = 'tcp:127.0.0.1:%s' % _port + manager_connection = 'ptcp:%s:127.0.0.1' % _port + + inactivity_probe = 10 + manager_event = WaitOvsManagerEvent( + manager_connection, inactivity_probe=inactivity_probe) + ovs.ovsdb.idl.notify_handler.watch_event(manager_event) + helpers.enable_connection_uri(ovsdb_cfg_connection, + inactivity_probe=inactivity_probe) + manager_event.wait() + self.addCleanup(ovs.ovsdb.remove_manager(manager_connection).execute) + # First call of enable_connection_uri cretes the manager + # and the list returned by get_manager contains it: + my_mans = ovs.ovsdb.get_manager().execute() + self.assertIn(manager_connection, my_mans) + + # after 2nd call of enable_connection_uri with new value of + # inactivity_probe will keep the original manager only the + # inactivity_probe value is set: + inactivity_probe = 100 + manager_event = WaitOvsManagerEvent( + manager_connection, inactivity_probe=inactivity_probe, + event='update') + ovs.ovsdb.idl.notify_handler.watch_event(manager_event) + helpers.enable_connection_uri(ovsdb_cfg_connection, + inactivity_probe=inactivity_probe) + manager_event.wait() + my_mans = ovs.ovsdb.get_manager().execute() + self.assertIn(manager_connection, my_mans) diff -Nru neutron-26.0.0/neutron/tests/functional/agent/test_dhcp_agent.py neutron-26.0.3/neutron/tests/functional/agent/test_dhcp_agent.py --- neutron-26.0.0/neutron/tests/functional/agent/test_dhcp_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/agent/test_dhcp_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -421,3 +421,43 @@ exception=RuntimeError("'dhcp_ready_on_ports' not be called")) self.mock_plugin_api.dhcp_ready_on_ports.assert_called_with( ports_to_send) + + def test_dhcp_processing_pool_size(self): + mock.patch.object(self.agent, 'call_driver').start().return_value = ( + True) + self.agent.update_isolated_metadata_proxy = mock.Mock() + self.agent.disable_isolated_metadata_proxy = mock.Mock() + + network_info_1 = self.network_dict_for_dhcp() + self.configure_dhcp_for_network(network=network_info_1) + self.assertEqual(agent.DHCP_PROCESS_GREENLET_MIN, + self.agent._pool.size) + + network_info_2 = self.network_dict_for_dhcp() + self.configure_dhcp_for_network(network=network_info_2) + self.assertEqual(agent.DHCP_PROCESS_GREENLET_MIN, + self.agent._pool.size) + + network_info_list = [network_info_1, network_info_2] + for _i in range(agent.DHCP_PROCESS_GREENLET_MAX + 1): + ni = self.network_dict_for_dhcp() + self.configure_dhcp_for_network(network=ni) + network_info_list.append(ni) + + self.assertEqual(agent.DHCP_PROCESS_GREENLET_MAX, + self.agent._pool.size) + + for network in network_info_list: + self.agent.disable_dhcp_helper(network.id) + + agent_network_info_len = len(self.agent.cache.get_network_ids()) + if agent_network_info_len < agent.DHCP_PROCESS_GREENLET_MIN: + self.assertEqual(agent.DHCP_PROCESS_GREENLET_MIN, + self.agent._pool.size) + elif (agent.DHCP_PROCESS_GREENLET_MIN <= agent_network_info_len <= + agent.DHCP_PROCESS_GREENLET_MAX): + self.assertEqual(agent_network_info_len, + self.agent._pool.size) + else: + self.assertEqual(agent.DHCP_PROCESS_GREENLET_MAX, + self.agent._pool.size) diff -Nru neutron-26.0.0/neutron/tests/functional/agent/test_l2_ovs_agent.py neutron-26.0.3/neutron/tests/functional/agent/test_l2_ovs_agent.py --- neutron-26.0.0/neutron/tests/functional/agent/test_l2_ovs_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/agent/test_l2_ovs_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -302,14 +302,9 @@ net_helpers.assert_ping(ns_phys, ip_int) net_helpers.assert_ping(self.namespace, ip_phys) - with net_helpers.async_ping(ns_phys, [ip_int]) as done: + with net_helpers.async_ping(ns_phys, [ip_int, ip_phys]) as done: + self.agent.setup_physical_bridges(self.agent.bridge_mappings) while not done(): - self.agent.setup_physical_bridges(self.agent.bridge_mappings) - time.sleep(0.25) - - with net_helpers.async_ping(self.namespace, [ip_phys]) as done: - while not done(): - self.agent.setup_physical_bridges(self.agent.bridge_mappings) time.sleep(0.25) def test_noresync_after_port_gone(self): diff -Nru neutron-26.0.0/neutron/tests/functional/base.py neutron-26.0.3/neutron/tests/functional/base.py --- neutron-26.0.0/neutron/tests/functional/base.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/base.py 2026-04-15 04:51:52.000000000 +0000 @@ -50,6 +50,7 @@ from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import worker from neutron.plugins.ml2.drivers import type_geneve # noqa from neutron import service # noqa +from neutron.services.logapi.drivers.ovn import driver as log_driver from neutron.tests import base from neutron.tests.common import base as common_base from neutron.tests.common import helpers @@ -175,6 +176,7 @@ self.addCleanup(exts.PluginAwareExtensionManager.clear_instance) self.ovsdb_server_mgr = None self._service_plugins = service_plugins + log_driver.DRIVER = None super().setUp() self.test_log_dir = os.path.join(DEFAULT_LOG_DIR, self.id()) base.setup_test_logging( @@ -197,6 +199,11 @@ self.mech_driver.log_driver) self.mech_driver.log_driver.plugin_driver = self.mech_driver self.mech_driver.log_driver._log_plugin_property = None + for driver in self.log_plugin.driver_manager.drivers: + if driver.name == "ovn": + self.ovn_log_driver = driver + if not hasattr(self, 'ovn_log_driver'): + self.ovn_log_driver = log_driver.OVNDriver() self.ovn_northd_mgr = None self.maintenance_worker = maintenance_worker mock.patch( diff -Nru neutron-26.0.0/neutron/tests/functional/pecan_wsgi/test_hooks.py neutron-26.0.3/neutron/tests/functional/pecan_wsgi/test_hooks.py --- neutron-26.0.0/neutron/tests/functional/pecan_wsgi/test_hooks.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/pecan_wsgi/test_hooks.py 2026-04-15 04:51:52.000000000 +0000 @@ -288,6 +288,12 @@ headers={'X-Project-Id': 'tenid'}, expect_errors=True) self.assertEqual(404, response.status_int) + self.assertEqual( + { + 'type': 'HTTPNotFound', + 'message': 'The resource could not be found.', + 'detail': '' + }, jsonutils.loads(response.body)) self.assertEqual(1, self.mock_plugin.get_meh.call_count) def test_after_on_get_excludes_admin_attribute(self): diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py 2026-04-15 04:51:52.000000000 +0000 @@ -228,3 +228,34 @@ common_utils.wait_until_true, lambda: mock_send_placement.called, timeout=2) + + @mock.patch.object(placement_extension, '_send_deferred_batch') + def test_chassis_bandwidth_initial_config_event(self, mock_send_placement): + ch_name = uuidutils.generate_uuid() + rp_uuid = uuidutils.generate_uuid() + ch_event = test_ovsdb_monitor.WaitForChassisPrivateCreateEvent(ch_name) + self.mech_driver.sb_ovn.idl.notify_handler.watch_event(ch_event) + self.mock_name2uuid.return_value = {'host1': rp_uuid} + self._create_chassis( + 'host1', ch_name, physical_nets=['phys1'], + bandwidths='br-provider0:1000:2000', + inventory_defaults='allocation_ratio:1.0;min_unit:2', + hypervisors='br-provider0:host1') + self.assertTrue(ch_event.wait()) + common_utils.wait_until_true(lambda: mock_send_placement.called, + timeout=2) + mock_send_placement.assert_called_once() + placement_state = mock_send_placement.call_args[0][0] + + device_mappings = {'phys1': ['br-provider0']} + self.assertEqual(placement_state._device_mappings, device_mappings) + + hypervisor_rps = {'br-provider0': {'name': 'host1', 'uuid': rp_uuid}} + self.assertEqual(placement_state._hypervisor_rps, hypervisor_rps) + + rp_bandwidths = {'br-provider0': {'egress': 1000, 'ingress': 2000}} + self.assertEqual(placement_state._rp_bandwidths, rp_bandwidths) + + rp_inventory_defaults = {'allocation_ratio': 1.0, 'min_unit': 2} + self.assertEqual(placement_state._rp_inventory_defaults, + rp_inventory_defaults) diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py 2026-04-15 04:51:52.000000000 +0000 @@ -69,6 +69,13 @@ qos_constants.RULE_TYPE_MINIMUM_BANDWIDTH: QOS_RULE_MINBW_1} } +QOS_RULES_5 = { + constants.EGRESS_DIRECTION: { + qos_constants.RULE_TYPE_BANDWIDTH_LIMIT: QOS_RULE_BW_1, + qos_constants.RULE_TYPE_DSCP_MARKING: QOS_RULE_DSCP_1, + qos_constants.RULE_TYPE_MINIMUM_BANDWIDTH: QOS_RULE_MINBW_1}, +} + class _TestOVNClientQosExtensionBase(base.TestOVNFunctionalBase): def setUp(self, maintenance_worker=False): @@ -78,7 +85,9 @@ def _check_rules_qos(self, rules, port_id, network_id, network_type, fip_id=None, ip_address=None, expected_ext_ids=None): qos_rules = copy.deepcopy(rules) - if network_type in (constants.TYPE_VLAN, constants.TYPE_FLAT): + min_bw = qos_rules.get(constants.EGRESS_DIRECTION, {}).get( + qos_constants.RULE_TYPE_MINIMUM_BANDWIDTH) + if network_type in constants.TYPE_PHYSICAL and min_bw: # Remove the egress max-rate and min-rate rules, these are defined # in the LSP.options field for a physical network. try: @@ -135,12 +144,13 @@ self.assertEqual(bandwidth, rule.bandwidth) def _check_rules_lsp(self, rules, port_id, network_type): - if network_type not in (constants.TYPE_VLAN, constants.TYPE_FLAT): + egress_rules = rules.get(constants.EGRESS_DIRECTION, {}) + min_bw = egress_rules.get(qos_constants.RULE_TYPE_MINIMUM_BANDWIDTH) + if not (network_type in constants.TYPE_PHYSICAL and min_bw): return # If there are no egress rules, it is checked that there are no # QoS parameters in the LSP.options dictionary. - egress_rules = rules.get(constants.EGRESS_DIRECTION, {}) qos_rule_lsp = self.qos_driver._ovn_lsp_rule(egress_rules) lsp = self.qos_driver.nb_idl.lsp_get(port_id).execute( check_error=True) @@ -267,6 +277,62 @@ constants.TYPE_VLAN) self._check_rules_lsp(_qos_rules, port['id'], constants.TYPE_VLAN) + def test_set_and_update_physical_network_qos(self): + # The goal of this test is to check how the OVN QoS registers and + # LSP.options are set and deleted, depending on the QoS policy rules. + # Check LP#2115952 for more information. + port = uuidutils.generate_uuid() + self._add_logical_switch_port(port) + + def _apply_rules(qos_rules): + with self.nb_api.transaction(check_error=True) as txn: + _qos_rules = copy.deepcopy(qos_rules) + for direction in constants.VALID_DIRECTIONS: + _qos_rules[direction] = _qos_rules.get(direction, {}) + self.mock_qos_rules.return_value = copy.deepcopy(_qos_rules) + self.qos_driver._update_port_qos_rules( + txn, port, self.network_1, constants.TYPE_VLAN, 'qos1', + None) + + # Loop this test twice, to check that all the QoS registers and + # parameters are correctly created, set or removed, regardless of the + # previous state. + for _ in range(2): + # Apply QOS_RULES_5: egress with max-bw, min-bw and DSCP rules. + # * Check the OVN QoS rule created has only DSCP information. + # * Check the LSP.options have the correct fields. + _apply_rules(QOS_RULES_5) + lsp = self.qos_driver.nb_idl.lsp_get(port).execute( + check_error=True) + for _param in ('qos_max_rate', 'qos_burst', 'qos_min_rate'): + self.assertIn(_param, lsp.options) + ls = self.qos_driver.nb_idl.lookup( + 'Logical_Switch', ovn_utils.ovn_name(self.network_1)) + self.assertEqual(1, len(ls.qos_rules)) + rule = ls.qos_rules[0] + self.assertIn(port, rule.match) + self.assertEqual({'dscp': QOS_RULE_DSCP_1['dscp_mark']}, + rule.action) + self.assertEqual({}, rule.bandwidth) + + # Apply QOS_RULES_3: egress with max-bw only rule. + # * Check the OVN QoS rule created has only max-bw information. + # * Check the LSP.options has no QoS information. + _apply_rules(QOS_RULES_3) + lsp = self.qos_driver.nb_idl.lsp_get(port).execute( + check_error=True) + for _param in ('qos_max_rate', 'qos_burst', 'qos_min_rate'): + self.assertNotIn(_param, lsp.options) + ls = self.qos_driver.nb_idl.lookup( + 'Logical_Switch', ovn_utils.ovn_name(self.network_1)) + self.assertEqual(1, len(ls.qos_rules)) + rule = ls.qos_rules[0] + self.assertIn(port, rule.match) + self.assertEqual({}, rule.action) + self.assertEqual({'burst': QOS_RULE_BW_1['max_burst_kbps'], + 'rate': QOS_RULE_BW_1['max_kbps']}, + rule.bandwidth) + class TestOVNClientQosExtensionEndToEnd(_TestOVNClientQosExtensionBase): diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl.py 2026-04-15 04:51:52.000000000 +0000 @@ -695,6 +695,57 @@ self.assertEqual(external_ids, lr.static_routes[0].external_ids) + def test_delete_acl_by_sg_id(self): + """Test ``delete_acl_by_sg_id`` deletes an ACL using the SG rule ID.""" + sg_id = uuidutils.generate_uuid() + sg_rule_id = uuidutils.generate_uuid() + pg_name = ovn_utils.ovn_port_group_name(sg_id) + external_ids = {ovn_const.OVN_SG_EXT_ID_KEY: sg_id} + + with self.nbapi.transaction(check_error=True) as txn: + txn.add(self.nbapi.pg_add(name=pg_name, acls=[], + external_ids=external_ids)) + acl = { + 'port_group': pg_name, + 'priority': ovn_const.ACL_PRIORITY_ALLOW, + 'action': ovn_const.ACL_ACTION_ALLOW_RELATED, + 'direction': 'to-lport', + 'match': f'outport == @{pg_name} && ip4', + ovn_const.OVN_SG_RULE_EXT_ID_KEY: sg_rule_id, + } + txn.add(self.nbapi.pg_acl_add(**acl, may_exist=True)) + + port_group = self.nbapi.get_port_group(pg_name) + self.assertIsNotNone(port_group) + acls_before = [a for a in port_group.acls + if (getattr(a, 'external_ids', {}).get( + ovn_const.OVN_SG_RULE_EXT_ID_KEY) == sg_rule_id)] + self.assertEqual(1, len(acls_before), + 'ACL with sg_rule_id should exist before delete') + + # Execute twice, the operation is idempotent: if the ACL does not + # exist, the command does nothing. + for _ in range(2): + self.nbapi.delete_acl_by_sg_id(sg_id, sg_rule_id).execute( + check_error=True) + + port_group = self.nbapi.get_port_group(pg_name) + self.assertIsNotNone(port_group) + acls_after = [a for a in port_group.acls + if (getattr(a, 'external_ids', {}).get( + ovn_const.OVN_SG_RULE_EXT_ID_KEY) == sg_rule_id)] + self.assertEqual(0, len(acls_after), + 'ACL with sg_rule_id should be removed after ' + 'delete') + + def test_delete_acl_by_sg_id_port_group_missing(self): + """Try to delete an ACL in a missing port group, no exception raised""" + sg_id = uuidutils.generate_uuid() + sg_rule_id = uuidutils.generate_uuid() + # Should not raise when port group does not exist and if_exists=True + self.nbapi.delete_acl_by_sg_id( + sg_id, sg_rule_id, if_exists=True).execute(check_error=True) + class TestIgnoreConnectionTimeout(BaseOvnIdlTest): @classmethod diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py 2026-04-15 04:51:52.000000000 +0000 @@ -34,6 +34,8 @@ from neutron.common.ovn import utils from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf as ovn_config from neutron.db import ovn_revision_numbers_db as db_rev +from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions import qos \ + as qos_extension from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import maintenance from neutron.services.portforwarding import constants as pf_consts from neutron.tests.functional import base @@ -208,17 +210,50 @@ return self.nb_api.lookup( 'Port_Group', utils.ovn_port_group_name(sg_id), default=None) - def _create_security_group_rule(self, sg_id): + def _create_security_group_rule(self, sg_id, + remote_address_group_id=None, + ethertype=n_const.IPv4): data = {'security_group_rule': {'security_group_id': sg_id, 'direction': 'ingress', 'protocol': n_const.PROTO_NAME_TCP, - 'ethertype': n_const.IPv4, + 'ethertype': ethertype, 'port_range_min': 22, 'port_range_max': 22}} + if remote_address_group_id: + data['security_group_rule']['remote_address_group_id'] = ( + remote_address_group_id) req = self.new_create_request('security-group-rules', data, self.fmt) res = req.get_response(self.api) return self.deserialize(self.fmt, res)['security_group_rule'] + def _create_address_group(self, name='ag_test', + addresses=None): + if addresses is None: + addresses = ['192.168.2.2/32', '2001:db8::/32'] + ag_args = {'project_id': self._tenant_id, + 'name': name, + 'description': 'test address group', + 'addresses': addresses} + return self._ovn_client._plugin.create_address_group( + self.context, {'address_group': ag_args}) + + def _find_address_set_for_ag(self, ag_id): + _as = {} + for ip_version in (n_const.IP_VERSION_4, + n_const.IP_VERSION_6): + as_name = utils.ovn_ag_addrset_name(ag_id, 'ip' + str(ip_version)) + _as[ip_version] = self.nb_api.lookup( + 'Address_Set', as_name, default=None) + return _as + + def _delete_address_set_for_ag(self, ag_id): + """Delete OVN Address_Set rows for an address group.""" + for ip_version in n_const.IP_ALLOWED_VERSIONS: + as_name = utils.ovn_ag_addrset_name( + ag_id, 'ip' + str(ip_version)) + self.nb_api.address_set_del( + as_name, if_exists=True).execute(check_error=True) + def _find_security_group_rule_row_by_id(self, sgr_id): for row in self.nb_api._tables['ACL'].rows.values(): if (row.external_ids.get( @@ -1453,6 +1488,122 @@ self.assertEqual( ls_dns_record.options.get('ovn-owned'), 'true') + def test_update_qos_fip_rule_priority(self): + def_prio = qos_extension.OVN_QOS_DEFAULT_RULE_PRIORITY + fip_prio = qos_extension.OVN_QOS_FIP_RULE_PRIORITY + neutron_net = self._create_network('network1') + ls_name = utils.ovn_name(neutron_net['id']) + self.nb_api.qos_add( + ls_name, 'from-lport', def_prio, "outport == 1", + 1000, 800, None, None, + external_ids={ovn_const.OVN_ROUTER_ID_EXT_ID_KEY: 1}) + self.nb_api.qos_add( + ls_name, 'from-lport', def_prio, "outport == 1", + 1000, 800, None, None, + external_ids={ovn_const.OVN_FIP_EXT_ID_KEY: 1}) + + self.assertRaises( + periodics.NeverAgain, + self.maint.update_qos_fip_rule_priority) + + for qos_rule in self.nb_api.qos_list(ls_name).execute( + check_errors=True): + if qos_rule.external_ids.get(ovn_const.OVN_FIP_EXT_ID_KEY): + self.assertEqual(fip_prio, qos_rule.priority) + else: + self.assertEqual(def_prio, qos_rule.priority) + + def test_update_security_group_with_address_group(self): + """Test missing Address_Sets are recreated and ACLs are updated. + + Simulates a pre-upgrade state where an Address Group existed in + Neutron but the corresponding OVN Address_Sets were never created, + so the ACL match string does not reference the Address_Set. + """ + # 1. Create an AG, a SG, and two SG rules (IPv4 + IPv6) pointing + # to the AG. + ag = self._create_address_group( + name='ag_maint_test', + addresses=['172.16.0.1/32', '2001:db8::1/128']) + neutron_sg = self._create_security_group() + sg_rule_v4 = self._create_security_group_rule( + neutron_sg['id'], remote_address_group_id=ag['id'], + ethertype=n_const.IPv4) + sg_rule_v6 = self._create_security_group_rule( + neutron_sg['id'], remote_address_group_id=ag['id'], + ethertype=n_const.IPv6) + + # 2. Verify the ACLs reference the Address_Sets and that both + # Address_Sets (IPv4, IPv6) exist. + ag_as_name_v4 = utils.ovn_ag_addrset_name(ag['id'], 'ip4') + ag_as_name_v6 = utils.ovn_ag_addrset_name(ag['id'], 'ip6') + + acl_v4 = self._find_security_group_rule_row_by_id(sg_rule_v4['id']) + self.assertIsNotNone(acl_v4) + acl_v4_uuid_before = acl_v4.uuid + self.assertIn(ag_as_name_v4, acl_v4.match) + + acl_v6 = self._find_security_group_rule_row_by_id(sg_rule_v6['id']) + self.assertIsNotNone(acl_v6) + acl_v6_uuid_before = acl_v6.uuid + self.assertIn(ag_as_name_v6, acl_v6.match) + + address_sets = self._find_address_set_for_ag(ag['id']) + self.assertIsNotNone(address_sets[n_const.IP_VERSION_4]) + self.assertIsNotNone(address_sets[n_const.IP_VERSION_6]) + + # 3. Simulate the pre-upgrade state: delete the Address_Sets and + # strip the Address_Set reference from both ACL matches. + self._delete_address_set_for_ag(ag['id']) + address_sets = self._find_address_set_for_ag(ag['id']) + self.assertIsNone(address_sets[n_const.IP_VERSION_4]) + self.assertIsNone(address_sets[n_const.IP_VERSION_6]) + + match_v4_without_ag = acl_v4.match.replace( + ' && ip4.src == $%s' % ag_as_name_v4, '') + self.nb_api.db_set( + 'ACL', acl_v4.uuid, + ('match', match_v4_without_ag)).execute(check_error=True) + acl_v4 = self._find_security_group_rule_row_by_id(sg_rule_v4['id']) + self.assertNotIn(ag_as_name_v4, acl_v4.match) + + match_v6_without_ag = acl_v6.match.replace( + ' && ip6.src == $%s' % ag_as_name_v6, '') + self.nb_api.db_set( + 'ACL', acl_v6.uuid, + ('match', match_v6_without_ag)).execute(check_error=True) + acl_v6 = self._find_security_group_rule_row_by_id(sg_rule_v6['id']) + self.assertNotIn(ag_as_name_v6, acl_v6.match) + + # 4. Run the maintenance task. + self.assertRaises( + periodics.NeverAgain, + self.maint.update_security_group_with_address_group) + + # 5. Verify the Address_Sets are recreated. + address_sets = self._find_address_set_for_ag(ag['id']) + self.assertEqual(['172.16.0.1/32'], + address_sets[n_const.IP_VERSION_4].addresses) + self.assertEqual(['2001:db8::1/128'], + address_sets[n_const.IP_VERSION_6].addresses) + + # 6. Verify the previous ACLs are deleted and new ones were created + # (different UUIDs) with matches that include the Address_Set + # references. + acl_uuids = set(self.nb_api._tables['ACL'].rows.keys()) + self.assertNotIn(acl_v4_uuid_before, acl_uuids) + self.assertNotIn(acl_v6_uuid_before, acl_uuids) + + acl_v4_after = self._find_security_group_rule_row_by_id( + sg_rule_v4['id']) + self.assertIsNotNone(acl_v4_after) + self.assertIn(ag_as_name_v4, acl_v4_after.match) + + acl_v6_after = self._find_security_group_rule_row_by_id( + sg_rule_v6['id']) + self.assertIsNotNone(acl_v6_after) + self.assertIn(ag_as_name_v6, acl_v6_after.match) + class TestLogMaintenance(_TestMaintenanceHelper, test_log_driver.LogApiTestCaseBase): diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py 2026-04-15 04:51:52.000000000 +0000 @@ -16,8 +16,12 @@ from neutron_lib.api.definitions import network_mtu as mtu_def from neutron_lib.api.definitions import provider_net from neutron_lib import constants +from neutron_lib.plugins import constants as plugins_constants +from neutron_lib.services.qos import constants as qos_const from oslo_config import cfg from oslo_utils import strutils +from oslo_utils import uuidutils +from sqlalchemy.dialects.mysql import dialect as mysql_dialect from neutron.common.ovn import constants as ovn_const from neutron.common.ovn import utils as ovn_utils @@ -25,13 +29,19 @@ from neutron.tests.functional import base from neutron.tests.unit.api import test_extensions from neutron.tests.unit.extensions import test_l3 +from neutron.tests.unit import testlib_api -class TestOVNClient(base.TestOVNFunctionalBase, +class TestOVNClient(testlib_api.MySQLTestCaseMixin, + base.TestOVNFunctionalBase, test_l3.L3NatTestCaseMixin): - def setUp(self, **kwargs): - super().setUp(**kwargs) + _extension_drivers = ['qos'] + + def setUp(self, *args): + service_plugins = {plugins_constants.QOS: 'qos'} + super().setUp(service_plugins=service_plugins) + self.assertEqual(mysql_dialect.name, self.db.engine.dialect.name) ext_mgr = test_l3.L3TestExtensionManager() self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr) @@ -309,3 +319,149 @@ # MTU connected to the router. self._check_gw_lrp_mtu(router_id, min(router_attached_net_mtus)) + + def test_create_router_port_multiple_routers(self): + # The goal of this test is to check that the GW LRPs are updated when + # the same network is the external GW for several routers. + net_ext_args = {provider_net.NETWORK_TYPE: 'geneve', + external_net.EXTERNAL: True, + mtu_def.MTU: 1400} + net_ext = self._make_network(self.fmt, 'test-ext-net', True, + as_admin=True, + arg_list=tuple(net_ext_args.keys()), + **net_ext_args) + ext_gw = {'network_id': net_ext['network']['id']} + self._make_subnet(self.fmt, net_ext, + gateway=constants.ATTR_NOT_SPECIFIED, + cidr='10.100.0.0/24') + nets_int = [] + routers = [] + subnets = [] + for idx in range(3): + cidr = f'10.{idx}.0.0/24' + net_int_args = {provider_net.NETWORK_TYPE: 'geneve', + mtu_def.MTU: 1300 + idx} + nets_int.append(self._make_network( + self.fmt, 'test-int-net', True, as_admin=True, + arg_list=tuple(net_ext_args.keys()), **net_int_args)) + subnets.append(self._make_subnet( + self.fmt, nets_int[-1], + gateway=constants.ATTR_NOT_SPECIFIED, cidr=cidr)) + routers.append(self._make_router( + self.fmt, external_gateway_info=ext_gw)) + + for router in routers: + lr_name = ovn_utils.ovn_name(router['router']['id']) + lrp = self.nb_api.lrp_list(lr_name).execute(check_errors=True)[0] + self.assertEqual(1400, int(lrp.options['gateway_mtu'])) + + # Add to every router a new internal subnet. That must update the + # GW LRP MTU. + for idx, router in enumerate(routers): + lr_name = ovn_utils.ovn_name(router['router']['id']) + subnet = subnets[idx] + self._router_interface_action( + 'add', router['router']['id'], subnet['subnet']['id'], + None) + lrps = self.nb_api.lrp_list(lr_name).execute(check_errors=True) + for lrp in lrps: + if strutils.bool_from_string( + lrp.external_ids[ovn_const.OVN_ROUTER_IS_EXT_GW]): + # New MTU=1300+idx (old MTU=1400) + self.assertEqual(1300 + idx, + int(lrp.options['gateway_mtu'])) + + # Remove the internal subnet. The GW LRP should restore the GW network + # MTU=1400. + for idx, router in enumerate(routers): + lr_name = ovn_utils.ovn_name(router['router']['id']) + subnet = subnets[idx] + self._router_interface_action( + 'remove', router['router']['id'], subnet['subnet']['id'], + None) + lrps = self.nb_api.lrp_list(lr_name).execute(check_errors=True) + for lrp in lrps: + if strutils.bool_from_string( + lrp.external_ids[ovn_const.OVN_ROUTER_IS_EXT_GW]): + # GW network MTU=1400 + self.assertEqual(1400, int(lrp.options['gateway_mtu'])) + + def test_update_port_with_qos_in_lsp_options(self): + # The goal of this test is to check that a Neutron port that has a + # QoS policy defined in the LSP.options (physical network, egress + # rules, max+min BW rules) is correctly updated and the LSP.options + # are defined when the Neutron port is updated (see LP#2106231). + def _check_bw(port_id, max_kbps=None, max_burst_kbps=None, + min_kbps=None): + lsp = self.nb_api.lookup('Logical_Switch_Port', port_id) + if max_kbps: + self.assertEqual( + '{}'.format(max_kbps * 1000), + lsp.options[ovn_const.LSP_OPTIONS_QOS_MAX_RATE]) + else: + self.assertNotIn(ovn_const.LSP_OPTIONS_QOS_MAX_RATE, + lsp.options) + if max_burst_kbps: + self.assertEqual( + '{}'.format(max_burst_kbps * 1000), + lsp.options[ovn_const.LSP_OPTIONS_QOS_BURST]) + else: + self.assertNotIn(ovn_const.LSP_OPTIONS_QOS_BURST, + lsp.options) + if min_kbps: + self.assertEqual( + '{}'.format(min_kbps * 1000), + lsp.options[ovn_const.LSP_OPTIONS_QOS_MIN_RATE]) + else: + self.assertNotIn(ovn_const.LSP_OPTIONS_QOS_MIN_RATE, + lsp.options) + + res = self._create_qos_policy(self.fmt, is_admin=True) + qos = self.deserialize(self.fmt, res)['policy'] + max_kbps, max_burst_kbps, min_kbps = 1000, 800, 600 + + self._create_qos_rule(self.fmt, qos['id'], + qos_const.RULE_TYPE_BANDWIDTH_LIMIT, + max_kbps=max_kbps, max_burst_kbps=max_burst_kbps, + is_admin=True) + self._create_qos_rule(self.fmt, qos['id'], + qos_const.RULE_TYPE_MINIMUM_BANDWIDTH, + min_kbps=min_kbps, is_admin=True) + net_args = {provider_net.NETWORK_TYPE: 'flat', + provider_net.PHYSICAL_NETWORK: 'datacentre'} + with self.network(uuidutils.generate_uuid(), + arg_list=tuple(net_args.keys()), as_admin=True, + **net_args) as net: + with self.subnet(net) as subnet, self.port(subnet) as port: + port_data = port['port'] + # Check no QoS options. + _check_bw(port_data['id']) + + # Add QoS policy. + req = self.new_update_request( + 'ports', + {'port': {'qos_policy_id': qos['id']}}, + port_data['id']) + req.get_response(self.api) + _check_bw(port_data['id'], max_kbps, max_burst_kbps, min_kbps) + + # Update port. + req = self.new_update_request( + 'ports', + {'port': {'name': uuidutils.generate_uuid()}}, + port_data['id']) + req.get_response(self.api) + _check_bw(port_data['id'], max_kbps, max_burst_kbps, min_kbps) + + def test_create_subnet_with_dhcp_options(self): + cfg.CONF.set_override('ovn_dhcp4_global_options', + 'ntp_server:1.2.3.4;1.2.3.5,wpad:1.2.3.6', + group='ovn') + with self.network('test-ovn-client') as net: + with self.subnet(net): + dhcp_options = self.nb_api.dhcp_options_list().execute( + check_error=True)[0] + self.assertEqual('{1.2.3.4, 1.2.3.5}', + dhcp_options.options['ntp_server']) + self.assertEqual('1.2.3.6', + dhcp_options.options['wpad']) diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py 2026-04-15 04:51:52.000000000 +0000 @@ -24,6 +24,7 @@ from neutron_lib import constants from neutron_lib import context from neutron_lib.db import api as db_api +from neutron_lib.services.logapi import constants as log_const from neutron_lib.services.qos import constants as qos_const from oslo_config import cfg from oslo_utils import uuidutils @@ -97,6 +98,12 @@ self.expected_dns_records = [] self.expected_ports_with_unknown_addr = [] self.expected_qos_records = [] + # Set of externally managed resources that should not + # be cleaned up by the sync_db + self.create_ext_port_groups = [] + self.create_ext_lrouter_ports = [] + self.create_ext_lrouter_routes = [] + self.ctx = context.get_admin_context() ovn_config.cfg.CONF.set_override('ovn_metadata_enabled', True, group='ovn') @@ -107,7 +114,7 @@ nb_idl=self.nb_api) def get_additional_service_plugins(self): - return {'qos': 'qos', 'segments': 'segments'} + return {'qos': 'qos', 'segments': 'segments', 'log': 'log'} def _api_for_resource(self, resource): if resource in ['security-groups']: @@ -514,6 +521,9 @@ self.create_lrouter_routes.append(('neutron-' + r1['id'], '10.13.0.0/24', '20.0.0.13')) + self.create_ext_lrouter_routes.append(('neutron-' + r1['id'], + '10.14.0.0/24', + '20.0.0.14')) self.delete_lrouter_routes.append(('neutron-' + r1['id'], '10.10.0.0/24', '20.0.0.10')) @@ -661,10 +671,18 @@ 'neutron-' + r1['id'])) self.create_lrouter_ports.append(('lrp-' + uuidutils.generate_uuid(), 'neutron-' + r1['id'])) + self.create_ext_lrouter_ports.append( + ('ext-lrp-' + uuidutils.generate_uuid(), 'neutron-' + r1['id']) + ) + self.create_ext_lrouter_ports.append( + ('ext-lrp-' + uuidutils.generate_uuid(), 'neutron-' + r1['id']) + ) self.delete_lrouters.append('neutron-' + r2['id']) self.create_port_groups.extend([{'name': 'pg1', 'acls': []}, {'name': 'pg2', 'acls': []}]) + self.create_ext_port_groups.extend([{'name': 'ext-pg1', 'acls': []}, + {'name': 'ext-pg2', 'acls': []}]) self.delete_port_groups.append( utils.ovn_port_group_name(n1_prtr['port']['security_groups'][0])) # Create a network and subnet with orphaned OVN resources. @@ -789,7 +807,14 @@ txn.add(self.nb_api.lr_del(lrouter_name, if_exists=True)) for lrport, lrouter_name in self.create_lrouter_ports: - txn.add(self.nb_api.add_lrouter_port(lrport, lrouter_name)) + external_ids = {ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: + lrouter_name} + txn.add(self.nb_api.add_lrouter_port( + lrport, lrouter_name, True, external_ids=external_ids)) + + for lrport, lrouter_name in self.create_ext_lrouter_ports: + txn.add(self.nb_api.add_lrouter_port( + lrport, lrouter_name, True)) for lrport, lrouter_name, networks in self.update_lrouter_ports: txn.add(self.nb_api.update_lrouter_port( @@ -806,6 +831,10 @@ ip_prefix=ip_prefix, nexthop=nexthop, **columns)) + for lr_name, ip_prefix, nexthop in self.create_ext_lrouter_routes: + txn.add(self.nb_api.add_static_route(lr_name, + ip_prefix=ip_prefix, + nexthop=nexthop)) routers = defaultdict(list) for lrouter_name, ip_prefix, nexthop in self.delete_lrouter_routes: routers[lrouter_name].append((ip_prefix, nexthop)) @@ -838,7 +867,12 @@ txn.add(self.nb_api.delete_acl(lswitch_name, lport_name, True)) + columns = { + 'external_ids': {ovn_const.OVN_SG_EXT_ID_KEY: 'sg_uuid'}, + } for pg in self.create_port_groups: + txn.add(self.nb_api.pg_add(**pg, **columns)) + for pg in self.create_ext_port_groups: txn.add(self.nb_api.pg_add(**pg)) for pg in self.delete_port_groups: txn.add(self.nb_api.pg_del(pg)) @@ -1080,7 +1114,7 @@ @staticmethod def _build_acl_for_pgs(priority, direction, log, name, action, - severity, match, port_group, **kwargs): + severity, match, meter, port_group, **kwargs): return { 'priority': priority, 'direction': direction, @@ -1089,6 +1123,7 @@ 'action': action, 'severity': severity, 'match': match, + 'meter': meter, 'external_ids': kwargs} def _validate_dhcp_opts(self, should_match=True): @@ -1144,7 +1179,7 @@ pass return acl_utils.filter_acl_dict(acl_to_compare, extra_fields) - def _validate_acls(self, should_match=True): + def _validate_acls(self, should_match=True, db_duplicate_port=None): # Get the neutron DB ACLs. db_acls = [] @@ -1161,6 +1196,9 @@ ovn_const.OVN_DROP_PORT_GROUP_NAME): db_acls.append(TestOvnNbSync._build_acl_for_pgs(**acl)) + self.ovn_log_driver.add_logging_options_to_acls(db_acls, + self.ctx) + # Get the list of ACLs stored in the OVN plugin IDL. plugin_acls = [] for row in _plugin_nb_ovn._tables['Logical_Switch'].rows.values(): @@ -1181,7 +1219,37 @@ for acl in getattr(row, 'acls', []): monitor_acls.append(self._build_acl_to_compare(acl)) + self.ovn_log_driver.add_logging_options_to_acls(monitor_acls, + self.ctx) + self.ovn_log_driver.add_logging_options_to_acls(plugin_acls, + self.ctx) + + # Values taken out from list for comparison, since ACLs from OVN DB + # have certain values on a list of just one object if should_match: + if db_duplicate_port: + # If we have a duplicate port, that indicates there are two + # DB entries that map to the same ACL. Remove the extra from + # our comparison. + dup_acl = None + for acl in db_acls: + if (str(db_duplicate_port) in acl['match'] and + acl not in plugin_acls): + dup_acl = acl + break + # There should have been a duplicate + self.assertIsNotNone(dup_acl) + db_acls.remove(dup_acl) + for acl in plugin_acls: + if isinstance(acl['severity'], list) and acl['severity']: + acl['severity'] = acl['severity'][0] + acl['name'] = acl['name'][0] + acl['meter'] = acl['meter'][0] + for acl in monitor_acls: + if isinstance(acl['severity'], list) and acl['severity']: + acl['severity'] = acl['severity'][0] + acl['name'] = acl['name'][0] + acl['meter'] = acl['meter'][0] self.assertCountEqual(db_acls, plugin_acls) self.assertCountEqual(db_acls, monitor_acls) else: @@ -1275,6 +1343,7 @@ self.ctx, port)) return ipv6_ra_configs + neutron_prefix = constants.DEVICE_OWNER_NEUTRON_PREFIX for router_id in db_router_ids: r_ports = self._list('ports', query_params='device_id=%s' % (router_id)) @@ -1293,18 +1362,26 @@ lrouter = idlutils.row_by_value( self.mech_driver.nb_ovn.idl, 'Logical_Router', 'name', 'neutron-' + str(router_id), None) - lports = getattr(lrouter, 'ports', []) + all_lports = getattr(lrouter, 'ports', []) + managed_lports = [ + lport for lport in all_lports + if (ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY in + lport.external_ids)] + plugin_lrouter_port_ids = [lport.name.replace('lrp-', '') - for lport in lports] + for lport in managed_lports] plugin_lport_networks = { lport.name.replace('lrp-', ''): lport.networks - for lport in lports} + for lport in managed_lports} plugin_lport_ra_configs = { lport.name.replace('lrp-', ''): lport.ipv6_ra_configs - for lport in lports} + for lport in managed_lports} sroutes = getattr(lrouter, 'static_routes', []) - plugin_routes = [sroute.ip_prefix + sroute.nexthop - for sroute in sroutes] + plugin_routes = [] + for sroute in sroutes: + if any(e_id.startswith(neutron_prefix) + for e_id in sroute.external_ids): + plugin_routes.append(sroute.ip_prefix + sroute.nexthop) nats = getattr(lrouter, 'nat', []) plugin_nats = [ nat.external_ip + nat.logical_ip + nat.type + @@ -1320,18 +1397,28 @@ lrouter = idlutils.row_by_value( self.nb_api.idl, 'Logical_Router', 'name', 'neutron-' + router_id, None) - lports = getattr(lrouter, 'ports', []) + all_lports = getattr(lrouter, 'ports', []) + managed_lports = [ + lport for lport in all_lports + if (ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY in + lport.external_ids)] monitor_lrouter_port_ids = [lport.name.replace('lrp-', '') - for lport in lports] + for lport in managed_lports] monitor_lport_networks = { lport.name.replace('lrp-', ''): lport.networks - for lport in lports} + for lport in managed_lports} monitor_lport_ra_configs = { lport.name.replace('lrp-', ''): lport.ipv6_ra_configs - for lport in lports} + for lport in managed_lports} sroutes = getattr(lrouter, 'static_routes', []) - monitor_routes = [sroute.ip_prefix + sroute.nexthop - for sroute in sroutes] + monitor_routes = [] + for sroute in sroutes: + if any(e_id.startswith(neutron_prefix) + for e_id in sroute.external_ids): + monitor_routes.append( + sroute.ip_prefix + sroute.nexthop + ) + nats = getattr(lrouter, 'nat', []) monitor_nats = [ nat.external_ip + nat.logical_ip + nat.type + @@ -1489,7 +1576,9 @@ mn_pgs = [] for row in self.nb_api.tables['Port_Group'].rows.values(): - mn_pgs.append(getattr(row, 'name', '')) + if (ovn_const.OVN_SG_EXT_ID_KEY in row.external_ids or + row.name == ovn_const.OVN_DROP_PORT_GROUP_NAME): + mn_pgs.append(getattr(row, 'name', '')) if should_match: self.assertCountEqual(nb_pgs, db_pgs) @@ -1595,6 +1684,46 @@ self._sync_resources(mode) self._validate_resources(should_match=should_match_after_sync) + if not restart_ovsdb_processes: + # Restarting ovsdb-server removes all its previous content. + # We can not expect to find external resources in the DB + # if it was wiped out. + self._validate_external_resources() + + def _validate_external_resources(self): + """Ensure that resources not owned by Neutron are in the OVN DB. + + This function is useful to validate that external resources survived + ovn_db_sync. + """ + db_routers = self._list('routers') + db_router_ids = [router['id'] for router in db_routers['routers']] + + pgs = [] + for pg in self.nb_api.tables['Port_Group'].rows.values(): + pgs.append(pg.name) + + lrports = [] + sroutes = [] + for router_id in db_router_ids: + lrouter = idlutils.row_by_value( + self.mech_driver.nb_ovn.idl, 'Logical_Router', 'name', + 'neutron-' + str(router_id), None) + + for lrport in getattr(lrouter, 'ports', []): + lrports.append(lrport.name) + + for route in getattr(lrouter, 'static_routes', []): + sroutes.append(route.ip_prefix + route.nexthop) + + for port_name, _ in self.create_ext_lrouter_ports: + self.assertIn(port_name, lrports) + + for _, prefix, next_hop in self.create_ext_lrouter_routes: + self.assertIn(prefix + next_hop, sroutes) + + for ext_pg in self.create_ext_port_groups: + self.assertIn(ext_pg['name'], pgs) def test_ovn_nb_sync_repair(self): self._test_ovn_nb_sync_helper(ovn_const.OVN_DB_SYNC_MODE_REPAIR) @@ -1764,26 +1893,41 @@ nb_synchronizer.sync_fip_qos_policies(ctx) self._validate_qos_records() - def _create_security_group_rule(self, sg_id, direction, tcp_port): + def _create_security_group_rule(self, sg_id, direction, tcp_port, + remote_ip_prefix=None): data = {'security_group_rule': {'security_group_id': sg_id, 'direction': direction, 'protocol': constants.PROTO_NAME_TCP, 'ethertype': constants.IPv4, 'port_range_min': tcp_port, 'port_range_max': tcp_port}} + if remote_ip_prefix: + data['security_group_rule']['remote_ip_prefix'] = remote_ip_prefix req = self.new_create_request('security-group-rules', data, self.fmt) res = req.get_response(self.api) sgr = self.deserialize(self.fmt, res) self.assertIn('security_group_rule', sgr) return sgr['security_group_rule']['id'] - def test_sync_acls(self): + def _test_sync_acls_helper(self, test_log=False, + log_event=log_const.ALL_EVENT): data = {'security_group': {'name': 'sg1'}} sg_req = self.new_create_request('security-groups', data) res = sg_req.get_response(self.api) sg = self.deserialize(self.fmt, res)['security_group'] sgr_ids = [] + + # If we are going to test ACLs with log enabled, set up a log object + if test_log: + log_data = {'log': {'project_id': self.ctx.project_id, + 'resource_type': 'security_group', + 'description': 'test net log', + 'name': 'logme', + 'enabled': True, + 'event': log_event}} + log_obj = self.log_plugin.create_log(self.ctx, log_data) + for tcp_port in range(8050, 8055): sgr_ids.append(self._create_security_group_rule( sg['id'], 'ingress', tcp_port)) @@ -1814,6 +1958,35 @@ nb_synchronizer.sync_acls(ctx) self._validate_acls() + # Remove log object to avoid overlapping + if test_log: + log_obj = self.log_plugin.delete_log(self.ctx, log_obj['id']) + + def test_sync_acls(self): + self._test_sync_acls_helper() + + def test_sync_acls_with_logging(self): + self._test_sync_acls_helper(test_log=True, + log_event=log_const.ACCEPT_EVENT) + self._test_sync_acls_helper(test_log=True, + log_event=log_const.ALL_EVENT) + self._test_sync_acls_helper(test_log=True, + log_event=log_const.DROP_EVENT) + + def test_sync_acls_overlapping_cidr(self): + data = {'security_group': {'name': 'sgdup'}} + sg_req = self.new_create_request('security-groups', data) + res = sg_req.get_response(self.api) + sg = self.deserialize(self.fmt, res)['security_group'] + + # Add SG rules that map to the same ACL due to normalizing the cidr + for ip_suffix in range(10, 12): + remote_ip_prefix = '192.168.0.' + str(ip_suffix) + '/24' + self._create_security_group_rule( + sg['id'], 'ingress', 9000, remote_ip_prefix=remote_ip_prefix) + + self._validate_acls(db_duplicate_port=9000) + class TestOvnSbSync(base.TestOVNFunctionalBase): @@ -1823,6 +1996,7 @@ self.plugin, self.mech_driver.sb_ovn, self.mech_driver) self.addCleanup(self.sb_synchronizer.stop) self.ctx = context.get_admin_context() + self.host1 = uuidutils.generate_uuid() def _sync_resources(self): self.sb_synchronizer.sync_hostname_and_physical_networks(self.ctx) @@ -1856,23 +2030,23 @@ def test_ovn_sb_sync_add_new_host(self): with self.network() as network: network_id = network['network']['id'] - self.create_segment(network_id, 'physnet1', 50) - self.add_fake_chassis('host1', ['physnet1']) + self.create_segment(network_id, self.physnet, 50) + self.add_fake_chassis(self.host1, [self.physnet]) segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) self.assertFalse(segment_hosts) self._sync_resources() segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - self.assertEqual({'host1'}, segment_hosts) + self.assertEqual({self.host1}, segment_hosts) def test_ovn_sb_sync_update_existing_host(self): with self.network() as network: network_id = network['network']['id'] - segment = self.create_segment(network_id, 'physnet1', 50) + segment = self.create_segment(network_id, self.physnet, 50) segments_db.update_segment_host_mapping( - self.ctx, 'host1', {segment['id']}) + self.ctx, self.host1, {segment['id']}) segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - self.assertEqual({'host1'}, segment_hosts) - self.add_fake_chassis('host1', ['physnet2']) + self.assertEqual({self.host1}, segment_hosts) + self.add_fake_chassis(self.host1, [self.physnet2]) self._sync_resources() segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) self.assertFalse(segment_hosts) @@ -1880,13 +2054,14 @@ def test_ovn_sb_sync_delete_stale_host(self): with self.network() as network: network_id = network['network']['id'] - segment = self.create_segment(network_id, 'physnet1', 50) + segment = self.create_segment(network_id, self.physnet, 50) segments_db.update_segment_host_mapping( - self.ctx, 'host1', {segment['id']}) - _ = self.create_agent('host1', bridge_mappings={'physnet1': 'eth0'}) + self.ctx, self.host1, {segment['id']}) + _ = self.create_agent(self.host1, + bridge_mappings={self.physnet: 'eth0'}) segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - self.assertEqual({'host1'}, segment_hosts) - # Since there is no chassis in the sb DB, host1 is the stale host + self.assertEqual({self.host1}, segment_hosts) + # Since there is no chassis in the sb DB, self.host1 is the stale host # recorded in neutron DB. It should be deleted after sync. self._sync_resources() segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) @@ -1895,57 +2070,61 @@ def test_ovn_sb_sync_host_with_no_agent_not_deleted(self): with self.network() as network: network_id = network['network']['id'] - segment = self.create_segment(network_id, 'physnet1', 50) + segment = self.create_segment(network_id, self.physnet, 50) segments_db.update_segment_host_mapping( - self.ctx, 'host1', {segment['id']}) - _ = self.create_agent('host1', bridge_mappings={'physnet1': 'eth0'}, + self.ctx, self.host1, {segment['id']}) + _ = self.create_agent(self.host1, + bridge_mappings={self.physnet: 'eth0'}, agent_type="Not OVN Agent") segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - self.assertEqual({'host1'}, segment_hosts) - # There is no chassis in the sb DB, host1 does not have an agent + self.assertEqual({self.host1}, segment_hosts) + # There is no chassis in the sb DB, self.host1 does not have an agent # so it is not deleted. self._sync_resources() segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - self.assertEqual({'host1'}, segment_hosts) + self.assertEqual({self.host1}, segment_hosts) def test_ovn_sb_sync_host_with_other_agent_type_not_deleted(self): with self.network() as network: network_id = network['network']['id'] - segment = self.create_segment(network_id, 'physnet1', 50) + segment = self.create_segment(network_id, self.physnet, 50) segments_db.update_segment_host_mapping( - self.ctx, 'host1', {segment['id']}) + self.ctx, self.host1, {segment['id']}) segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - self.assertEqual({'host1'}, segment_hosts) - # There is no chassis in the sb DB, host1 does not have an agent + self.assertEqual({self.host1}, segment_hosts) + # There is no chassis in the sb DB, self.host1 does not have an agent # so it is not deleted. self._sync_resources() segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - self.assertEqual({'host1'}, segment_hosts) + self.assertEqual({self.host1}, segment_hosts) def test_ovn_sb_sync(self): + host2 = uuidutils.generate_uuid() + host3 = uuidutils.generate_uuid() + host4 = uuidutils.generate_uuid() with self.network() as network: network_id = network['network']['id'] - seg1 = self.create_segment(network_id, 'physnet1', 50) - self.create_segment(network_id, 'physnet2', 51) + seg1 = self.create_segment(network_id, self.physnet, 50) + self.create_segment(network_id, self.physnet2, 51) segments_db.update_segment_host_mapping( - self.ctx, 'host1', {seg1['id']}) + self.ctx, self.host1, {seg1['id']}) segments_db.update_segment_host_mapping( - self.ctx, 'host2', {seg1['id']}) + self.ctx, host2, {seg1['id']}) segments_db.update_segment_host_mapping( - self.ctx, 'host3', {seg1['id']}) + self.ctx, host3, {seg1['id']}) segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) - _ = self.create_agent('host1') - _ = self.create_agent('host2', bridge_mappings={'physnet2': 'eth0'}) - _ = self.create_agent('host3', bridge_mappings={'physnet3': 'eth0'}) - self.assertEqual({'host1', 'host2', 'host3'}, segment_hosts) - self.add_fake_chassis('host2', ['physnet2']) - self.add_fake_chassis('host3', ['physnet3']) - self.add_fake_chassis('host4', ['physnet1']) + _ = self.create_agent(self.host1) + _ = self.create_agent(host2, bridge_mappings={self.physnet2: 'eth0'}) + _ = self.create_agent(host3, bridge_mappings={self.physnet3: 'eth0'}) + self.assertEqual({self.host1, host2, host3}, segment_hosts) + self.add_fake_chassis(host2, [self.physnet2]) + self.add_fake_chassis(host3, [self.physnet3]) + self.add_fake_chassis(host4, [self.physnet]) self._sync_resources() segment_hosts = segments_db.get_hosts_mapped_with_segments(self.ctx) # host1 should be cleared since it is not in the chassis DB. host3 # should be cleared since there is no segment for mapping. - self.assertEqual({'host2', 'host4'}, segment_hosts) + self.assertEqual({host2, host4}, segment_hosts) class TestOvnNbSyncOverTcp(TestOvnNbSync): diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py 2026-04-15 04:51:52.000000000 +0000 @@ -69,6 +69,22 @@ super().__init__(events, 'Chassis_Private', conditions, timeout=15) +class WaitForPortBindingFIPEvent(event.WaitEvent): + event_name = 'WaitForPortBindingFIPEvent' + + def __init__(self, fip): + events = (self.ROW_UPDATE, ) + self._fip = fip + super().__init__(events, 'Port_Binding', {}, timeout=15) + + def match_fn(self, event, row, old=None): + try: + return (row.external_ids[ovn_const.OVN_PORT_FIP_EXT_ID_KEY] == + self._fip) + except (AttributeError, KeyError): + return False + + class DistributedLockTestEvent(event.WaitEvent): ONETIME = False COUNTER = 0 @@ -219,10 +235,15 @@ port = self.create_port() # Ensure that the MAC_Binding entry gets deleted after creating a FIP + fip_event = WaitForPortBindingFIPEvent('100.0.0.21') + self.mech_driver.sb_ovn.idl.notify_handler.watch_event(fip_event) fip = self._create_fip(port, '100.0.0.21') + self.assertTrue(fip_event.wait()) + # TODO(ralonsoh): restore the timeout=15 value (or even lower) once + # the eventlet removal finishes. n_utils.wait_until_true( lambda: not self._check_mac_binding_exists(macb_id), - timeout=15, sleep=1) + timeout=30, sleep=1) # Now that the FIP is created, add a new MAC_Binding entry with the # same IP address @@ -231,9 +252,11 @@ # Ensure that the MAC_Binding entry gets deleted after deleting the FIP self.l3_plugin.delete_floatingip(self.context, fip['id']) + # TODO(ralonsoh): restore the timeout=15 value (or even lower) once + # the eventlet removal finishes. n_utils.wait_until_true( lambda: not self._check_mac_binding_exists(macb_id), - timeout=15, sleep=1) + timeout=30, sleep=1) def _test_port_binding_and_status(self, port_id, action, status): # This function binds or unbinds port to chassis and diff -Nru neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/test_type_tunnel.py neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/test_type_tunnel.py --- neutron-26.0.0/neutron/tests/functional/plugins/ml2/drivers/test_type_tunnel.py 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/plugins/ml2/drivers/test_type_tunnel.py 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,92 @@ +# Copyright 2025 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 concurrent import futures +import time + +from neutron_lib import constants +from neutron_lib import context +from neutron_lib.db import api as db_api +from oslo_config import cfg + +from neutron.conf import common as common_config +from neutron.conf.plugins.ml2 import config as ml2_config +from neutron.conf.plugins.ml2.drivers import driver_type as driver_type_config +from neutron.objects import network_segment_range as range_obj +from neutron.plugins.ml2.drivers import type_geneve +from neutron.tests.unit import testlib_api + + +def _initialize_network_segment_range_support(type_driver, start_time): + # This method is similar to + # ``_TunnelTypeDriverBase.initialize_network_segment_range_support``. + # The method first deletes the existing default network ranges and then + # creates the new ones. It also adds an extra second before closing the + # DB transaction. + admin_context = context.get_admin_context() + with db_api.CONTEXT_WRITER.using(admin_context): + type_driver._delete_expired_default_network_segment_ranges( + admin_context, start_time) + type_driver._populate_new_default_network_segment_ranges( + admin_context, start_time) + time.sleep(1) + + +class TunnelTypeDriverBaseTestCase(testlib_api.SqlTestCase): + def setUp(self): + super().setUp() + cfg.CONF.register_opts(common_config.core_opts) + ml2_config.register_ml2_plugin_opts() + driver_type_config.register_ml2_drivers_geneve_opts() + ml2_config.cfg.CONF.set_override( + 'service_plugins', 'network_segment_range') + self.min = 1001 + self.max = 1020 + self.net_type = constants.TYPE_GENEVE + ml2_config.cfg.CONF.set_override( + 'vni_ranges', f'{self.min}:{self.max}', group='ml2_type_geneve') + self.admin_ctx = context.get_admin_context() + self.type_driver = type_geneve.GeneveTypeDriver() + self.type_driver.initialize() + + def test_initialize_network_segment_range_support(self): + # Execute the initialization several times with different start times. + for start_time in range(3): + self.type_driver.initialize_network_segment_range_support( + start_time) + sranges = range_obj.NetworkSegmentRange.get_objects(self.admin_ctx) + self.assertEqual(1, len(sranges)) + self.assertEqual(self.net_type, sranges[0].network_type) + self.assertEqual(self.min, sranges[0].minimum) + self.assertEqual(self.max, sranges[0].maximum) + self.assertEqual([(self.min, self.max)], + self.type_driver.tunnel_ranges) + + def test_initialize_network_segment_range_support_parallel_execution(self): + max_workers = 3 + _futures = [] + with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + for idx in range(max_workers): + _futures.append(executor.submit( + _initialize_network_segment_range_support, + self.type_driver, idx)) + for _future in _futures: + _future.result() + + sranges = range_obj.NetworkSegmentRange.get_objects(self.admin_ctx) + self.assertEqual(1, len(sranges)) + self.assertEqual(self.net_type, sranges[0].network_type) + self.assertEqual(self.min, sranges[0].minimum) + self.assertEqual(self.max, sranges[0].maximum) diff -Nru neutron-26.0.0/neutron/tests/functional/services/ovn_l3/test_plugin.py neutron-26.0.3/neutron/tests/functional/services/ovn_l3/test_plugin.py --- neutron-26.0.0/neutron/tests/functional/services/ovn_l3/test_plugin.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/services/ovn_l3/test_plugin.py 2026-04-15 04:51:52.000000000 +0000 @@ -791,7 +791,7 @@ # Tries to create 5 routers with a gateway. Since we're using # physnet4, the chassis candidates will be chassis4 initially. num_routers = len(self._create_routers_wait_pb( - 1, 20, gw_info=gw_info, bind_chassis=chassis4)) + 1, 5, gw_info=gw_info, bind_chassis=chassis4)) self.l3_plugin.schedule_unhosted_gateways() expected = {chassis4: {1: num_routers}} self.assertEqual(expected, self._get_gwc_dict()) diff -Nru neutron-26.0.0/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py neutron-26.0.3/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py --- neutron-26.0.0/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -26,13 +26,38 @@ from neutron.tests.functional import base -class WaitForLogicalSwitchPortUpdateEvent(event.WaitEvent): - event_name = 'WaitForDataPathBindingCreateEvent' +class WaitForPortBindingDeleteEvent(event.WaitEvent): + def __init__(self, port_id): + table = 'Port_Binding' + events = (self.ROW_DELETE, ) + conditions = (('logical_port', '=', port_id), ) + super().__init__(events, table, conditions, timeout=10) + + +class WaitForPortBindingCreateEvent(event.WaitEvent): + def __init__(self, port_id): + table = 'Port_Binding' + events = (self.ROW_CREATE, ) + conditions = (('logical_port', '=', port_id), ) + super().__init__(events, table, conditions, timeout=10) - def __init__(self): + +class WaitForLSPSubportEvent(event.WaitEvent): + event_name = 'WaitForLSPSubportEvent' + + def __init__(self, port_id): table = 'Logical_Switch_Port' - events = (self.ROW_UPDATE,) - super().__init__(events, table, None, timeout=15) + events = (self.ROW_UPDATE, self.ROW_CREATE) + conditions = (('name', '=', port_id), ) + super().__init__(events, table, conditions, timeout=10) + + def matches(self, event, row, old=None): + # Check the "conditions" defined (name=port_id) + if not super().matches(event, row, old): + return False + device_owner = row.external_ids.get( + ovn_const.OVN_DEVICE_OWNER_EXT_ID_KEY) + return device_owner == trunk_consts.TRUNK_SUBPORT_OWNER class TestOVNTrunkDriver(base.TestOVNFunctionalBase): @@ -126,15 +151,26 @@ def test_subport_delete(self): with self.subport() as subport: + lsp_subport = WaitForLSPSubportEvent(subport['port_id']) + self.mech_driver.nb_ovn.idl.notify_handler.watch_event( + lsp_subport) + pb_create = WaitForPortBindingCreateEvent(subport['port_id']) + self.mech_driver.sb_ovn.idl.notify_handler.watch_event( + pb_create) with self.trunk([subport]) as trunk: - lsp_event = WaitForLogicalSwitchPortUpdateEvent() - self.mech_driver.nb_ovn.idl.notify_handler.watch_events( - (lsp_event,)) + # Wait for the subport LSP to be assigned as a subport. + self.assertTrue(lsp_subport.wait()) + self.assertTrue(pb_create.wait()) + + pb_delete = WaitForPortBindingDeleteEvent(subport['port_id']) + self.mech_driver.sb_ovn.idl.notify_handler.watch_event( + pb_delete) self.trunk_plugin.remove_subports(self.context, trunk['id'], {'sub_ports': [subport]}) new_trunk = self.trunk_plugin.get_trunk(self.context, trunk['id']) - self.assertTrue(lsp_event.wait()) + # Wait for the subport LSP to be unbound. + self.assertTrue(pb_delete.wait()) self._verify_trunk_info(new_trunk, has_items=False) def test_trunk_delete(self): diff -Nru neutron-26.0.0/neutron/tests/unit/agent/dhcp/test_agent.py neutron-26.0.3/neutron/tests/unit/agent/dhcp/test_agent.py --- neutron-26.0.0/neutron/tests/unit/agent/dhcp/test_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/agent/dhcp/test_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -876,6 +876,9 @@ 'neutron.agent.linux.external_process.ProcessManager' ) self.external_process = self.external_process_p.start() + self.mock_resize_p = mock.patch('neutron.agent.dhcp.agent.' + 'DhcpAgent._resize_process_pool') + self.mock_resize = self.mock_resize_p.start() self.mock_wait_until_address_ready_p = mock.patch( 'neutron.agent.linux.ip_lib.' 'IpAddrCommand.wait_until_address_ready') diff -Nru neutron-26.0.0/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py neutron-26.0.3/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py --- neutron-26.0.0/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py 2026-04-15 04:51:52.000000000 +0000 @@ -1090,6 +1090,49 @@ sg_removed_mock.assert_called_once_with(1) delete_sg_mock.assert_called_once_with(1) + def test__cleanup_stale_sg_members_and_ports(self): + self._prepare_security_group() + self.firewall.sg_to_delete = {1} + new_members = {constants.IPv4: [1]} + self.firewall.update_security_group_members(1, new_members) + port_dict = {'device': 'port-id', + 'security_groups': [1]} + self.firewall.prepare_port_filter(port_dict) + with mock.patch.object(self.firewall.conj_ip_manager, + 'sg_removed') as sg_removed_mock,\ + mock.patch.object(self.firewall.sg_port_map, + 'delete_sg') as delete_sg_mock: + self.firewall._cleanup_stale_sg() + sg_removed_mock.assert_not_called() + delete_sg_mock.assert_not_called() + + def test__cleanup_stale_sg_just_members(self): + self._prepare_security_group() + self.firewall.sg_to_delete = {1} + new_members = {constants.IPv4: [1]} + self.firewall.update_security_group_members(1, new_members) + with mock.patch.object(self.firewall.conj_ip_manager, + 'sg_removed') as sg_removed_mock,\ + mock.patch.object(self.firewall.sg_port_map, + 'delete_sg') as delete_sg_mock: + self.firewall._cleanup_stale_sg() + sg_removed_mock.assert_not_called() + delete_sg_mock.assert_not_called() + + def test__cleanup_stale_sg_just_ports(self): + self._prepare_security_group() + self.firewall.sg_to_delete = {1} + port_dict = {'device': 'port-id', + 'security_groups': [1]} + self.firewall.prepare_port_filter(port_dict) + with mock.patch.object(self.firewall.conj_ip_manager, + 'sg_removed') as sg_removed_mock,\ + mock.patch.object(self.firewall.sg_port_map, + 'delete_sg') as delete_sg_mock: + self.firewall._cleanup_stale_sg() + sg_removed_mock.assert_not_called() + delete_sg_mock.assert_not_called() + def test_get_ovs_port(self): ovs_port = self.firewall.get_ovs_port('port_id') self.assertEqual(self.fake_ovs_port, ovs_port) diff -Nru neutron-26.0.0/neutron/tests/unit/agent/metadata/test_agent.py neutron-26.0.3/neutron/tests/unit/agent/metadata/test_agent.py --- neutron-26.0.0/neutron/tests/unit/agent/metadata/test_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/agent/metadata/test_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +import socketserver from unittest import mock import ddt @@ -393,7 +394,7 @@ self.cfg_p = mock.patch.object(agent, 'cfg') self.cfg = self.cfg_p.start() looping_call_p = mock.patch( - 'oslo_service.loopingcall.FixedIntervalLoopingCall') + 'neutron.common.loopingcall.FixedIntervalLoopingCall') self.looping_mock = looping_call_p.start() self.cfg.CONF.metadata_proxy_socket = '/the/path' self.cfg.CONF.metadata_workers = 0 @@ -435,6 +436,21 @@ agent.UnixDomainMetadataProxy(mock.Mock()) unlink.assert_called_once_with('/the/path') + @mock.patch.object(agent, 'MetadataProxyHandler') + @mock.patch.object(socketserver, 'ThreadingUnixStreamServer') + @mock.patch.object(fileutils, 'ensure_tree') + def test_run(self, ensure_dir, server, handler): + p = agent.UnixDomainMetadataProxy(self.cfg.CONF) + p.run() + + ensure_dir.assert_called_once_with('/the', mode=0o755) + server.assert_has_calls([ + mock.call('/the/path', mock.ANY), + mock.call().serve_forever()]) + self.looping_mock.assert_called_once_with(f=p._report_state) + self.looping_mock.return_value.start.assert_called_once_with( + interval=mock.ANY) + def test_main(self): with mock.patch.object(agent, 'UnixDomainMetadataProxy') as proxy: with mock.patch.object(metadata_agent, 'config') as config: diff -Nru neutron-26.0.0/neutron/tests/unit/common/ovn/test_acl.py neutron-26.0.3/neutron/tests/unit/common/ovn/test_acl.py --- neutron-26.0.0/neutron/tests/unit/common/ovn/test_acl.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/common/ovn/test_acl.py 2026-04-15 04:51:52.000000000 +0000 @@ -60,7 +60,8 @@ 'lport': self.fake_port['id'], 'lswitch': 'neutron-network_id1', 'match': 'outport == "fake_port_id1" && ip', - 'priority': 1001} + 'priority': 1001, + 'meter': []} acl_from_lport = {'action': 'drop', 'direction': 'from-lport', 'external_ids': {'neutron:lport': self.fake_port['id']}, @@ -68,7 +69,8 @@ 'lport': self.fake_port['id'], 'lswitch': 'neutron-network_id1', 'match': 'inport == "fake_port_id1" && ip', - 'priority': 1001} + 'priority': 1001, + 'meter': []} for acl in acls: if 'to-lport' in acl.values(): self.assertEqual(acl_to_lport, acl) diff -Nru neutron-26.0.0/neutron/tests/unit/common/ovn/test_utils.py neutron-26.0.3/neutron/tests/unit/common/ovn/test_utils.py --- neutron-26.0.0/neutron/tests/unit/common/ovn/test_utils.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/common/ovn/test_utils.py 2026-04-15 04:51:52.000000000 +0000 @@ -606,6 +606,7 @@ 'neutron_lib.plugins.directory.get_plugin').start() self.VNIC_FAKE_NORMAL = 'fake-vnic-normal' self.VNIC_FAKE_OTHER = 'fake-vnic-other' + self.VNIC_FAKE_THIRD = 'fake-vnic-third' # Replace constants.OVN_PORT_BINDING_PROFILE_PARAMS to allow synthesis _params = constants.OVN_PORT_BINDING_PROFILE_PARAMS.copy() @@ -614,15 +615,15 @@ {'key': [str, type(None)]}, self.VNIC_FAKE_NORMAL, None), constants.OVNPortBindingProfileParamSet( - {'key': [str], 'other_key': [str]}, - self.VNIC_FAKE_OTHER, None), - constants.OVNPortBindingProfileParamSet( { 'key': [str], 'other_key': [int], 'third_key': [str] }, self.VNIC_FAKE_OTHER, constants.PORT_CAP_SWITCHDEV), + constants.OVNPortBindingProfileParamSet( + {'key': [str], 'other_key': [str]}, + self.VNIC_FAKE_THIRD, None), ]) self.OVN_PORT_BINDING_PROFILE_PARAMS = mock.patch.object( constants, @@ -737,6 +738,27 @@ {portbindings.VNIC_TYPE: portbindings.VNIC_REMOTE_MANAGED, constants.OVN_PORT_BINDING_PROFILE: expect})) + def test_valid_input_surplus_capabilities(self): + capabilities = ['rx', 'tx', 'sg', 'tso', 'gso', 'gro', 'rxvlan', + 'txvlan', 'rxhash', 'rdma', 'txudptnl'] + binding_profile = { + 'pci_vendor_info': 'dead:beef', + 'pci_slot': '0000:ca:fe.42', + 'physical_network': 'physnet1', + 'card_serial_number': 'AB2000X00042', + 'pf_mac_address': '00:53:00:00:00:42', + 'vf_num': 42, + constants.PORT_CAP_PARAM: capabilities + } + expect = binding_profile.copy() + del(expect[constants.PORT_CAP_PARAM]) + self.assertEqual( + utils.BPInfo(expect, portbindings.VNIC_REMOTE_MANAGED, + capabilities), + utils.validate_and_get_data_from_binding_profile( + {portbindings.VNIC_TYPE: portbindings.VNIC_REMOTE_MANAGED, + constants.OVN_PORT_BINDING_PROFILE: binding_profile})) + def test_valid_input_surplus_keys(self): # Confirm that extra keys are allowed binding_profile = { @@ -810,12 +832,12 @@ utils.validate_and_get_data_from_binding_profile( {portbindings.VNIC_TYPE: self.VNIC_FAKE_NORMAL, constants.OVN_PORT_BINDING_PROFILE: binding_profile})) - # It is valid for VNIC_FAKE_OTHER + # It is valid for VNIC_FAKE_THIRD expected_bp = binding_profile.copy() self.assertEqual( - utils.BPInfo(expected_bp, self.VNIC_FAKE_OTHER, []), + utils.BPInfo(expected_bp, self.VNIC_FAKE_THIRD, []), utils.validate_and_get_data_from_binding_profile( - {portbindings.VNIC_TYPE: self.VNIC_FAKE_OTHER, + {portbindings.VNIC_TYPE: self.VNIC_FAKE_THIRD, constants.OVN_PORT_BINDING_PROFILE: binding_profile})) def test_overlapping_param_set_different_vnic_type_and_capability(self): @@ -825,13 +847,13 @@ 'other_key': 42, 'third_key': 'value', } - # This param set is not valid for VNIC_FAKE_OTHER without capability + # This param set is not valid for VNIC_FAKE_THIRD without capability expect = binding_profile.copy() del(expect['third_key']) self.assertRaises( neutron_lib.exceptions.InvalidInput, utils.validate_and_get_data_from_binding_profile, - {portbindings.VNIC_TYPE: self.VNIC_FAKE_OTHER, + {portbindings.VNIC_TYPE: self.VNIC_FAKE_THIRD, constants.OVN_PORT_BINDING_PROFILE: binding_profile}) # This param set is also not valid as the capabilities do not match binding_profile = { diff -Nru neutron-26.0.0/neutron/tests/unit/conf/policies/test_l3_conntrack_helper.py neutron-26.0.3/neutron/tests/unit/conf/policies/test_l3_conntrack_helper.py --- neutron-26.0.0/neutron/tests/unit/conf/policies/test_l3_conntrack_helper.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/conf/policies/test_l3_conntrack_helper.py 2026-04-15 04:51:52.000000000 +0000 @@ -28,19 +28,30 @@ super().setUp() self.router = { 'id': uuidutils.generate_uuid(), + 'tenant_id': self.project_id, 'project_id': self.project_id} + self.alt_router = { + 'id': uuidutils.generate_uuid(), + 'tenant_id': self.alt_project_id, + 'project_id': self.alt_project_id} + self.target = { - 'project_id': self.project_id, 'router_id': self.router['id'], 'ext_parent_router_id': self.router['id']} - self.alt_target = { - 'project_id': self.alt_project_id, - 'router_id': self.router['id'], - 'ext_parent_router_id': self.router['id']} + 'router_id': self.alt_router['id'], + 'ext_parent_router_id': self.alt_router['id']} + + routers = { + self.router['id']: self.router, + self.alt_router['id']: self.alt_router, + } + + def get_router(context, router_id, fields=None): + return routers[router_id] self.plugin_mock = mock.Mock() - self.plugin_mock.get_router.return_value = self.router + self.plugin_mock.get_router.side_effect = get_router mock.patch( 'neutron_lib.plugins.directory.get_plugin', return_value=self.plugin_mock).start() diff -Nru neutron-26.0.0/neutron/tests/unit/conf/policies/test_local_ip_association.py neutron-26.0.3/neutron/tests/unit/conf/policies/test_local_ip_association.py --- neutron-26.0.0/neutron/tests/unit/conf/policies/test_local_ip_association.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/conf/policies/test_local_ip_association.py 2026-04-15 04:51:52.000000000 +0000 @@ -28,19 +28,30 @@ super().setUp() self.local_ip = { 'id': uuidutils.generate_uuid(), + 'tenant_id': self.project_id, 'project_id': self.project_id} + self.alt_local_ip = { + 'id': uuidutils.generate_uuid(), + 'tenant_id': self.alt_project_id, + 'project_id': self.alt_project_id} self.target = { - 'project_id': self.project_id, 'local_ip_id': self.local_ip['id'], 'ext_parent_local_ip_id': self.local_ip['id']} self.alt_target = { - 'project_id': self.alt_project_id, - 'local_ip_id': self.local_ip['id'], - 'ext_parent_local_ip_id': self.local_ip['id']} + 'local_ip_id': self.alt_local_ip['id'], + 'ext_parent_local_ip_id': self.alt_local_ip['id']} + + local_ips = { + self.local_ip['id']: self.local_ip, + self.alt_local_ip['id']: self.alt_local_ip, + } + + def get_local_ip(context, lip_id, fields=None): + return local_ips[lip_id] self.plugin_mock = mock.Mock() - self.plugin_mock.get_local_ip.return_value = self.local_ip + self.plugin_mock.get_local_ip.side_effect = get_local_ip mock.patch( 'neutron_lib.plugins.directory.get_plugin', return_value=self.plugin_mock).start() diff -Nru neutron-26.0.0/neutron/tests/unit/conf/policies/test_port.py neutron-26.0.3/neutron/tests/unit/conf/policies/test_port.py --- neutron-26.0.0/neutron/tests/unit/conf/policies/test_port.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/conf/policies/test_port.py 2026-04-15 04:51:52.000000000 +0000 @@ -29,20 +29,33 @@ self.network = { 'id': uuidutils.generate_uuid(), + 'tenant_id': self.project_id, 'project_id': self.project_id} + self.alt_network = { + 'id': uuidutils.generate_uuid(), + 'tenant_id': self.alt_project_id, + 'project_id': self.alt_project_id} self.target = { + 'tenant_id': self.project_id, 'project_id': self.project_id, - 'tenant_id': self.alt_project_id, 'network_id': self.network['id'], 'ext_parent_network_id': self.network['id']} self.alt_target = { + 'tenant_id': self.project_id, 'project_id': self.alt_project_id, - 'tenant_id': self.alt_project_id, - 'network_id': self.network['id'], - 'ext_parent_network_id': self.network['id']} + 'network_id': self.alt_network['id'], + 'ext_parent_network_id': self.alt_network['id']} + + networks = { + self.network['id']: self.network, + self.alt_network['id']: self.alt_network, + } + + def get_network(context, id, fields=None): + return networks[id] self.plugin_mock = mock.Mock() - self.plugin_mock.get_network.return_value = self.network + self.plugin_mock.get_network.side_effect = get_network mock.patch( 'neutron_lib.plugins.directory.get_plugin', return_value=self.plugin_mock).start() @@ -814,10 +827,8 @@ target['device_owner'] = 'network:test' alt_target = self.alt_target.copy() alt_target['device_owner'] = 'network:test' - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port:device_owner', - target) + self.assertTrue( + policy.enforce(self.context, 'create_port:device_owner', target)) self.assertRaises( base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_port:device_owner', @@ -1056,10 +1067,8 @@ target['device_owner'] = 'network:test' alt_target = self.alt_target.copy() alt_target['device_owner'] = 'network:test' - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:device_owner', - target) + self.assertTrue( + policy.enforce(self.context, 'update_port:device_owner', target)) self.assertRaises( base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_port:device_owner', @@ -1227,6 +1236,199 @@ target['device_owner'] = 'network:test' alt_target = self.alt_target.copy() alt_target['device_owner'] = 'network:test' + self.assertTrue( + policy.enforce(self.context, 'create_port:device_owner', target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port:device_owner', + alt_target) + + def test_create_port_with_mac_address(self): + self.assertTrue( + policy.enforce(self.context, 'create_port:mac_address', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port:mac_address', + self.alt_target) + + def test_create_port_with_fixed_ips(self): + self.assertTrue( + policy.enforce(self.context, 'create_port:fixed_ips', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port:fixed_ips', + self.alt_target) + + def test_create_port_with_fixed_ips_and_ip_address(self): + self.assertTrue( + policy.enforce(self.context, 'create_port:fixed_ips:ip_address', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port:fixed_ips:ip_address', + self.alt_target) + + def test_create_port_with_fixed_ips_and_subnet_id(self): + self.assertTrue( + policy.enforce(self.context, 'create_port:fixed_ips:subnet_id', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port:fixed_ips:subnet_id', + self.alt_target) + + def test_create_port_with_port_security_enabled(self): + self.assertTrue( + policy.enforce(self.context, 'create_port:port_security_enabled', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port:port_security_enabled', + self.alt_target) + + def test_create_port_with_allowed_address_pairs(self): + self.assertTrue( + policy.enforce( + self.context, 'create_port:allowed_address_pairs', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:allowed_address_pairs', + self.alt_target) + + def test_create_port_with_allowed_address_pairs_and_mac_address(self): + self.assertTrue( + policy.enforce( + self.context, 'create_port:allowed_address_pairs:mac_address', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:allowed_address_pairs:mac_address', + self.alt_target) + + def test_create_port_with_allowed_address_pairs_and_ip_address(self): + self.assertTrue( + policy.enforce( + self.context, 'create_port:allowed_address_pairs:ip_address', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_port:allowed_address_pairs:ip_address', + self.alt_target) + + def test_update_port_with_device_owner(self): + target = self.target.copy() + target['device_owner'] = 'network:test' + alt_target = self.alt_target.copy() + alt_target['device_owner'] = 'network:test' + self.assertTrue( + policy.enforce(self.context, 'update_port:device_owner', target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_port:device_owner', + alt_target) + + def test_update_port_with_mac_address(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_port:mac_address', + self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_port:mac_address', + self.alt_target) + + def test_update_port_with_fixed_ips(self): + self.assertTrue( + policy.enforce(self.context, 'update_port:fixed_ips', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_port:fixed_ips', + self.alt_target) + + def test_update_port_with_fixed_ips_and_ip_address(self): + self.assertTrue( + policy.enforce(self.context, 'update_port:fixed_ips', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_port:fixed_ips:ip_address', + self.alt_target) + + def test_update_port_with_fixed_ips_and_subnet_id(self): + self.assertTrue( + policy.enforce(self.context, 'update_port:fixed_ips', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_port:fixed_ips:subnet_id', + self.alt_target) + + def test_update_port_with_port_security_enabled(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:port_security_enabled', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_port:port_security_enabled', + self.alt_target) + + def test_update_port_with_allowed_address_pairs(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:allowed_address_pairs', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:allowed_address_pairs', + self.alt_target) + + def test_update_port_with_allowed_address_pairs_and_mac_address(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:allowed_address_pairs:mac_address', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:allowed_address_pairs:mac_address', + self.alt_target) + + def test_update_port_with_allowed_address_pairs_and_ip_address(self): + self.assertTrue( + policy.enforce( + self.context, 'update_port:allowed_address_pairs:ip_address', + self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_port:allowed_address_pairs:ip_address', + self.alt_target) + + +class ProjectReaderTests(ProjectMemberTests): + + def setUp(self): + super().setUp() + self.context = self.project_reader_ctx + + def test_create_port(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_port', self.alt_target) + + def test_create_port_with_device_owner(self): + target = self.target.copy() + target['device_owner'] = 'network:test' + alt_target = self.alt_target.copy() + alt_target['device_owner'] = 'network:test' self.assertRaises( base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_port:device_owner', @@ -1256,16 +1458,6 @@ policy.enforce, self.context, 'create_port:fixed_ips', self.alt_target) - def test_create_port_with_fixed_ips_and_ip_address(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port:fixed_ips:ip_address', - self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port:fixed_ips:ip_address', - self.alt_target) - def test_create_port_with_fixed_ips_and_subnet_id(self): self.assertRaises( base_policy.PolicyNotAuthorized, @@ -1322,59 +1514,55 @@ self.context, 'create_port:allowed_address_pairs:ip_address', self.alt_target) - def test_update_port_with_device_owner(self): - target = self.target.copy() - target['device_owner'] = 'network:test' - alt_target = self.alt_target.copy() - alt_target['device_owner'] = 'network:test' + def test_create_port_with_fixed_ips_and_ip_address(self): self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:device_owner', - target) + policy.enforce, self.context, 'create_port:fixed_ips:ip_address', + self.target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:device_owner', - alt_target) + policy.enforce, self.context, 'create_port:fixed_ips:ip_address', + self.alt_target) - def test_update_port_with_mac_address(self): + def test_create_port_with_binding_vnic_type(self): self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:mac_address', + policy.enforce, self.context, 'create_port:binding:vnic_type', self.target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:mac_address', + policy.enforce, self.context, 'create_port:binding:vnic_type', self.alt_target) - def test_update_port_with_fixed_ips(self): + def test_create_port_tags(self): self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:fixed_ips', - self.target) + policy.enforce, self.context, 'create_port:tags', self.target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:fixed_ips', - self.alt_target) + policy.enforce, self.context, 'create_port:tags', self.alt_target) - def test_update_port_with_fixed_ips_and_ip_address(self): + def test_update_port(self): self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:fixed_ips:ip_address', - self.target) + policy.enforce, self.context, 'update_port', self.target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:fixed_ips:ip_address', - self.alt_target) + policy.enforce, self.context, 'update_port', self.alt_target) - def test_update_port_with_fixed_ips_and_subnet_id(self): + def test_update_port_with_device_owner(self): + target = self.target.copy() + target['device_owner'] = 'network:test' + alt_target = self.alt_target.copy() + alt_target['device_owner'] = 'network:test' self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:fixed_ips:subnet_id', - self.target) + policy.enforce, self.context, 'update_port:device_owner', + target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port:fixed_ips:subnet_id', - self.alt_target) + policy.enforce, self.context, 'update_port:device_owner', + alt_target) def test_update_port_with_port_security_enabled(self): self.assertRaises( @@ -1422,46 +1610,41 @@ self.context, 'update_port:allowed_address_pairs:ip_address', self.alt_target) - -class ProjectReaderTests(ProjectMemberTests): - - def setUp(self): - super().setUp() - self.context = self.project_reader_ctx - - def test_create_port(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port', self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port', self.alt_target) - - def test_create_port_with_binding_vnic_type(self): + def test_update_port_with_fixed_ips(self): self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port:binding:vnic_type', + policy.enforce, + self.context, 'update_port:fixed_ips', self.target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port:binding:vnic_type', + policy.enforce, + self.context, 'update_port:fixed_ips', self.alt_target) - def test_create_port_tags(self): + def test_update_port_with_fixed_ips_and_ip_address(self): self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port:tags', self.target) + policy.enforce, + self.context, 'update_port:fixed_ips:ip_address', + self.target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'create_port:tags', self.alt_target) + policy.enforce, + self.context, 'update_port:fixed_ips:ip_address', + self.alt_target) - def test_update_port(self): + def test_update_port_with_fixed_ips_and_subnet_id(self): self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port', self.target) + policy.enforce, + self.context, 'update_port:fixed_ips:subnet_id', + self.target) self.assertRaises( base_policy.PolicyNotAuthorized, - policy.enforce, self.context, 'update_port', self.alt_target) + policy.enforce, + self.context, 'update_port:fixed_ips:subnet_id', + self.alt_target) def test_update_port_with_binding_vnic_type(self): self.assertRaises( diff -Nru neutron-26.0.0/neutron/tests/unit/db/test_l3_db.py neutron-26.0.3/neutron/tests/unit/db/test_l3_db.py --- neutron-26.0.0/neutron/tests/unit/db/test_l3_db.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/db/test_l3_db.py 2026-04-15 04:51:52.000000000 +0000 @@ -1081,6 +1081,11 @@ self.mixin, payload=mock.ANY), mock.call(resources.PORT, events.BEFORE_CREATE, mock.ANY, payload=mock.ANY), + # TODO(slaweq): use constant from + # neutron_lib.callbacks.resources once it will be available + # and released + mock.call('allowed_address_pair', events.BEFORE_CREATE, + mock.ANY, payload=mock.ANY), mock.call(resources.PORT, events.PRECOMMIT_CREATE, mock.ANY, payload=mock.ANY), mock.call(resources.PORT, events.AFTER_CREATE, diff -Nru neutron-26.0.0/neutron/tests/unit/db/test_l3_hamode_db.py neutron-26.0.3/neutron/tests/unit/db/test_l3_hamode_db.py --- neutron-26.0.0/neutron/tests/unit/db/test_l3_hamode_db.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/db/test_l3_hamode_db.py 2026-04-15 04:51:52.000000000 +0000 @@ -188,6 +188,29 @@ self.assertIn((self.agent1['id'], 'active'), agent_ids) self.assertIn((self.agent2['id'], 'standby'), agent_ids) + def test_get_l3_bindings_hosting_router_with_ha_states_null_agent(self): + router = self._create_router() + self.plugin.update_routers_states( + self.admin_ctx, {router['id']: 'active'}, self.agent1['host']) + mock_bindings = self.plugin.get_ha_router_port_bindings( + self.admin_ctx, [router['id']]) + mock_bindings[0].state = constants.HA_ROUTER_STATE_STANDBY + target = ( + 'neutron.db.l3_hamode_db.L3_HA_NAT_db_mixin' + '.get_ha_router_port_bindings' + ) + with ( + mock.patch( + target, + return_value=mock_bindings), + mock.patch.object(mock_bindings[0], 'agent', new=None) + ): + bindings = ( + self.plugin.get_l3_bindings_hosting_router_with_ha_states( + self.admin_ctx, router['id']) + ) + self.assertEqual(len(bindings), 1) + def test_get_l3_bindings_hosting_router_with_ha_states_not_scheduled(self): router = self._create_router(ha=False) # Check that there no L3 agents scheduled for this router diff -Nru neutron-26.0.0/neutron/tests/unit/ipam/test_subnet_alloc.py neutron-26.0.3/neutron/tests/unit/ipam/test_subnet_alloc.py --- neutron-26.0.0/neutron/tests/unit/ipam/test_subnet_alloc.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/ipam/test_subnet_alloc.py 2026-04-15 04:51:52.000000000 +0000 @@ -197,3 +197,34 @@ 'fe80::/63') with mock.patch("sqlalchemy.orm.query.Query.update", return_value=0): self.assertRaises(db_exc.RetryRequest, sa.allocate_subnet, req) + + def test_subnetpool_any_request_no_gateway_ip_set(self): + sp = self._create_subnet_pool(self.plugin, self.ctx, 'test-sp', + ['10.1.0.0/16', '192.168.1.0/24'], + 21, 4) + sp = self.plugin._get_subnetpool(self.ctx, sp['id']) + with db_api.CONTEXT_WRITER.using(self.ctx): + sa = subnet_alloc.SubnetAllocator(sp, self.ctx) + req = ipam_req.AnySubnetRequest(self._tenant_id, + uuidutils.generate_uuid(), + constants.IPv4, 21, + set_gateway_ip=False) + res = sa.allocate_subnet(req) + detail = res.get_details() + self.assertIsNone(detail.gateway_ip) + self.assertFalse(detail.set_gateway_ip) + + def test_subnetpool_specific_request_no_gateway_ip_set(self): + sp = self._create_subnet_pool(self.plugin, self.ctx, 'test-sp', + ['10.1.0.0/16', '192.168.1.0/24'], + 21, 4) + sp = self.plugin._get_subnetpool(self.ctx, sp['id']) + with db_api.CONTEXT_WRITER.using(self.ctx): + sa = subnet_alloc.SubnetAllocator(sp, self.ctx) + req = ipam_req.SpecificSubnetRequest( + self._tenant_id, uuidutils.generate_uuid(), + '10.1.2.0/27', set_gateway_ip=False) + res = sa.allocate_subnet(req) + detail = res.get_details() + self.assertIsNone(detail.gateway_ip) + self.assertFalse(detail.set_gateway_ip) diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/base_type_tunnel.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/base_type_tunnel.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/base_type_tunnel.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/base_type_tunnel.py 2026-04-15 04:51:52.000000000 +0000 @@ -49,7 +49,7 @@ super().setUp() self.driver = self.DRIVER_CLASS() self.driver.tunnel_ranges = TUNNEL_RANGES - self.driver.sync_allocations() + self.driver._sync_allocations() self.context = context.Context() def test_tunnel_type(self): @@ -84,7 +84,7 @@ self.driver.get_allocation(self.context, (TUN_MAX + 1))) self.driver.tunnel_ranges = UPDATED_TUNNEL_RANGES - self.driver.sync_allocations() + self.driver._sync_allocations() self.assertIsNone( self.driver.get_allocation(self.context, (TUN_MIN + 5 - 1))) @@ -108,7 +108,7 @@ self.driver.reserve_provider_segment(self.context, segment) self.driver.tunnel_ranges = UPDATED_TUNNEL_RANGES - self.driver.sync_allocations() + self.driver._sync_allocations() self.assertTrue( self.driver.get_allocation(self.context, tunnel_id).allocated) @@ -127,7 +127,7 @@ return [] with mock.patch.object( type_tunnel, 'chunks', side_effect=verify_no_chunk) as chunks: - self.driver.sync_allocations() + self.driver._sync_allocations() # No writing operation is done, fast exit: current allocations # already present. self.assertEqual(0, len(chunks.mock_calls)) @@ -295,7 +295,7 @@ super().setUp() self.driver = self.DRIVER_CLASS() self.driver.tunnel_ranges = self.TUNNEL_MULTI_RANGES - self.driver.sync_allocations() + self.driver._sync_allocations() self.context = context.Context() def test_release_segment(self): @@ -486,7 +486,7 @@ # one of the `service_plugins` self.driver._initialize(RAW_TUNNEL_RANGES) self.driver.initialize_network_segment_range_support(self.start_time) - self.driver.sync_allocations() + self.driver._sync_allocations() ret = obj_network_segment_range.NetworkSegmentRange.get_objects( self.context) self.assertEqual(1, len(ret)) @@ -502,9 +502,9 @@ def test__delete_expired_default_network_segment_ranges(self): self.driver.tunnel_ranges = TUNNEL_RANGES - self.driver.sync_allocations() + self.driver._sync_allocations() self.driver._delete_expired_default_network_segment_ranges( - self.start_time) + self.context, self.start_time) ret = obj_network_segment_range.NetworkSegmentRange.get_objects( self.context, network_type=self.driver.get_type()) self.assertEqual(0, len(ret)) diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_tun.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_tun.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_tun.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_tun.py 2026-04-15 04:51:52.000000000 +0000 @@ -16,6 +16,7 @@ from unittest import mock +from neutron_lib import constants as lib_constants from neutron_lib.plugins.ml2 import ovs_constants as ovs_const from neutron.tests.unit.plugins.ml2.drivers.openvswitch.agent.openflow.native \ @@ -48,6 +49,75 @@ self.setup_bridge_mock('br-tun', self.br_tun_cls) self.stamp = self.br.default_cookie + def _get_learn_flows(self, ofpp, patch_int_ofport): + (dp, ofp, ofpp) = self._get_dp() + # flows_data is list of tuples (priority, match) + flows_data = [ + (2, ofpp.OFPMatch( + eth_type=self.ether_types.ETH_TYPE_ARP, + arp_tha=lib_constants.BROADCAST_MAC + )), + (2, ofpp.OFPMatch( + eth_type=self.ether_types.ETH_TYPE_IPV6, + ip_proto=self.in_proto.IPPROTO_ICMPV6, + icmpv6_type=self.icmpv6.ND_ROUTER_ADVERT + )), + (2, ofpp.OFPMatch( + eth_type=self.ether_types.ETH_TYPE_IPV6, + ip_proto=self.in_proto.IPPROTO_ICMPV6, + icmpv6_type=self.icmpv6.ND_NEIGHBOR_ADVERT + )), + (1, ofpp.OFPMatch()) + ] + learn_flows = [] + for priority, match in flows_data: + learn_flows.append( + call._send_msg( + ofpp.OFPFlowMod( + dp, + cookie=self.stamp, + instructions=[ + ofpp.OFPInstructionActions( + ofp.OFPIT_APPLY_ACTIONS, [ + ofpp.NXActionLearn( + cookie=self.stamp, + hard_timeout=300, + priority=1, + specs=[ + ofpp.NXFlowSpecMatch( + dst=('vlan_tci', 0), + n_bits=12, + src=('vlan_tci', 0)), + ofpp.NXFlowSpecMatch( + dst=('eth_dst', 0), + n_bits=48, + src=('eth_src', 0)), + ofpp.NXFlowSpecLoad( + dst=('vlan_tci', 0), + n_bits=16, + src=0), + ofpp.NXFlowSpecLoad( + dst=('tunnel_id', 0), + n_bits=64, + src=('tunnel_id', 0)), + ofpp.NXFlowSpecOutput( + dst='', + n_bits=32, + src=('in_port', 0)), + ], + table_id=20), + ofpp.OFPActionOutput(patch_int_ofport, 0), + ] + ), + ], + match=match, + priority=priority, + table_id=10), + active_bundle=None + ) + ) + return learn_flows + def test_setup_default_table(self): patch_int_ofport = 5555 arp_responder_enabled = False @@ -110,47 +180,9 @@ instructions=[], match=ofpp.OFPMatch(), priority=0, table_id=6), - active_bundle=None), - call._send_msg( - ofpp.OFPFlowMod( - dp, - cookie=self.stamp, - instructions=[ - ofpp.OFPInstructionActions(ofp.OFPIT_APPLY_ACTIONS, [ - ofpp.NXActionLearn( - cookie=self.stamp, - hard_timeout=300, - priority=1, - specs=[ - ofpp.NXFlowSpecMatch( - dst=('vlan_tci', 0), - n_bits=12, - src=('vlan_tci', 0)), - ofpp.NXFlowSpecMatch( - dst=('eth_dst', 0), - n_bits=48, - src=('eth_src', 0)), - ofpp.NXFlowSpecLoad( - dst=('vlan_tci', 0), - n_bits=16, - src=0), - ofpp.NXFlowSpecLoad( - dst=('tunnel_id', 0), - n_bits=64, - src=('tunnel_id', 0)), - ofpp.NXFlowSpecOutput( - dst='', - n_bits=32, - src=('in_port', 0)), - ], - table_id=20), - ofpp.OFPActionOutput(patch_int_ofport, 0), - ]), - ], - match=ofpp.OFPMatch(), - priority=1, - table_id=10), - active_bundle=None), + active_bundle=None)] + expected += self._get_learn_flows(ofpp, patch_int_ofport) + expected += [ call._send_msg( ofpp.OFPFlowMod(dp, cookie=self.stamp, @@ -243,47 +275,9 @@ instructions=[], match=ofpp.OFPMatch(), priority=0, table_id=6), - active_bundle=None), - call._send_msg( - ofpp.OFPFlowMod( - dp, - cookie=self.stamp, - instructions=[ - ofpp.OFPInstructionActions(ofp.OFPIT_APPLY_ACTIONS, [ - ofpp.NXActionLearn( - cookie=self.stamp, - hard_timeout=300, - priority=1, - specs=[ - ofpp.NXFlowSpecMatch( - dst=('vlan_tci', 0), - n_bits=12, - src=('vlan_tci', 0)), - ofpp.NXFlowSpecMatch( - dst=('eth_dst', 0), - n_bits=48, - src=('eth_src', 0)), - ofpp.NXFlowSpecLoad( - dst=('vlan_tci', 0), - n_bits=16, - src=0), - ofpp.NXFlowSpecLoad( - dst=('tunnel_id', 0), - n_bits=64, - src=('tunnel_id', 0)), - ofpp.NXFlowSpecOutput( - dst='', - n_bits=32, - src=('in_port', 0)), - ], - table_id=20), - ofpp.OFPActionOutput(patch_int_ofport, 0), - ]), - ], - match=ofpp.OFPMatch(), - priority=1, - table_id=10), - active_bundle=None), + active_bundle=None)] + expected += self._get_learn_flows(ofpp, patch_int_ofport) + expected += [ call._send_msg( ofpp.OFPFlowMod(dp, cookie=self.stamp, diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/test_ovs_neutron_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -4083,6 +4083,7 @@ self, device_owner, ip_version=n_const.IP_VERSION_4, aaps=False): self._setup_for_dvr_test() port_obj = {"id": "fake-port-uuid"} + local_port_obj = {"id": "fake-port-uuid"} aap_mac = 'aa:bb:cc:dd:ee:ff' aap_mac2 = 'aa:bb:cc:dd:ee:fe' aap_mac3 = 'aa:bb:cc:dd:ee:fd' @@ -4107,7 +4108,7 @@ 'mac_address': aap_mac}, {'ip_address': '2001:100::11', 'mac_address': aap_mac2}, - {'ip_address': '2001:100::0/0', + {'ip_address': '::/0', 'mac_address': aap_mac3} ] self._port.dvr_mac = self.agent.dvr_agent.dvr_mac_address @@ -4186,6 +4187,9 @@ 'failed_devices_up': [], 'failed_devices_down': []}),\ mock.patch.object(self.agent.dvr_agent.plugin_rpc, + 'get_ports_on_host_by_subnet', + return_value=[local_port_obj]),\ + mock.patch.object(self.agent.dvr_agent.plugin_rpc, 'get_ports', return_value=[port_obj]),\ mock.patch.object(self.agent, 'int_br', new=int_br),\ @@ -4223,6 +4227,11 @@ device_owner=DEVICE_OWNER_COMPUTE) self._test_treat_devices_removed_for_dvr( device_owner=DEVICE_OWNER_COMPUTE, ip_version=n_const.IP_VERSION_6) + self._test_treat_devices_removed_for_dvr( + device_owner=DEVICE_OWNER_COMPUTE, aaps=True) + self._test_treat_devices_removed_for_dvr( + device_owner=DEVICE_OWNER_COMPUTE, ip_version=n_const.IP_VERSION_6, + aaps=True) def test_treat_devices_removed_for_dvr_with_dhcp_ports(self): self._test_treat_devices_removed_for_dvr( diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/agent/test_neutron_agent.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/agent/test_neutron_agent.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/agent/test_neutron_agent.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/agent/test_neutron_agent.py 2026-04-15 04:51:52.000000000 +0000 @@ -12,7 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. +import collections import datetime +import random from unittest import mock import eventlet @@ -30,21 +32,46 @@ super().setUp() self.agent_cache = neutron_agent.AgentCache(driver=mock.ANY) self.addCleanup(self._clean_agent_cache) - self.names_ref = [] - for i in range(10): # Add 10 agents. + self.agents = {} + self.num_agents = 10 # Add 10 agents. + for i in range(self.num_agents): + agent_type = random.choice(ovn_const.OVN_AGENT_TYPES) + other_config = {} + if agent_type == ovn_const.OVN_CONTROLLER_GW_AGENT: + # 'enable-chassis-as-gw' is mandatory if the controller is + # a gateway chassis; if not, it will default to + # 'OVN Controller agent'. Check ``ControllerGatewayAgent`` + # class. + other_config = {'ovn-cms-options': 'enable-chassis-as-gw'} chassis = fakes.FakeOvsdbRow.create_one_ovsdb_row( - attrs={'other_config': {}}) + attrs={'other_config': other_config, + 'hostname': f'host{i:d}', + }) + ext_ids = {} + if agent_type == ovn_const.OVN_METADATA_AGENT: + ext_ids = { + ovn_const.OVN_AGENT_METADATA_ID_KEY: 'chassis' + str(i)} + elif agent_type == ovn_const.OVN_NEUTRON_AGENT: + ext_ids = { + ovn_const.OVN_AGENT_NEUTRON_ID_KEY: 'chassis' + str(i)} chassis_private = fakes.FakeOvsdbRow.create_one_ovsdb_row( attrs={'name': 'chassis' + str(i), 'other_config': {}, 'chassis': [chassis], - 'nb_cfg_timestamp': timeutils.utcnow_ts() * 1000}) - self.agent_cache.update(ovn_const.OVN_CONTROLLER_AGENT, - chassis_private) - self.names_ref.append('chassis' + str(i)) + 'nb_cfg_timestamp': timeutils.utcnow_ts() * 1000, + 'external_ids': ext_ids, + }) + self.agent_cache.update(agent_type, chassis_private) + self.agents['chassis' + str(i)] = agent_type + + self.assertEqual(self.num_agents, len(list(self.agent_cache))) + for agent_class in (neutron_agent.NeutronAgent, + neutron_agent.MetadataAgent, + neutron_agent.OVNNeutronAgent): + mock.patch.object(agent_class, 'alive', return_value=True).start() def _clean_agent_cache(self): - self.agent_cache.agents = {} + del self.agent_cache def _list_agents(self): self.names_read = [] @@ -69,18 +96,19 @@ pool.spawn(self._list_agents) pool.spawn(self._add_and_delete_agents) pool.waitall() - self.assertEqual(self.names_ref, self.names_read) + self.assertEqual(list(self.agents.keys()), self.names_read) def test_agents_by_chassis_private(self): + ext_ids = {ovn_const.OVN_AGENT_METADATA_ID_KEY: 'chassis5'} chassis_private = fakes.FakeOvsdbRow.create_one_ovsdb_row( - attrs={'name': 'chassis5'}) + attrs={'name': 'chassis5', + 'external_ids': ext_ids}) agents = self.agent_cache.agents_by_chassis_private(chassis_private) agents = list(agents) self.assertEqual(1, len(agents)) self.assertEqual('chassis5', agents[0].agent_id) - @mock.patch.object(neutron_agent.ControllerAgent, 'alive') - def test_heartbeat_timestamp_format(self, agent_alive): + def test_heartbeat_timestamp_format(self): chassis_private = fakes.FakeOvsdbRow.create_one_ovsdb_row( attrs={'name': 'chassis5'}) agents = self.agent_cache.agents_by_chassis_private(chassis_private) @@ -89,8 +117,85 @@ agent.updated_at = datetime.datetime( year=2023, month=2, day=23, hour=1, minute=2, second=3, microsecond=456789).replace(tzinfo=datetime.timezone.utc) - agent_alive.return_value = True # Verify that both microseconds and timezone are dropped self.assertEqual(str(agent.as_dict()['heartbeat_timestamp']), '2023-02-23 01:02:03') + + def test_list_agents_filtering_host_same_type(self): + for idx in range(len(self.agents)): + host = f'host{idx:d}' + agents = self.agent_cache.get_agents(filters={'host': host}) + self.assertEqual(1, len(agents)) + self.assertEqual(host, agents[0].as_dict()['host']) + + def test_list_agents_filtering_host_as_iterable(self): + hosts = [] + for idx in range(len(self.agents)): + hosts.append(f'host{idx:d}') + + agents = self.agent_cache.get_agents(filters={'host': hosts}) + self.assertEqual(len(self.agents), len(agents)) + + def test_list_agents_filtering_agent_type_same_type(self): + agent_types = collections.defaultdict(int) + for _type in self.agents.values(): + agent_types[_type] = agent_types[_type] + 1 + + for _type in agent_types: + agents = self.agent_cache.get_agents( + filters={'agent_type': _type}) + self.assertEqual(agent_types[_type], len(agents)) + self.assertEqual(_type, agents[0].as_dict()['agent_type']) + + def test_list_agents_filtering_agent_type_as_iterable(self): + agents = self.agent_cache.get_agents( + filters={'agent_type': ovn_const.OVN_AGENT_TYPES}) + self.assertEqual(self.num_agents, len(agents)) + + @mock.patch.object(neutron_agent, 'LOG') + def test_list_agents_filtering_wrong_type(self, mock_log): + agents = self.agent_cache.get_agents(filters={'host': 111}) + self.assertEqual(0, len(agents)) + mock_log.info.assert_called_once() + + def test_list_agents_filtering_same_string_in_filter(self): + # As reported in LP#2110094, if two registers have the same substring, + # the filter didn't work. + # Chassis 1, hostname: compute-0 + chassis = fakes.FakeOvsdbRow.create_one_ovsdb_row( + attrs={'other_config': {}, + 'hostname': 'compute-0'}) + chassis_private = fakes.FakeOvsdbRow.create_one_ovsdb_row( + attrs={'name': 'chassis1', + 'other_config': {}, + 'chassis': [chassis], + 'nb_cfg_timestamp': timeutils.utcnow_ts() * 1000, + 'external_ids': {}}) + self.agent_cache.update(ovn_const.OVN_CONTROLLER_AGENT, + chassis_private) + + # Chassis 2, hostname: dcn1-compute-0 + chassis = fakes.FakeOvsdbRow.create_one_ovsdb_row( + attrs={'other_config': {}, + 'hostname': 'dcn1-compute-0'}) + chassis_private = fakes.FakeOvsdbRow.create_one_ovsdb_row( + attrs={'name': 'chassis2', + 'other_config': {}, + 'chassis': [chassis], + 'nb_cfg_timestamp': timeutils.utcnow_ts() * 1000, + 'external_ids': {}}) + self.agent_cache.update(ovn_const.OVN_CONTROLLER_AGENT, + chassis_private) + + agents = self.agent_cache.get_agents( + filters={'host': 'compute-0'}) + self.assertEqual(1, len(agents)) + + agents = self.agent_cache.get_agents( + filters={'host': 'dcn1-compute-0'}) + self.assertEqual(1, len(agents)) + + agents = self.agent_cache.get_agents( + filters={'host': ['compute-0', 'dcn1-compute-0']}) + self.assertEqual(2, len(agents)) diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py 2026-04-15 04:51:52.000000000 +0000 @@ -15,6 +15,7 @@ from unittest import mock from neutron_lib import constants as n_const +from oslo_utils import uuidutils from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions \ import placement as p_extension @@ -32,6 +33,9 @@ self.plugin_driver = mock.Mock() self.placement_driver = p_extension.OVNClientPlacementExtension( self.plugin_driver) + # Ensure the ``OVNClientPlacementExtension`` object is new, that the + # previous instance has been deleted. + self.assertEqual(self.plugin_driver, self.placement_driver._driver) self.placement_client = mock.Mock( update_trait=mock.Mock(__name__='update_trait'), ensure_resource_provider=mock.Mock(__name__='ensure_rp'), @@ -46,6 +50,18 @@ 'resource_providers': [{'name': 'compute1', 'uuid': 'uuid1'}, {'name': 'compute2', 'uuid': 'uuid2'}] } + self.name2uuid = self._gen_name2uuid(['compute1', + 'compute2', + ]) + self.addCleanup(self._delete_placement_singleton_instance) + + def _delete_placement_singleton_instance(self): + del self.placement_driver + + @staticmethod + def _gen_name2uuid(hypervisor_list): + return {hypervisor: uuidutils.generate_uuid() for + hypervisor in hypervisor_list} def test_read_initial_chassis_config(self): # Add two public networks, a RP per bridge and the correlation between @@ -231,3 +247,43 @@ n_const.RP_HYPERVISORS: {} }} _check_expected_config(init_conf, expected) + + def test_build_placement_state_no_rp_deleted(self): + chassis = fakes.FakeChassis.create( + bridge_mappings=['public1:br-ext1', 'public2:br-ext2'], + rp_bandwidths=['br-ext1:1000:2000', 'br-ext2:3000:4000'], + rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5}, + rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute1']) + chassis_old = fakes.FakeChassis.create( + bridge_mappings=['public1:br-ext1', 'public2:br-ext2'], + rp_bandwidths=['br-ext1:1001:2002', 'br-ext2:3003:4004'], + rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5}, + rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute1']) + report = self.placement_driver.build_placement_state( + chassis, self.name2uuid, chassis_old=chassis_old) + self.assertEqual(set(), report._rp_deleted) + + def test_build_placement_state_rp_deleted(self): + chassis = fakes.FakeChassis.create( + bridge_mappings=['public1:br-ext1', 'public2:br-ext2'], + rp_bandwidths=['br-ext1:1000:2000'], + rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5}, + rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute1']) + chassis_old = fakes.FakeChassis.create( + bridge_mappings=['public1:br-ext1', 'public2:br-ext2'], + rp_bandwidths=['br-ext1:1001:2002', 'br-ext2:3003:4004'], + rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5}, + rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute1']) + report = self.placement_driver.build_placement_state( + chassis, self.name2uuid, chassis_old=chassis_old) + self.assertEqual({'br-ext2'}, report._rp_deleted) + + def test_build_placement_state_no_old_chassis(self): + chassis = fakes.FakeChassis.create( + bridge_mappings=['public1:br-ext1', 'public2:br-ext2'], + rp_bandwidths=['br-ext1:1000:2000'], + rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5}, + rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute1']) + report = self.placement_driver.build_placement_state( + chassis, self.name2uuid) + self.assertEqual(set(), report._rp_deleted) diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_qos.py 2026-04-15 04:51:52.000000000 +0000 @@ -263,9 +263,11 @@ rule = {qos_constants.RULE_TYPE_BANDWIDTH_LIMIT: QOS_RULE_BW_1} match = self.qos_driver._ovn_qos_rule_match( direction, 'port_id', ip_address, 'resident_port') + priority = (qos_extension.OVN_QOS_FIP_RULE_PRIORITY if fip_id else + qos_extension.OVN_QOS_DEFAULT_RULE_PRIORITY) expected = {'burst': 100, 'rate': 200, 'direction': 'to-lport', 'match': match, - 'priority': qos_extension.OVN_QOS_DEFAULT_RULE_PRIORITY, + 'priority': priority, 'switch': 'neutron-network_id', 'external_ids': external_ids} result = self.qos_driver._ovn_qos_rule( @@ -288,10 +290,11 @@ rule = {qos_constants.RULE_TYPE_DSCP_MARKING: QOS_RULE_DSCP_1} match = self.qos_driver._ovn_qos_rule_match( direction, 'port_id', ip_address, 'resident_port') + priority = (qos_extension.OVN_QOS_FIP_RULE_PRIORITY if fip_id else + qos_extension.OVN_QOS_DEFAULT_RULE_PRIORITY) expected = {'direction': 'from-lport', 'match': match, 'dscp': 16, 'switch': 'neutron-network_id', - 'priority': qos_extension.OVN_QOS_DEFAULT_RULE_PRIORITY, - 'external_ids': external_ids} + 'priority': priority, 'external_ids': external_ids} result = self.qos_driver._ovn_qos_rule( direction, rule, 'port_id', 'network_id', fip_id=fip_id, ip_address=ip_address, resident_port='resident_port') @@ -301,8 +304,7 @@ qos_constants.RULE_TYPE_DSCP_MARKING: QOS_RULE_DSCP_2} expected = {'direction': 'from-lport', 'match': match, 'rate': 300, 'dscp': 20, 'switch': 'neutron-network_id', - 'priority': qos_extension.OVN_QOS_DEFAULT_RULE_PRIORITY, - 'external_ids': external_ids} + 'priority': priority, 'external_ids': external_ids} result = self.qos_driver._ovn_qos_rule( direction, rule, 'port_id', 'network_id', fip_id=fip_id, ip_address=ip_address, resident_port='resident_port') diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py 2026-04-15 04:51:52.000000000 +0000 @@ -183,25 +183,46 @@ 'networks': ['10.0.3.0/24'], 'options': {ovn_const.OVN_GATEWAY_CHASSIS_KEY: None}}, {'name': 'xrp-id-b1', - 'external_ids': {}, 'networks': ['20.0.1.0/24']}, + 'external_ids': { + ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: + utils.ovn_name('lr-id-b'), + }, 'networks': ['20.0.1.0/24']}, {'name': utils.ovn_lrouter_port_name('orp-id-b2'), - 'external_ids': {}, 'networks': ['20.0.2.0/24'], + 'external_ids': { + ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: + utils.ovn_name('lr-id-b')}, + 'networks': ['20.0.2.0/24'], 'options': {ovn_const.OVN_GATEWAY_CHASSIS_KEY: 'host-2'}}, {'name': utils.ovn_lrouter_port_name('orp-id-b3'), - 'external_ids': {}, 'networks': ['20.0.3.0/24'], + 'external_ids': { + ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: + utils.ovn_name('lr-id-b')}, + 'networks': ['20.0.3.0/24'], 'options': {}}, {'name': utils.ovn_lrouter_port_name('gwc'), 'external_ids': {ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: 'lr-id-f', ovn_const.OVN_ROUTER_IS_EXT_GW: str(True)}, 'networks': ['10.0.4.0/24'], + 'options': {}}, + {'name': utils.ovn_lrouter_port_name('not-managed'), + 'external_ids': { + 'owner': 'not-owned-by-neutron'}, + 'networks': ['10.0.5.0/24'], 'options': {}}], 'gateway_chassis': [ {'chassis_name': 'fake-chassis', 'name': utils.ovn_lrouter_port_name('gwc') + '_fake-chassis'}], 'static_routes': [{'ip_prefix': '20.0.0.0/16', - 'nexthop': '10.0.3.253'}, + 'nexthop': '10.0.3.253', + 'external_ids': { + ovn_const.OVN_SUBNET_EXT_ID_KEY: 'uuid_1'}}, {'ip_prefix': '10.0.0.0/16', - 'nexthop': '20.0.2.253'}], + 'nexthop': '20.0.2.253', + 'external_ids': { + ovn_const.OVN_SUBNET_EXT_ID_KEY: 'uuid_2'}}, + {'ip_prefix': '30.0.0.0/16', + 'nexthop': '30.0.4.253', + 'external_ids': {'owner': 'not-owned-by-neutron'}}], 'nats': [{'external_ip': '10.0.3.1', 'logical_ip': '20.0.0.0/16', 'type': 'snat'}, {'external_ip': '20.0.2.1', 'logical_ip': '10.0.0.0/24', @@ -481,7 +502,7 @@ def _test_get_all_logical_routers_with_rports(self, is_gw_port): # Test empty - mapping = self.nb_ovn_idl.get_all_logical_switches_with_ports() + mapping = self.nb_ovn_idl.get_all_logical_routers_with_rports() self.assertCountEqual(mapping, {}) # Test loaded values self._load_nb_db() diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py 2026-04-15 04:51:52.000000000 +0000 @@ -401,30 +401,44 @@ attrs={'name': 'ls0', 'other_config': { constants.MCAST_SNOOP: 'false', - constants.MCAST_FLOOD_UNREGISTERED: 'false'}}) + constants.MCAST_FLOOD_UNREGISTERED: 'false'}, + 'external_ids': { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: 'port0'}}) ls1 = fakes.FakeOvsdbRow.create_one_ovsdb_row( attrs={'name': 'ls1', - 'other_config': {}}) + 'other_config': {}, + 'external_ids': { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: 'port1'}}) ls2 = fakes.FakeOvsdbRow.create_one_ovsdb_row( attrs={'name': 'ls2', 'other_config': { constants.MCAST_SNOOP: 'true', - constants.MCAST_FLOOD_UNREGISTERED: 'false'}}) + constants.MCAST_FLOOD_UNREGISTERED: 'false'}, + 'external_ids': { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: 'port2'}}) ls3 = fakes.FakeOvsdbRow.create_one_ovsdb_row( attrs={'name': '', - 'other_config': {}}) + 'other_config': {}, + 'external_ids': { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: 'port3'}}) ls4 = fakes.FakeOvsdbRow.create_one_ovsdb_row( attrs={'name': '', - 'other_config': {constants.MCAST_SNOOP: 'false'}}) - + 'other_config': {constants.MCAST_SNOOP: 'false'}, + 'external_ids': { + constants.OVN_NETWORK_NAME_EXT_ID_KEY: 'port4'}}) + ls5 = fakes.FakeOvsdbRow.create_one_ovsdb_row( + attrs={'name': 'ls5', + 'other_config': {}, + 'external_ids': {}}) nb_idl.ls_list.return_value.execute.return_value = [ls0, ls1, ls2, ls3, - ls4] + ls4, ls5] self.assertRaises(periodics.NeverAgain, self.periodic.check_for_igmp_snoop_support) # "ls2" is not part of the transaction because it already - # have the right value set; "ls3" and "ls4" do not have a name set. + # have the right value set; "ls3" and "ls4" do not have a name set; + # "ls5" is not managed by neutron. expected_calls = [ mock.call('Logical_Switch', 'ls0', ('other_config', { @@ -479,7 +493,14 @@ 'external_ids': { constants.OVN_NETWORK_NAME_EXT_ID_KEY: 'neutron-net1'}}) - nb_idl.db_find_rows.return_value.execute.return_value = [p0, p1] + # Port p2 is not owned by Neutron and should not be affected. + p2 = fakes.FakeOvsdbRow.create_one_ovsdb_row( + attrs={'type': constants.LSP_TYPE_EXTERNAL, + 'name': 'p2', + 'ha_chassis_group': [hcg1], + 'external_ids': {}}) + + nb_idl.db_find_rows.return_value.execute.return_value = [p0, p1, p2] mock_sync_ha_chassis_group_network.return_value = hcg0.uuid, mock.ANY # Invoke the periodic method, it meant to run only once at startup diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py 2026-04-15 04:51:52.000000000 +0000 @@ -99,9 +99,13 @@ super().setUp() self.get_plugin = mock.patch( 'neutron_lib.plugins.directory.get_plugin').start() + self.get_pb_bsah = mock.patch( + 'neutron_lib.plugins.utils.' + 'get_port_binding_by_status_and_host').start() # Disable tenacity wait for UT - self.ovn_client._wait_for_port_bindings_host.retry.wait = wait_none() + self.ovn_client._wait_for_active_port_bindings_host.retry.wait = ( + wait_none()) def test__add_router_ext_gw_default_route(self): plugin = mock.MagicMock() @@ -246,8 +250,9 @@ context = mock.MagicMock() host_id = 'fake-binding-host-id' port_id = 'fake-port-id' - db_port = mock.Mock( - id=port_id, port_bindings=[mock.Mock(host=host_id)]) + 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.ovn_client.update_lsp_host_info(context, db_port) @@ -259,14 +264,16 @@ context = mock.MagicMock() host_id = 'fake-binding-host-id' port_id = 'fake-port-id' + port_binding = mock.Mock(host=host_id) + port_binding_no_host = mock.Mock(host="") db_port_no_host = mock.Mock( - id=port_id, port_bindings=[mock.Mock(host="")]) - db_port = mock.Mock( - id=port_id, port_bindings=[mock.Mock(host=host_id)]) + id=port_id, port_bindings=[port_binding_no_host]) + self.get_pb_bsah.return_value = None with mock.patch.object( - self.ovn_client, '_wait_for_port_bindings_host') as mock_wait: - mock_wait.return_value = db_port + self.ovn_client, + '_wait_for_active_port_bindings_host') as mock_wait: + mock_wait.return_value = port_binding self.ovn_client.update_lsp_host_info(context, db_port_no_host) # Assert _wait_for_port_bindings_host was called @@ -282,9 +289,11 @@ port_id = 'fake-port-id' db_port_no_host = mock.Mock( id=port_id, port_bindings=[mock.Mock(host="")]) + self.get_pb_bsah.return_value = None with mock.patch.object( - self.ovn_client, '_wait_for_port_bindings_host') as mock_wait: + self.ovn_client, + '_wait_for_active_port_bindings_host') as mock_wait: mock_wait.side_effect = RuntimeError("boom") self.ovn_client.update_lsp_host_info(context, db_port_no_host) @@ -316,39 +325,45 @@ self.nb_idl.db_set.assert_not_called() @mock.patch.object(ml2_db, 'get_port') - def test__wait_for_port_bindings_host(self, mock_get_port): + def test__wait_for_active_port_bindings_host(self, mock_get_port): context = mock.MagicMock() host_id = 'fake-binding-host-id' port_id = 'fake-port-id' + port_binding = mock.Mock(host=host_id) + port_binding_no_host = mock.Mock(host="") db_port_no_host = mock.Mock( - id=port_id, port_bindings=[mock.Mock(host="")]) + id=port_id, port_bindings=[port_binding_no_host]) db_port = mock.Mock( - id=port_id, port_bindings=[mock.Mock(host=host_id)]) + id=port_id, port_bindings=[port_binding]) + # no active binding, no binding with host, binding with host + self.get_pb_bsah.side_effect = (None, port_binding_no_host, + port_binding) - mock_get_port.side_effect = (db_port_no_host, db_port) + mock_get_port.side_effect = (db_port_no_host, db_port_no_host, db_port) - ret = self.ovn_client._wait_for_port_bindings_host( + ret = self.ovn_client._wait_for_active_port_bindings_host( context, port_id) - self.assertEqual(ret, db_port) + self.assertEqual(ret, port_binding) expected_calls = [mock.call(context, port_id), mock.call(context, port_id)] mock_get_port.assert_has_calls(expected_calls) @mock.patch.object(ml2_db, 'get_port') - def test__wait_for_port_bindings_host_fail(self, mock_get_port): + def test__wait_for_active_port_bindings_host_fail(self, mock_get_port): context = mock.MagicMock() port_id = 'fake-port-id' db_port_no_pb = mock.Mock(id=port_id, port_bindings=[]) db_port_no_host = mock.Mock( id=port_id, port_bindings=[mock.Mock(host="")]) + self.get_pb_bsah.return_value = None mock_get_port.side_effect = ( db_port_no_pb, db_port_no_host, db_port_no_host) self.assertRaises( - RuntimeError, self.ovn_client._wait_for_port_bindings_host, + RuntimeError, self.ovn_client._wait_for_active_port_bindings_host, context, port_id) expected_calls = [mock.call(context, port_id), diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py 2026-04-15 04:51:52.000000000 +0000 @@ -16,6 +16,8 @@ from unittest import mock from neutron_lib import constants as const +from neutron_lib.services.logapi import constants as log_const +from oslo_utils import uuidutils from neutron.common.ovn import acl from neutron.common.ovn import constants as ovn_const @@ -26,10 +28,18 @@ from neutron.tests.unit import fake_resources as fakes from neutron.tests.unit.plugins.ml2.drivers.ovn.mech_driver import \ test_mech_driver +from neutron.tests.unit.services.logapi.drivers.ovn import test_driver OvnPortInfo = collections.namedtuple('OvnPortInfo', ['name']) +class FakeACL: + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + @mock.patch.object(ovn_plugin.OVNL3RouterPlugin, '_sb_ovn', mock.Mock()) class TestOvnNbSyncML2(test_mech_driver.OVNMechanismDriverTestCase): @@ -39,6 +49,7 @@ # We want metadata enabled to increase coverage super().setUp(enable_metadata=True) + self.test_log_driver = test_driver.TestOVNDriver() self.subnet = {'cidr': '10.0.0.0/24', 'id': 'subnet1', 'subnetpool_id': None, @@ -117,49 +128,42 @@ 'host_routes': [], 'ip_version': 4}] + self.security_group_rules = [ + { + 'remote_group_id': None, + 'direction': 'ingress', + 'remote_ip_prefix': const.IPv4_ANY, + 'protocol': 'tcp', + 'ethertype': 'IPv4', + 'project_id': 'project1', + 'port_range_max': 65535, + 'port_range_min': 1, + 'id': 'ruleid1', + 'security_group_id': 'sg1', + 'normalized_cidr': '' + }, { + 'remote_group_id': 'sg2', + 'direction': 'egress', + 'remote_ip_prefix': const.IPv4_ANY, + 'protocol': 'tcp', + 'ethertype': 'IPv4', + 'project_id': 'project1', + 'port_range_max': 65535, + 'port_range_min': 1, + 'id': 'ruleid2', + 'security_group_id': 'sg2', + 'normalized_cidr': '' + }, + ] + self.security_groups = [ - {'id': 'sg1', 'tenant_id': 'tenant1', - 'security_group_rules': [{'remote_group_id': None, - 'direction': 'ingress', - 'remote_ip_prefix': const.IPv4_ANY, - 'protocol': 'tcp', - 'ethertype': 'IPv4', - 'tenant_id': 'tenant1', - 'port_range_max': 65535, - 'port_range_min': 1, - 'id': 'ruleid1', - 'security_group_id': 'sg1'}], + {'id': 'sg1', 'project_id': 'project1', + 'security_group_rules': [self.security_group_rules[0]], 'name': 'all-tcp'}, - {'id': 'sg2', 'tenant_id': 'tenant1', - 'security_group_rules': [{'remote_group_id': 'sg2', - 'direction': 'egress', - 'remote_ip_prefix': const.IPv4_ANY, - 'protocol': 'tcp', - 'ethertype': 'IPv4', - 'tenant_id': 'tenant1', - 'port_range_max': 65535, - 'port_range_min': 1, - 'id': 'ruleid1', - 'security_group_id': 'sg2'}], + {'id': 'sg2', 'project_id': 'project1', + 'security_group_rules': [self.security_group_rules[1]], 'name': 'all-tcpe'}] - self.sg_port_groups_ovn = [mock.Mock(), mock.Mock(), mock.Mock()] - self.sg_port_groups_ovn[0].configure_mock( - name='pg_sg1', - external_ids={ovn_const.OVN_SG_EXT_ID_KEY: 'sg1'}, - ports=[], - acls=[]) - self.sg_port_groups_ovn[1].configure_mock( - name='pg_unknown_del', - external_ids={ovn_const.OVN_SG_EXT_ID_KEY: 'sg2'}, - ports=[], - acls=[]) - self.sg_port_groups_ovn[2].configure_mock( - name='neutron_pg_drop', - external_ids=[], - ports=[], - acls=[]) - self.ports = [ {'id': 'p1n1', 'device_owner': 'compute:None', @@ -216,6 +220,90 @@ 'ip_address': '90.0.0.10'}], 'network_id': 'ext-net'}] + self.sg_port_groups_ovn = [mock.Mock(), mock.Mock(), mock.Mock(), + mock.Mock(), mock.Mock()] + self.sg_port_groups_ovn[0].configure_mock( + name='pg_sg1', + external_ids={ovn_const.OVN_SG_EXT_ID_KEY: 'sg1'}, + ports=[], + acls=[FakeACL( + name=[], + meter=[], + severity=[], + direction='to-lport', + action='allow-related', + log=False, + priority=1002, + match=('outport == @pg_sg1 && ip4 && tcp && ' + 'tcp.dst >= 1 && tcp.dst <= 65535'), + external_ids={ovn_const.OVN_SG_RULE_EXT_ID_KEY: 'ruleid1'} + )]) + self.sg_port_groups_ovn[1].configure_mock( + name='pg_unknown_del', + external_ids={ovn_const.OVN_SG_EXT_ID_KEY: 'sg2'}, + ports=[], + acls=[]) + self.sg_port_groups_ovn[2].configure_mock( + name=ovn_const.OVN_DROP_PORT_GROUP_NAME, + external_ids={}, + ports=[], + acls=[ + FakeACL( + name=[], + meter=[], + severity=[], + direction='to-lport', + action='drop', + log=False, + priority=1001, + match=('outport == @%s && ip' % + ovn_const.OVN_DROP_PORT_GROUP_NAME), + external_ids={} + ), + FakeACL( + name=[], + meter=[], + severity=[], + direction='from-lport', + action='drop', + log=False, + priority=1001, + match=('inport == @%s && ip' % + ovn_const.OVN_DROP_PORT_GROUP_NAME), + external_ids={} + ) + ]) + self.sg_port_groups_ovn[3].configure_mock( + name='pg_sg_stale', + external_ids={ovn_const.OVN_SG_EXT_ID_KEY: 'sg_stale'}, + ports=[], + acls=[FakeACL( + name=[], + meter=[], + severity=[], + direction='to-lport', + action='allow-related', + log=False, + priority=1000, + match='outport == @pg_sg_stale', + external_ids={ovn_const.OVN_SG_RULE_EXT_ID_KEY: 'stale_rule'} + )]) + self.sg_port_groups_ovn[4].configure_mock( + name='external_pg', + external_ids={'owner': 'not-owned-by-neutron'}, + ports=[], + acls=[FakeACL( + name=[], + meter=[], + severity=[], + direction=[], + action='allow-related', + log=False, + priority=1000, + match='outport == @external_pg', + external_ids={'owner': 'not-owned-by-neutron'} + )]) + self.ports_ovn = [OvnPortInfo('p1n1'), OvnPortInfo('p1n2'), OvnPortInfo('p2n1'), OvnPortInfo('p2n2'), OvnPortInfo('p3n1'), OvnPortInfo('p3n3')] @@ -227,7 +315,10 @@ 'lswitch': 'lswitch1', 'lport': 'lport1'}], 'lport2': [{'id': 'acl2', 'priority': 00, 'policy': 'drop', - 'lswitch': 'lswitch2', 'lport': 'lport2'}], + 'lswitch': 'lswitch2', 'lport': 'lport2'}, + {'id': 'aclr3', 'priority': 00, 'log': True, + 'policy': 'drop', 'lswitch': 'lswitch2', + 'meter': 'acl_log_meter', 'label': 1, 'lport': 'lport2'}], # ACLs need to be kept as-is by the sync tool 'p2n2': [{'lport': 'p2n2', 'direction': 'to-lport', @@ -263,7 +354,9 @@ 'external_fixed_ips': [ {'subnet_id': 'ext-subnet', 'ip_address': '100.0.0.2'}]}}, - {'id': 'r4', 'routes': []}] + {'id': 'r4', 'routes': []}, + {'id': 'r5', 'routes': [], + 'flavor_id': 'user-defined'}] self.get_sync_router_ports = [ {'fixed_ips': [{'subnet_id': 'subnet1', @@ -382,7 +475,7 @@ return {'r1': ['172.16.0.0/24', '172.16.2.0/24'], 'r2': ['192.168.2.0/24']}.get(router_id, []) - def _test_mocks_helper(self, ovn_nb_synchronizer): + def _test_mocks_helper(self, ovn_nb_synchronizer, test_logging=False): core_plugin = ovn_nb_synchronizer.core_plugin ovn_api = ovn_nb_synchronizer.ovn_api ovn_driver = ovn_nb_synchronizer.ovn_driver @@ -423,26 +516,76 @@ # following block is used for acl syncing unit-test # With the given set of values in the unit testing, - # 19 neutron acls should have been there, - # 4 acls are returned as current ovn acls, - # two of which will match with neutron. - # So, in this example 17 will be added, 2 removed + # 5 Port Groups are created in the OVN db, + # 2 of them should be deleted as they are managed by Neutron SGs and + # don't match any SG in the Neutron DB. + # One of those Port Groups has also ACL which should be deleted. + # One Port Group is missing (sg2) and should be created in OVN DB. + # There is one ACL in that missing ACL which should be created in the + # OVN DB too. + core_plugin.get_ports = mock.Mock() core_plugin.get_ports.side_effect = get_ports() mock.patch.object(acl, '_get_subnet_from_cache', return_value=self.subnet).start() mock.patch.object(acl, 'acl_remote_group_id', side_effect=self.matches).start() + if test_logging: + log_objs = [self.test_log_driver._fake_log_obj( + event=log_const.DROP_EVENT, resource_id=None, id='1111')] + mock.patch.object(ovn_nb_synchronizer.ovn_log_driver, '_get_logs', + return_value=log_objs).start() + mock.patch.object(ovn_nb_synchronizer.ovn_log_driver, + '_pgs_from_log_obj', return_value=[ + {'name': 'neutron_pg_drop', + 'external_ids': {}, + 'acls': [uuidutils.generate_uuid()]}] + ).start() + core_plugin.get_security_group = mock.MagicMock( side_effect=self.security_groups) ovn_nb_synchronizer.get_acls = mock.Mock() ovn_nb_synchronizer.get_acls.return_value = self.acls_ovn core_plugin.get_security_groups = mock.MagicMock( return_value=self.security_groups) + + def get_security_group_rules(context, filters=None): + rules = [] + for rule in self.security_group_rules: + if filters['security_group_id'] == rule['security_group_id']: + rules.append(rule) + return rules + + core_plugin.get_security_group_rules = mock.MagicMock( + side_effect=get_security_group_rules) get_sg_port_groups = mock.MagicMock() + if test_logging: + for pg in self.sg_port_groups_ovn: + for pg_acl in pg.acls: + if pg.name == ovn_const.OVN_DROP_PORT_GROUP_NAME: + pg_acl.log = True + pg_acl.name = ['neutron-1111'] + pg_acl.severity = ['info'] + pg_acl.meter = ['acl_log_meter'] get_sg_port_groups.execute.return_value = self.sg_port_groups_ovn ovn_api.db_list_rows.return_value = get_sg_port_groups ovn_api.lsp_list.execute.return_value = self.ports_ovn + + # we need to mock ACL table schema to actually be able to search for + # specific fields in the mocked ACL objects: + mock.patch.dict( + ovn_nb_synchronizer.ovn_api._tables['ACL'].columns, + {'name': mock.Mock(), + 'meter': mock.Mock(), + 'severity': mock.Mock(), + 'direction': mock.Mock(), + 'action': mock.Mock(), + 'priority': mock.Mock(), + 'match': mock.Mock(), + 'log': mock.Mock(), + 'external_ids': mock.Mock()} + ).start() + # end of acl-sync block # The following block is used for router and router port syncing tests @@ -578,8 +721,11 @@ delete_dhcp_options_list, add_port_groups_list, del_port_groups_list, - create_metadata_list): - self._test_mocks_helper(ovn_nb_synchronizer) + add_acls_list, + del_acls_list, + create_metadata_list, + test_logging=False): + self._test_mocks_helper(ovn_nb_synchronizer, test_logging) ovn_api = ovn_nb_synchronizer.ovn_api mock.patch.object(impl_idl_ovn.OvsdbNbOvnIdl, 'from_worker').start() @@ -602,6 +748,21 @@ ovn_api.pg_del.assert_has_calls( del_port_groups_calls, any_order=True) + add_acls_calls = [mock.call(may_exist=True, **a) + for a in add_acls_list] + self.assertEqual( + len(add_acls_list), + ovn_api.pg_acl_add.call_count) + ovn_api.pg_acl_add.assert_has_calls( + add_acls_calls, any_order=True) + del_acls_calls = [mock.call(*d) + for d in del_acls_list] + ovn_api.pg_acl_del.assert_has_calls( + del_acls_calls, any_order=True) + self.assertEqual( + len(del_acls_list), + ovn_api.pg_acl_del.call_count) + self.assertEqual( len(create_network_list), ovn_nb_synchronizer._ovn_client.create_network.call_count) @@ -762,7 +923,12 @@ ovn_api.delete_dhcp_options.assert_has_calls( delete_dhcp_options_calls, any_order=True) - def test_ovn_nb_sync_mode_repair(self): + if test_logging: + # 2 times when doing add_logging_options_to_acls + self.assertEqual(2, ovn_nb_synchronizer.ovn_log_driver. + _pgs_from_log_obj.call_count) + + def _test_ovn_nb_sync_mode_repair(self, test_logging=False): create_network_list = [{'net': {'id': 'n2', 'mtu': 1450}, 'ext_ids': {}}] @@ -858,8 +1024,22 @@ {'external_ids': {ovn_const.OVN_SG_EXT_ID_KEY: 'sg2'}, 'name': 'pg_sg2', 'acls': []}] - del_port_groups_list = ['pg_unknown_del'] - + del_port_groups_list = ['pg_unknown_del', 'pg_sg_stale'] + add_acls_list = [ + {'port_group': 'pg_sg2', + 'priority': 1002, + 'action': 'allow-related', + 'log': False, + 'name': 'neutron-1111' if test_logging else [], + 'severity': 'info' if test_logging else [], + 'direction': 'from-lport', + 'match': ('inport == @pg_sg2 && ip4 && tcp && ' + 'tcp.dst >= 1 && tcp.dst <= 65535'), + 'meter': 'acl_log_meter' if test_logging else [], + ovn_const.OVN_SG_RULE_EXT_ID_KEY: 'ruleid2'}, + ] + del_acls_list = [ + ('pg_sg_stale', 'to-lport', 1000, 'outport == @pg_sg_stale')] add_subnet_dhcp_options_list = [(self.subnets[2], self.networks[1]), (self.subnets[1], self.networks[0])] delete_dhcp_options_list = ['UUID2', 'UUID4', 'UUID5'] @@ -890,7 +1070,16 @@ delete_dhcp_options_list, add_port_groups_list, del_port_groups_list, - create_metadata_list) + add_acls_list, + del_acls_list, + create_metadata_list, + test_logging) + + def test_ovn_nb_sync_mode_repair(self): + self._test_ovn_nb_sync_mode_repair(test_logging=False) + + def test_ovn_nb_sync_mode_repair_logs_created(self): + self._test_ovn_nb_sync_mode_repair(test_logging=True) def test_ovn_nb_sync_mode_log(self): create_network_list = [] @@ -913,6 +1102,8 @@ delete_dhcp_options_list = [] add_port_groups_list = [] del_port_groups_list = [] + add_acls_list = [] + del_acls_list = [] create_metadata_list = [] ovn_nb_synchronizer = ovn_db_sync.OvnNbSynchronizer( @@ -940,6 +1131,8 @@ delete_dhcp_options_list, add_port_groups_list, del_port_groups_list, + add_acls_list, + del_acls_list, create_metadata_list) def _test_ovn_nb_sync_calculate_routes_helper(self, @@ -1204,7 +1397,8 @@ return_value=hosts_in_neutron) as mock_ghmws: ovn_sb_synchronizer.sync_hostname_and_physical_networks(mock.ANY) mock_ghmws.assert_called_once_with( - mock.ANY, include_agent_types={ovn_const.OVN_CONTROLLER_AGENT}) + mock.ANY, + include_agent_types=set(ovn_const.OVN_CONTROLLER_TYPES)) all_hosts = set(hostname_with_physnets.keys()) | hosts_in_neutron self.assertEqual( len(all_hosts), diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -97,7 +97,9 @@ self.mech_driver.nb_ovn = fakes.FakeOvsdbNbOvnIdl() self.mech_driver.sb_ovn = fakes.FakeOvsdbSbOvnIdl() self.mech_driver._post_fork_event.set() - self.mech_driver._ovn_client._qos_driver = mock.Mock() + self.mech_driver._ovn_client._qos_driver = mock.Mock( + get_lsp_options_qos=mock.Mock(return_value={}) + ) self._agent_cache = neutron_agent.AgentCache(self.mech_driver) agent1 = self._add_agent('agent1') neutron_agent.AgentCache().get_agents = mock.Mock() @@ -187,6 +189,8 @@ self.rp_ns = self.mech_driver.resource_provider_uuid5_namespace self.placement_ext = self.mech_driver._ovn_client.placement_extension self.placement_ext._reset(self.placement_ext._driver) + mock.patch.object(self.mech_driver._ovn_client._qos_driver, + 'get_lsp_options_qos', return_value={}).start() self.fake_subnet = fakes.FakeSubnet.create_one_subnet().info() @@ -1232,8 +1236,8 @@ else: self.mech_driver._plugin.nova_notifier.\ record_port_status_changed.assert_called_once_with( - mock.ANY, const.PORT_STATUS_ACTIVE, - const.PORT_STATUS_DOWN, None) + mock.ANY, const.PORT_STATUS_DOWN, + const.PORT_STATUS_ACTIVE, None) self.mech_driver._plugin.nova_notifier.\ send_port_status.assert_called_once_with( None, None, mock.ANY) @@ -2970,7 +2974,7 @@ @mock.patch.object(ml2_plugin.Ml2Plugin, 'get_network', return_value={}) @mock.patch.object(ovn_utils, '_filter_candidates_for_ha_chassis_group') def test_sync_ha_chassis_group_network(self, mock_candidates, *args): - self.nb_ovn.ha_chassis_group_get.side_effect = idlutils.RowNotFound + self.nb_ovn.lookup.return_value = None fake_txn = mock.Mock() hcg_info = self._build_hcg_info(network_id='fake-net-id') mock_candidates.return_value = {'ch0', 'ch1', 'ch2', 'ch3'} @@ -3016,8 +3020,8 @@ 'ha_chassis': [hc0, hc1, hc2, hc3]} fake_ha_chassis_group = fakes.FakeOvsdbRow.create_one_ovsdb_row( attrs=hcg_attrs) - self.nb_ovn.ha_chassis_group_get().execute.return_value = ( - fake_ha_chassis_group) + # HA_Chassis_Group lookup. + self.nb_ovn.lookup.return_value = fake_ha_chassis_group self.sb_ovn.get_gateway_chassis_from_cms_options.return_value = ( hcg_info.chassis_list) @@ -3179,6 +3183,53 @@ def test_node_uuid_no_worker_id(self, *args): self.assertEqual(123456789, self.mech_driver.node_uuid) + def test_create_port_with_allowed_address_pairs(self): + with self.network() as network: + with self.subnet(network, cidr='10.0.0.0/24'): + self._make_port( + self.fmt, network['network']['id'], + device_owner=const.DEVICE_OWNER_DISTRIBUTED, + fixed_ips=[{'ip_address': '10.0.0.2'}], + as_admin=True, + arg_list=('device_owner', 'fixed_ips')) + port1 = self._make_port( + self.fmt, network['network']['id'], + allowed_address_pairs=[{'ip_address': '10.0.0.3'}], + as_admin=True, + arg_list=('allowed_address_pairs',))['port'] + self.assertEqual( + [{'ip_address': '10.0.0.3', + 'mac_address': port1['mac_address']}], + port1['allowed_address_pairs']) + self._make_port( + self.fmt, network['network']['id'], + allowed_address_pairs=[{'ip_address': '10.0.0.2'}], + expected_res_status=exc.HTTPBadRequest.code, + arg_list=('allowed_address_pairs',)) + port2 = self._show('ports', port1['id'])['port'] + self.assertEqual( + [{'ip_address': '10.0.0.3', + 'mac_address': port2['mac_address']}], + port2['allowed_address_pairs']) + + # Now test the same but giving a subnet as allowed address + # pair, this should be fine as we treat only /32 and /128 IPs + # in allowed_address_pairs as Virtual IPs, there is no block + # anything when bigger CIDR is set as that don't break metadata + new_port = self._make_port( + self.fmt, network['network']['id'], + allowed_address_pairs=[{'ip_address': '10.0.0.2/26'}], + arg_list=('allowed_address_pairs',))['port'] + port3 = self._show('ports', port1['id'])['port'] + self.assertEqual( + [{'ip_address': '10.0.0.3', + 'mac_address': port3['mac_address']}], + port3['allowed_address_pairs']) + self.assertEqual( + [{'ip_address': '10.0.0.2/26', + 'mac_address': new_port['mac_address']}], + new_port['allowed_address_pairs']) + class OVNMechanismDriverTestCase(MechDriverSetupBase, test_plugin.Ml2PluginV2TestCase): @@ -4471,6 +4522,15 @@ sg_r = self._create_sg_rule(sg['id'], 'ingress', const.PROTO_NAME_UDP, ethertype=const.IPv6) + + # Updating an ACL will call 'check_for_row_by_value_and_retry' + # for the PG at least once. + pg_name = ovn_utils.ovn_port_group_name(sg['id']) + cfrbvar = self.mech_driver.nb_ovn.check_for_row_by_value_and_retry + cfrbvar.assert_has_calls([ + mock.call('Port_Group', 'name', pg_name) + ]) + self.assertEqual( 1, self.mech_driver.nb_ovn.pg_acl_add.call_count) diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/test_type_vlan.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/test_type_vlan.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/drivers/test_type_vlan.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/drivers/test_type_vlan.py 2026-04-15 04:51:52.000000000 +0000 @@ -25,11 +25,14 @@ from oslo_config import cfg from testtools import matchers +from neutron.common import wsgi_utils +from neutron.conf.plugins.ml2 import config as ml2_config from neutron.objects import network_segment_range as obj_network_segment_range from neutron.objects.plugins.ml2 import vlanallocation as vlan_alloc_obj from neutron.plugins.ml2.drivers import type_vlan from neutron.tests.unit import testlib_api + PROVIDER_NET = 'phys_net1' TENANT_NET = 'phys_net2' UNCONFIGURED_NET = 'no_net' @@ -361,6 +364,9 @@ class VlanTypeTestWithNetworkSegmentRange(testlib_api.SqlTestCase): def setUp(self): + ml2_config.register_ml2_plugin_opts() + mock.patch.object(wsgi_utils, 'get_api_worker_id', + return_value=wsgi_utils.FIRST_WORKER_ID).start() super().setUp() cfg.CONF.set_override('network_vlan_ranges', NETWORK_VLAN_RANGES, @@ -400,7 +406,7 @@ def test__delete_expired_default_network_segment_ranges(self): self.driver._delete_expired_default_network_segment_ranges( - self.start_time) + self.context, self.start_time) ret = obj_network_segment_range.NetworkSegmentRange.get_objects( self.context, network_type=self.driver.get_type()) self.assertEqual(0, len(ret)) diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/extensions/test_dns_domain_keywords.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/extensions/test_dns_domain_keywords.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/extensions/test_dns_domain_keywords.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/extensions/test_dns_domain_keywords.py 2026-04-15 04:51:52.000000000 +0000 @@ -84,10 +84,10 @@ new_dns_name=test_dns_integration.NEWDNSNAME, new_dns_domain=None, **kwargs): test_dns_integration.mock_client.reset_mock() - ip_addresses = [netaddr.IPAddress(ip['ip_address']) - for ip in port['fixed_ips']] - records_v4 = [ip for ip in ip_addresses if ip.version == 4] - records_v6 = [ip for ip in ip_addresses if ip.version == 6] + records_v4 = [ip['ip_address'] for ip in port['fixed_ips'] + if netaddr.IPAddress(ip['ip_address']).version == 4] + records_v6 = [ip['ip_address'] for ip in port['fixed_ips'] + if netaddr.IPAddress(ip['ip_address']).version == 6] recordsets = [] if records_v4: recordsets.append({'id': test_dns_integration.V4UUID, diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py 2026-04-15 04:51:52.000000000 +0000 @@ -126,10 +126,10 @@ def _update_port_for_test(self, port, new_dns_name=NEWDNSNAME, new_dns_domain=None, **kwargs): mock_client.reset_mock() - ip_addresses = [netaddr.IPAddress(ip['ip_address']) - for ip in port['fixed_ips']] - records_v4 = [ip for ip in ip_addresses if ip.version == 4] - records_v6 = [ip for ip in ip_addresses if ip.version == 6] + records_v4 = [ip['ip_address'] for ip in port['fixed_ips'] + if netaddr.IPAddress(ip['ip_address']).version == 4] + records_v6 = [ip['ip_address'] for ip in port['fixed_ips'] + if netaddr.IPAddress(ip['ip_address']).version == 6] recordsets = [] if records_v4: recordsets.append({'id': V4UUID, 'records': records_v4}) diff -Nru neutron-26.0.0/neutron/tests/unit/plugins/ml2/test_plugin.py neutron-26.0.3/neutron/tests/unit/plugins/ml2/test_plugin.py --- neutron-26.0.0/neutron/tests/unit/plugins/ml2/test_plugin.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/plugins/ml2/test_plugin.py 2026-04-15 04:51:52.000000000 +0000 @@ -43,6 +43,7 @@ from oslo_config import cfg from oslo_db import exception as db_exc from oslo_utils import netutils +from oslo_utils import timeutils from oslo_utils import uuidutils import testtools import webob @@ -65,6 +66,7 @@ from neutron.plugins.ml2.common import exceptions as ml2_exc from neutron.plugins.ml2 import db as ml2_db from neutron.plugins.ml2 import driver_context +from neutron.plugins.ml2.drivers import type_tunnel from neutron.plugins.ml2.drivers import type_vlan from neutron.plugins.ml2 import managers from neutron.plugins.ml2 import models @@ -596,6 +598,64 @@ self.assertIn("network", res.json['NeutronError']['message']) +class TestMl2AgentNotifications(Ml2PluginV2TestCase): + + class Agent: + def __init__(self, agent_dict): + for field in agent_dict: + setattr(self, field, agent_dict[field]) + + def test_delete_agent_notified(self): + agent_status = {'agent_type': constants.AGENT_TYPE_OVS, + 'binary': constants.AGENT_PROCESS_OVS, + 'host': 'AHOST', + 'topic': 'N/A', + 'configurations': {'tunnel_types': ['vxlan'], + 'tunneling_ip': '100.101.2.3'}} + agent = self.plugin.create_or_update_agent(self.context, + dict(agent_status), + timeutils.utcnow()) + agnt = self.Agent(agent[1]) + with mock.patch.object( + self.plugin.notifier, 'tunnel_delete') as m_t_del: + with mock.patch.object( + type_tunnel.EndpointTunnelTypeDriver, + 'delete_endpoint') as m_del_ep: + self.plugin.delete_agent_notified( + resource='agent', event='after_delete', trigger=None, + payload=events.DBEventPayload( + self.context, states=(agnt,), + resource_id=agent[1]['id'])) + m_t_del.assert_called_once_with( + context=mock.ANY, + tunnel_ip='100.101.2.3', tunnel_type='vxlan') + m_del_ep.assert_called_once_with('100.101.2.3') + + def test_delete_agent_notified_non_ovs(self): + agent_status = {'agent_type': constants.AGENT_TYPE_NIC_SWITCH, + 'binary': constants.AGENT_PROCESS_NIC_SWITCH, + 'host': 'AHOST', + 'topic': 'N/A', + 'configurations': {'tunnel_types': ['vxlan'], + 'tunneling_ip': '100.101.2.3'}} + agent = self.plugin.create_or_update_agent(self.context, + dict(agent_status), + timeutils.utcnow()) + agnt = self.Agent(agent[1]) + with mock.patch.object( + self.plugin.notifier, 'tunnel_delete') as m_t_del: + with mock.patch.object( + type_tunnel.EndpointTunnelTypeDriver, + 'delete_endpoint') as m_del_ep: + self.plugin.delete_agent_notified( + resource='agent', event='after_delete', trigger=None, + payload=events.DBEventPayload( + self.context, states=(agnt,), + resource_id=agent[1]['id'])) + m_t_del.assert_not_called() + m_del_ep.assert_not_called() + + class TestMl2NetworksV2AgentMechDrivers(Ml2PluginV2TestCase): _mechanism_drivers = ['logger', 'test', 'test_with_agent'] diff -Nru neutron-26.0.0/neutron/tests/unit/services/auto_allocate/test_db.py neutron-26.0.3/neutron/tests/unit/services/auto_allocate/test_db.py --- neutron-26.0.0/neutron/tests/unit/services/auto_allocate/test_db.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/services/auto_allocate/test_db.py 2026-04-15 04:51:52.000000000 +0000 @@ -107,7 +107,7 @@ "NETWORK", "precommit_update", "test_plugin", payload=events.DBEventPayload( self.ctx, request_body=kwargs['request'], - states=(kwargs['network'],))) + states=({}, kwargs['network']))) get_external_nets.assert_not_called() get_external_net.assert_not_called() network_mock.update.assert_not_called() @@ -190,6 +190,111 @@ get_external_net.assert_not_called() network_mock.update.assert_not_called() + def test_ensure_external_network_default_value_update(self): + network_id = uuidutils.generate_uuid() + kwargs = { + "context": self.ctx, + "request": { + "id": network_id, + api_const.IS_DEFAULT: True, + }, + "network": { + "id": network_id, + api_const.IS_DEFAULT: False, + external_net_apidef.EXTERNAL: True, + }, + "original_network": { + "id": network_id, + api_const.IS_DEFAULT: False, + external_net_apidef.EXTERNAL: True, + } + } + network_mock = mock.MagicMock(network_id=network_id, is_default=False) + with mock.patch( + 'neutron.objects.network.ExternalNetwork.get_objects', + return_value=[network_mock] + ) as get_external_nets, mock.patch( + 'neutron.objects.network.ExternalNetwork.get_object', + return_value=network_mock + ) as get_external_net: + db._ensure_external_network_default_value_callback( + "NETWORK", "precommit_update", "test_plugin", + payload=events.DBEventPayload( + self.ctx, request_body=kwargs['request'], + states=(kwargs['original_network'], kwargs['network']))) + get_external_nets.assert_called_once_with( + self.ctx, _pager=mock.ANY, is_default=True) + get_external_net.assert_called_once_with( + self.ctx, network_id=network_id) + network_mock.update.assert_called_once_with() + + def test_ensure_external_network_default_value_internal_update(self): + network_id = uuidutils.generate_uuid() + kwargs = { + "context": self.ctx, + "request": { + "id": network_id, + api_const.IS_DEFAULT: True, + }, + "network": { + "id": network_id, + api_const.IS_DEFAULT: False, + external_net_apidef.EXTERNAL: False, + }, + "original_network": { + "id": network_id, + api_const.IS_DEFAULT: False, + external_net_apidef.EXTERNAL: False, + } + } + network_mock = mock.MagicMock(network_id='fake_id', is_default=False) + with mock.patch( + 'neutron.objects.network.ExternalNetwork.get_objects', + return_value=[network_mock] + ) as get_external_nets, mock.patch( + 'neutron.objects.network.ExternalNetwork.get_object', + return_value=network_mock + ) as get_external_net: + db._ensure_external_network_default_value_callback( + "NETWORK", "precommit_update", "test_plugin", + payload=events.DBEventPayload( + self.ctx, request_body=kwargs['request'], + states=(kwargs['original_network'], kwargs['network']))) + get_external_nets.assert_not_called() + get_external_net.assert_not_called() + network_mock.update.assert_not_called() + + def test_ensure_external_network_default_value_internal_create(self): + network_id = uuidutils.generate_uuid() + kwargs = { + "context": self.ctx, + "request": { + "id": network_id, + api_const.IS_DEFAULT: True, + }, + "network": { + "id": network_id, + api_const.IS_DEFAULT: False, + external_net_apidef.EXTERNAL: False, + }, + } + network_mock = mock.MagicMock(network_id='fake_id', is_default=False) + with mock.patch( + 'neutron.objects.network.ExternalNetwork.get_objects', + return_value=[network_mock] + ) as get_external_nets, mock.patch( + 'neutron.objects.network.ExternalNetwork.get_object', + return_value=network_mock + ) as get_external_net: + db._ensure_external_network_default_value_callback( + "NETWORK", "precommit_update", "test_plugin", + payload=events.DBEventPayload( + self.ctx, request_body=kwargs['request'], + states=({}, kwargs['network']))) + get_external_nets.assert_not_called() + get_external_net.assert_not_called() + network_mock.update.assert_not_called() + def test__provision_external_connectivity_expected_cleanup(self): """Test that the right resources are cleaned up.""" subnets = [ diff -Nru neutron-26.0.0/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py neutron-26.0.3/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py --- neutron-26.0.0/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -169,6 +169,31 @@ ) self.admin_client.recordsets.delete.assert_not_called() + def test_delete_single_record_from_two_records(self): + # Set up two records similar to test_delete_record_set + self.client.recordsets.list.return_value = [ + {'id': 123, 'records': ['192.168.0.10']}, + {'id': 456, 'records': ['2001:db8:0:1::1']} + ] + + cfg.CONF.set_override( + 'allow_reverse_dns_lookup', False, group='designate' + ) + + # Delete only the first record (IPv4) out of the two + self.driver.delete_record_set( + self.context, 'example.test.', 'test', + ['192.168.0.10'] + ) + + # Verify that only the IPv4 record was deleted + self.client.recordsets.delete.assert_called_once_with( + 'example.test.', 123 + ) + + # Admin client should not be called since reverse DNS is disabled + self.admin_client.recordsets.delete.assert_not_called() + def test_delete_record_set_with_reverse_dns(self): self.client.recordsets.list.return_value = [ {'id': 123, 'records': ['192.168.0.10']}, diff -Nru neutron-26.0.0/neutron/tests/unit/services/logapi/drivers/ovn/test_driver.py neutron-26.0.3/neutron/tests/unit/services/logapi/drivers/ovn/test_driver.py --- neutron-26.0.0/neutron/tests/unit/services/logapi/drivers/ovn/test_driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/services/logapi/drivers/ovn/test_driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -328,3 +328,48 @@ self.assertEqual(len(pg_dict["acls"]), info_args[2]) self.assertEqual(log_name, info_args[3]) self.assertEqual(1, self._nb_ovn.db_set.call_count) + + def test_add_label_related(self): + mock.patch.object(self._log_driver, '_pgs_from_log_obj', return_value=[ + {'name': 'neutron_pg_drop', + 'external_ids': {}, + 'acls': [uuidutils.generate_uuid()]}]).start() + neutron_acl = {'port_group': 'neutron_pg_drop', + 'priority': 1001, + 'action': 'drop', + 'log': True, + 'name': '', + 'severity': 'info', + 'direction': 'to-lport', + 'match': 'outport == @neutron_pg_drop && ip'} + log_objs = [self._fake_log_obj(event=log_const.DROP_EVENT)] + with mock.patch.object(self._log_driver, '_get_logs', + return_value=log_objs): + self._log_driver.add_label_related(neutron_acl, self.context) + self.assertNotEqual(neutron_acl['label'], 0) + + def test_add_logging_options_to_acls(self): + mock.patch.object(self._log_driver, '_pgs_from_log_obj', return_value=[ + {'name': 'neutron_pg_drop', 'external_ids': {}, + 'acls': [uuidutils.generate_uuid()]}]).start() + n_acls = [{'port_group': 'neutron_pg_drop', + 'priority': 1001, + 'action': 'drop', + 'log': False, + 'name': '', + 'severity': '', + 'direction': 'to-lport', + 'match': 'outport == @neutron_pg_drop && ip'}] + log_objs = [self._fake_log_obj(event=log_const.DROP_EVENT, + resource_id=None, + id='1111')] + + with mock.patch.object(self._log_driver, '_get_logs', + return_value=log_objs): + self._log_driver.add_logging_options_to_acls(n_acls, self.context) + for acl in n_acls: + self.assertEqual(acl['severity'], 'info') + self.assertTrue(acl['log']) + self.assertEqual(acl['name'], + ovn_utils.ovn_name(log_objs[0].id)) + self.assertEqual(acl['meter'], self._log_driver.meter_name) diff -Nru neutron-26.0.0/neutron/tests/unit/services/ovn_l3/test_plugin.py neutron-26.0.3/neutron/tests/unit/services/ovn_l3/test_plugin.py --- neutron-26.0.0/neutron/tests/unit/services/ovn_l3/test_plugin.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/services/ovn_l3/test_plugin.py 2026-04-15 04:51:52.000000000 +0000 @@ -466,6 +466,8 @@ router_id = 'router-id' self.l3_inst._nb_ovn.db_get.return_value.execute.return_value = { ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: router_id} + self.l3_inst._nb_ovn.lookup.return_value = mock.Mock( + external_ids={'neutron:router_name': router_id}) payload = self._create_payload_for_router_interface(router_id, pass_subnet=False) self.ovn_drv._process_remove_router_interface( @@ -2187,8 +2189,6 @@ } self.l3_inst._nb_ovn.ls_get.return_value.execute.return_value = ( mock.Mock(external_ids=ext_ids)) - self.l3_inst._nb_ovn.ha_chassis_group_get.return_value.execute.\ - return_value = None # Note(dongj): According to bug #1657693, status of an unassociated # floating IP is set to DOWN. Revise expected_status to DOWN for related @@ -2222,6 +2222,8 @@ 'neutron-fake_device', [(constants.IPv4_ANY, '120.0.0.1')]) def test_router_update_gateway_upon_subnet_create_max_ips_ipv6(self): + # HA_Chassis_Group lookup. + self.l3_inst._nb_ovn.lookup.return_value = None super(). \ test_router_update_gateway_upon_subnet_create_max_ips_ipv6() expected_ext_ids = { @@ -2240,3 +2242,15 @@ def test_create_floatingip_with_assoc(self, **kwargs): self.l3_inst._nb_ovn.lookup.return_value = mock.Mock(load_balancer=[]) super().test_create_floatingip_with_assoc(**kwargs) + + def test_route_update_with_external_route(self): + # HA_Chassis_Group lookup. + self.l3_inst._nb_ovn.lookup.return_value = None + super().test_route_update_with_external_route() + + def _test_router_create_show_ext_gwinfo(self, snat_input_value, + snat_expected_value): + # HA_Chassis_Group lookup. + self.l3_inst._nb_ovn.lookup.return_value = None + super()._test_router_create_show_ext_gwinfo(snat_input_value, + snat_expected_value) diff -Nru neutron-26.0.0/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py neutron-26.0.3/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py --- neutron-26.0.0/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py 2026-04-15 04:51:52.000000000 +0000 @@ -18,6 +18,7 @@ from neutron_lib.callbacks import events from neutron_lib.callbacks import registry from neutron_lib.callbacks import resources +from neutron_lib import constants as nlib_consts from neutron_lib import exceptions as n_exc from neutron_lib.services.trunk import constants as trunk_consts from oslo_config import cfg @@ -25,9 +26,13 @@ from neutron.common.ovn.constants import OVN_ML2_MECH_DRIVER_NAME from neutron.objects.ports import Port from neutron.objects.ports import PortBinding +from neutron.objects import trunk as trunk_objects +from neutron.services.trunk import drivers from neutron.services.trunk.drivers.ovn import trunk_driver +from neutron.services.trunk import plugin as trunk_plugin from neutron.tests import base from neutron.tests.unit import fake_resources +from neutron.tests.unit.plugins.ml2 import test_plugin class FakePayload: @@ -430,6 +435,70 @@ m__unset_sub_ports.assert_not_called() +class TestTrunkHandlerWithPlugin(test_plugin.Ml2PluginV2TestCase): + def setUp(self): + super().setUp() + self.drivers_patch = mock.patch.object(drivers, 'register').start() + self.compat_patch = mock.patch.object( + trunk_plugin.TrunkPlugin, 'check_compatibility').start() + self.trunk_plugin = trunk_plugin.TrunkPlugin() + self.trunk_plugin.add_segmentation_type('vlan', lambda x: True) + self.plugin_driver = mock.Mock() + self.trunk_handler = trunk_driver.OVNTrunkHandler(self.plugin_driver) + + def _create_test_trunk(self, port, subports=None): + subports = subports if subports else [] + trunk = {'port_id': port['port']['id'], + 'project_id': 'test_tenant', + 'sub_ports': subports} + response = ( + self.trunk_plugin.create_trunk(self.context, {'trunk': trunk})) + return response + + def _get_trunk_obj(self, trunk_id): + return trunk_objects.Trunk.get_object(self.context, id=trunk_id) + + def test_parent_active_triggers_trunk_active(self): + with self.port() as new_parent: + new_parent['status'] = nlib_consts.PORT_STATUS_ACTIVE + old_parent = {'status': nlib_consts.PORT_STATUS_DOWN} + old_trunk = self._create_test_trunk(new_parent) + old_trunk = self._get_trunk_obj(old_trunk['id']) + old_trunk.update(status=trunk_consts.TRUNK_DOWN_STATUS) + trunk_details = {'trunk_id': old_trunk.id} + new_parent['trunk_details'] = trunk_details + old_parent['trunk_details'] = trunk_details + self.trunk_handler.port_updated( + resources.PORT, + events.AFTER_UPDATE, + None, + payload=events.DBEventPayload( + self.context, states=(old_parent, new_parent))) + new_trunk = self._get_trunk_obj(old_trunk.id) + self.assertEqual( + trunk_consts.TRUNK_ACTIVE_STATUS, new_trunk.status) + + def test_parent_build_does_not_trigger_trunk_active(self): + with self.port() as new_parent: + new_parent['status'] = nlib_consts.PORT_STATUS_BUILD + old_parent = {'status': nlib_consts.PORT_STATUS_DOWN} + old_trunk = self._create_test_trunk(new_parent) + old_trunk = self._get_trunk_obj(old_trunk['id']) + old_trunk.update(status=trunk_consts.TRUNK_DOWN_STATUS) + trunk_details = {'trunk_id': old_trunk.id} + new_parent['trunk_details'] = trunk_details + old_parent['trunk_details'] = trunk_details + self.trunk_handler.port_updated( + resources.PORT, + events.AFTER_UPDATE, + None, + payload=events.DBEventPayload( + self.context, states=(old_parent, new_parent))) + new_trunk = self._get_trunk_obj(old_trunk.id) + self.assertNotEqual( + trunk_consts.TRUNK_ACTIVE_STATUS, new_trunk.status) + + class TestTrunkDriver(base.BaseTestCase): def test_is_loaded(self): driver = trunk_driver.OVNTrunkDriver.create(mock.Mock()) diff -Nru neutron-26.0.0/neutron/tests/unit/services/trunk/test_plugin.py neutron-26.0.3/neutron/tests/unit/services/trunk/test_plugin.py --- neutron-26.0.0/neutron/tests/unit/services/trunk/test_plugin.py 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/neutron/tests/unit/services/trunk/test_plugin.py 2026-04-15 04:51:52.000000000 +0000 @@ -19,7 +19,6 @@ from neutron_lib.callbacks import events from neutron_lib.callbacks import registry from neutron_lib.callbacks import resources -from neutron_lib import constants as neutron_const from neutron_lib.plugins import directory from neutron_lib.services.trunk import constants import testtools @@ -286,32 +285,6 @@ {'sub_ports': [{'port_id': subport['port']['id']}]}) self.assertEqual(constants.TRUNK_DOWN_STATUS, trunk['status']) - def test__trigger_trunk_status_change_parent_port_status_down(self): - callback = register_mock_callback(resources.TRUNK, events.AFTER_UPDATE) - with self.port() as parent: - parent['status'] = neutron_const.PORT_STATUS_DOWN - original_port = {'status': neutron_const.PORT_STATUS_DOWN} - _, _ = ( - self._test__trigger_trunk_status_change( - parent, original_port, - constants.TRUNK_DOWN_STATUS, - constants.TRUNK_DOWN_STATUS)) - callback.assert_not_called() - - def test__trigger_trunk_status_change_parent_port_status_up(self): - callback = register_mock_callback(resources.TRUNK, events.AFTER_UPDATE) - with self.port() as parent: - parent['status'] = neutron_const.PORT_STATUS_ACTIVE - original_port = {'status': neutron_const.PORT_STATUS_DOWN} - _, _ = ( - self._test__trigger_trunk_status_change( - parent, original_port, - constants.TRUNK_DOWN_STATUS, - constants.TRUNK_ACTIVE_STATUS)) - callback.assert_called_once_with( - resources.TRUNK, events.AFTER_UPDATE, - self.trunk_plugin, payload=mock.ANY) - def test__trigger_trunk_status_change_vif_type_changed_unbound(self): callback = register_mock_callback(resources.TRUNK, events.AFTER_UPDATE) with self.port() as parent: diff -Nru neutron-26.0.0/releasenotes/notes/block-metadata-port-IP-address-to-be-used-as-virtual-ip-by-ovn-driver-0d46fed7652fea7a.yaml neutron-26.0.3/releasenotes/notes/block-metadata-port-IP-address-to-be-used-as-virtual-ip-by-ovn-driver-0d46fed7652fea7a.yaml --- neutron-26.0.0/releasenotes/notes/block-metadata-port-IP-address-to-be-used-as-virtual-ip-by-ovn-driver-0d46fed7652fea7a.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/block-metadata-port-IP-address-to-be-used-as-virtual-ip-by-ovn-driver-0d46fed7652fea7a.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,8 @@ +--- +fixes: + - | + When ML2/OVN backend is used, usage of the metadata port IP address as a + virtual IP address is blocked. That means that setting such IP address as + allowed_address_pair for other port is not allowed and API will return 400 + error in such case. For more information, see bug + `2116249 `_. diff -Nru neutron-26.0.0/releasenotes/notes/do-not-sync-OVN-ACLs-which-do-not-belongs-to-the-neutron-f0758ac56f8dd2d7.yaml neutron-26.0.3/releasenotes/notes/do-not-sync-OVN-ACLs-which-do-not-belongs-to-the-neutron-f0758ac56f8dd2d7.yaml --- neutron-26.0.0/releasenotes/notes/do-not-sync-OVN-ACLs-which-do-not-belongs-to-the-neutron-f0758ac56f8dd2d7.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/do-not-sync-OVN-ACLs-which-do-not-belongs-to-the-neutron-f0758ac56f8dd2d7.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,9 @@ +--- +other: + - | + The ``neutron-ovn-db-sync`` utility only manages OVN ACL rules containing + the ``neutron:security_group_rule_id`` key in external_ids. Any ACL rules + lacking this key are treated as external and will remain untouched by the + synchronization process. Consequently, these rules will not be automatically + removed, which may affect the intended behavior of the security groups + created in Neutron. diff -Nru neutron-26.0.0/releasenotes/notes/ovn-acl-with-address-set-89d4a6b6614b52c4.yaml neutron-26.0.3/releasenotes/notes/ovn-acl-with-address-set-89d4a6b6614b52c4.yaml --- neutron-26.0.0/releasenotes/notes/ovn-acl-with-address-set-89d4a6b6614b52c4.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/ovn-acl-with-address-set-89d4a6b6614b52c4.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,10 @@ +--- +fixes: + - | + After change `949854 `_, + the Address Groups were stored in OVN as ``Address_Set`` and the security + group rules referring to them had a match pointing to the ``Address_Set`` + register. Now the OVN maintenance task fixes the ``ACL`` registers created + before this patch, adding the corresponding ``Address_Set`` and updating + the ``ACL`` register. For more information, see bug: + `2141589 `_. diff -Nru neutron-26.0.0/releasenotes/notes/ovn-db-sync-gw-agent-cd049668511ac730.yaml neutron-26.0.3/releasenotes/notes/ovn-db-sync-gw-agent-cd049668511ac730.yaml --- neutron-26.0.0/releasenotes/notes/ovn-db-sync-gw-agent-cd049668511ac730.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/ovn-db-sync-gw-agent-cd049668511ac730.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,7 @@ +--- +fixes: + - | + ``ovn-db-sync`` skipped chassis that were also gateways for syncing the + segment host mappings but all other operations included them so add syncing + them to ``ovn-db-sync``. For more information see bug + `2116960 `_. diff -Nru neutron-26.0.0/releasenotes/notes/ovn-placement-delete-resource-provider-72c09b7df7238984.yaml neutron-26.0.3/releasenotes/notes/ovn-placement-delete-resource-provider-72c09b7df7238984.yaml --- neutron-26.0.0/releasenotes/notes/ovn-placement-delete-resource-provider-72c09b7df7238984.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/ovn-placement-delete-resource-provider-72c09b7df7238984.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,7 @@ +--- +other: + - | + The ML2/OVN Placement extension now removes any existing resource provider + deleted from the updated local node configuration. If the resource provider + has allocations, Placement will return an exception and it will not be + deleted. diff -Nru neutron-26.0.0/releasenotes/notes/ovn-placement-init-config-6198f572e1dadcba.yaml neutron-26.0.3/releasenotes/notes/ovn-placement-init-config-6198f572e1dadcba.yaml --- neutron-26.0.0/releasenotes/notes/ovn-placement-init-config-6198f572e1dadcba.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/ovn-placement-init-config-6198f572e1dadcba.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,8 @@ +--- +issues: + - | + The ML2/OVN Placement initial configuration is executed now in the Neutron + API process and removed from the maintenance worker; since the migration + to WSGI, now the API and the maintenance worker are different processes. + When an OVN ``Chassis`` creation event is received, the configuration is + read, a ``PlacementState`` object created and sent to the Placement API. diff -Nru neutron-26.0.0/releasenotes/notes/ovn-qos-fip-rule-priority-16ad3908790dfa7d.yaml neutron-26.0.3/releasenotes/notes/ovn-qos-fip-rule-priority-16ad3908790dfa7d.yaml --- neutron-26.0.0/releasenotes/notes/ovn-qos-fip-rule-priority-16ad3908790dfa7d.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/ovn-qos-fip-rule-priority-16ad3908790dfa7d.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,8 @@ +--- +fixes: + - | + The OVN QoS floating IP rule has precedence over the OVN QoS router + rule. If both are present in the same router and port (the one + assigned to the floating IP), the floating IP rule will now apply. + For more information, see bug + `2110018 `_. diff -Nru neutron-26.0.0/releasenotes/notes/ovn-qos-max-bw-physical-networks-843dfce4a60fc38f.yaml neutron-26.0.3/releasenotes/notes/ovn-qos-max-bw-physical-networks-843dfce4a60fc38f.yaml --- neutron-26.0.0/releasenotes/notes/ovn-qos-max-bw-physical-networks-843dfce4a60fc38f.yaml 1970-01-01 00:00:00.000000000 +0000 +++ neutron-26.0.3/releasenotes/notes/ovn-qos-max-bw-physical-networks-843dfce4a60fc38f.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -0,0 +1,7 @@ +--- +fixes: + - | + When using the ML2/OVN mechanism driver, the QoS policies with maximum + bandwidth rules only are always enforced using the internal OVN policers, + regardless of the direction and network type. It is not relevant if the + QoS policy has or not DSCP rules. diff -Nru neutron-26.0.0/tox.ini neutron-26.0.3/tox.ini --- neutron-26.0.0/tox.ini 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/tox.ini 2026-04-15 04:51:52.000000000 +0000 @@ -3,6 +3,13 @@ minversion = 3.18.0 skipsdist = False ignore_basepython_conflict = True +# NOTE(elod.illes): latest virtualenv bundles setuptools 82.0.0, which +# dropped pkg_resources module source from the package, which is used +# in pbr-6.1.1, thus virtualenv needs to be pinned to fix the gate. +# also tox needs to be pinned to handle missing packaging.pylock +requires = + tox<4.44.0 + virtualenv<20.37.0 [testenv] description = @@ -85,7 +92,7 @@ Run functional gate tests that require sudo privileges. setenv = {[testenv:dsvm-functional]setenv} deps = {[testenv:dsvm-functional]deps} -test_regex = .*MySQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*|.*TestMaintenance|.*TestLogMaintenance|.*TestNBDbMonitor.* +test_regex = .*MySQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*|.*TestMaintenance|.*TestLogMaintenance|.*TestNBDbMonitor.*|.*test_ovn_client.*|.*test_initialize_network_segment_range_support_parallel_execution.*|.*test_direct_route_for_address_scope.*|.*test_fip_connection_for_address_scope.* commands = bash {toxinidir}/tools/deploy_rootwrap.sh {toxinidir} {envdir}/etc {envdir}/bin stestr run --slowest --exclude-regex ({[testenv:dsvm-functional-gate]test_regex}|neutron.tests.functional.agent.l3.*) {posargs} @@ -139,7 +146,7 @@ {[testenv]deps} bashate>=2.1.1 # Apache-2.0 bandit>=1.7.5 # Apache-2.0 - flake8-import-order>=0.18.2,<0.19.0 # LGPLv3 + flake8-import-order>=0.19.0 # LGPLv3 pylint==3.2.0 # GPLv2 mypy==1.14.1 commands= @@ -205,6 +212,8 @@ -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2025.1} -r{toxinidir}/doc/requirements.txt -r{toxinidir}/requirements.txt + # NOTE(haleyb): pin setuptools to the last version to support pkg_resources + setuptools<82.0.0 commands = sphinx-build -W -b html doc/source doc/build/html [testenv:pdf-docs] @@ -279,7 +288,9 @@ Run bandit security checks. deps = {[testenv:pep8]deps} # B104: Possible binding to all interfaces -commands = bandit -r neutron -x tests -n5 -s B104 +# B311: Standard pseudo-random generators are not suitable for +# security/cryptographic purposes +commands = bandit -r neutron -x tests -n5 -s B104,B311 [testenv:bashate] description = diff -Nru neutron-26.0.0/zuul.d/job-templates.yaml neutron-26.0.3/zuul.d/job-templates.yaml --- neutron-26.0.0/zuul.d/job-templates.yaml 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/zuul.d/job-templates.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -39,14 +39,6 @@ vars: configure_swap_size: 4096 irrelevant-files: *irrelevant-files - check-arm64: - jobs: - - openstack-tox-py39-arm64: # from openstack-python3-jobs-arm64 template - timeout: 4800 - irrelevant-files: *irrelevant-files - - openstack-tox-py312-arm64: # from openstack-python3-jobs-arm64 template - timeout: 4800 - irrelevant-files: *irrelevant-files gate: jobs: - openstack-tox-py39: # from openstack-python3-jobs template @@ -114,8 +106,7 @@ - neutron-ovs-tempest-with-sqlalchemy-master - neutron-ovs-tempest-fips - neutron-ovn-tempest-ovs-release-fips - - devstack-tobiko-neutron: - voting: true + - devstack-tobiko - ironic-tempest-ipa-wholedisk-bios-agent_ipmitool-tinyipa - openstacksdk-functional-devstack-networking - neutron-ovs-tempest-plugin-iptables_hybrid-nftables diff -Nru neutron-26.0.0/zuul.d/tempest-singlenode.yaml neutron-26.0.3/zuul.d/tempest-singlenode.yaml --- neutron-26.0.0/zuul.d/tempest-singlenode.yaml 2025-03-27 16:34:31.000000000 +0000 +++ neutron-26.0.3/zuul.d/tempest-singlenode.yaml 2026-04-15 04:51:52.000000000 +0000 @@ -701,6 +701,9 @@ devstack_services: br-ex-tcpdump: true br-int-flows: true + devstack_localrc: + TEMPEST_BRANCH: 45.0.0 + TEMPEST_VENV_UPPER_CONSTRAINTS: '/opt/stack/requirements/upper-constraints.txt' devstack_local_conf: test-config: "$TEMPEST_CONFIG": @@ -717,6 +720,9 @@ vars: nslookup_target: 'opendev.org' configure_swap_size: 4096 + devstack_localrc: + TEMPEST_BRANCH: 45.0.0 + TEMPEST_VENV_UPPER_CONSTRAINTS: '/opt/stack/requirements/upper-constraints.txt' devstack_local_conf: test-config: "$TEMPEST_CONFIG":