From 0ed3ab9d80fcc3581559df52930c3f0d3a15618a Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Fri, 15 Mar 2024 14:50:24 +0000 Subject: [PATCH 001/184] Update .gitreview for stable/2024.1 Change-Id: I20700dbde715a8d130a5d6fabb5d75e9b2afb37a --- .gitreview | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitreview b/.gitreview index bda84bf1231..0994d554922 100644 --- a/.gitreview +++ b/.gitreview @@ -2,3 +2,4 @@ host=review.opendev.org port=29418 project=openstack/neutron.git +defaultbranch=stable/2024.1 From 14f5d52fa7a4beb1f5af3f8686bae83ffc7fc6b4 Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Fri, 15 Mar 2024 14:50:27 +0000 Subject: [PATCH 002/184] Update TOX_CONSTRAINTS_FILE for stable/2024.1 Update the URL to the upper-constraints file to point to the redirect rule on releases.openstack.org so that anyone working on this branch will switch to the correct upper-constraints list automatically when the requirements repository branches. Until the requirements repository has as stable/2024.1 branch, tests will continue to use the upper-constraints list on master. Change-Id: I1371b181b1042a25a43686566952cdc6a1c23bef --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 0452abb1f65..94a1cfcd6f1 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,7 @@ passenv = TRACE_FAILONLY TOX_ENV_SRC_MODULES usedevelop = True deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt hacking>=6.1.0,<6.2.0 # Apache-2.0 @@ -151,7 +151,7 @@ commands = {posargs} # upper constraints will not be used for deps listed in requirements.txt # and may cause issues deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/doc/requirements.txt -r{toxinidir}/requirements.txt commands = sphinx-build -W -b html doc/source doc/build/html From fed96541c778768b279f1116f7b330aa9e4ab491 Mon Sep 17 00:00:00 2001 From: Miguel Lavalle Date: Thu, 14 Mar 2024 18:09:28 -0500 Subject: [PATCH 003/184] Fix making all user defined flavor routers HA Since [1] was merged, user defined flavor routers with the HA attribute set to False cannot be created. This change fixes it. Closes-Bug: #2057983 [1] https://review.opendev.org/c/openstack/neutron/+/910889 Change-Id: Ic72979cfe535c1bb8cba77fb82a380c167509060 (cherry picked from commit 26ff51bf05dd8b61d96489f6b459e8f62f855823) --- neutron/db/ovn_l3_hamode_db.py | 4 ++++ neutron/tests/unit/db/test_ovn_l3_hamode_db.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/neutron/db/ovn_l3_hamode_db.py b/neutron/db/ovn_l3_hamode_db.py index b3123cdde22..ffdc6948720 100644 --- a/neutron/db/ovn_l3_hamode_db.py +++ b/neutron/db/ovn_l3_hamode_db.py @@ -17,6 +17,7 @@ from neutron_lib.callbacks import registry from neutron_lib.callbacks import resources +from neutron.common.ovn import utils from neutron.db import l3_attrs_db @@ -31,5 +32,8 @@ def _precommit_router_create(self, resource, event, trigger, payload): # NOTE(ralonsoh): OVN L3 router HA flag is mandatory and True always, # enforced by ``OvnDriver.ha_support`` set to ``MANDATORY``. This flag # cannot be updated. + router = payload.latest_state + if not utils.is_ovn_provider_router(router): + return router_db = payload.metadata['router_db'] self.set_extra_attr_value(router_db, 'ha', True) diff --git a/neutron/tests/unit/db/test_ovn_l3_hamode_db.py b/neutron/tests/unit/db/test_ovn_l3_hamode_db.py index ab260d335e5..475340baad5 100644 --- a/neutron/tests/unit/db/test_ovn_l3_hamode_db.py +++ b/neutron/tests/unit/db/test_ovn_l3_hamode_db.py @@ -48,3 +48,10 @@ def test_create_router(self): router_db = self._create_router(router_dict) router = router_obj.Router.get_object(self.ctx, id=router_db.id) self.assertTrue(router.extra_attributes.ha) + + def test_create_no_ovn_router(self): + router_dict = {'name': 'foo_router', 'admin_state_up': True, + 'distributed': False, 'flavor_id': 'uuid'} + router_db = self._create_router(router_dict) + router = router_obj.Router.get_object(self.ctx, id=router_db.id) + self.assertFalse(router.extra_attributes.ha) From 0eccc52f826f21459a285c06a454a3b818d30ca0 Mon Sep 17 00:00:00 2001 From: Robert Breker Date: Sun, 17 Mar 2024 14:43:50 +0000 Subject: [PATCH 004/184] Enhance IptablesFirewallDriver with remote address groups This change enhances the IptablesFirewallDriver with support for remote address groups. Previously, this feature was only available in the OVSFirewallDriver. This commit harmonizes the capabilities across both firewall drivers, and by inheritance also to OVSHybridIptablesFirewallDriver. Background - The Neutron API allows operators to configure remote address groups [1], however the OVSHybridIptablesFirewallDriver and IptablesFirewallDriver do not implement these remote group restrictions. When configuring security group rules with remote address groups, connections get enabled based on other rule parameters, ignoring the configured remote address group restrictions. This behaviour undocumented, and may lead to more-open-than-configured network access. Closes-Bug: #2058138 Change-Id: I76b3cb46ee603fa5e829537af41316bb42a6f30f (cherry picked from commit 5e1188ef38da3f196aadf82a3842fa982c9a0c83) --- neutron/agent/linux/iptables_firewall.py | 12 +++++++++--- .../unit/agent/linux/test_iptables_firewall.py | 16 ++++++++++++++++ ...t-remote-address-groups-89da589aad3c01d3.yaml | 8 ++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/iptables-support-remote-address-groups-89da589aad3c01d3.yaml diff --git a/neutron/agent/linux/iptables_firewall.py b/neutron/agent/linux/iptables_firewall.py index a775857b3df..c81fae9f3db 100644 --- a/neutron/agent/linux/iptables_firewall.py +++ b/neutron/agent/linux/iptables_firewall.py @@ -622,9 +622,15 @@ def _select_sg_rules_for_port(self, port, direction): rule, port, direction)) return port_rules + def _get_any_remote_group_id_in_rule(self, rule): + remote_group_id = rule.get('remote_group_id') + if not remote_group_id: + remote_group_id = rule.get('remote_address_group_id') + return remote_group_id + def _expand_sg_rule_with_remote_ips(self, rule, port, direction): """Expand a remote group rule to rule per remote group IP.""" - remote_group_id = rule.get('remote_group_id') + remote_group_id = self._get_any_remote_group_id_in_rule(rule) if remote_group_id: ethertype = rule['ethertype'] port_ips = port.get('fixed_ips', []) @@ -646,7 +652,7 @@ def _get_remote_sg_ids(self, port, direction=None): for sg_id in sg_ids: for rule in self.sg_rules.get(sg_id, []): if not direction or rule['direction'] == direction: - remote_sg_id = rule.get('remote_group_id') + remote_sg_id = self._get_any_remote_group_id_in_rule(rule) ether_type = rule.get('ethertype') if remote_sg_id and ether_type: remote_sg_ids[ether_type].add(remote_sg_id) @@ -726,7 +732,7 @@ def _generate_plain_rule_args(self, sg_rule): return args def _convert_sg_rule_to_iptables_args(self, sg_rule): - remote_gid = sg_rule.get('remote_group_id') + remote_gid = self._get_any_remote_group_id_in_rule(sg_rule) if self.enable_ipset and remote_gid: return self._generate_ipset_rule_args(sg_rule, remote_gid) else: diff --git a/neutron/tests/unit/agent/linux/test_iptables_firewall.py b/neutron/tests/unit/agent/linux/test_iptables_firewall.py index e4131771ec3..16a21115952 100644 --- a/neutron/tests/unit/agent/linux/test_iptables_firewall.py +++ b/neutron/tests/unit/agent/linux/test_iptables_firewall.py @@ -2457,6 +2457,22 @@ def test_filter_defer_apply_off_with_sg_only_ipv6_rule(self): self.firewall.ipset.assert_has_calls(calls, True) + def test__get_any_remote_group_id_in_rule_with_remote_group(self): + sg_rule = {'direction': 'ingress', + 'remote_group_id': FAKE_SGID, + 'ethertype': _IPv4} + + self.assertEqual(FAKE_SGID, + self.firewall._get_any_remote_group_id_in_rule(sg_rule)) + + def test__get_any_remote_group_id_in_rule_with_remote_address_group(self): + sg_rule = {'direction': 'ingress', + 'remote_address_group_id': FAKE_SGID, + 'ethertype': _IPv6} + + self.assertEqual(FAKE_SGID, + self.firewall._get_any_remote_group_id_in_rule(sg_rule)) + def test_sg_rule_expansion_with_remote_ips(self): other_ips = [('10.0.0.2', 'fa:16:3e:aa:bb:c1'), ('10.0.0.3', 'fa:16:3e:aa:bb:c2'), diff --git a/releasenotes/notes/iptables-support-remote-address-groups-89da589aad3c01d3.yaml b/releasenotes/notes/iptables-support-remote-address-groups-89da589aad3c01d3.yaml new file mode 100644 index 00000000000..160b6b2af5a --- /dev/null +++ b/releasenotes/notes/iptables-support-remote-address-groups-89da589aad3c01d3.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Remote address group support was added to the iptables-based firewall + drivers (IptablesFirewallDriver and OVSHybridIptablesFirewallDriver), + Previously it was only available in the OVSFirewallDriver. + For more information, see bug + `2058138 `_. \ No newline at end of file From d683804dfa046b80c9d4758ab7aa649f3ca7af18 Mon Sep 17 00:00:00 2001 From: Miguel Lavalle Date: Mon, 25 Mar 2024 17:30:01 -0500 Subject: [PATCH 005/184] Check unspecified flavor in user defined driver In order to decide whether to process a router related request, the user defined router flavor OVN driver needs to check the flavor_id specified in the request. This change adds the code to test the case when the API passed the flavor_id as unspecified. Change-Id: I4d7d9d5582b97246cad63ef7f5511b159d6c6791 Closes-Bug: #2059051 (cherry picked from commit 9d729bda207847b4c94d570eacdd26951294f49f) --- .../services/ovn_l3/service_providers/user_defined.py | 3 ++- .../ovn_l3/service_providers/test_user_defined.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/neutron/services/ovn_l3/service_providers/user_defined.py b/neutron/services/ovn_l3/service_providers/user_defined.py index ea1af67e3a7..e0d516ea4c3 100644 --- a/neutron/services/ovn_l3/service_providers/user_defined.py +++ b/neutron/services/ovn_l3/service_providers/user_defined.py @@ -16,6 +16,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 const from neutron_lib.plugins import constants as plugin_constants from neutron_lib.plugins import directory from oslo_log import log as logging @@ -44,7 +45,7 @@ def _flavor_plugin(self): def _is_user_defined_provider(self, context, router): flavor_id = router.get('flavor_id') - if flavor_id is None: + if flavor_id is None or flavor_id is const.ATTR_NOT_SPECIFIED: return False flavor = self._flavor_plugin.get_flavor(context, flavor_id) provider = self._flavor_plugin.get_flavor_next_provider( diff --git a/neutron/tests/unit/services/ovn_l3/service_providers/test_user_defined.py b/neutron/tests/unit/services/ovn_l3/service_providers/test_user_defined.py index ba02b957ec6..135ec55ee13 100644 --- a/neutron/tests/unit/services/ovn_l3/service_providers/test_user_defined.py +++ b/neutron/tests/unit/services/ovn_l3/service_providers/test_user_defined.py @@ -14,6 +14,7 @@ from unittest import mock from neutron_lib.callbacks import events +from neutron_lib import constants as const from neutron.db.models import l3 @@ -56,6 +57,14 @@ def test__is_user_defined_provider(self): self.assertFalse(self.provider._is_user_defined_provider( self.context, self.router)) + # test flavor_id request not specified + self.router.flavor_id = None + self.assertFalse(self.provider._is_user_defined_provider( + self.context, self.router)) + self.router.flavor_id = const.ATTR_NOT_SPECIFIED + self.assertFalse(self.provider._is_user_defined_provider( + self.context, self.router)) + def test_router_processing(self): with mock.patch.object(user_defined.LOG, 'debug') as log: payload = events.DBEventPayload( From 646270de5b740e6bc35f070ababf3e4f14e47f38 Mon Sep 17 00:00:00 2001 From: Anton Kurbatov Date: Mon, 25 Mar 2024 18:49:52 +0000 Subject: [PATCH 006/184] Fixing the 500 HTTP code in the metadata service if Nova is down If the Nova metadata service is unavailable, the requests.request() function may raise a ConnectionError. This results in the upper code returning a 500 HTTP status code to the user along with a traceback. Let's handle this scenario and instead return a 503 HTTP status code (service unavailable). If the Nova service is down and is behind another proxy (such as Nginx), then instead of a ConnectionError, the request may result in receiving a 502 or 503 HTTP status code. Let's also consider this situation and add support for an additional 504 code. Closes-Bug: #2059032 Change-Id: I16be18c46a6796224b0793dc385b0ddec01739c4 (cherry picked from commit 6395b4fe8ed99855853587fa93cb59fd2691aed5) --- neutron/agent/metadata/agent.py | 27 ++++++++++--------- neutron/agent/ovn/metadata/server.py | 27 ++++++++++--------- .../tests/unit/agent/metadata/test_agent.py | 21 +++++++++++++++ .../unit/agent/ovn/metadata/test_server.py | 21 +++++++++++++++ ...hance-error-handling-3655404d44249097.yaml | 6 +++++ 5 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 releasenotes/notes/metadata-proxy-enhance-error-handling-3655404d44249097.yaml diff --git a/neutron/agent/metadata/agent.py b/neutron/agent/metadata/agent.py index c3d94979ed9..5c0a2062e8c 100644 --- a/neutron/agent/metadata/agent.py +++ b/neutron/agent/metadata/agent.py @@ -246,12 +246,18 @@ def _proxy_request(self, instance_id, tenant_id, req): client_cert = (self.conf.nova_client_cert, self.conf.nova_client_priv_key) - resp = requests.request(method=req.method, url=url, - headers=headers, - data=req.body, - cert=client_cert, - verify=verify_cert, - timeout=60) + 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.') + explanation = str(msg) + return webob.exc.HTTPServiceUnavailable(explanation=explanation) if resp.status_code == 200: req.response.content_type = resp.headers['content-type'] @@ -264,12 +270,6 @@ def _proxy_request(self, instance_id, tenant_id, req): 'response usually occurs when shared secrets do not match.' ) return webob.exc.HTTPForbidden() - elif resp.status_code == 400: - return webob.exc.HTTPBadRequest() - elif resp.status_code == 404: - return webob.exc.HTTPNotFound() - elif resp.status_code == 409: - return webob.exc.HTTPConflict() elif resp.status_code == 500: msg = _( 'Remote metadata server experienced an internal server error.' @@ -277,6 +277,9 @@ def _proxy_request(self, instance_id, tenant_id, req): LOG.warning(msg) explanation = str(msg) return webob.exc.HTTPInternalServerError(explanation=explanation) + elif resp.status_code in (400, 404, 409, 502, 503, 504): + webob_exc_cls = webob.exc.status_map.get(resp.status_code) + return webob_exc_cls() else: raise Exception(_('Unexpected response code: %s') % resp.status_code) diff --git a/neutron/agent/ovn/metadata/server.py b/neutron/agent/ovn/metadata/server.py index 466b70681a9..58ef4cff82e 100644 --- a/neutron/agent/ovn/metadata/server.py +++ b/neutron/agent/ovn/metadata/server.py @@ -168,12 +168,18 @@ def _proxy_request(self, instance_id, tenant_id, req): client_cert = (self.conf.nova_client_cert, self.conf.nova_client_priv_key) - resp = requests.request(method=req.method, url=url, - headers=headers, - data=req.body, - cert=client_cert, - verify=verify_cert, - timeout=60) + 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.') + explanation = str(msg) + return webob.exc.HTTPServiceUnavailable(explanation=explanation) if resp.status_code == 200: req.response.content_type = resp.headers['content-type'] @@ -186,12 +192,6 @@ def _proxy_request(self, instance_id, tenant_id, req): 'response usually occurs when shared secrets do not match.' ) return webob.exc.HTTPForbidden() - elif resp.status_code == 400: - return webob.exc.HTTPBadRequest() - elif resp.status_code == 404: - return webob.exc.HTTPNotFound() - elif resp.status_code == 409: - return webob.exc.HTTPConflict() elif resp.status_code == 500: msg = _( 'Remote metadata server experienced an internal server error.' @@ -199,6 +199,9 @@ def _proxy_request(self, instance_id, tenant_id, req): LOG.warning(msg) explanation = str(msg) return webob.exc.HTTPInternalServerError(explanation=explanation) + elif resp.status_code in (400, 404, 409, 502, 503, 504): + webob_exc_cls = webob.exc.status_map.get(resp.status_code) + return webob_exc_cls() else: raise Exception(_('Unexpected response code: %s') % resp.status_code) diff --git a/neutron/tests/unit/agent/metadata/test_agent.py b/neutron/tests/unit/agent/metadata/test_agent.py index cc8ff95d5bc..eb03ef2e6ba 100644 --- a/neutron/tests/unit/agent/metadata/test_agent.py +++ b/neutron/tests/unit/agent/metadata/test_agent.py @@ -17,6 +17,7 @@ import ddt import netaddr from neutron_lib import constants as n_const +import requests import testtools import webob @@ -469,10 +470,30 @@ def test_proxy_request_500(self): self.assertIsInstance(self._proxy_request_test_helper(500), webob.exc.HTTPInternalServerError) + def test_proxy_request_502(self): + self.assertIsInstance(self._proxy_request_test_helper(502), + webob.exc.HTTPBadGateway) + + def test_proxy_request_503(self): + self.assertIsInstance(self._proxy_request_test_helper(503), + webob.exc.HTTPServiceUnavailable) + + def test_proxy_request_504(self): + self.assertIsInstance(self._proxy_request_test_helper(504), + webob.exc.HTTPGatewayTimeout) + def test_proxy_request_other_code(self): with testtools.ExpectedException(Exception): self._proxy_request_test_helper(302) + def test_proxy_request_conenction_error(self): + req = mock.Mock(path_info='/the_path', query_string='', headers={}, + method='GET', body='') + with mock.patch('requests.request') as mock_request: + mock_request.side_effect = requests.ConnectionError() + retval = self.handler._proxy_request('the_id', 'tenant_id', req) + self.assertIsInstance(retval, webob.exc.HTTPServiceUnavailable) + class TestMetadataProxyHandlerNewCache(TestMetadataProxyHandlerBase, _TestMetadataProxyHandlerCacheMixin): diff --git a/neutron/tests/unit/agent/ovn/metadata/test_server.py b/neutron/tests/unit/agent/ovn/metadata/test_server.py index c8ede299357..aa154868299 100644 --- a/neutron/tests/unit/agent/ovn/metadata/test_server.py +++ b/neutron/tests/unit/agent/ovn/metadata/test_server.py @@ -18,6 +18,7 @@ from oslo_config import cfg from oslo_config import fixture as config_fixture from oslo_utils import fileutils +import requests import testtools import webob @@ -232,10 +233,30 @@ def test_proxy_request_500(self): self.assertIsInstance(self._proxy_request_test_helper(500), webob.exc.HTTPInternalServerError) + def test_proxy_request_502(self): + self.assertIsInstance(self._proxy_request_test_helper(502), + webob.exc.HTTPBadGateway) + + def test_proxy_request_503(self): + self.assertIsInstance(self._proxy_request_test_helper(503), + webob.exc.HTTPServiceUnavailable) + + def test_proxy_request_504(self): + self.assertIsInstance(self._proxy_request_test_helper(504), + webob.exc.HTTPGatewayTimeout) + def test_proxy_request_other_code(self): with testtools.ExpectedException(Exception): self._proxy_request_test_helper(302) + def test_proxy_request_conenction_error(self): + req = mock.Mock(path_info='/the_path', query_string='', headers={}, + method='GET', body='') + with mock.patch('requests.request') as mock_request: + mock_request.side_effect = requests.ConnectionError() + retval = self.handler._proxy_request('the_id', 'tenant_id', req) + self.assertIsInstance(retval, webob.exc.HTTPServiceUnavailable) + class TestUnixDomainMetadataProxy(base.BaseTestCase): def setUp(self): diff --git a/releasenotes/notes/metadata-proxy-enhance-error-handling-3655404d44249097.yaml b/releasenotes/notes/metadata-proxy-enhance-error-handling-3655404d44249097.yaml new file mode 100644 index 00000000000..7f085db8cfe --- /dev/null +++ b/releasenotes/notes/metadata-proxy-enhance-error-handling-3655404d44249097.yaml @@ -0,0 +1,6 @@ +--- +other: + - | + Enhance error handling in the Neutron metadata service for cases when the + Nova metadata service is unavailable, ensuring correct HTTP status codes + are returned. From 51961e195fa9ac474af45b366831cc521cbc6e34 Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Wed, 6 Dec 2023 16:37:24 -0500 Subject: [PATCH 007/184] Fix KeyError failure in _sync_subnet_dhcp_options() If the netron-ovn-db-sync-util is run while neutron-server is active (which is not recommended), it can randomly fail if there are active API calls in flight to create networks and/or subnets. Skip the subnet and log a warning if detected. Closes-bug: #2045811 Change-Id: Ic5d9608277dd5c4881b3e4b494e1864be0bed1b4 (cherry picked from commit e4323e1f209ea1c63fe7af5275ea2b96f52b8740) --- .../ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py | 12 +++++++++++- .../ovn/mech_driver/ovsdb/test_ovn_db_sync.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py index 11d87839328..8637688f456 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py @@ -870,10 +870,20 @@ def _sync_subnet_dhcp_options(self, ctx, db_networks, LOG.warning('DHCP options for subnet %s present in ' 'Neutron but out of sync with OVN NB DB', subnet_id) if self.mode == SYNC_MODE_REPAIR: + # If neutron-server is running we could race and find a + # subnet without a cached network, just skip it to avoid + # a KeyError below. + network_id = utils.ovn_name(subnet['network_id']) + if network_id not in db_networks: + LOG.warning('Network %s for subnet %s not found in OVN NB ' + 'DB network cache, possible race condition, ' + 'please check that neutron-server is stopped! ' + 'Skipping subnet.', network_id, subnet_id) + continue try: LOG.warning('Adding/Updating DHCP options for subnet %s ' 'in OVN NB DB', subnet_id) - network = db_networks[utils.ovn_name(subnet['network_id'])] + network = db_networks[network_id] # _ovn_client._add_subnet_dhcp_options doesn't create # a new row in DHCP_Options if the row already exists. # See commands.AddDHCPOptionsCommand. diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py index 6d9a060b014..67f078ed478 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py @@ -104,6 +104,17 @@ def setUp(self): 'gateway_ip': '20.0.0.1', 'dns_nameservers': [], 'host_routes': [], + 'ip_version': 4}, + # A subnet without a known network should be skipped, + # see bug #2045811 + {'id': 'notfound', + 'network_id': 'notfound', + 'enable_dhcp': True, + 'cidr': '30.0.0.0/24', + 'tenant_id': 'tenant1', + 'gateway_ip': '30.0.0.1', + 'dns_nameservers': [], + 'host_routes': [], 'ip_version': 4}] self.security_groups = [ From ac1472c8cffe64d32a012c73227595f2f7806de9 Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Tue, 7 May 2024 20:03:14 +0000 Subject: [PATCH 008/184] Don't update revision number if object was not modified If there were not changes made to data in the database there is no reason to bump revision numbers because the underlying drivers won't change too. This saves cycles in case empty updates are incoming to the API. Co-Authored-By: Ihar Hrachyshka Closes-bug: #2065094 Change-Id: Ib74fdab7a8927ef9cc24ef7810e9cf2c264941eb (cherry picked from commit 5795c192b840ae327bc9e32d5183f177daa9b55b) --- neutron/services/revisions/revision_plugin.py | 6 +++--- .../tests/unit/services/revisions/test_revision_plugin.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/neutron/services/revisions/revision_plugin.py b/neutron/services/revisions/revision_plugin.py index 24e2e7f7f7e..28e5e91c904 100644 --- a/neutron/services/revisions/revision_plugin.py +++ b/neutron/services/revisions/revision_plugin.py @@ -78,14 +78,14 @@ def _get_objects_to_bump_revision(self, dirty_objects): def bump_revisions(self, session, context, instances): self._enforce_if_match_constraints(session) # bump revision number for updated objects in the session + modified_objs = {o for o in session.dirty if session.is_modified(o)} self._bump_obj_revisions( - session, - self._get_objects_to_bump_revision(session.dirty)) + session, self._get_objects_to_bump_revision(modified_objs)) # see if any created/updated/deleted objects bump the revision # of another object objects_with_related_revisions = [ - o for o in session.deleted | session.dirty | session.new + o for o in modified_objs | set(session.deleted) | set(session.new) if getattr(o, 'revises_on_change', ()) ] collected = session.info.setdefault('_related_bumped', set()) diff --git a/neutron/tests/unit/services/revisions/test_revision_plugin.py b/neutron/tests/unit/services/revisions/test_revision_plugin.py index 8f5ae05916d..7d9cc9a5ddd 100644 --- a/neutron/tests/unit/services/revisions/test_revision_plugin.py +++ b/neutron/tests/unit/services/revisions/test_revision_plugin.py @@ -143,13 +143,13 @@ def test_constrained_port_update_handles_db_retries(self): # update with self.port() as port: rev = port['port']['revision_number'] - new = {'port': {'name': 'nigiri'}} def concurrent_increment(s): db_api.sqla_remove(se.Session, 'before_commit', concurrent_increment) # slip in a concurrent update that will bump the revision plugin = directory.get_plugin() + new = {'port': {'name': 'nigiri'}} plugin.update_port(nctx.get_admin_context(), port['port']['id'], new) raise db_exc.DBDeadlock() @@ -160,13 +160,16 @@ def concurrent_increment(s): # transaction, the revision number is tested only once the first # time the revision number service is executed for this session and # object. + new = {'port': {'name': 'sushi'}} self._update('ports', port['port']['id'], new, headers={'If-Match': 'revision_number=%s' % rev}, expected_code=exc.HTTPOk.code) + new = {'port': {'name': 'salmon'}} self._update('ports', port['port']['id'], new, headers={'If-Match': 'revision_number=%s' % str(int(rev) + 2)}, expected_code=exc.HTTPOk.code) + new = {'port': {'name': 'tea'}} self._update('ports', port['port']['id'], new, headers={'If-Match': 'revision_number=1'}, expected_code=exc.HTTPPreconditionFailed.code) From a61598f64bf7dda0da782de0d69dd274f73c398f Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Wed, 8 May 2024 11:44:23 -0400 Subject: [PATCH 009/184] Put monitors dictionary back in MetadataDriverBase class When the common Metadata Driver was created in [0], the monitors dictionary was dropped accidentally. This causes tracebacks in the fullstack L3-HA tests when after_router_updated() is called. Put it back along with its related tests. [0] https://review.opendev.org/c/openstack/neutron/+/894399 Closes-bug: #2065145 Change-Id: I137ed7cec9e0eafdb3a351e5a414f5a0c16f33e5 (cherry picked from commit 5b62e27154c976cfd5707029a94e22e23ecbddef) --- neutron/agent/metadata/driver_base.py | 6 ++++++ neutron/tests/unit/agent/metadata/test_driver.py | 1 + neutron/tests/unit/agent/ovn/metadata/test_driver.py | 1 + 3 files changed, 8 insertions(+) diff --git a/neutron/agent/metadata/driver_base.py b/neutron/agent/metadata/driver_base.py index 8fb3952dde1..8c8e8f46f72 100644 --- a/neutron/agent/metadata/driver_base.py +++ b/neutron/agent/metadata/driver_base.py @@ -159,6 +159,8 @@ def cleanup_config_file(cls, uuid, state_path): class MetadataDriverBase(object, metaclass=abc.ABCMeta): + monitors = {} + @staticmethod @abc.abstractmethod def haproxy_configurator(): @@ -253,6 +255,8 @@ def spawn_monitored_metadata_proxy(cls, monitor, ns_name, port, conf, return monitor.register(uuid, METADATA_SERVICE_NAME, pm) + cls.monitors[uuid] = pm + @classmethod def destroy_monitored_metadata_proxy(cls, monitor, uuid, conf, ns_name): monitor.unregister(uuid, METADATA_SERVICE_NAME) @@ -272,6 +276,8 @@ def destroy_monitored_metadata_proxy(cls, monitor, uuid, conf, ns_name): configurator = cls.haproxy_configurator() configurator.cleanup_config_file(uuid, cfg.CONF.state_path) + cls.monitors.pop(uuid, None) + @classmethod def _get_metadata_proxy_process_manager(cls, router_id, conf, ns_name=None, callback=None): diff --git a/neutron/tests/unit/agent/metadata/test_driver.py b/neutron/tests/unit/agent/metadata/test_driver.py index 707d7f702d5..0fe5ba44ffe 100644 --- a/neutron/tests/unit/agent/metadata/test_driver.py +++ b/neutron/tests/unit/agent/metadata/test_driver.py @@ -312,6 +312,7 @@ def test_spawn_metadata_proxy_handles_process_exception(self, error_log): network_id=network_id) error_log.assert_called_once() process_monitor.register.assert_not_called() + self.assertNotIn(network_id, metadata_driver.MetadataDriver.monitors) def test_create_config_file_wrong_user(self): with mock.patch('pwd.getpwnam', side_effect=KeyError): diff --git a/neutron/tests/unit/agent/ovn/metadata/test_driver.py b/neutron/tests/unit/agent/ovn/metadata/test_driver.py index 190ae0ac3de..263c8daca32 100644 --- a/neutron/tests/unit/agent/ovn/metadata/test_driver.py +++ b/neutron/tests/unit/agent/ovn/metadata/test_driver.py @@ -197,6 +197,7 @@ def test_spawn_metadata_proxy_handles_process_exception(self, error_log): error_log.assert_called_once() process_monitor.register.assert_not_called() + self.assertNotIn(network_id, metadata_driver.MetadataDriver.monitors) def test_create_config_file_wrong_user(self): with mock.patch('pwd.getpwnam', side_effect=KeyError): From d8208fc51482737a2aeed2a1c5e61737a2808d94 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Wed, 22 May 2024 15:28:05 +0200 Subject: [PATCH 010/184] Return both project_id when validating auto allocate network When neutron API is called to check requirements for the auto_allocate topology, it needs to return not only 'tenant_id' field but also 'project_id' as that is required for the policy enforcement. Without this 'project_id' field requirements check was failing for member and reader users as they got 404 from the Neutron API. And the reason why Neutron was returning 404 was that it wasn't passing policy enforcement due to missing project_id field in the 'target' object. Closes-bug: #2066369 Change-Id: Idf96a82bc6c8cb0b47dfde3baba94b42a8a8beba (cherry picked from commit dfc01beab22f1c2b977d3e399c3fcda69a72082d) --- neutron/services/auto_allocate/db.py | 4 +++- neutron/tests/unit/services/auto_allocate/test_db.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/neutron/services/auto_allocate/db.py b/neutron/services/auto_allocate/db.py index c51777f1473..1d6d77519e8 100644 --- a/neutron/services/auto_allocate/db.py +++ b/neutron/services/auto_allocate/db.py @@ -194,7 +194,9 @@ def _check_requirements(self, context, tenant_id): except n_exc.NotFound: raise exceptions.AutoAllocationFailure( reason=_("No default subnetpools defined")) - return {'id': 'dry-run=pass', 'tenant_id': tenant_id} + return {'id': 'dry-run=pass', + 'tenant_id': tenant_id, + 'project_id': tenant_id} def _validate(self, context, tenant_id): """Validate and return the tenant to be associated to the topology.""" diff --git a/neutron/tests/unit/services/auto_allocate/test_db.py b/neutron/tests/unit/services/auto_allocate/test_db.py index 83167d7ee62..2ff3cf7955a 100644 --- a/neutron/tests/unit/services/auto_allocate/test_db.py +++ b/neutron/tests/unit/services/auto_allocate/test_db.py @@ -351,7 +351,10 @@ def test__check_requirements_happy_path_for_kevin(self): mock.patch.object( self.mixin, '_get_supported_subnetpools'): result = self.mixin._check_requirements(self.ctx, 'foo_tenant') - expected = {'id': 'dry-run=pass', 'tenant_id': 'foo_tenant'} + expected = { + 'id': 'dry-run=pass', + 'tenant_id': 'foo_tenant', + 'project_id': 'foo_tenant'} self.assertEqual(expected, result) def test__cleanup_handles_failures(self): From 5bdd0efb3970a52c60043f166bc728778ac3f395 Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Tue, 28 May 2024 13:11:58 +0530 Subject: [PATCH 011/184] [stable only] Do not fail on missing logical router ports set_gateway_mtu runs for all the gateway ports for a network and if one of the ports get's deleted in meanwhile whole transaction fails. To handle this we need to add if_exists=True to the transaction but for that it needs to be supported in ovsdbapp. It's fixed in ovsdbapp with [1] but would require to bump ovsdbapp minimal version in requirements.txt which we normally don't do for stable branches. So using "update_lrouter_port" instead as that have the required option available. Before [2] that was only used but during the switch if_exists part was missed. [1] https://review.opendev.org/q/I56685478214aae7b6d3a2a3187297ad4eb1869a3 [2] https://review.opendev.org/c/openstack/neutron/+/762695 Closes-Bug: #2065701 Related-Bug: #2060163 Change-Id: I447990509cdea9830228d3bc92a97062cc57a472 --- .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 3 ++- neutron/tests/unit/fake_resources.py | 1 - .../plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py | 4 ++-- neutron/tests/unit/services/ovn_l3/test_plugin.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index f94fc799ef3..fa4639c050f 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -2058,7 +2058,8 @@ def set_gateway_mtu(self, context, prov_net, txn=None): for port in ports: lrp_name = utils.ovn_lrouter_port_name(port['id']) options = self._gen_router_port_options(port, prov_net) - commands.append(self._nb_idl.lrp_set_options(lrp_name, **options)) + commands.append(self._nb_idl.update_lrouter_port( + lrp_name, if_exists=True, **options)) self._transaction(commands, txn=txn) def _check_network_changes_in_ha_chassis_groups(self, diff --git a/neutron/tests/unit/fake_resources.py b/neutron/tests/unit/fake_resources.py index f7aed0489ef..436d2ebc4b5 100644 --- a/neutron/tests/unit/fake_resources.py +++ b/neutron/tests/unit/fake_resources.py @@ -63,7 +63,6 @@ def __init__(self, **kwargs): self.delete_lswitch_port = mock.Mock() self.get_acls_for_lswitches = mock.Mock() self.lrp_del = mock.Mock() - self.lrp_set_options = mock.Mock() self.lr_add = mock.Mock() self.update_lrouter = mock.Mock() self.lr_del = mock.Mock() diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 112324c772e..ac46346f257 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -2573,8 +2573,8 @@ def _test_update_network_fragmentation(self, new_mtu, expected_opts, grps, self.mech_driver.update_network_postcommit(fake_ctx) lrp_name = ovn_utils.ovn_lrouter_port_name(port['port']['id']) - self.nb_ovn.lrp_set_options.assert_called_once_with( - lrp_name, **expected_opts) + self.nb_ovn.update_lrouter_port.assert_called_once_with( + lrp_name, if_exists=True, **expected_opts) def test_update_network_need_to_frag_enabled(self): ovn_conf.cfg.CONF.set_override('ovn_emit_need_to_frag', True, diff --git a/neutron/tests/unit/services/ovn_l3/test_plugin.py b/neutron/tests/unit/services/ovn_l3/test_plugin.py index 5f09a53ecbd..4fcb6b58a6f 100644 --- a/neutron/tests/unit/services/ovn_l3/test_plugin.py +++ b/neutron/tests/unit/services/ovn_l3/test_plugin.py @@ -1987,7 +1987,7 @@ def test_add_router_interface_need_to_frag_enabled_then_remove( self.l3_inst._nb_ovn.add_lrouter_port.assert_called_once_with( **fake_router_port_assert) # Since if_exists = True it will safely return - self.l3_inst._nb_ovn.lrp_set_options( + self.l3_inst._nb_ovn.update_lrouter_port( name='lrp-router-port-id', if_exists=True, options=fake_router_port_assert) # If no if_exists is provided, it is defaulted to true, so this From 4221f706ce269c06e2acb194612c5241dd2c1e83 Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Tue, 21 May 2024 18:36:39 +0530 Subject: [PATCH 012/184] [functional tests] compatibility with ovsdbapp>=2.6.1 ovsdbapp>=2.6.1 handles cleanup of Chassis_Private record with chassis delete so we don't need explicit delete. The compatibility part can be dropped when we update requirements.txt to ovsdbapp>=2.6.1. Closes-Bug: #2066263 Change-Id: I45c6e6a1c3536cf4f2d90b01a3577eec9eaf3743 (cherry picked from commit 20b9893e34dda0b448ac75c795867cb46de5e127) --- neutron/tests/functional/base.py | 12 ++++++++++-- .../ml2/drivers/ovn/mech_driver/test_mech_driver.py | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/neutron/tests/functional/base.py b/neutron/tests/functional/base.py index ef6b30748d3..8c079ceaaed 100644 --- a/neutron/tests/functional/base.py +++ b/neutron/tests/functional/base.py @@ -28,6 +28,7 @@ from oslo_log import log from oslo_utils import timeutils from oslo_utils import uuidutils +from ovsdbapp.backend.ovs_idl import idlutils from neutron.agent.linux import utils from neutron.api import extensions as exts @@ -443,5 +444,12 @@ def append_cms_options(ext_ids, value): def del_fake_chassis(self, chassis, if_exists=True): self.sb_api.chassis_del( chassis, if_exists=if_exists).execute(check_error=True) - self.sb_api.db_destroy( - 'Chassis_Private', chassis).execute(check_error=True) + try: + self.sb_api.db_destroy( + 'Chassis_Private', chassis).execute(check_error=True) + except idlutils.RowNotFound: + # NOTE(ykarel ): ovsdbapp >= 2.6.1 handles Chassis_Private + # record delete with chassis + # try/except can be dropped when neutron requirements.txt + # include ovsdbapp>=2.6.1 + pass diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 25d9730b759..e942eb6cec6 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -1234,7 +1234,8 @@ def test_agent_list(self): # then Chassis_Private.chassis = []; both metadata and controller # agents will still be present in the agent list. agent_event = AgentWaitEvent(self.mech_driver, [self.chassis], - events=(event.RowEvent.ROW_UPDATE,)) + events=(event.RowEvent.ROW_UPDATE, + event.RowEvent.ROW_DELETE,)) self.sb_api.idl.notify_handler.watch_event(agent_event) self.sb_api.chassis_del(self.chassis).execute(check_error=True) self.assertTrue(agent_event.wait()) From 0d8cc09c4a5f652503c4ba9d968959b522aca980 Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Thu, 6 Jun 2024 16:17:07 +0530 Subject: [PATCH 013/184] [stable only] Fix KeyError in set_gateway_mtu Got missed in initial fixes[1], this patch fixes it. [1] https://review.opendev.org/q/I447990509cdea9830228d3bc92a97062cc57a472 Closes-Bug: #2065701 Related-Bug: #2060163 Change-Id: Icdab45ab0873c003977e3f02277d267116002973 --- neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 2 +- .../plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index fa4639c050f..65ee4d7d8af 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -2059,7 +2059,7 @@ def set_gateway_mtu(self, context, prov_net, txn=None): lrp_name = utils.ovn_lrouter_port_name(port['id']) options = self._gen_router_port_options(port, prov_net) commands.append(self._nb_idl.update_lrouter_port( - lrp_name, if_exists=True, **options)) + lrp_name, if_exists=True, options=options)) self._transaction(commands, txn=txn) def _check_network_changes_in_ha_chassis_groups(self, diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index ac46346f257..78bd97ce1fb 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -2574,7 +2574,7 @@ def _test_update_network_fragmentation(self, new_mtu, expected_opts, grps, lrp_name = ovn_utils.ovn_lrouter_port_name(port['port']['id']) self.nb_ovn.update_lrouter_port.assert_called_once_with( - lrp_name, if_exists=True, **expected_opts) + lrp_name, if_exists=True, options=expected_opts) def test_update_network_need_to_frag_enabled(self): ovn_conf.cfg.CONF.set_override('ovn_emit_need_to_frag', True, From 966fa566e5df29289136105b70ea795890c0062e Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Thu, 16 May 2024 10:42:50 -0400 Subject: [PATCH 014/184] Revert "[OVN] Prevent Trunk creation/deletion with parent port bound" There are three reasons to revert this patch. 1. It broke RPC push API for trunks because it added port db model to event payload that is not serializeable. 2. It also broke the callback event payload interface, which requires that all entries in .states attribute belong to the same core object. To quote from neutron-lib, ``` # an iterable of states for the resource from the newest to the oldest # for example db states or api request/response # the actual object type for states will vary depending on event caller self.states = ... ``` 3. There is no good justification why ml2/ovn would not allow this operation. The rationale for the original patch was to align the behavior with ml2/ovs, but we don't such parity requirements. The 409 error that can be returned by the API endpoints is backend specific. To quote api-ref, ``` 409 The operation returns this error code for one of these reasons: A system configuration prevents the operation from succeeding. ``` AFAIU there is nothing that prevents ml2/ovn to create a trunk in this situation. This will have to be backported in all supported branches (the original patch was backported down to Wallaby). Conflicts: neutron/services/trunk/drivers/ovn/trunk_driver.py This reverts commit 833a6d82cd705548130cdac73a88d388f52c7824. Closes-Bug: #2065707 Related-Bug: #2022059 Change-Id: I067c2f7286b2684b67b4389ca085d06a93f856ce (cherry picked from commit ac15191f88a63bd5e0510c3602fb6d19c9ac1c92) --- neutron/common/utils.py | 16 --------- neutron/db/l3_dvr_db.py | 15 ++++++++- .../trunk/drivers/ovn/trunk_driver.py | 26 --------------- neutron/services/trunk/plugin.py | 5 ++- .../trunk/drivers/ovn/test_trunk_driver.py | 33 ------------------- neutron/tests/unit/db/test_l3_dvr_db.py | 3 +- .../trunk/drivers/ovn/test_trunk_driver.py | 4 --- .../tests/unit/services/trunk/test_plugin.py | 3 +- ...nk-check-parent-port-eeca2eceaca9d158.yaml | 6 ---- 9 files changed, 18 insertions(+), 93 deletions(-) delete mode 100644 releasenotes/notes/ovn-trunk-check-parent-port-eeca2eceaca9d158.yaml diff --git a/neutron/common/utils.py b/neutron/common/utils.py index 412489cf67f..3ac808ba5aa 100644 --- a/neutron/common/utils.py +++ b/neutron/common/utils.py @@ -37,12 +37,9 @@ from eventlet.green import subprocess import netaddr from neutron_lib.api.definitions import availability_zone as az_def -from neutron_lib.api.definitions import portbindings -from neutron_lib.api.definitions import portbindings_extended from neutron_lib import constants as n_const from neutron_lib import context as n_context from neutron_lib.db import api as db_api -from neutron_lib.plugins import utils as plugin_utils from neutron_lib.services.qos import constants as qos_consts from neutron_lib.services.trunk import constants as trunk_constants from neutron_lib.utils import helpers @@ -1106,16 +1103,3 @@ def parse_permitted_ethertypes(permitted_ethertypes): continue return ret - - -# TODO(slaweq): this should be moved to neutron_lib.plugins.utils module -def is_port_bound(port, log_message=True): - active_binding = plugin_utils.get_port_binding_by_status_and_host( - port.get('port_bindings', []), n_const.ACTIVE) - if not active_binding: - if log_message: - LOG.warning('Binding for port %s was not found.', port) - return False - return active_binding[portbindings_extended.VIF_TYPE] not in ( - portbindings.VIF_TYPE_UNBOUND, - portbindings.VIF_TYPE_BINDING_FAILED) diff --git a/neutron/db/l3_dvr_db.py b/neutron/db/l3_dvr_db.py index 5b4f1d99532..1f3b3eb8962 100644 --- a/neutron/db/l3_dvr_db.py +++ b/neutron/db/l3_dvr_db.py @@ -17,6 +17,7 @@ from neutron_lib.api.definitions import external_net as extnet_apidef from neutron_lib.api.definitions import l3 as l3_apidef from neutron_lib.api.definitions import portbindings +from neutron_lib.api.definitions import portbindings_extended from neutron_lib.api.definitions import router_admin_state_down_before_update from neutron_lib.api import validators from neutron_lib.callbacks import events @@ -70,6 +71,18 @@ def is_admin_state_down_necessary(): return _IS_ADMIN_STATE_DOWN_NECESSARY +# TODO(slaweq): this should be moved to neutron_lib.plugins.utils module +def is_port_bound(port): + active_binding = plugin_utils.get_port_binding_by_status_and_host( + port.get("port_bindings", []), const.ACTIVE) + if not active_binding: + LOG.warning("Binding for port %s was not found.", port) + return False + return active_binding[portbindings_extended.VIF_TYPE] not in [ + portbindings.VIF_TYPE_UNBOUND, + portbindings.VIF_TYPE_BINDING_FAILED] + + @registry.has_registry_receivers class DVRResourceOperationHandler(object): """Contains callbacks for DVR operations. @@ -1422,7 +1435,7 @@ def is_router_distributed(self, context, router_id): def get_ports_under_dvr_connected_subnet(self, context, subnet_id): ports = dvr_mac_db.get_ports_query_by_subnet_and_ip(context, subnet_id) - ports = [p for p in ports if n_utils.is_port_bound(p)] + ports = [p for p in ports if is_port_bound(p)] # TODO(slaweq): if there would be way to pass to neutron-lib only # list of extensions which actually should be processed, than setting # process_extensions=True below could avoid that second loop and diff --git a/neutron/services/trunk/drivers/ovn/trunk_driver.py b/neutron/services/trunk/drivers/ovn/trunk_driver.py index d0228844ddb..f57e45a858d 100644 --- a/neutron/services/trunk/drivers/ovn/trunk_driver.py +++ b/neutron/services/trunk/drivers/ovn/trunk_driver.py @@ -22,12 +22,10 @@ from oslo_log import log from neutron.common.ovn import constants as ovn_const -from neutron.common import utils as n_utils 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.services.trunk.drivers import base as trunk_base -from neutron.services.trunk import exceptions as trunk_exc SUPPORTED_INTERFACES = ( @@ -157,10 +155,6 @@ def _unset_binding_profile(self, context, subport, ovn_txn): LOG.debug("Done unsetting parent for subport %s", subport.port_id) return db_port - @staticmethod - def _is_port_bound(port): - return n_utils.is_port_bound(port, log_message=False) - def trunk_created(self, resource, event, trunk_plugin, payload): trunk = payload.states[0] # Check if parent port is handled by OVN. @@ -176,18 +170,6 @@ def trunk_deleted(self, resource, event, trunk_plugin, payload): if trunk.sub_ports: self._unset_sub_ports(trunk.sub_ports) - def trunk_created_precommit(self, resource, event, trunk_plugin, payload): - # payload.desired_state below is the trunk object - parent_port = payload.desired_state.db_obj.port - if self._is_port_bound(parent_port): - raise trunk_exc.ParentPortInUse(port_id=parent_port.id) - - def trunk_deleted_precommit(self, resource, event, trunk_plugin, payload): - trunk = payload.states[0] - parent_port = payload.states[1] - if self._is_port_bound(parent_port): - raise trunk_exc.TrunkInUse(trunk_id=trunk.id) - def subports_added(self, resource, event, trunk_plugin, payload): trunk = payload.states[0] subports = payload.metadata['subports'] @@ -226,14 +208,6 @@ def register(self, resource, event, trigger, payload=None): resource, event, trigger, payload=payload) self._handler = OVNTrunkHandler(self.plugin_driver) - registry.subscribe( - self._handler.trunk_created_precommit, - resources.TRUNK, - events.PRECOMMIT_CREATE) - registry.subscribe( - self._handler.trunk_deleted_precommit, - resources.TRUNK, - events.PRECOMMIT_DELETE) registry.subscribe( self._handler.trunk_created, resources.TRUNK, events.AFTER_CREATE) registry.subscribe( diff --git a/neutron/services/trunk/plugin.py b/neutron/services/trunk/plugin.py index d19515777db..edb98ef5dea 100644 --- a/neutron/services/trunk/plugin.py +++ b/neutron/services/trunk/plugin.py @@ -294,7 +294,6 @@ def delete_trunk(self, context, trunk_id): trunk = self._get_trunk(context, trunk_id) rules.trunk_can_be_managed(context, trunk) trunk_port_validator = rules.TrunkPortValidator(trunk.port_id) - parent_port = trunk.db_obj.port if trunk_port_validator.can_be_trunked_or_untrunked(context): # NOTE(status_police): when a trunk is deleted, the logical # object disappears from the datastore, therefore there is no @@ -308,7 +307,7 @@ def delete_trunk(self, context, trunk_id): 'deleting trunk port %s: %s', trunk_id, str(e)) payload = events.DBEventPayload(context, resource_id=trunk_id, - states=(trunk, parent_port)) + states=(trunk,)) registry.publish(resources.TRUNK, events.PRECOMMIT_DELETE, self, payload=payload) else: @@ -318,7 +317,7 @@ def delete_trunk(self, context, trunk_id): registry.publish(resources.TRUNK, events.AFTER_DELETE, self, payload=events.DBEventPayload( context, resource_id=trunk_id, - states=(trunk, parent_port))) + states=(trunk,))) @db_base_plugin_common.convert_result_to_dict def add_subports(self, context, trunk_id, subports): diff --git a/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py b/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py index fa281232c42..39d5eb6cd6e 100644 --- a/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py +++ b/neutron/tests/functional/services/trunk/drivers/ovn/test_trunk_driver.py @@ -14,8 +14,6 @@ import contextlib -from neutron_lib.api.definitions import portbindings -from neutron_lib.callbacks import exceptions as n_exc from neutron_lib import constants as n_consts from neutron_lib.objects import registry as obj_reg from neutron_lib.plugins import utils @@ -23,7 +21,6 @@ from oslo_utils import uuidutils from neutron.common.ovn import constants as ovn_const -from neutron.objects import ports as port_obj from neutron.services.trunk import plugin as trunk_plugin from neutron.tests.functional import base @@ -108,25 +105,6 @@ def test_trunk_create_with_subports(self): with self.trunk([subport]) as trunk: self._verify_trunk_info(trunk, has_items=True) - def test_trunk_create_parent_port_bound(self): - with self.network() as network: - with self.subnet(network=network) as subnet: - with self.port(subnet=subnet) as parent_port: - pb = port_obj.PortBinding.get_objects( - self.context, port_id=parent_port['port']['id']) - port_obj.PortBinding.update_object( - self.context, {'vif_type': portbindings.VIF_TYPE_OVS}, - port_id=pb[0].port_id, host=pb[0].host) - tenant_id = uuidutils.generate_uuid() - trunk = {'trunk': { - 'port_id': parent_port['port']['id'], - 'tenant_id': tenant_id, 'project_id': tenant_id, - 'admin_state_up': True, - 'name': 'trunk', 'sub_ports': []}} - self.assertRaises(n_exc.CallbackFailure, - self.trunk_plugin.create_trunk, - self.context, trunk) - def test_subport_add(self): with self.subport() as subport: with self.trunk() as trunk: @@ -149,14 +127,3 @@ def test_trunk_delete(self): with self.trunk() as trunk: self.trunk_plugin.delete_trunk(self.context, trunk['id']) self._verify_trunk_info({}, has_items=False) - - def test_trunk_delete_parent_port_bound(self): - with self.trunk() as trunk: - bp = port_obj.PortBinding.get_objects( - self.context, port_id=trunk['port_id']) - port_obj.PortBinding.update_object( - self.context, {'vif_type': portbindings.VIF_TYPE_OVS}, - port_id=bp[0].port_id, host=bp[0].host) - self.assertRaises(n_exc.CallbackFailure, - self.trunk_plugin.delete_trunk, - self.context, trunk['id']) diff --git a/neutron/tests/unit/db/test_l3_dvr_db.py b/neutron/tests/unit/db/test_l3_dvr_db.py index 6767127bc0d..78171d8366f 100644 --- a/neutron/tests/unit/db/test_l3_dvr_db.py +++ b/neutron/tests/unit/db/test_l3_dvr_db.py @@ -30,7 +30,6 @@ from neutron_lib.plugins import utils as plugin_utils from oslo_utils import uuidutils -from neutron.common import utils as n_utils from neutron.db import agents_db from neutron.db import l3_dvr_db from neutron.db import l3_dvrscheduler_db @@ -1511,7 +1510,7 @@ def test_is_router_distributed(self): self.assertTrue( self.mixin.is_router_distributed(self.ctx, router_id)) - @mock.patch.object(n_utils, 'is_port_bound') + @mock.patch.object(l3_dvr_db, "is_port_bound") def test_get_ports_under_dvr_connected_subnet(self, is_port_bound_mock): router_dict = {'name': 'test_router', 'admin_state_up': True, 'distributed': True} diff --git a/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py b/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py index b036778877e..f98e16556be 100644 --- a/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py +++ b/neutron/tests/unit/services/trunk/drivers/ovn/test_trunk_driver.py @@ -454,10 +454,6 @@ def test_register(self): with mock.patch.object(registry, 'subscribe') as mock_subscribe: driver.register(mock.ANY, mock.ANY, mock.Mock()) calls = [ - mock.call.mock_subscribe( - mock.ANY, resources.TRUNK, events.PRECOMMIT_CREATE), - mock.call.mock_subscribe( - mock.ANY, resources.TRUNK, events.PRECOMMIT_DELETE), mock.call.mock_subscribe( mock.ANY, resources.TRUNK, events.AFTER_CREATE), mock.call.mock_subscribe( diff --git a/neutron/tests/unit/services/trunk/test_plugin.py b/neutron/tests/unit/services/trunk/test_plugin.py index 271c16e66db..f2c26cdcef3 100644 --- a/neutron/tests/unit/services/trunk/test_plugin.py +++ b/neutron/tests/unit/services/trunk/test_plugin.py @@ -162,8 +162,7 @@ def _test_trunk_delete_notify(self, event): resources.TRUNK, event, self.trunk_plugin, payload=mock.ANY) payload = callback.mock_calls[0][2]['payload'] self.assertEqual(self.context, payload.context) - self.assertEqual(trunk_obj, payload.states[0]) - self.assertEqual(parent_port['port']['id'], payload.states[1].id) + self.assertEqual(trunk_obj, payload.latest_state) self.assertEqual(trunk['id'], payload.resource_id) def test_delete_trunk_notify_after_delete(self): diff --git a/releasenotes/notes/ovn-trunk-check-parent-port-eeca2eceaca9d158.yaml b/releasenotes/notes/ovn-trunk-check-parent-port-eeca2eceaca9d158.yaml deleted file mode 100644 index 111dc99ca12..00000000000 --- a/releasenotes/notes/ovn-trunk-check-parent-port-eeca2eceaca9d158.yaml +++ /dev/null @@ -1,6 +0,0 @@ ---- -fixes: - - | - Now the ML2/OVN trunk driver prevents a trunk creation if the parent port - is already bound. In the same way, if a parent port being used in a trunk - is bound, the trunk cannot be deleted. From f7e9b2e5b31f9275036e644e13365ad78bb17b2b Mon Sep 17 00:00:00 2001 From: Miro Tomaska Date: Fri, 7 Jun 2024 19:14:00 +0000 Subject: [PATCH 015/184] Revert "Use HasStandardAttributes as parent class for Tags DB model" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 85d3fff97e55ba85f72cda4365ad0441c10bd9f6. Reason for revert: The original change was made as a “cheap win” to optimize the number of queries the neutron server makes during testing. This did improve the number of queries made but introduced regression in real world deployments where some customers(through automation) would define hundreds of tags per port across a large deployment. I am proposing to revert this change in favor of the old “subquery” relation in order to fix this regression. In addition, I filed the Related-Bug #2069061 to investigate using `selectin` as the more appropriate long term solution. Change-Id: I83ec349e49e1f343da8996cab149d76443120873 Closes-bug: #2068761 Related-Bug: #2069061 --- neutron/db/models/tag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/db/models/tag.py b/neutron/db/models/tag.py index 14e680fccc7..55195340d03 100644 --- a/neutron/db/models/tag.py +++ b/neutron/db/models/tag.py @@ -26,6 +26,6 @@ class Tag(model_base.BASEV2): tag = sa.Column(sa.String(255), nullable=False, primary_key=True) standard_attr = orm.relationship( 'StandardAttribute', load_on_pending=True, - backref=orm.backref('tags', lazy='joined', viewonly=True), + backref=orm.backref('tags', lazy='subquery', viewonly=True), sync_backref=False) revises_on_change = ('standard_attr', ) From 1098929a5446d5f4e535e7972fb972a371d22292 Mon Sep 17 00:00:00 2001 From: Michel Nederlof Date: Mon, 10 Jun 2024 12:18:46 +0200 Subject: [PATCH 016/184] [OVN] Fix virtual parent match for PortBindingUpdateVirtualPortsEvent As mentioned in change [1], the condition should be a `is None` as per inline comment. [1] https://review.opendev.org/c/openstack/neutron/+/896883 Related-Bug: #2038413 Change-Id: I3666cf0509747863ca2a416c8bfc065582573734 (cherry picked from commit 170d99f2d53f77d4c66e505f310fd9d8f3481149) --- .../ovn/mech_driver/ovsdb/ovsdb_monitor.py | 2 +- .../mech_driver/ovsdb/test_ovsdb_monitor.py | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py index 56b1b1923ff..81a1ca3b0d1 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py @@ -568,7 +568,7 @@ def match_fn(self, event, row, old): # which means we need to update the host_id information return True - if getattr(old, 'options', None) is not None: + if getattr(old, 'options', None) is None: # The "old.options" dictionary is not being modified, # thus the virtual parents didn't change. return False diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py index 0bf92fc6ef9..f0c0e82e418 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py @@ -329,6 +329,65 @@ def test_event_matches(self): 'type': '_fake_'})) +class TestPortBindingUpdateVirtualPortsEvent(base.BaseTestCase): + def setUp(self): + super().setUp() + self.event = ovsdb_monitor.PortBindingUpdateVirtualPortsEvent(None) + + self.pbtable = fakes.FakeOvsdbTable.create_one_ovsdb_table( + attrs={'name': 'Port_Binding'}) + self.ovsdb_row = fakes.FakeOvsdbRow.create_one_ovsdb_row + + self.row = self.ovsdb_row( + attrs={'_table': self.pbtable, + 'chassis': 'newchassis', + 'options': { + 'virtual-parents': 'uuid1,uuid2'}}) + + def test_delete_event_matches(self): + # Delete event (only type virtual). + self.assertFalse(self.event.match_fn( + self.event.ROW_DELETE, + self.ovsdb_row(attrs={'_table': self.pbtable, 'type': '_fake_'}), + None)) + self.assertTrue(self.event.match_fn( + self.event.ROW_DELETE, + self.ovsdb_row(attrs={'_table': self.pbtable, 'type': 'virtual'}), + None)) + + def test_event_no_match_no_options(self): + # Unrelated portbind change (no options in old, so no virtual parents) + self.assertFalse(self.event.match_fn( + self.event.ROW_UPDATE, self.row, + self.ovsdb_row(attrs={'_table': self.pbtable, + 'name': 'somename'}))) + + def test_event_no_match_other_options_change(self): + # Non-virtual parent change, no chassis has changed + old = self.ovsdb_row(attrs={'_table': self.pbtable, + 'options': { + 'virtual-parents': 'uuid1,uuid2', + 'other-opt': '_fake_'}}) + + self.assertFalse(self.event.match_fn(self.event.ROW_UPDATE, + self.row, old)) + + def test_event_match_chassis_change(self): + # Port binding change (chassis changed, and marked in old) + self.assertTrue(self.event.match_fn( + self.event.ROW_UPDATE, self.row, + self.ovsdb_row(attrs={'_table': self.pbtable, + 'chassis': 'fakechassis'}))) + + def test_event_match_virtual_parent_change(self): + # Virtual parent change + old = self.ovsdb_row(attrs={'_table': self.pbtable, + 'options': { + 'virtual-parents': 'uuid1,uuid3'}}) + self.assertTrue(self.event.match_fn(self.event.ROW_UPDATE, + self.row, old)) + + class TestOvnNbIdlNotifyHandler(test_mech_driver.OVNMechanismDriverTestCase): def setUp(self): From f0901c75e2a47043101fa77aca6672ecc1213520 Mon Sep 17 00:00:00 2001 From: Fernando Royo Date: Tue, 11 Jun 2024 14:22:18 +0200 Subject: [PATCH 017/184] [OVN] Bump revision number after update_virtual_port_host This patch adds bump revision after updating the hostname of a virtual port (more specifically its associated port). This way there is no misalignment between the revision number of Neutron DB and OVN DB. It also avoids the unnecessary execution of the maintenance task to simply match the revision_number. Closes-Bug: #2069046 Change-Id: I2734984f10341ab97ebbdee11389d214bb1150f3 (cherry picked from commit f210a904793b585dafea8085ed62e06f3fed2e6e) --- .../drivers/ovn/mech_driver/mech_driver.py | 12 ++++++-- .../mech_driver/ovsdb/test_ovsdb_monitor.py | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index 35b65e2c8a8..174c94576d9 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -1081,9 +1081,11 @@ def update_virtual_port_host(self, port_id, chassis_id): hostname = '' # Updates neutron database with hostname for virtual port - self._plugin.update_virtual_port_host(n_context.get_admin_context(), - port_id, hostname) - + context = n_context.get_admin_context() + self._plugin.update_virtual_port_host(context, port_id, hostname) + db_port = self._plugin.get_port(context, port_id) + check_rev_cmd = self.nb_ovn.check_revision_number( + port_id, db_port, ovn_const.TYPE_PORTS) # Updates OVN NB database with hostname for lsp virtual port with self.nb_ovn.transaction(check_error=True) as txn: ext_ids = ('external_ids', @@ -1091,6 +1093,10 @@ def update_virtual_port_host(self, port_id, chassis_id): txn.add( self.nb_ovn.db_set( 'Logical_Switch_Port', port_id, ext_ids)) + txn.add(check_rev_cmd) + if check_rev_cmd.result == ovn_const.TXN_COMMITTED: + ovn_revision_numbers_db.bump_revision(context, db_port, + ovn_const.TYPE_PORTS) def get_workers(self): """Get any worker instances that should have their own process diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py index 495c77cec25..f8f3522381a 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py @@ -450,6 +450,30 @@ def _check_port_host_set(self, port_id, host_id): # Check that both neutron and ovn are the same as given host_id return port[portbindings.HOST_ID] == host_id == ovn_host_id + def _check_port_and_port_binding_revision_number(self, port_id): + + def is_port_and_port_binding_same_revision_number(port_id): + # This function checks if given port matches the revision_number + # in the neutron DB as well as in the OVN DB for the port_binding + core_plugin = directory.get_plugin() + + # Get port from neutron DB + port = core_plugin.get_ports( + self.context, filters={'id': [port_id]})[0] + + # Get port binding from OVN DB + bp = self._find_port_binding(port_id) + ovn_port_binding_revision_number = bp.external_ids.get( + ovn_const.OVN_REV_NUM_EXT_ID_KEY, ovn_const.INITIAL_REV_NUM) + + # Check that both neutron and ovn are the same as given host_id + return port['revision_number'] == int( + ovn_port_binding_revision_number) + + check = functools.partial( + is_port_and_port_binding_same_revision_number,port_id) + n_utils.wait_until_true(check, timeout=10) + def test_virtual_port_host_update_upon_failover(self): # NOTE: we can't simulate traffic, but we can simulate the event that # would've been triggered by OVN, which is what we do. @@ -469,6 +493,7 @@ def test_virtual_port_host_update_upon_failover(self): vip_address = vip['fixed_ips'][0]['ip_address'] allowed_address_pairs = [{'ip_address': vip_address}] self._check_port_binding_type(vip['id'], '') + self._check_port_and_port_binding_revision_number(vip['id']) # 3) Create two ports with the allowed address pairs set. hosts = ('ovs-host1', second_chassis_name) @@ -485,6 +510,7 @@ def test_virtual_port_host_update_upon_failover(self): # have been assigned to the port binding self._check_port_binding_type(vip['id'], ovn_const.LSP_TYPE_VIRTUAL) self._check_port_virtual_parents(vip['id'], ','.join(port_ids)) + self._check_port_and_port_binding_revision_number(vip['id']) # 5) Bind the ports to a host, so a chassis is bound, which is # required for the update_virtual_port_host method. Without this @@ -492,11 +518,13 @@ def test_virtual_port_host_update_upon_failover(self): self._test_port_binding_and_status(ports[0]['id'], 'bind', 'ACTIVE') self.chassis = second_chassis self._test_port_binding_and_status(ports[1]['id'], 'bind', 'ACTIVE') + self._check_port_and_port_binding_revision_number(vip['id']) # 6) For both ports, bind vip on parent and check hostname in DBs for idx in range(len(ports)): # Set port binding to the first port, and update the chassis self._set_port_binding_virtual_parent(vip['id'], ports[idx]['id']) + self._check_port_and_port_binding_revision_number(vip['id']) # Check if the host_id has been updated in OVN and DB # by the event that eventually calls for method @@ -504,6 +532,7 @@ def test_virtual_port_host_update_upon_failover(self): n_utils.wait_until_true( lambda: self._check_port_host_set(vip['id'], hosts[idx]), timeout=10) + self._check_port_and_port_binding_revision_number(vip['id']) class TestNBDbMonitorOverTcp(TestNBDbMonitor): From f94f8b63842099904ff88906e8221a366a809f99 Mon Sep 17 00:00:00 2001 From: LIU Yulong Date: Thu, 27 Jan 2022 17:01:43 +0800 Subject: [PATCH 018/184] Add a default goto table=94 for openvswitch fw If enable explicitly_egress_direct=True and set port as no security group and port_security=False, the ingress flood will reappear. The pipleline is: Ingress table_0 -> table_60 -> NORMAL -> VM Egress table_0 -> ... -> table_94 -> output Because ingress final action is normal, the br-int will learn the source MAC, but egress final action is output. So VM's mac will never be learnt by the br-int. Then ingress flood comes again. This patch adds a default direct flow to table 94 during the openflow security group init and explicitly_egress_direct=True, then the pipleline will be: Ingress table_0 -> table_60 -> table_94 -> output VM Egress table_0 -> ... -> table_94 -> output And this patch adds the flows coming from patch port which will match local vlan then go to table 94 do the same direct actions. Above flood issue will be addressed by these flows. Closes-Bug: #2051351 Change-Id: Ia61784174ee610b338f26660b2954330abc131a1 (cherry picked from commit d6f56c5f96c42e1682f3d1723a65253429778c20) --- .../internals/openvswitch_firewall.rst | 13 ++++++++++ .../linux/openvswitch_firewall/firewall.py | 24 +++++++++++++++++++ neutron/conf/plugins/ml2/drivers/ovs_conf.py | 6 ++++- .../agent/openflow/native/br_int.py | 2 +- .../openvswitch_firewall/test_firewall.py | 7 +++++- .../agent/openflow/native/test_br_int.py | 2 +- 6 files changed, 50 insertions(+), 4 deletions(-) diff --git a/doc/source/contributor/internals/openvswitch_firewall.rst b/doc/source/contributor/internals/openvswitch_firewall.rst index 8db8ee0837e..5bcefcef002 100644 --- a/doc/source/contributor/internals/openvswitch_firewall.rst +++ b/doc/source/contributor/internals/openvswitch_firewall.rst @@ -525,6 +525,19 @@ will be: table=94, priority=10,reg6=0x284,dl_src=fa:16:3e:24:57:c7,dl_dst=00:00:00:00:00:00/01:00:00:00:00:00 actions=push_vlan:0x8100,set_field:0x1->vlan_vid,output:3 table=94, priority=1 actions=NORMAL +The OVS firewall will initialize a default goto table 94 flow +on TRANSIENT_TABLE |table_60|, if ``explicitly_egress_direct`` +is set to True, which is mainly for ports without security groups +and disabled port_security. For instance: + +:: + table=60, priority=2 actions=resubmit(,94) + +Then for packets from the outside to VM without security functionalities +(--disable-port-security --no-security-group) +will go to table 94 and do the same direct actions. + + OVS firewall integration points ------------------------------- diff --git a/neutron/agent/linux/openvswitch_firewall/firewall.py b/neutron/agent/linux/openvswitch_firewall/firewall.py index 4bc8fe9c39d..8e7c3f5df8e 100644 --- a/neutron/agent/linux/openvswitch_firewall/firewall.py +++ b/neutron/agent/linux/openvswitch_firewall/firewall.py @@ -646,6 +646,14 @@ def _initialize_common_flows(self): 'resubmit(,%d)' % ovs_consts.BASE_EGRESS_TABLE, ) + if cfg.CONF.AGENT.explicitly_egress_direct: + self._add_flow( + table=ovs_consts.TRANSIENT_TABLE, + priority=2, + actions='resubmit(,%d)' % ( + ovs_consts.ACCEPTED_EGRESS_TRAFFIC_NORMAL_TABLE) + ) + def _initialize_third_party_tables(self): self.int_br.br.add_flow( table=ovs_consts.ACCEPTED_EGRESS_TRAFFIC_NORMAL_TABLE, @@ -1255,6 +1263,7 @@ def install_accepted_egress_direct_flow(self, mac, vlan_tag, dst_port, return # Prevent flood for accepted egress traffic + # For packets from internal ports or VM ports. self._add_flow( flow_group_id=dst_port, table=ovs_consts.ACCEPTED_EGRESS_TRAFFIC_NORMAL_TABLE, @@ -1263,6 +1272,15 @@ def install_accepted_egress_direct_flow(self, mac, vlan_tag, dst_port, reg_net=vlan_tag, actions='output:{:d}'.format(dst_port) ) + # For packets from patch ports. + self._add_flow( + flow_group_id=dst_port, + table=ovs_consts.ACCEPTED_EGRESS_TRAFFIC_NORMAL_TABLE, + priority=12, + dl_dst=mac, + dl_vlan=vlan_tag, + actions='strip_vlan,output:{:d}'.format(dst_port) + ) # The former flow may not match, that means the destination port is # not in this host. So, we direct the packet to mapped bridge(s). @@ -1311,6 +1329,12 @@ def delete_accepted_egress_direct_flow(self, mac, vlan_tag): dl_src=mac, reg_net=vlan_tag) + self._delete_flows( + table=ovs_consts.ACCEPTED_EGRESS_TRAFFIC_NORMAL_TABLE, + dl_dst=mac, + dl_vlan=vlan_tag + ) + def _initialize_tracked_egress(self, port): # Drop invalid packets self._add_flow( diff --git a/neutron/conf/plugins/ml2/drivers/ovs_conf.py b/neutron/conf/plugins/ml2/drivers/ovs_conf.py index c05a895b38f..8cf3075d3aa 100644 --- a/neutron/conf/plugins/ml2/drivers/ovs_conf.py +++ b/neutron/conf/plugins/ml2/drivers/ovs_conf.py @@ -222,12 +222,16 @@ "outgoing IP packet carrying GRE/VXLAN tunnel.")), cfg.BoolOpt('baremetal_smartnic', default=False, help=_("Enable the agent to process Smart NIC ports.")), + # TODO(liuyulong): consider adding a new configuration + # item to control ingress behavior. cfg.BoolOpt('explicitly_egress_direct', default=False, help=_("When set to True, the accepted egress unicast " "traffic will not use action NORMAL. The accepted " "egress packets will be taken care of in the final " "egress tables direct output flows for unicast " - "traffic.")), + "traffic. This will aslo change the pipleline for " + "ingress traffic to ports without security, the final " + "output action will be hit in table 94. ")), ] dhcp_opts = [ diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py index 181ae62d3c8..550c5a1c05e 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/openflow/native/br_int.py @@ -64,7 +64,7 @@ def setup_default_table(self, enable_openflow_dhcp=False, self.install_goto(dest_table_id=PACKET_RATE_LIMIT) self.install_goto(dest_table_id=constants.TRANSIENT_TABLE, table_id=PACKET_RATE_LIMIT) - self.install_normal(table_id=constants.TRANSIENT_TABLE, priority=3) + self.install_normal(table_id=constants.TRANSIENT_TABLE, priority=1) self.init_dhcp(enable_openflow_dhcp=enable_openflow_dhcp, enable_dhcpv6=enable_dhcpv6) self.install_drop(table_id=constants.ARP_SPOOF_TABLE) diff --git a/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py b/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py index af1fbe69b2e..ee3359e98be 100644 --- a/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py +++ b/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py @@ -919,8 +919,13 @@ def test_delete_all_port_flows(self): "reg6": port.vlan_tag} flow7 = mock.call(**call_args7) + call_args8 = {"table": ovs_consts.ACCEPTED_EGRESS_TRAFFIC_NORMAL_TABLE, + "dl_dst": port.mac, + "dl_vlan": port.vlan_tag} + flow8 = mock.call(**call_args8) + self.mock_bridge.br.delete_flows.assert_has_calls( - [flow1, flow2, flow3, flow6, flow7, flow4, flow5]) + [flow1, flow2, flow3, flow6, flow7, flow8, flow4, flow5]) def test_prepare_port_filter_initialized_port(self): port_dict = {'device': 'port-id', diff --git a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py index 5c4be724397..400895ea359 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py +++ b/neutron/tests/unit/plugins/ml2/drivers/openvswitch/agent/openflow/native/test_br_int.py @@ -75,7 +75,7 @@ def test_setup_default_table(self): ]), ], match=ofpp.OFPMatch(), - priority=3, + priority=1, table_id=ovs_constants.TRANSIENT_TABLE), active_bundle=None), call._send_msg(ofpp.OFPFlowMod(dp, From 7e36795563aa4345b41308eb08abc6f18fe04e65 Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Tue, 18 Jun 2024 19:08:14 +0530 Subject: [PATCH 019/184] [FT] Run test_periodic_sync_routers_task tests serially It's seen that these tests interfered(removed router namespace) with test_metadata_proxy_rate_limiting_ipv6 but can interfere with others too, let's run these serially to avoid random failures. Following tests will run serially now:- - test_periodic_sync_routers_task - test_periodic_sync_routers_task_routers_deleted_while_agent_down - test_periodic_sync_routers_task_routers_deleted_while_agent_sync Closes-Bug: #2069744 Change-Id: I34598cb9ad39c96f5e46d98af1185992c5eb3446 (cherry picked from commit bf82263df027a5c5213422feb12eefd6de9fa867) --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 94a1cfcd6f1..49cb4f89007 100644 --- a/tox.ini +++ b/tox.ini @@ -78,8 +78,8 @@ setenv = {[testenv:dsvm-functional]setenv} deps = {[testenv:dsvm-functional]deps} commands = bash {toxinidir}/tools/deploy_rootwrap.sh {toxinidir} {envdir}/etc {envdir}/bin - stestr run --slowest --exclude-regex (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.) {posargs} - stestr run --slowest --combine --concurrency 1 (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.) {posargs} + stestr run --slowest --exclude-regex (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task) {posargs} + stestr run --slowest --combine --concurrency 1 (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task) {posargs} [testenv:dsvm-fullstack] setenv = {[testenv]setenv} From 3d97c4f2ac34ee2402d1067d6828f5f3e53e713c Mon Sep 17 00:00:00 2001 From: Lucas Alvares Gomes Date: Mon, 17 Jun 2024 13:53:04 +0100 Subject: [PATCH 020/184] [OVN] Sanitize the classless-static-route DHCP option This patch ensures that the "classless-static-route" is wrapped in {} as expected by OVN and also merges the default routes with the user inputted ones so everything works as expected. Closes-Bug: #2069625 Change-Id: I302a872161c55df447a05b31d99c702537502a2f Signed-off-by: Lucas Alvares Gomes (cherry picked from commit ceee380a1835d706579aa0f3597ad9e0ce1a37ee) --- neutron/common/ovn/constants.py | 4 ++ neutron/common/ovn/utils.py | 7 ++++ .../ovn/mech_driver/ovsdb/ovn_client.py | 18 +++++++++ neutron/tests/unit/common/ovn/test_utils.py | 14 +++++++ .../ovn/mech_driver/test_mech_driver.py | 38 +++++++++++++++++++ 5 files changed, 81 insertions(+) diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index 82f0bef7438..6f29ba78e3b 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -217,6 +217,10 @@ 'wpad', 'tftp_server'] +OVN_MAP_TYPE_DHCP_OPTS = [ + 'classless_static_route', +] + # Special option for disabling DHCP via extra DHCP options DHCP_DISABLED_OPT = 'dhcp_disabled' diff --git a/neutron/common/ovn/utils.py b/neutron/common/ovn/utils.py index f4f1afdda65..7550c030cf1 100644 --- a/neutron/common/ovn/utils.py +++ b/neutron/common/ovn/utils.py @@ -250,6 +250,10 @@ def is_dhcp_option_quoted(opt_value): return opt_value.startswith('"') and opt_value.endswith('"') +def is_dhcp_option_a_map(opt_value): + return opt_value.startswith('{') and opt_value.endswith('}') + + def get_lsp_dhcp_opts(port, ip_version): # Get dhcp options from Neutron port, for setting DHCP_Options row # in OVN. @@ -288,6 +292,9 @@ def get_lsp_dhcp_opts(port, ip_version): if (opt in constants.OVN_STR_TYPE_DHCP_OPTS and not is_dhcp_option_quoted(edo['opt_value'])): edo['opt_value'] = '"%s"' % edo['opt_value'] + elif (opt in constants.OVN_MAP_TYPE_DHCP_OPTS and + not is_dhcp_option_a_map(edo['opt_value'])): + edo['opt_value'] = '{%s}' % edo['opt_value'] lsp_dhcp_opts[opt] = edo['opt_value'] return (lsp_dhcp_disabled, lsp_dhcp_opts) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 65ee4d7d8af..4fb2f725554 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -194,6 +194,18 @@ def _get_subnet_dhcp_options_for_port(self, port, ip_version): return opts return get_opts[0] + def _merge_map_dhcp_option(self, opt, port_opts, subnet_opts): + """Merge a port and subnet map DHCP option. + + If a DHCP option exists in both port and subnet, the port + should inherit the values from the subnet. + """ + port_opt = port_opts[opt] + subnet_opt = subnet_opts.get(opt) + if not subnet_opt: + return port_opt + return '{%s, %s}' % (subnet_opt[1:-1], port_opt[1:-1]) + def _get_port_dhcp_options(self, port, ip_version): """Return dhcp options for port. @@ -226,6 +238,12 @@ def _get_port_dhcp_options(self, port, ip_version): if not lsp_dhcp_opts: return subnet_dhcp_options + # Check for map DHCP options + for opt in ovn_const.OVN_MAP_TYPE_DHCP_OPTS: + if opt in lsp_dhcp_opts: + lsp_dhcp_opts[opt] = self._merge_map_dhcp_option( + opt, lsp_dhcp_opts, subnet_dhcp_options['options']) + # This port has extra DHCP options defined, so we will create a new # row in DHCP_Options table for it. subnet_dhcp_options['options'].update(lsp_dhcp_opts) diff --git a/neutron/tests/unit/common/ovn/test_utils.py b/neutron/tests/unit/common/ovn/test_utils.py index 30b435d072a..e0821d682f9 100644 --- a/neutron/tests/unit/common/ovn/test_utils.py +++ b/neutron/tests/unit/common/ovn/test_utils.py @@ -521,6 +521,20 @@ def test_get_lsp_dhcp_opts_for_domain_search(self): expected_options = {'domain_search_list': '"openstack.org,ovn.org"'} self.assertEqual(expected_options, options) + def test_get_lsp_dhcp_opts_sanitize_map(self): + opt = {'opt_name': 'classless-static-route', + 'opt_value': '128.128.128.128/32,22.2.0.2', + 'ip_version': 4} + port = {portbindings.VNIC_TYPE: portbindings.VNIC_NORMAL, + edo_ext.EXTRADHCPOPTS: [opt]} + dhcp_disabled, options = utils.get_lsp_dhcp_opts(port, 4) + self.assertFalse(dhcp_disabled) + # Assert option got translated to "classless_static_route" and + # the value is a map (wrapped with {}) + expected_options = { + 'classless_static_route': '{128.128.128.128/32,22.2.0.2}'} + self.assertEqual(expected_options, options) + class TestGetDhcpDnsServers(base.BaseTestCase): diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 78bd97ce1fb..c913ddbeaf3 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -4009,6 +4009,44 @@ def test__get_subnet_dhcp_options_for_port_v6_dhcp_disabled(self): self._test__get_subnet_dhcp_options_for_port(ip_version=6, enable_dhcp=False) + def test_get_port_dhcp_options_classless_static_route(self): + port = { + 'id': 'foo-port', + 'device_owner': 'compute:None', + 'fixed_ips': [{'subnet_id': 'foo-subnet', + 'ip_address': '10.0.0.11'}], + 'extra_dhcp_opts': [ + {'ip_version': 4, 'opt_name': 'classless-static-route', + 'opt_value': '128.128.128.128/32,22.2.0.2'}]} + + self.mech_driver._ovn_client._get_subnet_dhcp_options_for_port = ( + mock.Mock( + return_value=({ + 'cidr': '10.0.0.0/24', + 'external_ids': {'subnet_id': 'foo-subnet'}, + 'options': { + 'classless_static_route': + '{169.254.169.254/32,10.0.0.2}',}, + 'uuid': 'foo-uuid'}))) + + # Expect both the subnet and port classless_static_route + # to be merged + expected_routes = ('{169.254.169.254/32,10.0.0.2, ' + '128.128.128.128/32,22.2.0.2}') + expected_dhcp_options = { + 'cidr': '10.0.0.0/24', + 'external_ids': {'subnet_id': 'foo-subnet', + 'port_id': 'foo-port'}, + 'options': {'classless_static_route': expected_routes} + } + + self.mech_driver.nb_ovn.add_dhcp_options.return_value = 'foo-val' + dhcp_options = self.mech_driver._ovn_client._get_port_dhcp_options( + port, 4) + self.assertEqual({'cmd': 'foo-val'}, dhcp_options) + self.mech_driver.nb_ovn.add_dhcp_options.assert_called_once_with( + 'foo-subnet', port_id='foo-port', **expected_dhcp_options) + class TestOVNMechanismDriverSecurityGroup(MechDriverSetupBase, test_security_group.Ml2SecurityGroupsTestCase): From 346d433aa8223fbbd7beba828fc338844bf7036c Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Wed, 3 Jul 2024 09:27:09 -0500 Subject: [PATCH 021/184] Return empty BpInfo if missing binding:profile https://review.opendev.org/c/openstack/neutron/+/867359 inadvertently dropped a return when binding:profile was missing, making it possible to hit a KeyError when trying to access port["binding:profile"]. This was seen in the Maintenance thread after adding a port. Fixes: b6750fb2b8 Closes-Bug: #2071822 Change-Id: I232daa2905904d464ddf84e66e857f8b1f08e941 (cherry picked from commit e5a8829c565755e4c7d4e8b2d52536234c90d8b4) --- neutron/common/ovn/utils.py | 2 +- neutron/tests/unit/common/ovn/test_utils.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/neutron/common/ovn/utils.py b/neutron/common/ovn/utils.py index 7550c030cf1..4beb073d31d 100644 --- a/neutron/common/ovn/utils.py +++ b/neutron/common/ovn/utils.py @@ -368,7 +368,7 @@ def validate_and_get_data_from_binding_profile(port): if (constants.OVN_PORT_BINDING_PROFILE not in port or not validators.is_attr_set( port[constants.OVN_PORT_BINDING_PROFILE])): - BPInfo({}, None, []) + return BPInfo({}, None, []) param_set = {} param_dict = {} vnic_type = port.get(portbindings.VNIC_TYPE, portbindings.VNIC_NORMAL) diff --git a/neutron/tests/unit/common/ovn/test_utils.py b/neutron/tests/unit/common/ovn/test_utils.py index e0821d682f9..f5393761f07 100644 --- a/neutron/tests/unit/common/ovn/test_utils.py +++ b/neutron/tests/unit/common/ovn/test_utils.py @@ -756,6 +756,11 @@ def test_valid_input_surplus_keys(self): {portbindings.VNIC_TYPE: portbindings.VNIC_DIRECT, constants.OVN_PORT_BINDING_PROFILE: binding_profile})) + def test_valid_input_no_binding_profile(self): + # Confirm that we treat a port without binding:profile as valid + self.assertEqual(utils.BPInfo({}, None, []), + utils.validate_and_get_data_from_binding_profile({})) + def test_unknown_profile_items_pruned(self): # Confirm that unknown profile items are pruned self.assertEqual( From f25cc2f503573e2288b61e262bcc3900c62c1a04 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 2 Jul 2024 07:29:44 +0000 Subject: [PATCH 022/184] Reorder subnet RBAC policy check strings The subnet policy rule ``ADMIN_OR_NET_OWNER_MEMBER`` requires to retrieve the network object from the database to read the project ID. When retrieving a list of subnets, this operation can slow down the API call. This patch is reordering the subnet RBAC policy checks to make this check at the end. As reported in the related LP bug, it is usual to have a "creator" project where different resources are created and then shared to others; in this case networks and subnets. All these subnets will belong to the same project. If a non-admin user from this project list all the subnets, with the code before to this patch it would be needed to retrieve all the networks to read the project ID. With the current code it is needed only to check that the user is a project reader. The following benchmark has been done in a VM running a standalone OpenStack deployment. One project has created 400 networks and 400 subnets (one per network). Each network has been shared with another project. API time to process "GET /networking/v2.0/subnets": * Without this patch: 5.5 seconds (average) * With this patch: 0.25 seconds (average) Related-Bug: #2071374 Related-Bug: #2037107 Change-Id: Ibca174213bba3c56fc18ec2732d80054ac95e859 (cherry picked from commit 729920da5e836fa7a27b1b85b3b2999146d905ba) --- neutron/conf/policies/subnet.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/neutron/conf/policies/subnet.py b/neutron/conf/policies/subnet.py index c84080605a0..5052e674763 100644 --- a/neutron/conf/policies/subnet.py +++ b/neutron/conf/policies/subnet.py @@ -95,17 +95,19 @@ policy.DocumentedRuleDefault( name='get_subnet', check_str=neutron_policy.policy_or( - base.ADMIN_OR_NET_OWNER_MEMBER, base.PROJECT_READER, - 'rule:shared'), + 'rule:shared', + base.ADMIN_OR_NET_OWNER_MEMBER, + ), scope_types=['project'], description='Get a subnet', operations=ACTION_GET, deprecated_rule=policy.DeprecatedRule( name='get_subnet', check_str=neutron_policy.policy_or( + 'rule:shared', neutron_policy.RULE_ADMIN_OR_OWNER, - 'rule:shared'), + ), deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY) ), @@ -124,9 +126,10 @@ policy.DocumentedRuleDefault( name='get_subnets_tags', check_str=neutron_policy.policy_or( - base.ADMIN_OR_NET_OWNER_MEMBER, base.PROJECT_READER, - 'rule:shared'), + 'rule:shared', + base.ADMIN_OR_NET_OWNER_MEMBER, + ), scope_types=['project'], description='Get the subnet tags', operations=ACTION_GET_TAGS, @@ -134,8 +137,8 @@ policy.DocumentedRuleDefault( name='update_subnet', check_str=neutron_policy.policy_or( - base.ADMIN_OR_NET_OWNER_MEMBER, - base.PROJECT_MEMBER), + base.PROJECT_MEMBER, + base.ADMIN_OR_NET_OWNER_MEMBER), scope_types=['project'], description='Update a subnet', operations=ACTION_PUT, @@ -172,8 +175,9 @@ policy.DocumentedRuleDefault( name='update_subnets_tags', check_str=neutron_policy.policy_or( + base.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, - base.PROJECT_MEMBER), + ), scope_types=['project'], description='Update the subnet tags', operations=ACTION_PUT_TAGS, @@ -181,8 +185,9 @@ policy.DocumentedRuleDefault( name='delete_subnet', check_str=neutron_policy.policy_or( + base.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, - base.PROJECT_MEMBER), + ), scope_types=['project'], description='Delete a subnet', operations=ACTION_DELETE, @@ -195,8 +200,9 @@ policy.DocumentedRuleDefault( name='delete_subnets_tags', check_str=neutron_policy.policy_or( + base.PROJECT_MEMBER, base.ADMIN_OR_NET_OWNER_MEMBER, - base.PROJECT_MEMBER), + ), scope_types=['project'], description='Delete the subnet tags', operations=ACTION_DELETE_TAGS, From 4c70d3ea61cf6f3580e7b5e9cfbe69b2e976e810 Mon Sep 17 00:00:00 2001 From: Roberto Bartzen Acosta Date: Thu, 27 Jun 2024 18:29:37 +0000 Subject: [PATCH 023/184] Change to use selectin for RBACs in SubnetPool DB load strategy To solve a performance issue when using network rbacs with thousands of entries in the subnets, networks, and networks rbacs tables, it's necessary to change the eager loader strategy to not create and process a "cartesian" product of thousands of unnecessary combinatios for the purpose of the relationship included between rbac rules and subnetpool database model. We don't need a many-to-many relationship here. So, we can use the selectin eager loading to make this relationship one-to-many and create the model with only the necessary steps, without exploding into a thousands of rows caused by the "left outer join" cascade. The "total" queries from this process would be divided into a series of smaller queries with much better performance, and the resulting huge select query will be resolved much faster without joined cascade, representing significant performance gains. Closes-bug: #2071374 Change-Id: I2e4fa0ffd2ad091ab6928bdf0d440b082c37def2 (cherry picked from commit 46edf255bde0603fe88b2dd9f4e482590e384382) --- neutron/db/models_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/db/models_v2.py b/neutron/db/models_v2.py index 3b1f2ece796..2b28c1b6fd3 100644 --- a/neutron/db/models_v2.py +++ b/neutron/db/models_v2.py @@ -302,7 +302,7 @@ class SubnetPool(standard_attr.HasStandardAttributes, model_base.BASEV2, lazy='subquery') rbac_entries = sa.orm.relationship(rbac_db_models.SubnetPoolRBAC, backref='subnetpools', - lazy='joined', + lazy='selectin', cascade='all, delete, delete-orphan') api_collections = [subnetpool_def.COLLECTION_NAME] collection_resource_map = {subnetpool_def.COLLECTION_NAME: From 94d86ba8b895b2f223363268020390be387f2818 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 28 Jun 2024 12:20:37 +0000 Subject: [PATCH 024/184] Add the port "fixed_ips" information in the DHCP RPC In [1], a method to process the DHCP events in the correct order was implemented. That method checks the port events in order to match the "fixed_ips" field. That implies the Neutron server provides this information in the port event, sent via RPC. However in [2], the "fixed_ips" information was removed from the ``DhcpAgentNotifyAPI._after_router_interface_deleted``, causing a periodic error in the ``DHCPResourceUpdate.__lt__`` method, as reported in the LP bug. This patch is restoring this field in the RPC message. [1]https://review.opendev.org/c/openstack/neutron/+/773160 [2]https://review.opendev.org/c/openstack/neutron/+/639814 Closes-Bug: #2071426 Change-Id: If1362b9b91794e74e8cf6bb233e661fba9fb3b26 (cherry picked from commit b0081ac6c0eca93f7589f5c910d0f6385d83dd47) --- neutron/api/rpc/agentnotifiers/dhcp_rpc_agent_api.py | 1 + .../tests/unit/api/rpc/agentnotifiers/test_dhcp_rpc_agent_api.py | 1 + 2 files changed, 2 insertions(+) diff --git a/neutron/api/rpc/agentnotifiers/dhcp_rpc_agent_api.py b/neutron/api/rpc/agentnotifiers/dhcp_rpc_agent_api.py index dc3963f4ef3..488bb228c98 100644 --- a/neutron/api/rpc/agentnotifiers/dhcp_rpc_agent_api.py +++ b/neutron/api/rpc/agentnotifiers/dhcp_rpc_agent_api.py @@ -284,6 +284,7 @@ def _after_router_interface_deleted(self, resource, event, trigger, port = payload.metadata.get('port') self._notify_agents(payload.context, 'port_delete_end', {'port_id': port['id'], + 'fixed_ips': port['fixed_ips'], 'network_id': port['network_id']}, port['network_id']) diff --git a/neutron/tests/unit/api/rpc/agentnotifiers/test_dhcp_rpc_agent_api.py b/neutron/tests/unit/api/rpc/agentnotifiers/test_dhcp_rpc_agent_api.py index 18b14c44241..c0b11d8c417 100644 --- a/neutron/tests/unit/api/rpc/agentnotifiers/test_dhcp_rpc_agent_api.py +++ b/neutron/tests/unit/api/rpc/agentnotifiers/test_dhcp_rpc_agent_api.py @@ -251,6 +251,7 @@ def test__notify_agents_with_router_interface_delete(self): payload = events.DBEventPayload( mock.Mock(), metadata={ 'port': {'id': 'foo_port_id', + 'fixed_ips': mock.ANY, 'network_id': 'foo_network_id'}}) self._test__notify_agents_with_function( lambda: self.notifier._after_router_interface_deleted( From 424f1e6f4c0e1122a3c80d566db44e91fb998ef2 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 5 Jul 2024 08:35:24 +0000 Subject: [PATCH 025/184] [FT] Add a timeout for the NB/SB connection stop method If the DB connection is not stopped at the defined timeout (10 seconds), the clean-up process will continue. Closes-Bug: #2034589 Change-Id: I6c3b4da49364c3fed86053515e79121acac078d6 (cherry picked from commit b40c728cbb0903d78a5d4d47336fe107f06b9f4d) --- neutron/tests/functional/base.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/neutron/tests/functional/base.py b/neutron/tests/functional/base.py index 8c079ceaaed..2d910f84e67 100644 --- a/neutron/tests/functional/base.py +++ b/neutron/tests/functional/base.py @@ -379,8 +379,12 @@ def stop(self): if self.maintenance_worker: self.mech_driver.nb_synchronizer.stop() self.mech_driver.sb_synchronizer.stop() - self.mech_driver.nb_ovn.ovsdb_connection.stop() - self.mech_driver.sb_ovn.ovsdb_connection.stop() + for ovn_conn in (self.mech_driver.nb_ovn.ovsdb_connection, + self.mech_driver.sb_ovn.ovsdb_connection): + try: + ovn_conn.stop(timeout=10) + except Exception: # pylint:disable=bare-except + pass def restart(self): self.stop() From 59bc8e476f17dee323973a0e6ea4cd4c343f77b6 Mon Sep 17 00:00:00 2001 From: Seyeong Kim Date: Thu, 4 Jul 2024 06:23:59 +0000 Subject: [PATCH 026/184] Checking pci_slot to avoid changing staus to BUILD forever Currently when sriov agent is enabled and migrating a non-sriov instance, non-sriov port status is frequently set to BUILD instead of ACTIVE. This is because the 'binding_activate' function in sriov-nic-agent sets it BUILD with get_device_details_from_port_id(as it calls _get_new_status). This patch checks network_ports in binding_activate and skip binding port if it is not sriov port Closes-Bug: #2072154 Change-Id: I2d7702e17c75c96ca2f29749dccab77cb2f4bcf4 (cherry picked from commit a311606fcdae488e76c29e0e5e4035f8da621a34) --- .../mech_sriov/agent/sriov_nic_agent.py | 42 ++++++++++++++----- .../mech_sriov/agent/test_sriov_nic_agent.py | 30 +++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py b/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py index e552aba48a1..d9e82ed1a88 100644 --- a/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py +++ b/neutron/plugins/ml2/drivers/mech_sriov/agent/sriov_nic_agent.py @@ -112,17 +112,39 @@ def network_update(self, context, **kwargs): def binding_activate(self, context, **kwargs): if kwargs.get('host') != self.agent.conf.host: return - LOG.debug("binding activate for port %s", kwargs.get('port_id')) - device_details = self.agent.get_device_details_from_port_id( - kwargs.get('port_id')) - mac = device_details.get('mac_address') - binding_profile = device_details.get('profile') - if binding_profile: - pci_slot = binding_profile.get('pci_slot') - self.agent.activated_bindings.add((mac, pci_slot)) + + port_id = kwargs.get('port_id') + + def _is_port_id_in_network(network_port, port_id): + for network_id, ports in network_port.items(): + for port in ports: + if port['port_id'] == port_id: + return True + return False + + is_port_id_sriov = _is_port_id_in_network( + self.agent.network_ports, port_id + ) + + if is_port_id_sriov: + LOG.debug("binding activate for port %s", port_id) + device_details = self.agent.get_device_details_from_port_id( + port_id) + mac = device_details.get('mac_address') + binding_profile = device_details.get('profile') + if binding_profile: + pci_slot = binding_profile.get('pci_slot') + self.agent.activated_bindings.add((mac, pci_slot)) + else: + LOG.warning( + "binding_profile not found for port %s.", + port_id + ) else: - LOG.warning("binding_profile not found for port %s.", - kwargs.get('port_id')) + LOG.warning( + "This port is not SRIOV, skip binding for port %s.", + port_id + ) def binding_deactivate(self, context, **kwargs): if kwargs.get('host') != self.agent.conf.host: diff --git a/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_sriov_nic_agent.py b/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_sriov_nic_agent.py index ede87aa439b..b0a4b783396 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_sriov_nic_agent.py +++ b/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_sriov_nic_agent.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import collections import copy from unittest import mock @@ -460,6 +461,7 @@ def __init__(self): self.activated_bindings = set() self.conf = mock.Mock() self.conf.host = 'host1' + self.network_ports = collections.defaultdict(list) class TestSriovNicSwitchRpcCallbacks(base.BaseTestCase): @@ -528,6 +530,12 @@ def test_binding_activate(self): } kwargs = self._create_fake_bindings(fake_port, self.agent.conf.host) kwargs['context'] = self.context + + self.agent.network_ports['network_id'].append({ + 'port_id': fake_port['id'], + 'device': 'fake_device' + }) + self.sriov_rpc_callback.binding_activate(**kwargs) # Assert agent.activated_binding set contains the new binding self.assertIn((fake_port['mac_address'], @@ -538,10 +546,32 @@ def test_binding_activate_no_host(self): fake_port = self._create_fake_port() kwargs = self._create_fake_bindings(fake_port, 'other-host') kwargs['context'] = self.context + + self.agent.network_ports[self.agent.conf.host].append({ + 'port_id': fake_port['id'], + 'device': 'fake_device' + }) + self.sriov_rpc_callback.binding_activate(**kwargs) # Assert no bindings were added self.assertEqual(set(), self.agent.activated_bindings) + def test_binding_activate_port_not_in_network(self): + fake_port = self._create_fake_port() + kwargs = self._create_fake_bindings(fake_port, self.agent.conf.host) + kwargs['context'] = self.context + + self.agent.network_ports['network_id'] = [] + + with mock.patch.object(sriov_nic_agent.LOG, + 'warning') as mock_warning: + self.sriov_rpc_callback.binding_activate(**kwargs) + # Check that the warning message was logged + expected_msg = ( + "This port is not SRIOV, skip binding for port %s." + ) + mock_warning.assert_called_once_with(expected_msg, fake_port['id']) + def test_binding_deactivate(self): # binding_deactivate() basically does nothing # call it with both the agent's host and other host to cover From 1f761c27f2b47212eadf0e6e192f7e8201ee2aa6 Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Fri, 14 Jun 2024 17:31:35 +0000 Subject: [PATCH 027/184] Don't print traceback if standard attr is missing on update If northd is very busy, it may happen port is deleted when handling an LSP down event causing standard attribute being gone when bumping ovn revision number. This is because the port is set down in SB DB first and then northd propagates that to NB DB, and then the event is emited. This patch just makes sure the traceback is not printed in case this happens. TrivialFix Closes-bug: #2069442 Change-Id: I7d21e4adc27fab411346e0458c92191e69ce6b30 Signed-off-by: Jakub Libosvar (cherry picked from commit 8ab385f97de99c464258ac74cf342b0353580823) --- .../drivers/ovn/mech_driver/mech_driver.py | 3 ++ .../ovn/mech_driver/test_mech_driver.py | 29 +++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index 174c94576d9..62633e36645 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -793,6 +793,9 @@ def _ovn_update_port(self, plugin_context, port, original_port, port['revision_number'] = db_port['revision_number'] self._ovn_update_port(plugin_context, port, original_port, retry_on_revision_mismatch=False) + except ovn_revision_numbers_db.StandardAttributeIDNotFound: + LOG.debug("Standard attribute was not found for port %s. It was " + "possibly deleted concurrently.", port['id']) def create_port_postcommit(self, context): """Create a port. diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index c913ddbeaf3..400909c2425 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -2392,8 +2392,11 @@ def test_update_port_postcommit_live_migration_revision_mismatch_once( '_is_port_provisioning_required', lambda *_: True) @mock.patch.object(mech_driver.OVNMechanismDriver, '_notify_dhcp_updated') @mock.patch.object(ovn_client.OVNClient, 'update_port') - def test_update_port_postcommit_revision_mismatch_not_after_live_migration( - self, mock_update_port, mock_notify_dhcp): + def _test_update_port_postcommit_with_exception( + self, mock_update_port, mock_notify_dhcp, + raised_exc, + resource_id_name, + **exc_extra_params): self.plugin.update_port_status = mock.Mock() self.plugin.get_port = mock.Mock(return_value=mock.MagicMock()) @@ -2411,10 +2414,12 @@ def test_update_port_postcommit_revision_mismatch_not_after_live_migration( fake_ctx = mock.Mock(current=fake_port, original=original_fake_port, plugin_context=fake_context) + + exc_params = exc_extra_params.copy() + exc_params[resource_id_name] = fake_port['id'] + mock_update_port.side_effect = [ - ovn_exceptions.RevisionConflict( - resource_id=fake_port['id'], - resource_type=ovn_const.TYPE_PORTS), + raised_exc(**exc_params), None] self.mech_driver.update_port_postcommit(fake_ctx) @@ -2424,6 +2429,20 @@ def test_update_port_postcommit_revision_mismatch_not_after_live_migration( self.assertEqual(1, mock_update_port.call_count) mock_notify_dhcp.assert_called_with(fake_port['id']) + def test_update_port_postcommit_revision_mismatch_not_after_live_migration( + self): + self._test_update_port_postcommit_with_exception( + raised_exc=ovn_exceptions.RevisionConflict, + resource_id_name='resource_id', + resource_type=ovn_const.TYPE_PORTS, + ) + + def test__ovn_update_port_missing_stdattribute(self): + """Make sure exception is handled.""" + self._test_update_port_postcommit_with_exception( + raised_exc=ovn_revision_numbers_db.StandardAttributeIDNotFound, + resource_id_name='resource_uuid') + def test_agent_alive_true(self): chassis_private = self._add_chassis_private(5) for agent_type in (ovn_const.OVN_CONTROLLER_AGENT, From 28466f849c48b1a506afba0d3b6e870dbbef485e Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Tue, 30 Jul 2024 10:23:20 +0530 Subject: [PATCH 028/184] [2024.1 only] Switch to 2024.1 neutron-tempest-plugin jobs Change-Id: I3364f4880828f5153804f42d3fba7d9579e2838b --- zuul.d/base.yaml | 4 ++-- zuul.d/project.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index efd4e5b5091..57dcd19c1cf 100644 --- a/zuul.d/base.yaml +++ b/zuul.d/base.yaml @@ -249,7 +249,7 @@ - job: name: neutron-linuxbridge-tempest-plugin-nftables - parent: neutron-tempest-plugin-linuxbridge + parent: neutron-tempest-plugin-linuxbridge-2024-1 pre-run: playbooks/install_nftables.yaml vars: devstack_local_conf: @@ -260,7 +260,7 @@ - job: name: neutron-ovs-tempest-plugin-iptables_hybrid-nftables - parent: neutron-tempest-plugin-openvswitch-iptables_hybrid + parent: neutron-tempest-plugin-openvswitch-iptables_hybrid-2024-1 pre-run: playbooks/install_nftables.yaml vars: devstack_local_conf: diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index 032064e1d14..7678ab17372 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -4,7 +4,7 @@ # Please update this document always when any changes to jobs are made. - project: templates: - - neutron-tempest-plugin-jobs + - neutron-tempest-plugin-jobs-2024-1 - openstack-cover-jobs - openstack-python3-jobs - openstack-python3-jobs-arm64 From 23886cf5c92a6e01c81d2550f0cd2c0f8c89ea75 Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Tue, 30 Jul 2024 17:32:46 +0530 Subject: [PATCH 029/184] [stable/2024.1 only] Drop -master jobs Similar to previous stable branches, drop these jobs as they only make sense for master branch. Change-Id: I81dde474be6027cd8548dcbfcd7b3f74cf3e00c2 --- .../contributor/testing/ci_scenario_jobs.rst | 15 -- zuul.d/base.yaml | 93 ------- zuul.d/job-templates.yaml | 20 -- zuul.d/tempest-multinode.yaml | 56 ----- zuul.d/tempest-singlenode.yaml | 234 ------------------ 5 files changed, 418 deletions(-) diff --git a/doc/source/contributor/testing/ci_scenario_jobs.rst b/doc/source/contributor/testing/ci_scenario_jobs.rst index 33612a258a2..88ff4983570 100644 --- a/doc/source/contributor/testing/ci_scenario_jobs.rst +++ b/doc/source/contributor/testing/ci_scenario_jobs.rst @@ -125,21 +125,6 @@ Currently we have in that queue jobs like listed below. | |(only tests related to | | | | | | | | | | | |Neutron and Nova) | | | | | | | | | | +----------------------------------------------+----------------------------------+-------+------------------+-------------+-----------------+----------+-------+--------+------------+-------------+ - |neutron-ovn-tempest-full-multinode-ovs-master |Various tempest api, scenario | 2 | Ubuntu Jammy | ovn | ovn | --- | --- | --- | --- | No | - | |and neutron_tempest_plugi tests | | | | | | | | | | - +----------------------------------------------+----------------------------------+-------+------------------+-------------+-----------------+----------+-------+--------+------------+-------------+ - |neutron-ovn-tempest-ovs-master |Various tempest api, scenario | 1 | Ubuntu Jammy | ovn | ovn | --- | --- | --- | --- | No | - | |and neutron_tempest_plugi tests | | | | | | | | | | - +----------------------------------------------+----------------------------------+-------+------------------+-------------+-----------------+----------+-------+--------+------------+-------------+ - |neutron-ovn-tempest-with-neutron-lib-master |tempest.api (without slow tests) | 1 | Ubuntu Jammy | openvswitch | openvswitch | legacy | False | False | True | No | - | |tempest.scenario | | | | | | | | | | - | |(only tests related to | | | | | | | | | | - | |Neutron and Nova) | | | | | | | | | | - +----------------------------------------------+----------------------------------+-------+------------------+-------------+-----------------+----------+-------+--------+------------+-------------+ - |neutron-ovn-tempest-ipv6-only-ovs-master |tempest.api (without slow tests) | 1 | Ubuntu Jammy | ovn | ovn | --- | False | False | True | Yes | - | |(only tests related to | | | | | | | | | | - | |Neutron and Nova) | | | | | | | | | | - +----------------------------------------------+----------------------------------+-------+------------------+-------------+-----------------+----------+-------+--------+------------+-------------+ And we also have Grenade jobs in the experimental queue. :: diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index 57dcd19c1cf..debb64c77d4 100644 --- a/zuul.d/base.yaml +++ b/zuul.d/base.yaml @@ -115,20 +115,6 @@ # stadium projects where they need to use stadium project as working dir. zuul_work_dir: src/opendev.org/openstack/neutron -- job: - name: neutron-fullstack-with-uwsgi-with-neutron-lib-master - branches: ^master$ - parent: neutron-fullstack-with-uwsgi - required-projects: - - openstack/neutron-lib - -- job: - name: neutron-fullstack-with-pyroute2-master - branches: ^master$ - parent: neutron-fullstack - required-projects: - - name: github.com/svinota/pyroute2 - - job: name: neutron-functional-with-uwsgi parent: neutron-functional @@ -142,13 +128,6 @@ # stadium projects where they need to use stadium project as working dir. zuul_work_dir: src/opendev.org/openstack/neutron -- job: - name: neutron-functional-with-uwsgi-with-neutron-lib-master - branches: ^master$ - parent: neutron-functional-with-uwsgi - required-projects: - - openstack/neutron-lib - - job: name: neutron-functional-with-uwsgi-fips parent: neutron-functional-with-uwsgi @@ -162,78 +141,6 @@ ISCSI_CHAP_ALGORITHMS: SHA3-256,SHA256 Q_BUILD_OVS_FROM_GIT: true -- job: - name: neutron-functional-with-pyroute2-master - branches: ^master$ - parent: neutron-functional - required-projects: - - name: github.com/svinota/pyroute2 - -- job: - name: neutron-functional-with-oslo-master - branches: ^master$ - parent: neutron-functional - description: | - This job installs all oslo libraries from source and executes the - Neutron functional tests. - # NOTE(ralonsoh): the list of required projects is retrieved from - # "openstack-tox-with-oslo-master-base" job. - # TODO(ralonsoh): push a patch to "openstack-zuul-jobs" to create - # a list reference of the required projects for "-oslo-master" jobs. - required-projects: - - openstack/automaton - - openstack/debtcollector - - openstack/futurist - - openstack/osprofiler - - openstack/oslo.cache - - openstack/oslo.concurrency - - openstack/oslo.config - - openstack/oslo.context - - openstack/oslo.db - - openstack/oslo.i18n - - openstack/oslo.log - - openstack/oslo.messaging - - openstack/oslo.middleware - - openstack/oslo.policy - - openstack/oslo.privsep - - openstack/oslo.reports - - openstack/oslo.rootwrap - - openstack/oslo.serialization - - openstack/oslo.service - - openstack/oslo.utils - - openstack/oslo.versionedobjects - - openstack/oslo.vmware - - openstack/oslotest - - openstack/pycadf - - openstack/stevedore - - openstack/taskflow - - openstack/tooz - - openstack/pbr - -- job: - name: neutron-functional-with-sqlalchemy-master - branches: ^master$ - parent: neutron-functional - required-projects: - - name: github.com/sqlalchemy/sqlalchemy - override-checkout: main - - openstack/oslo.db - - openstack/neutron-lib - - name: github.com/sqlalchemy/alembic - override-checkout: main - -- job: - name: openstack-tox-py311-with-sqlalchemy-master - branches: ^master$ - parent: openstack-tox-py311 - required-projects: - - name: github.com/sqlalchemy/sqlalchemy - override-checkout: main - - openstack/oslo.db - - openstack/neutron-lib - - name: github.com/sqlalchemy/alembic - override-checkout: main - - job: name: neutron-fullstack-with-uwsgi-fips parent: neutron-fullstack-with-uwsgi diff --git a/zuul.d/job-templates.yaml b/zuul.d/job-templates.yaml index 57b9740b0b7..da78c5645cd 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -29,9 +29,6 @@ - openstack-tox-cover: # from openstack-cover-jobs template timeout: 4800 irrelevant-files: *irrelevant-files - - openstack-tox-py311-with-sqlalchemy-master: - timeout: 3600 - irrelevant-files: *irrelevant-files check-arm64: jobs: - openstack-tox-py38-arm64: # from openstack-python3-jobs-arm64 template @@ -64,13 +61,8 @@ name: neutron-experimental-jobs experimental: jobs: - - neutron-functional-with-uwsgi-with-neutron-lib-master - - neutron-fullstack-with-uwsgi-with-neutron-lib-master - - neutron-ovn-tempest-full-multinode-ovs-master - neutron-ovn-grenade-multinode - - neutron-ovn-tempest-ovs-master - neutron-ovn-tempest-ovs-release - - neutron-ovs-tempest-with-neutron-lib-master - neutron-ovn-tempest-with-uwsgi-loki # Jobs added to the periodic queue by templates defined in # https://opendev.org/openstack/openstack-zuul-jobs/src/branch/master/zuul.d/project-templates.yaml @@ -90,21 +82,12 @@ jobs: &neutron-periodic-jobs - neutron-functional - neutron-functional-with-uwsgi-fips - - neutron-functional-with-pyroute2-master - - neutron-functional-with-sqlalchemy-master - neutron-fullstack - neutron-fullstack-with-uwsgi-fips - - neutron-fullstack-with-pyroute2-master - neutron-ovs-tempest-slow - neutron-ovn-tempest-slow - - neutron-ovs-tempest-with-os-ken-master - neutron-ovn-tempest-postgres-full - neutron-ovn-tempest-mariadb-full - - neutron-ovn-tempest-ipv6-only-ovs-master - - neutron-ovn-tempest-ovs-master-centos-9-stream - - neutron-ovn-tempest-with-neutron-lib-master - - neutron-ovn-tempest-with-sqlalchemy-master - - neutron-ovs-tempest-with-sqlalchemy-master - neutron-ovs-tempest-fips - neutron-ovn-tempest-ovs-release-fips - devstack-tobiko-neutron: @@ -117,9 +100,6 @@ - openstack-tox-py39-with-oslo-master: timeout: 3600 irrelevant-files: *irrelevant-files - - neutron-functional-with-oslo-master - - neutron-ovs-tempest-with-oslo-master - - neutron-ovn-tempest-ovs-release-with-oslo-master - neutron-tempest-plugin-linuxbridge experimental: jobs: *neutron-periodic-jobs diff --git a/zuul.d/tempest-multinode.yaml b/zuul.d/tempest-multinode.yaml index 1db4e502a30..543e67af874 100644 --- a/zuul.d/tempest-multinode.yaml +++ b/zuul.d/tempest-multinode.yaml @@ -523,59 +523,3 @@ vars: tox_envlist: slow-serial tempest_test_regex: "" - -- job: - # TODO(slaweq): propose job with ovs-release and move -master one to - # experimental queue - name: neutron-ovn-tempest-full-multinode-ovs-master - branches: ^master$ - parent: neutron-ovn-multinode-base - run: playbooks/multinode-devstack-custom.yaml - vars: - tox_envlist: all - tempest_test_regex: "^(?!.*\ - (?:.*\\[.*slow.*\\])|\ - (?:tempest.api.network.admin.test_quotas.QuotasTest.test_lbaas_quotas.*)|\ - (?:tempest.api.network.test_load_balancer.*)|\ - (?:tempest.scenario.test_load_balancer.*)|\ - (?:tempest.api.network.admin.test_load_balancer.*)|\ - (?:tempest.api.network.admin.test_lbaas.*)|\ - (?:tempest.api.network.test_fwaas_extensions.*)|\ - (?:tempest.api.network.test_metering_extensions.*)|\ - (?:tempest.thirdparty.boto.test_s3.*)|\ - (?:tempest.api.identity*)|\ - (?:tempest.api.image*)|\ - (?:tempest.api.volume*)|\ - (?:tempest.api.compute.images*)|\ - (?:tempest.api.compute.keypairs*)|\ - (?:tempest.api.compute.certificates*)|\ - (?:tempest.api.compute.flavors*)|\ - (?:tempest.api.compute.test_quotas*)|\ - (?:tempest.api.compute.test_versions*)|\ - (?:tempest.api.compute.volumes*)|\ - (?:tempest.api.compute.admin.test_flavor*)|\ - (?:tempest.api.compute.admin.test_volume*)|\ - (?:tempest.api.compute.admin.test_hypervisor*)|\ - (?:tempest.api.compute.admin.test_aggregate*)|\ - (?:tempest.api.compute.admin.test_quota*)|\ - (?:tempest.scenario.test_volume*))\ - ((^neutron_tempest_plugin.api)|\ - (^neutron_tempest_plugin.scenario)|\ - (tempest.(api|scenario|thirdparty))).*$" - zuul_copy_output: - '{{ devstack_base_dir }}/data/ovs': 'logs' - '{{ devstack_base_dir }}/data/ovn': 'logs' - '{{ devstack_log_dir }}/ovsdb-server-nb.log': 'logs' - '{{ devstack_log_dir }}/ovsdb-server-sb.log': 'logs' - devstack_localrc: - OVN_BUILD_FROM_SOURCE: True - OVN_BRANCH: main - # NOTE(ykarel): OVN main branch following OVS stable branch - OVS_BRANCH: branch-3.3 - group-vars: - subnode: - devstack_localrc: - OVN_BUILD_FROM_SOURCE: True - OVN_BRANCH: main - # NOTE(ykarel): OVN main branch following OVS stable branch - OVS_BRANCH: branch-3.3 diff --git a/zuul.d/tempest-singlenode.yaml b/zuul.d/tempest-singlenode.yaml index 7dbb0f2e3d8..96980cf210f 100644 --- a/zuul.d/tempest-singlenode.yaml +++ b/zuul.d/tempest-singlenode.yaml @@ -293,130 +293,6 @@ pre-run: playbooks/add_mariadb_repo.yaml irrelevant-files: *tempest-db-irrelevant-files -- job: - name: neutron-ovs-tempest-with-os-ken-master - branches: ^master$ - parent: neutron-ovs-tempest-base - timeout: 10800 - required-projects: - - openstack/neutron - - openstack/tempest - - openstack/os-ken - vars: - devstack_plugins: - neutron: https://opendev.org/openstack/neutron.git - devstack_services: - br-ex-tcpdump: true - br-int-flows: true - # Cinder services - c-api: false - c-bak: false - c-sch: false - c-vol: false - cinder: false - # Swift services - s-account: false - s-container: false - s-object: false - s-proxy: false - irrelevant-files: - - ^\.pylintrc$ - - ^test-requirements.txt$ - - ^.*\.conf\.sample$ - - ^.*\.rst$ - - ^doc/.*$ - - ^neutron/locale/.*$ - - ^neutron/tests/.*$ - - ^releasenotes/.*$ - - ^setup.cfg$ - - ^tools/.*$ - - ^tox.ini$ - - ^plugin.spec$ - - ^tools/ovn_migration/.*$ - - ^vagrant/.*$ - - ^neutron/agent/windows/.*$ - - ^neutron/plugins/ml2/drivers/linuxbridge/.*$ - - ^neutron/plugins/ml2/drivers/macvtap/.*$ - - ^neutron/plugins/ml2/drivers/mech_sriov/.*$ - - ^roles/.*functional.*$ - - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml - -- job: - name: neutron-ovn-tempest-with-neutron-lib-master - branches: ^master$ - parent: tempest-integrated-networking - timeout: 10800 - required-projects: - - openstack/neutron - - openstack/tempest - - openstack/neutron-lib - vars: - devstack_plugins: - neutron: https://opendev.org/openstack/neutron.git - devstack_services: - br-ex-tcpdump: true - br-int-flows: true - # Cinder services - c-api: false - c-bak: false - c-sch: false - c-vol: false - cinder: false - # Swift services - s-account: false - s-container: false - s-object: false - s-proxy: false - zuul_copy_output: - '/var/log/ovn': 'logs' - '/var/log/openvswitch': 'logs' - '/var/lib/ovn': 'logs' - -- job: - name: neutron-ovs-tempest-with-neutron-lib-master - branches: ^master$ - parent: neutron-ovs-tempest-base - required-projects: - - openstack/neutron-lib - -- job: - name: neutron-ovs-tempest-with-oslo-master - branches: ^master$ - parent: neutron-ovs-tempest-base - description: | - Job testing for devstack/tempest testing Neutron with OVS driver. - This job installs all oslo libraries from source. - required-projects: - - openstack/automaton - - openstack/debtcollector - - openstack/futurist - - openstack/osprofiler - - openstack/oslo.cache - - openstack/oslo.concurrency - - openstack/oslo.config - - openstack/oslo.context - - openstack/oslo.db - - openstack/oslo.i18n - - openstack/oslo.log - - openstack/oslo.messaging - - openstack/oslo.middleware - - openstack/oslo.policy - - openstack/oslo.privsep - - openstack/oslo.reports - - openstack/oslo.rootwrap - - openstack/oslo.serialization - - openstack/oslo.service - - openstack/oslo.utils - - openstack/oslo.versionedobjects - - openstack/oslo.vmware - - openstack/oslotest - - openstack/pycadf - - openstack/stevedore - - openstack/taskflow - - openstack/tooz - - openstack/pbr - - job: name: neutron-ovn-tempest-with-uwsgi parent: tempest-integrated-networking @@ -620,57 +496,6 @@ description: Job testing for devstack/tempest testing Neutron with ovn driver and latest OVN version provided by the packages released in the operating system this job is deployed on parent: neutron-ovn-base -- job: - name: neutron-ovn-tempest-ovs-master - branches: ^master$ - description: Job testing for devstack/tempest testing Neutron with ovn driver and OVN master branch - parent: neutron-ovn-base - vars: - devstack_localrc: - OVN_BUILD_FROM_SOURCE: True - OVN_BRANCH: main - # NOTE(ykarel): OVN main branch following OVS stable branch - OVS_BRANCH: branch-3.3 - -- job: - name: neutron-ovn-tempest-ovs-release-with-oslo-master - branches: ^master$ - description: | - Job testing for devstack/tempest testing Neutron with OVN driver. - This job installs all oslo libraries from source. - parent: neutron-ovn-tempest-ovs-release - required-projects: - - openstack/neutron - - openstack/tempest - - openstack/automaton - - openstack/debtcollector - - openstack/futurist - - openstack/osprofiler - - openstack/oslo.cache - - openstack/oslo.concurrency - - openstack/oslo.config - - openstack/oslo.context - - openstack/oslo.db - - openstack/oslo.i18n - - openstack/oslo.log - - openstack/oslo.messaging - - openstack/oslo.middleware - - openstack/oslo.policy - - openstack/oslo.privsep - - openstack/oslo.reports - - openstack/oslo.rootwrap - - openstack/oslo.serialization - - openstack/oslo.service - - openstack/oslo.utils - - openstack/oslo.versionedobjects - - openstack/oslo.vmware - - openstack/oslotest - - openstack/pycadf - - openstack/stevedore - - openstack/taskflow - - openstack/tooz - - openstack/pbr - - job: name: neutron-ovn-tempest-ovs-master-centos-9-stream description: Job testing for devstack/tempest testing Neutron with ovn driver and OVN master branch and CentOS 9-Stream @@ -769,62 +594,3 @@ (test_update_router_admin_state)|\ (test_dhcp_stateful_router)|\ (TestSecurityGroupsBasicOps)" - -- job: - name: neutron-ovn-tempest-ipv6-only-ovs-master - branches: ^master$ - parent: neutron-ovn-tempest-ipv6-only-base - vars: - devstack_localrc: - OVN_BUILD_FROM_SOURCE: True - OVN_BRANCH: "main" - # NOTE(ykarel): OVN main branch following OVS stable branch - OVS_BRANCH: branch-3.3 - -- job: - name: neutron-ovn-tempest-with-sqlalchemy-master - branches: ^master$ - parent: tempest-integrated-networking - timeout: 10800 - required-projects: - - openstack/neutron - - openstack/tempest - - openstack/oslo.db - - openstack/neutron-lib - - name: github.com/sqlalchemy/sqlalchemy - override-checkout: main - - name: github.com/sqlalchemy/alembic - override-checkout: main - vars: - devstack_plugins: - neutron: https://opendev.org/openstack/neutron.git - devstack_services: - br-ex-tcpdump: true - br-int-flows: true - # Cinder services - c-api: false - c-bak: false - c-sch: false - c-vol: false - cinder: false - # Swift services - s-account: false - s-container: false - s-object: false - s-proxy: false - zuul_copy_output: - '/var/log/ovn': 'logs' - '/var/log/openvswitch': 'logs' - '/var/lib/ovn': 'logs' - -- job: - name: neutron-ovs-tempest-with-sqlalchemy-master - branches: ^master$ - parent: neutron-ovs-tempest-base - required-projects: - - name: github.com/sqlalchemy/sqlalchemy - override-checkout: main - - openstack/oslo.db - - openstack/neutron-lib - - name: github.com/sqlalchemy/alembic - override-checkout: main From 54f98783fddf14982e92d3a8cd296dee08eb2966 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Tue, 30 Jul 2024 14:50:36 +0200 Subject: [PATCH 030/184] Use has_lock_periodic decorator for the tasks which require ovn db lock This is follow up patch for the [1] which introduced this new decorator. [1] https://review.opendev.org/c/openstack/neutron/+/896544 Change-Id: I2de3b5d7ba5783dd82acacda89ab4b64c2d29149 (cherry picked from commit 2a6bc5db237d28ddfdda16aea7c1b3416f3e14a4) --- .../ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index fbaf91064ec..30623539397 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -537,16 +537,13 @@ def check_port_has_address_scope(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @periodics.periodic(spacing=600, run_immediately=True) + @has_lock_periodic(spacing=600, run_immediately=True) def check_for_ha_chassis_group(self): # If external ports is not supported stop running # this periodic task if not self._ovn_client.is_external_ports_supported(): raise periodics.NeverAgain() - if not self.has_lock: - return - external_ports = self._nb_idl.db_find_rows( 'Logical_Switch_Port', ('type', '=', ovn_const.LSP_TYPE_EXTERNAL) ).execute(check_error=True) @@ -822,7 +819,7 @@ def remove_gw_ext_ids_from_logical_router(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @periodics.periodic(spacing=600, run_immediately=True) + @has_lock_periodic(spacing=600, run_immediately=True) def check_baremetal_ports_dhcp_options(self): """Update baremetal ports DHCP options @@ -834,9 +831,6 @@ def check_baremetal_ports_dhcp_options(self): if not self._ovn_client.is_external_ports_supported(): raise periodics.NeverAgain() - if not self.has_lock: - return - context = n_context.get_admin_context() ports = ports_obj.Port.get_ports_by_vnic_type_and_host( context, portbindings.VNIC_BAREMETAL) From 757f7e163c8cfa81ce86e6c3591abdf84501910c Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Tue, 16 Jul 2024 18:35:40 -0500 Subject: [PATCH 031/184] Actualy set global "removal limit" options Neither fdb_removal_limit nor mac_binding_removal_limit config options currently get set in the OVN DB. This patch corrects that and adds missing testing for the MAC_Binding aging maintenance task. Fixes: 0a554b4f29 ("Add support for OVN MAC_Binding aging") Fixes: 1e9f50c736 ("Add support for FDB aging") Closes-Bug: #2073309 Change-Id: I80d79faeb9f1057d398ee750ae6e246598fd13d2 (cherry picked from commit b4c8cc600a21469e247a0012585969a7897a0929) --- .../conf/plugins/ml2/drivers/ovn/ovn_conf.py | 4 ++ .../ovn/mech_driver/ovsdb/maintenance.py | 9 +++- .../ovn/mech_driver/ovsdb/test_maintenance.py | 49 +++++++++++++++++++ .../ovn/mech_driver/ovsdb/test_maintenance.py | 17 +++++-- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py b/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py index fd85ced165c..72b97798aa9 100644 --- a/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py +++ b/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py @@ -376,3 +376,7 @@ def get_fdb_removal_limit(): def get_ovn_mac_binding_age_threshold(): # This value is always stored as a string in the OVN DB return str(cfg.CONF.ovn.mac_binding_age_threshold) + + +def get_ovn_mac_binding_removal_limit(): + return str(cfg.CONF.ovn_nb_global.mac_binding_removal_limit) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index 30623539397..a5775acf665 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -752,7 +752,10 @@ def check_fdb_aging_settings(self): Ensure FDB aging settings are enforced. """ context = n_context.get_admin_context() - cmds = [] + cmds = [self._nb_idl.db_set( + "NB_Global", '.', + options={"fdb_removal_limit": + ovn_conf.get_fdb_removal_limit()})] config_fdb_age_threshold = ovn_conf.get_fdb_age_threshold() # Get provider networks @@ -783,6 +786,10 @@ def check_fdb_aging_settings(self): def update_mac_aging_settings(self): """Ensure that MAC_Binding aging options are set""" with self._nb_idl.transaction(check_error=True) as txn: + txn.add(self._nb_idl.db_set( + "NB_Global", ".", + options={"mac_binding_removal_limit": + ovn_conf.get_ovn_mac_binding_removal_limit()})) txn.add(self._nb_idl.set_router_mac_age_limit()) raise periodics.NeverAgain() diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index 0272722864e..00ec7932099 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -775,8 +775,12 @@ def test_check_for_aging_settings(self): self.assertEqual( '0', ls.other_config.get(ovn_const.LS_OPTIONS_FDB_AGE_THRESHOLD)) + self.assertEqual( + '0', self.nb_api.nb_global.options.get("fdb_removal_limit", '0')) + # Change the value of the configuration cfg.CONF.set_override('fdb_age_threshold', 5, group='ovn') + cfg.CONF.set_override('fdb_removal_limit', 100, group='ovn_nb_global') # Call the maintenance task and check that the value has been # updated in the Logical Switch @@ -787,6 +791,51 @@ def test_check_for_aging_settings(self): self.assertEqual( '5', ls.other_config.get(ovn_const.LS_OPTIONS_FDB_AGE_THRESHOLD)) + self.assertEqual( + '100', self.nb_api.nb_global.options.get("fdb_removal_limit")) + + def test_update_mac_aging_settings(self): + ext_net = self._create_network('ext_networktest', external=True) + ext_subnet = self._create_subnet( + 'ext_subnettest', + ext_net['id'], + **{'cidr': '100.0.0.0/24', + 'gateway_ip': '100.0.0.254', + 'allocation_pools': [ + {'start': '100.0.0.2', 'end': '100.0.0.253'}], + 'enable_dhcp': False}) + self._create_network('network1test', external=False) + external_gateway_info = { + 'enable_snat': True, + 'network_id': ext_net['id'], + 'external_fixed_ips': [ + {'ip_address': '100.0.0.2', 'subnet_id': ext_subnet['id']}]} + router = self._create_router( + 'routertest', external_gateway_info=external_gateway_info) + + options = self.nb_api.nb_global.options + lr = self.nb_api.get_lrouter(router["id"]) + + self.assertEqual( + '0', lr.options.get(ovn_const.LR_OPTIONS_MAC_AGE_LIMIT)) + + self.assertEqual('0', options.get('mac_binding_removal_limit', '0')) + + cfg.CONF.set_override("mac_binding_age_threshold", 5, group="ovn") + cfg.CONF.set_override("mac_binding_removal_limit", 100, + group="ovn_nb_global") + + # Call the maintenance task and check that the value has been + # updated in the Logical Switch + self.assertRaises(periodics.NeverAgain, + self.maint.update_mac_aging_settings) + + lr = self.nb_api.get_lrouter(router['id']) + options = self.nb_api.nb_global.options + + self.assertEqual( + '5', lr.options.get(ovn_const.LR_OPTIONS_MAC_AGE_LIMIT)) + self.assertEqual('100', options['mac_binding_removal_limit']) def test_floating_ip(self): ext_net = self._create_network('ext_networktest', external=True) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index d3625a7b61b..f083610fcd5 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -808,9 +808,13 @@ def test_check_fdb_aging_settings(self): periodics.NeverAgain, self.periodic.check_fdb_aging_settings) - self.fake_ovn_client._nb_idl.db_set.assert_called_once_with( - 'Logical_Switch', 'neutron-foo', - ('other_config', {constants.LS_OPTIONS_FDB_AGE_THRESHOLD: '5'})) + self.fake_ovn_client._nb_idl.db_set.assert_has_calls([ + mock.call('NB_Global', '.', + options={'fdb_removal_limit': + ovn_conf.get_fdb_removal_limit()}), + mock.call('Logical_Switch', 'neutron-foo', + ('other_config', + {constants.LS_OPTIONS_FDB_AGE_THRESHOLD: '5'}))]) def test_check_fdb_aging_settings_with_threshold_set(self): cfg.CONF.set_override('fdb_age_threshold', 5, group='ovn') @@ -825,7 +829,12 @@ def test_check_fdb_aging_settings_with_threshold_set(self): periodics.NeverAgain, self.periodic.check_fdb_aging_settings) - self.fake_ovn_client._nb_idl.db_set.assert_not_called() + # It doesn't really matter if db_set is called or not for the + # ls. This is called one time at startup and python-ovs will + # not send the transaction if it doesn't cause a change + self.fake_ovn_client._nb_idl.db_set.assert_called_once_with( + 'NB_Global', '.', + options={'fdb_removal_limit': ovn_conf.get_fdb_removal_limit()}) def test_remove_gw_ext_ids_from_logical_router(self): nb_idl = self.fake_ovn_client._nb_idl From 829e95dd7a02d962b3140f3c9c399f0b053463b9 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Sat, 20 Jul 2024 00:46:04 +0000 Subject: [PATCH 032/184] Initialize the policy enforcer for the "tagging" service plugin The "tagging" service plugin API extension does use the policy enforcer since [1]. If a tag API call is done just after the Neutron server has been initialized and the policy enforcer, that is a global variable per API worker, has not been initialized, the API call will fail. This patch initializes the policy enforcer as is done in the ``PolicyHook``, that is called by many other API resources that inherit from the ``APIExtensionDescriptor`` class. [1]https://review.opendev.org/q/I9f3e032739824f268db74c5a1b4f04d353742dbd Closes-Bug: #2073782 Change-Id: Ia35c51fb81cfc0a55c5a2436fc5c55f2b4c9bd01 (cherry picked from commit 776178e90763d004ccb595b131cdd4dd617cd34f) --- neutron/extensions/tagging.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/neutron/extensions/tagging.py b/neutron/extensions/tagging.py index 828cf6c0b6b..432677ebe67 100644 --- a/neutron/extensions/tagging.py +++ b/neutron/extensions/tagging.py @@ -13,6 +13,7 @@ import abc import copy +import functools from neutron_lib.api.definitions import port from neutron_lib.api import extensions as api_extensions @@ -60,6 +61,14 @@ RESOURCES_AND_PARENTS = {'subnets': ('network', subnet.Subnet.get_network_id)} +def _policy_init(f): + @functools.wraps(f) + def func(self, *args, **kwargs): + policy.init() + return f(self, *args, **kwargs) + return func + + class TagResourceNotFound(exceptions.NotFound): message = _("Resource %(resource)s %(resource_id)s could not be found.") @@ -127,6 +136,7 @@ def _get_parent_resource_and_id(self, context, kwargs): return resource, kwargs[key], parent, parent_id return None, None, None, None + @_policy_init def index(self, request, **kwargs): # GET /v2.0/{parent_resource}/{parent_resource_id}/tags ctx = request.context @@ -136,6 +146,7 @@ def index(self, request, **kwargs): policy.enforce(ctx, 'get_%s_%s' % (res, TAGS), target) return self.plugin.get_tags(ctx, res, res_id) + @_policy_init def show(self, request, id, **kwargs): # GET /v2.0/{parent_resource}/{parent_resource_id}/tags/{tag} # id == tag @@ -152,6 +163,7 @@ def create(self, request, **kwargs): # POST /v2.0/{parent_resource}/{parent_resource_id}/tags raise webob.exc.HTTPNotFound("not supported") + @_policy_init def update(self, request, id, **kwargs): # PUT /v2.0/{parent_resource}/{parent_resource_id}/tags/{tag} # id == tag @@ -166,6 +178,7 @@ def update(self, request, id, **kwargs): notify_tag_action(ctx, 'create.end', res, res_id, [id]) return result + @_policy_init def update_all(self, request, body, **kwargs): # PUT /v2.0/{parent_resource}/{parent_resource_id}/tags # body: {"tags": ["aaa", "bbb"]} @@ -181,6 +194,7 @@ def update_all(self, request, body, **kwargs): body['tags']) return result + @_policy_init def delete(self, request, id, **kwargs): # DELETE /v2.0/{parent_resource}/{parent_resource_id}/tags/{tag} # id == tag @@ -195,6 +209,7 @@ def delete(self, request, id, **kwargs): notify_tag_action(ctx, 'delete.end', res, res_id, [id]) return result + @_policy_init def delete_all(self, request, **kwargs): # DELETE /v2.0/{parent_resource}/{parent_resource_id}/tags ctx = request.context From 178255ef38bcc90160270cb961fa6a3ba700593a Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Wed, 6 Mar 2024 20:13:58 +0000 Subject: [PATCH 033/184] Use oslo_service's SignalHandler for signals When Neutron is killed with SIGTERM (like via systemctl), when using ML2/OVN neutron workers do not exit and instead are eventually killed with SIGKILL when the graceful timeout is reached (often around 1 minute). This is happening due to the signal handlers for SIGTERM. There are multiple issues. 1) oslo_service, ml2/ovn mech_driver, and ml2/ovo_rpc.py all call signal.signal(signal.SIGTERM, ...) overwriting each others signal handlers. 2) SIGTERM is handled in the main thread, and running blocking code there causes AssertionErrors in eventlet which also prevents the process from exiting. 3) The ml2/ovn cleanup code doesn't cause the process to end, so it interrupts the killing of the process. oslo_service has a singleton SignalHandler class that solves all of these issues Closes-Bug: #2056366 Depends-On: https://review.opendev.org/c/openstack/oslo.service/+/913512 Change-Id: I730a12746bceaa744c658854e38439420efc4629 Signed-off-by: Terry Wilson (cherry picked from commit a4e49b6b8fcf9acfa4e84c65de19ffd56b9022e7) --- neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py | 5 +++-- neutron/plugins/ml2/ovo_rpc.py | 7 ++++--- requirements.txt | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index 62633e36645..23504c98abf 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -19,7 +19,6 @@ import functools import multiprocessing import operator -import signal import threading import types import uuid @@ -43,6 +42,7 @@ from oslo_config import cfg from oslo_db import exception as os_db_exc from oslo_log import log +from oslo_service import service as oslo_service from oslo_utils import timeutils from ovsdbapp.backend.ovs_idl import idlutils @@ -313,8 +313,9 @@ def _setup_hash_ring(self): themselves to the hash ring. """ # Attempt to remove the node from the ring when the worker stops + sh = oslo_service.SignalHandler() atexit.register(self._remove_node_from_hash_ring) - signal.signal(signal.SIGTERM, self._remove_node_from_hash_ring) + sh.add_handler("SIGTERM", self._remove_node_from_hash_ring) admin_context = n_context.get_admin_context() if not self._hash_ring_probe_event.is_set(): diff --git a/neutron/plugins/ml2/ovo_rpc.py b/neutron/plugins/ml2/ovo_rpc.py index ffbb02ca38a..95e944f6256 100644 --- a/neutron/plugins/ml2/ovo_rpc.py +++ b/neutron/plugins/ml2/ovo_rpc.py @@ -13,7 +13,6 @@ import atexit import queue -import signal import threading import traceback import weakref @@ -24,6 +23,7 @@ from neutron_lib import context as n_ctx from neutron_lib.db import api as db_api from oslo_log import log as logging +from oslo_service import service from neutron.api.rpc.callbacks import events as rpc_events from neutron.api.rpc.handlers import resources_rpc @@ -38,8 +38,9 @@ def _setup_change_handlers_cleanup(): atexit.register(_ObjectChangeHandler.clean_up) - signal.signal(signal.SIGINT, _ObjectChangeHandler.clean_up) - signal.signal(signal.SIGTERM, _ObjectChangeHandler.clean_up) + sh = service.SignalHandler() + sh.add_handler("SIGINT", _ObjectChangeHandler.clean_up) + sh.add_handler("SIGTERM", _ObjectChangeHandler.clean_up) class _ObjectChangeHandler(object): diff --git a/requirements.txt b/requirements.txt index 101d4f3d578..e8d0e8ff40f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ oslo.privsep>=2.3.0 # Apache-2.0 oslo.reports>=1.18.0 # Apache-2.0 oslo.rootwrap>=5.15.0 # Apache-2.0 oslo.serialization>=2.25.0 # Apache-2.0 -oslo.service>=2.8.0 # Apache-2.0 +oslo.service>=3.4.1 # Apache-2.0 oslo.upgradecheck>=1.3.0 # Apache-2.0 oslo.utils>=7.0.0 # Apache-2.0 oslo.versionedobjects>=1.35.1 # Apache-2.0 From 6d695035eebbaeff657bf40ecd760ffebee2bc96 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 19 Jul 2024 18:03:16 +0000 Subject: [PATCH 034/184] Do not release the executor inside ``_check_child_processes`` The method ``ProcessMonitor._check_child_processes`` was releasing the thread executor inside a method that creates a lock for the resource "_check_child_processes". Despite this resource is not used anywhere else (at least for this instance), this could lead to a potential deadlock. The current implementation of ``lockutils.synchronized`` with the default value "external=False" and "fair=False" is a ``threading.Lock()`` instance. The goal of this lock is, precisely, to execute the code inside the locked code without any interruption and then to be able to release the executor. Closes-Bug: #2073743 Change-Id: I44c7a4ce81a67b86054832ac050cf5b465727adf (cherry picked from commit baa57ab38d754bfa2dba488feb9429c1380d616c) --- neutron/agent/linux/external_process.py | 1 - 1 file changed, 1 deletion(-) diff --git a/neutron/agent/linux/external_process.py b/neutron/agent/linux/external_process.py index 77f634a558b..f58f186d049 100644 --- a/neutron/agent/linux/external_process.py +++ b/neutron/agent/linux/external_process.py @@ -272,7 +272,6 @@ def _check_child_processes(self): 'resource_type': self._resource_type, 'uuid': service_id.uuid}) self._execute_action(service_id) - eventlet.sleep(0) def _periodic_checking_thread(self): while self._monitor_processes: From 18b3f3ef8c8c6086e5b2bfbe8304bdc5c31e34e9 Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Tue, 4 Jun 2024 12:23:27 -0400 Subject: [PATCH 035/184] Fix regex lines in zuul.d/* files Commit 260c968118934 broke the gate by causing jobs to not get run when it added RE2 compatibility for irrelevant-files. Digging found that RE2 doesn't support negative lookahead (and won't ever), so it's impossible to replace the previous pcre filter with a similar RE2 filter. Instead of reverting to the original filter, which is considered obsolete by zuul, this patch fixes the issue by explicitly listing all files under zuul.d/ except the one that we actually want to trigger the jobs: zuul.d/project.yaml. Listing all the files in the directory for every job is not ideal, and we may revisit it later, or perhaps even reconsider the extensive use of irrelevant-files in the neutron tree. This will have to wait for when the gate is in better shape though. [0] https://github.com/google/re2/issues/156 Conflicts: zuul.d/base.yaml zuul.d/grenade.yaml zuul.d/job-templates.yaml zuul.d/project.yaml zuul.d/rally.yaml zuul.d/tempest-multinode.yaml zuul.d/tempest-singlenode.yaml Related-bug: #2065821 Change-Id: I3bba89ac14414c6b7d375072ae92d2e0b5497736 (cherry picked from commit 11027e3e1ef9a58d5b2faa575a3764bd33cd2a08) --- zuul.d/base.yaml | 16 ++++++++++++-- zuul.d/grenade.yaml | 16 ++++++++++++-- zuul.d/job-templates.yaml | 8 ++++++- zuul.d/project.yaml | 8 ++++++- zuul.d/rally.yaml | 16 ++++++++++++-- zuul.d/tempest-multinode.yaml | 16 ++++++++++++-- zuul.d/tempest-singlenode.yaml | 40 +++++++++++++++++++++++++++++----- 7 files changed, 105 insertions(+), 15 deletions(-) diff --git a/zuul.d/base.yaml b/zuul.d/base.yaml index debb64c77d4..b8fd32bf66c 100644 --- a/zuul.d/base.yaml +++ b/zuul.d/base.yaml @@ -35,7 +35,13 @@ - ^roles/add_mariadb_repo/.*$ - ^roles/nftables/.*$ - ^rally-jobs/.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml vars: configure_swap_size: 8192 Q_BUILD_OVS_FROM_GIT: True @@ -100,7 +106,13 @@ - ^roles/add_mariadb_repo/.*$ - ^roles/nftables/.*$ - ^rally-jobs/.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - job: name: neutron-fullstack-with-uwsgi diff --git a/zuul.d/grenade.yaml b/zuul.d/grenade.yaml index 709f073a7d4..2d1fe227c37 100644 --- a/zuul.d/grenade.yaml +++ b/zuul.d/grenade.yaml @@ -36,7 +36,13 @@ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - ^vagrant/.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml vars: grenade_devstack_localrc: shared: @@ -253,7 +259,13 @@ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - ^vagrant/.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml roles: - zuul: openstack/neutron-tempest-plugin required-projects: diff --git a/zuul.d/job-templates.yaml b/zuul.d/job-templates.yaml index da78c5645cd..259e6930476 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -22,7 +22,13 @@ - ^playbooks/.*$ - ^roles/.*$ - ^rally-jobs/.*$ - - ^zuul.d/(?!(job-templates)).*\.yaml + # Ignore everything except for zuul.d/job-templates.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/project.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - openstack-tox-py311: # from openstack-python3-jobs template timeout: 3600 irrelevant-files: *irrelevant-files diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index 7678ab17372..133fe3de7fc 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -62,7 +62,13 @@ - ^neutron/scheduler/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml gate: jobs: diff --git a/zuul.d/rally.yaml b/zuul.d/rally.yaml index 870c2e7fa7f..abe1e051ef8 100644 --- a/zuul.d/rally.yaml +++ b/zuul.d/rally.yaml @@ -82,7 +82,13 @@ - ^neutron/common/ovn/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - job: name: neutron-ovn-rally-task @@ -127,7 +133,13 @@ - ^neutron/scheduler/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml vars: devstack_plugins: neutron: https://opendev.org/openstack/neutron diff --git a/zuul.d/tempest-multinode.yaml b/zuul.d/tempest-multinode.yaml index 543e67af874..6780e1c0f6b 100644 --- a/zuul.d/tempest-multinode.yaml +++ b/zuul.d/tempest-multinode.yaml @@ -75,7 +75,13 @@ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - ^vagrant/.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml vars: tox_envlist: integrated-network devstack_localrc: @@ -406,7 +412,13 @@ - ^neutron/scheduler/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml roles: - zuul: zuul/zuul-jobs - zuul: openstack/neutron-tempest-plugin diff --git a/zuul.d/tempest-singlenode.yaml b/zuul.d/tempest-singlenode.yaml index 96980cf210f..6a6f89894cb 100644 --- a/zuul.d/tempest-singlenode.yaml +++ b/zuul.d/tempest-singlenode.yaml @@ -86,7 +86,13 @@ - ^neutron/common/ovn/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - job: name: neutron-ovs-tempest-dvr @@ -150,7 +156,13 @@ - ^neutron/common/ovn/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - job: name: neutron-ovs-tempest-iptables_hybrid @@ -255,7 +267,13 @@ - ^neutron/plugins/ml2/drivers/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - job: name: neutron-ovn-tempest-mariadb-full @@ -346,7 +364,13 @@ - ^vagrant/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - job: name: neutron-ovn-tempest-with-uwsgi-loki @@ -489,7 +513,13 @@ - ^neutron/scheduler/.*$ - ^roles/.*functional.*$ - ^playbooks/.*functional.*$ - - ^zuul.d/(?!(project)).*\.yaml + # Ignore everything except for zuul.d/project.yaml + - ^zuul.d/base.yaml + - ^zuul.d/grenade.yaml + - ^zuul.d/job-templates.yaml + - ^zuul.d/rally.yaml + - ^zuul.d/tempest-multinode.yaml + - ^zuul.d/tempest-singlenode.yaml - job: name: neutron-ovn-tempest-ovs-release From be2f5fabbf3c8863f2026aca9b326aae7032cd09 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Fri, 26 Jul 2024 12:02:27 +0200 Subject: [PATCH 036/184] Fix setting correct 'reside-on-chassis-redirect' in the maintenance task Setting of the 'reside-on-chassis-redirect' was skipped for LRP ports of the provider tenant networks in patch [1] but later patch [2] removed this limitation from the ovn_client but not from the maintenance task. Due to that this option wasn't updated after e.g. change of the 'enable_distributed_floating_ip' config option and connectivity to the existing Floating IPs associated to the ports in vlan tenant networks was broken. This patch removes that limitation and this option is now updated for all of the Logical_Router_Ports for vlan networks, not only for external gateways. [1] https://review.opendev.org/c/openstack/neutron/+/871252 [2] https://review.opendev.org/c/openstack/neutron/+/878450 Conflicts: neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py Closes-bug: #2073987 Change-Id: I56e791847c8f4f3a07f543689bf22fde8160c9b7 (cherry picked from commit 4b1bfb93e380b8dce78935395b2cda57076e5476) --- .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py | 5 +---- .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index a5775acf665..3c744358118 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -719,10 +719,7 @@ def check_vlan_distributed_ports(self): # Get router ports belonging to VLAN networks vlan_nets = self._ovn_client._plugin.get_networks( context, {pnet.NETWORK_TYPE: [n_const.TYPE_VLAN]}) - # FIXME(ltomasbo): Once Bugzilla 2162756 is fixed the - # is_provider_network check should be removed - vlan_net_ids = [vn['id'] for vn in vlan_nets - if not utils.is_provider_network(vn)] + vlan_net_ids = [vn['id'] for vn in vlan_nets] router_ports = self._ovn_client._plugin.get_ports( context, {'network_id': vlan_net_ids, 'device_owner': n_const.ROUTER_PORT_OWNERS}) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 4fb2f725554..acd4c41a0e8 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -1676,8 +1676,6 @@ def _gen_router_port_options(self, port, network=None): # logical router port is centralized in the chassis hosting the # distributed gateway port. # https://github.com/openvswitch/ovs/commit/85706c34d53d4810f54bec1de662392a3c06a996 - # FIXME(ltomasbo): Once Bugzilla 2162756 is fixed the - # is_provider_network check should be removed if network.get(pnet.NETWORK_TYPE) == const.TYPE_VLAN: reside_redir_ch = self._get_reside_redir_for_gateway_port( port['device_id']) From 1c79acb2f48ecb162246456392c495dfa388b495 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Tue, 30 Jul 2024 14:17:44 +0200 Subject: [PATCH 037/184] Lower spacing time of the OVN maintenance tasks which should be run once Some of the OVN maintenance tasks are expected to be run just once and then they raise periodic.NeverAgain() to not be run anymore. Those tasks also require to have acquried ovn db lock so that only one of the maintenance workers really runs them. All those tasks had set 600 seconds as a spacing time so they were run every 600 seconds. This works fine usually but that may cause small issue in the environments were Neutron is run in POD as k8s/openshift application. In such case, when e.g. configuration of neutron is updated, it may happen that first new POD with Neutron is spawned and only once it is already running, k8s will stop old POD. Because of that maintenance worker running in the new neutron-server POD will not acquire lock on the OVN DB (old POD still holds the lock) and will not run all those maintenance tasks immediately. After old POD will be terminated, one of the new PODs will at some point acquire that lock and then will run all those maintenance tasks but this would cause 600 seconds delay in running them. To avoid such long waiting time to run those maintenance tasks, this patch lowers its spacing time from 600 to just 5 seconds. Additionally maintenance tasks which are supposed to be run only once and only by the maintenance worker which has acquired ovn db lock will now be stopped (periodic.NeverAgain will be raised) after 100 attempts of run. This will avoid running them every 5 seconds forever on the workers which don't acquire lock on the OVN DB at all. Conflicts: neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py Closes-bug: #2074209 Change-Id: Iabb4bb427588c1a5da27a5d313f75b5bd23805b2 (cherry picked from commit 04c217bcd0eda07d52a60121b6f86236ba6e26ee) --- neutron/common/ovn/constants.py | 2 + .../ovn/mech_driver/ovsdb/maintenance.py | 116 ++++++++++++++---- .../ovn/mech_driver/ovsdb/test_maintenance.py | 57 +++++++++ 3 files changed, 154 insertions(+), 21 deletions(-) diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index 6f29ba78e3b..5c0aa8951ba 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -275,6 +275,8 @@ TYPE_SECURITY_GROUP_RULES) DB_CONSISTENCY_CHECK_INTERVAL = 300 # 5 minutes +MAINTENANCE_TASK_RETRY_LIMIT = 100 # times +MAINTENANCE_ONE_RUN_TASK_SPACING = 5 # seconds # The order in which the resources should be created or updated by the # maintenance task: Root ones first and leafs at the end. diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index 3c744358118..c6ab5fbbd33 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -54,14 +54,28 @@ INCONSISTENCY_TYPE_DELETE = 'delete' -def has_lock_periodic(*args, **kwargs): +def has_lock_periodic(*args, periodic_run_limit=0, **kwargs): def wrapper(f): + _retries = 0 + @functools.wraps(f) @periodics.periodic(*args, **kwargs) def decorator(self, *args, **kwargs): # This periodic task is included in DBInconsistenciesPeriodics # since it uses the lock to ensure only one worker is executing + # additonally, if periodic_run_limit parameter with value > 0 is + # provided and lock is not acquired for periodic_run_limit + # times, task will not be run anymore by this maintenance worker + nonlocal _retries if not self.has_lock: + if periodic_run_limit > 0: + if _retries >= periodic_run_limit: + LOG.debug("Have not been able to acquire lock to run " + "task '%s' after %s tries, limit reached. " + "No more attempts will be made.", + f, _retries) + raise periodics.NeverAgain() + _retries += 1 return return f(self, *args, **kwargs) return decorator @@ -442,7 +456,10 @@ def _delete_floatingip_and_pf(self, context, fip_id): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_global_dhcp_opts(self): if (not ovn_conf.get_global_dhcpv4_opts() and not ovn_conf.get_global_dhcpv6_opts()): @@ -472,7 +489,10 @@ def check_global_dhcp_opts(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_for_igmp_snoop_support(self): snooping_conf = ovs_conf.get_igmp_snooping_enabled() flood_conf = ovs_conf.get_igmp_flood_unregistered() @@ -502,7 +522,10 @@ def check_for_igmp_snoop_support(self): # TODO(czesla): Remove this in the A+4 cycle # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_port_has_address_scope(self): ports = self._nb_idl.db_find_rows( "Logical_Switch_Port", ("type", "!=", ovn_const.LSP_TYPE_LOCALNET) @@ -537,7 +560,10 @@ def check_port_has_address_scope(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_for_ha_chassis_group(self): # If external ports is not supported stop running # this periodic task @@ -565,7 +591,10 @@ def check_for_ha_chassis_group(self): # TODO(lucasagomes): Remove this in the B+3 cycle # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_for_mcast_flood_reports(self): mcast_flood_conf = ovs_conf.get_igmp_flood() mcast_flood_reports_conf = ovs_conf.get_igmp_flood_reports() @@ -623,7 +652,10 @@ def check_for_mcast_flood_reports(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_localnet_port_has_learn_fdb(self): ports = self._nb_idl.db_find_rows( "Logical_Switch_Port", ("type", "=", ovn_const.LSP_TYPE_LOCALNET) @@ -650,7 +682,10 @@ def check_localnet_port_has_learn_fdb(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_redirect_type_router_gateway_ports(self): """Check OVN router gateway ports Check for the option "redirect-type=bridged" value for @@ -708,7 +743,10 @@ def check_redirect_type_router_gateway_ports(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_vlan_distributed_ports(self): """Check VLAN distributed ports Check for the option "reside-on-redirect-chassis" value for @@ -743,7 +781,10 @@ def check_vlan_distributed_ports(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_fdb_aging_settings(self): """Check FDB aging settings Ensure FDB aging settings are enforced. @@ -779,7 +820,10 @@ def check_fdb_aging_settings(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @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_mac_aging_settings(self): """Ensure that MAC_Binding aging options are set""" with self._nb_idl.transaction(check_error=True) as txn: @@ -795,7 +839,10 @@ def update_mac_aging_settings(self): # "external_ids:OVN_GW_PORT_EXT_ID_KEY" from to each router. # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def remove_gw_ext_ids_from_logical_router(self): """Remove `gw_port_id` and `gw_network_id` external_ids from LRs""" cmds = [] @@ -823,7 +870,10 @@ def remove_gw_ext_ids_from_logical_router(self): # A static spacing value is used here, but this method will only run # once per lock due to the use of periodics.NeverAgain(). - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_baremetal_ports_dhcp_options(self): """Update baremetal ports DHCP options @@ -906,7 +956,10 @@ def update_port_virtual_type(self): raise periodics.NeverAgain() # TODO(ralonsoh): Remove this in the Antelope+4 cycle - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def create_router_extra_attributes_registers(self): """Create missing ``RouterExtraAttributes`` registers. @@ -927,7 +980,10 @@ def create_router_extra_attributes_registers(self): raise periodics.NeverAgain() # TODO(slaweq): Remove this in the E cycle (C+2 as it will be next SLURP) - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def add_gw_port_info_to_logical_router_port(self): """Add info if LRP is connecting internal subnet or ext gateway.""" cmds = [] @@ -961,7 +1017,10 @@ def add_gw_port_info_to_logical_router_port(self): txn.add(cmd) raise periodics.NeverAgain() - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_router_default_route_empty_dst_ip(self): """Check routers with default route with empty dst-ip (LP: #2002993). """ @@ -986,7 +1045,10 @@ def check_router_default_route_empty_dst_ip(self): raise periodics.NeverAgain() # TODO(ralonsoh): Remove this in the Antelope+4 cycle - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def add_vnic_type_and_pb_capabilities_to_lsp(self): """Add the port VNIC type and port binding capabilities to the LSP. @@ -1018,7 +1080,10 @@ def add_vnic_type_and_pb_capabilities_to_lsp(self): raise periodics.NeverAgain() - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def check_fair_meter_consistency(self): """Update the logging meter after neutron-server reload @@ -1138,7 +1203,10 @@ def update_router_distributed_flag(self): raise periodics.NeverAgain() - @has_lock_periodic(spacing=600, run_immediately=True) + @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_nat_floating_ip_with_gateway_port_reference(self): """Set NAT rule gateway_port column to any floating IP without router gateway port uuid reference - LP#2035281. @@ -1185,7 +1253,10 @@ def update_nat_floating_ip_with_gateway_port_reference(self): raise periodics.NeverAgain() # TODO(ralonsoh): Remove this method in the C+2 cycle (next SLURP release) - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def add_provider_resource_association_to_routers(self): """Add the ``ProviderResourceAssociation`` register to all routers""" provider_name = 'ovn' @@ -1204,7 +1275,10 @@ def add_provider_resource_association_to_routers(self): raise periodics.NeverAgain() # TODO(ralonsoh): Remove this method in the C+2 cycle (next SLURP release) - @has_lock_periodic(spacing=600, run_immediately=True) + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) def remove_invalid_gateway_chassis_from_unbound_lrp(self): """Removes all invalid 'Gateway_Chassis' from unbound LRPs""" is_gw = ovn_const.OVN_ROUTER_IS_EXT_GW diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index f083610fcd5..eb024855ac6 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -31,12 +31,69 @@ from neutron.db import ovn_revision_numbers_db from neutron.objects import ports as ports_obj from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import maintenance +from neutron.tests import base from neutron.tests.unit import fake_resources as fakes from neutron.tests.unit.plugins.ml2 import test_security_group as test_sg from neutron.tests.unit import testlib_api from neutron_lib import exceptions as n_exc +class TestHasLockPeriodicDecorator(base.BaseTestCase): + + def test_decorator_no_limit_have_lock(self): + run_counter = 0 + + @maintenance.has_lock_periodic( + periodic_run_limit=0, spacing=30) + def test_maintenance_task(worker): + nonlocal run_counter + run_counter += 1 + + worker_mock = mock.MagicMock() + worker_mock.has_lock = True + + for _ in range(3): + test_maintenance_task(worker_mock) + self.assertEqual(3, run_counter) + + def test_decorator_no_lock_no_limit(self): + run_counter = 0 + + @maintenance.has_lock_periodic( + periodic_run_limit=0, spacing=30) + def test_maintenance_task(worker): + nonlocal run_counter + run_counter += 1 + + worker_mock = mock.MagicMock() + has_lock_values = [False, False, True] + + for has_lock in has_lock_values: + worker_mock.has_lock = has_lock + test_maintenance_task(worker_mock) + self.assertEqual(1, run_counter) + + def test_decorator_no_lock_with_limit(self): + run_counter = 0 + + @maintenance.has_lock_periodic( + periodic_run_limit=1, spacing=30) + def test_maintenance_task(worker): + nonlocal run_counter + run_counter += 1 + + worker_mock = mock.MagicMock() + + worker_mock.has_lock = False + test_maintenance_task(worker_mock) + self.assertEqual(0, run_counter) + + worker_mock.has_lock = False + self.assertRaises(periodics.NeverAgain, + test_maintenance_task, worker_mock) + self.assertEqual(0, run_counter) + + class TestSchemaAwarePeriodicsBase(testlib_api.SqlTestCaseLight): def test__set_schema_aware_periodics(self): From eb7e22adeca85ed1049d89197cc92e996e2bfce0 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 11 Jul 2024 06:43:13 +0000 Subject: [PATCH 038/184] Adopt to StandardAttribute load method change to "selectin" Required since the Depends-On patch included, without it postgres job fails with:- AttributeError: 'NoneType' object has no attribute 'id' Depends-On: https://review.opendev.org/c/openstack/neutron-lib/+/923926 Related-Bug: #2072567 Change-Id: I8f2229eb0a9d8dce927ded004037eda93ce3650d (cherry picked from commit f17cc24e8adb2bf18af32a45a44e68790c50dc6b) --- neutron/db/db_base_plugin_common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/neutron/db/db_base_plugin_common.py b/neutron/db/db_base_plugin_common.py index a903da17144..5e2770ceb8f 100644 --- a/neutron/db/db_base_plugin_common.py +++ b/neutron/db/db_base_plugin_common.py @@ -230,10 +230,10 @@ def _make_port_dict(self, port, fields=None, bulk=False): if isinstance(port, port_obj.Port): port_data = port.db_obj - standard_attr_id = port.db_obj.standard_attr.id + standard_attr_id = port.db_obj.standard_attr_id else: port_data = port - standard_attr_id = port.standard_attr.id + standard_attr_id = port.standard_attr_id mac = port["mac_address"] if isinstance(mac, netaddr.EUI): From 9da2e4aedbb6969a73e0b48d90a2c7f722346bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elvira=20Garc=C3=ADa?= Date: Fri, 9 Aug 2024 18:16:59 +0200 Subject: [PATCH 039/184] Get ips from system dns resolver without scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, is_valid_ipv6 accepts ipv6 addresses with scope. However netaddr library won't accept an address with scope. Now, get_noscope_ipv6() can be used to avoid this situation. In a future we will be able to use the same function which is also being defined on oslo.utils. https://review.opendev.org/c/openstack/oslo.utils/+/925469 Closes-Bug: #2073894 Signed-off-by: Elvira García Change-Id: I27f25f90c54d7aaa3c4a7b5317b4b8a4122e4068 (cherry picked from commit 1ed8609a6818d99133bf56483adb9bce8c886fd6) --- neutron/common/ipv6_utils.py | 18 ++++++++++++++++++ neutron/common/ovn/utils.py | 3 +++ neutron/tests/unit/common/test_ipv6_utils.py | 13 +++++++++++++ 3 files changed, 34 insertions(+) diff --git a/neutron/common/ipv6_utils.py b/neutron/common/ipv6_utils.py index ee715af0786..a2ab7303a60 100644 --- a/neutron/common/ipv6_utils.py +++ b/neutron/common/ipv6_utils.py @@ -16,6 +16,8 @@ """ IPv6-related utilities and helper functions. """ +import ipaddress + import netaddr from neutron_lib import constants as const from oslo_log import log @@ -56,3 +58,19 @@ def valid_ipv6_url(host, port): else: uri = '%s:%s' % (host, port) return uri + + +# TODO(egarciar): Remove and use oslo.utils version of this function whenever +# it is available for Neutron. +# https://review.opendev.org/c/openstack/oslo.utils/+/925469 +def get_noscope_ipv6(address): + try: + _ipv6 = ipaddress.IPv6Address(address) + if _ipv6.scope_id: + address = address.removesuffix('%' + _ipv6.scope_id) + return address + except (ipaddress.AddressValueError, AttributeError): + if netutils.is_valid_ipv6(address): + parts = address.rsplit("%", 1) + return parts[0] + raise diff --git a/neutron/common/ovn/utils.py b/neutron/common/ovn/utils.py index 4beb073d31d..c210685b908 100644 --- a/neutron/common/ovn/utils.py +++ b/neutron/common/ovn/utils.py @@ -41,6 +41,7 @@ from neutron._i18n import _ from neutron.common import _constants as n_const +from neutron.common import ipv6_utils from neutron.common.ovn import constants from neutron.common.ovn import exceptions as ovn_exc from neutron.common import utils as common_utils @@ -605,6 +606,8 @@ def get_system_dns_resolvers(resolver_file=DNS_RESOLVER_FILE): valid_ip = (netutils.is_valid_ipv4(line, strict=True) or netutils.is_valid_ipv6(line)) if valid_ip: + if netutils.is_valid_ipv6(line): + line = ipv6_utils.get_noscope_ipv6(line) resolvers.append(line) return resolvers diff --git a/neutron/tests/unit/common/test_ipv6_utils.py b/neutron/tests/unit/common/test_ipv6_utils.py index 09eda127b70..0041ccbc368 100644 --- a/neutron/tests/unit/common/test_ipv6_utils.py +++ b/neutron/tests/unit/common/test_ipv6_utils.py @@ -98,3 +98,16 @@ def test_valid_hostname_url(self): port = 443 self.assertEqual("controller:443", ipv6_utils.valid_ipv6_url(host, port)) + + +class TestNoscopeIpv6(base.BaseTestCase): + def test_get_noscope_ipv6(self): + self.assertEqual('2001:db8::f0:42:8329', + ipv6_utils.get_noscope_ipv6('2001:db8::f0:42:8329%1')) + self.assertEqual('ff02::5678', + ipv6_utils.get_noscope_ipv6('ff02::5678%eth0')) + self.assertEqual('fe80::1', + ipv6_utils.get_noscope_ipv6('fe80::1%eth0')) + self.assertEqual('::1', ipv6_utils.get_noscope_ipv6('::1%eth0')) + self.assertEqual('::1', ipv6_utils.get_noscope_ipv6('::1')) + self.assertRaises(ValueError, ipv6_utils.get_noscope_ipv6, '::132:::') From 6bf44437488486e4e026aac05f344b3ca9a28419 Mon Sep 17 00:00:00 2001 From: Miguel Lavalle Date: Tue, 18 Jun 2024 19:36:13 -0500 Subject: [PATCH 040/184] Fix support of IPv6 only networks in OVN metadata agent When an IPv6 only network is used as the sole network for a VM and there are no other bound ports on the same network in the same chassis, the OVN metadata agent concludes that the associated namespace is not needed and deletes it. As a consequence, the VM cannot access the metadata service. With this change, the namespace is preserved if there is at least one bound port on the chassis with either IPv4 or IPv6 addresses. Closes-Bug: #2069482 Change-Id: Ie15c3344161ad521bf10b98303c7bb730351e2d8 (cherry picked from commit f7000f3d57bc59732522c4943d6ff2e9dfcf7d31) --- neutron/agent/ovn/metadata/agent.py | 41 ++++++++++++------- .../unit/agent/ovn/metadata/test_agent.py | 17 ++++++-- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/neutron/agent/ovn/metadata/agent.py b/neutron/agent/ovn/metadata/agent.py index 4b060c20a1e..ba9589951a0 100644 --- a/neutron/agent/ovn/metadata/agent.py +++ b/neutron/agent/ovn/metadata/agent.py @@ -617,9 +617,10 @@ def _ensure_datapath_checksum(self, namespace): iptables_mgr.ipv4['mangle'].add_rule('POSTROUTING', rule, wrap=False) iptables_mgr.apply() - def _get_port_ip4_ips(self, port): + def _get_port_ip4_ips_and_ip6_flag(self, port): # Retrieve IPv4 addresses from the port mac column which is in form - # [" ... "] + # [" ... "]. Also return True if the port + # has at least one IPv6 address if not port.mac: LOG.warning("Port %s MAC column is empty, cannot retrieve IP " "addresses", port.uuid) @@ -629,10 +630,17 @@ def _get_port_ip4_ips(self, port): if not ips: LOG.debug("Port %s IP addresses were not retrieved from the " "Port_Binding MAC column %s", port.uuid, mac_field_attrs) - return [ip for ip in ips if ( - utils.get_ip_version(ip) == n_const.IP_VERSION_4)] + ip4_ips = [] + any_ip6 = False + for ip in ips: + if utils.get_ip_version(ip) == n_const.IP_VERSION_4: + ip4_ips.append(ip) + else: + any_ip6 = True + return ip4_ips, any_ip6 - def _active_subnets_cidrs(self, datapath_ports_ips, metadata_port_cidrs): + def _active_subnets_cidrs(self, datapath_ports_ip4_ips, + metadata_port_cidrs): active_subnets_cidrs = set() # Prepopulate a dictionary where each metadata_port_cidr(string) maps # to its netaddr.IPNetwork object. This is so we dont have to @@ -642,7 +650,7 @@ def _active_subnets_cidrs(self, datapath_ports_ips, metadata_port_cidrs): for metadata_port_cidr in metadata_port_cidrs if metadata_port_cidr } - for datapath_port_ip in datapath_ports_ips: + for datapath_port_ip in datapath_ports_ip4_ips: ip_obj = netaddr.IPAddress(datapath_port_ip) for metadata_cidr, metadata_cidr_obj in \ metadata_cidrs_to_network_objects.items(): @@ -652,9 +660,10 @@ def _active_subnets_cidrs(self, datapath_ports_ips, metadata_port_cidrs): return active_subnets_cidrs def _process_cidrs(self, current_namespace_cidrs, - datapath_ports_ips, metadata_port_subnet_cidrs, lla): + datapath_ports_ip4_ips, + metadata_port_subnet_cidrs, lla): active_subnets_cidrs = self._active_subnets_cidrs( - datapath_ports_ips, metadata_port_subnet_cidrs) + datapath_ports_ip4_ips, metadata_port_subnet_cidrs) cidrs_to_add = active_subnets_cidrs - current_namespace_cidrs @@ -713,18 +722,22 @@ def _get_provision_params(self, datapath): chassis_ports = self.sb_idl.get_ports_on_chassis( self._chassis, include_additional_chassis=True) - datapath_ports_ips = [] + datapath_ports_ip4_ips = [] + any_ip6 = False for chassis_port in self._vif_ports(chassis_ports): if str(chassis_port.datapath.uuid) == datapath_uuid: - datapath_ports_ips.extend(self._get_port_ip4_ips(chassis_port)) + ip4_ips, ip6_flag = self._get_port_ip4_ips_and_ip6_flag( + chassis_port) + datapath_ports_ip4_ips.extend(ip4_ips) + any_ip6 = any_ip6 or ip6_flag - if not datapath_ports_ips: + if not (datapath_ports_ip4_ips or any_ip6): LOG.debug("No valid VIF ports were found for network %s, " "tearing the namespace down if needed", net_name) self.teardown_datapath(net_name) return - return net_name, datapath_ports_ips, metadata_port_info + return net_name, datapath_ports_ip4_ips, metadata_port_info def provision_datapath(self, port_binding): """Provision the datapath so that it can serve metadata. @@ -744,7 +757,7 @@ def provision_datapath(self, port_binding): provision_params = self._get_provision_params(datapath) if not provision_params: return - net_name, datapath_ports_ips, metadata_port_info = provision_params + net_name, datapath_ports_ip4_ips, metadata_port_info = provision_params LOG.info("Provisioning metadata for network %s", net_name) # Create the VETH pair if it's not created. Also the add_veth function @@ -780,7 +793,7 @@ def provision_datapath(self, port_binding): cidrs_to_add, cidrs_to_delete = self._process_cidrs( {dev['cidr'] for dev in ip2.addr.list()}, - datapath_ports_ips, + datapath_ports_ip4_ips, metadata_port_info.ip_addresses, ip_lib.get_ipv6_lladdr(metadata_port_info.mac) ) diff --git a/neutron/tests/unit/agent/ovn/metadata/test_agent.py b/neutron/tests/unit/agent/ovn/metadata/test_agent.py index 672999e375a..296ff640465 100644 --- a/neutron/tests/unit/agent/ovn/metadata/test_agent.py +++ b/neutron/tests/unit/agent/ovn/metadata/test_agent.py @@ -30,6 +30,7 @@ from neutron.agent.ovn.metadata import agent from neutron.agent.ovn.metadata import driver from neutron.common.ovn import constants as ovn_const +from neutron.common import utils from neutron.conf.agent.metadata import config as meta_conf from neutron.conf.agent.ovn.metadata import config as ovn_meta_conf from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf @@ -357,12 +358,12 @@ def test__get_provision_params_returns_none_when_no_vif_ports(self): self.assertIsNone(self.agent._get_provision_params(datapath)) tdp.assert_called_once_with(network_id) - def test__get_provision_params_returns_provision_parameters(self): + def _test__get_provision_params_returns_provision_parameters(self, + port_ip): """The happy path when datapath has ports with "external" or ""(blank) types and metadata port contains MAC and subnet CIDRs. """ network_id = '1' - port_ip = '1.2.3.4' metada_port_mac = "fa:16:3e:22:65:18" metada_port_subnet_cidr = "10.204.0.10/29" metada_port_logical_port = "3b66c176-199b-48ec-8331-c1fd3f6e2b44" @@ -388,13 +389,23 @@ def test__get_provision_params_returns_provision_parameters(self): net_name, datapath_port_ips, metadata_port_info = actual_params self.assertEqual(network_id, net_name) - self.assertListEqual([port_ip], datapath_port_ips) + + if utils.get_ip_version(port_ip) == n_const.IP_VERSION_4: + self.assertListEqual([port_ip], datapath_port_ips) self.assertEqual(metada_port_mac, metadata_port_info.mac) self.assertSetEqual(set([metada_port_subnet_cidr]), metadata_port_info.ip_addresses) self.assertEqual(metada_port_logical_port, metadata_port_info.logical_port) + def test__get_provision_params_returns_provision_parameters(self): + self._test__get_provision_params_returns_provision_parameters( + '1.2.3.4') + + def test__get_provision_params_returns_provision_parameters_ipv6(self): + self._test__get_provision_params_returns_provision_parameters( + 'fe80::f816:3eff:feb6:c0c0') + def test_provision_datapath(self): """Test datapath provisioning. From b07e7867f4a9ed2082f15a8dbbeb5c9581922d56 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 3 Sep 2024 09:30:54 +0000 Subject: [PATCH 041/184] [SR-IOV] The port status=DOWN has precedence in the VF link status If a ML2/SR-IOV port is disabled (status=DOWN), it will have precedence on the VF link state value over the "auto" value. That will stop any transmission from the VF. Closes-Bug: #2078789 Change-Id: I11d973d245dd391623e501aa14b470daa780b4db (cherry picked from commit 8211c29158d6fc8a1af938c326dfbaa685428a4a) --- .../plugins/ml2/drivers/mech_sriov/agent/pci_lib.py | 8 ++++++-- .../ml2/drivers/mech_sriov/agent/test_pci_lib.py | 11 ++++++++++- ...state-disable-has-precedence-2adecdf959dc0f9e.yaml | 7 +++++++ 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/sriov-vf-state-disable-has-precedence-2adecdf959dc0f9e.yaml diff --git a/neutron/plugins/ml2/drivers/mech_sriov/agent/pci_lib.py b/neutron/plugins/ml2/drivers/mech_sriov/agent/pci_lib.py index 32931841c12..55a43bcf9dc 100644 --- a/neutron/plugins/ml2/drivers/mech_sriov/agent/pci_lib.py +++ b/neutron/plugins/ml2/drivers/mech_sriov/agent/pci_lib.py @@ -73,10 +73,14 @@ def set_vf_state(self, vf_index, state, auto=False): @param auto: set link_state to auto (0) """ ip = self.device(self.dev_name) - if auto: + # NOTE(ralonsoh): the state=False --> "disable" (2) has precedence over + # "auto" (0) and "enable" (1). + if state is False: + link_state = 2 + elif auto: link_state = 0 else: - link_state = 1 if state else 2 + link_state = 1 vf_config = {'vf': vf_index, 'link_state': link_state} ip.link.set_vf_feature(vf_config) diff --git a/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_pci_lib.py b/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_pci_lib.py index 8276e189a4f..962c0772ee3 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_pci_lib.py +++ b/neutron/tests/unit/plugins/ml2/drivers/mech_sriov/agent/test_pci_lib.py @@ -67,20 +67,29 @@ def test_get_vf_state_not_present(self): self.assertEqual(pci_lib.LinkState.disable.name, result) def test_set_vf_state(self): + # state=True, auto=False --> link_state=enable self.pci_wrapper.set_vf_state(self.VF_INDEX, True) vf = {'vf': self.VF_INDEX, 'link_state': 1} self.mock_ip_device.link.set_vf_feature.assert_called_once_with(vf) + # state=False, auto=False --> link_state=disable self.mock_ip_device.link.set_vf_feature.reset_mock() self.pci_wrapper.set_vf_state(self.VF_INDEX, False) vf = {'vf': self.VF_INDEX, 'link_state': 2} self.mock_ip_device.link.set_vf_feature.assert_called_once_with(vf) + # state=True, auto=True --> link_state=auto self.mock_ip_device.link.set_vf_feature.reset_mock() - self.pci_wrapper.set_vf_state(self.VF_INDEX, False, auto=True) + self.pci_wrapper.set_vf_state(self.VF_INDEX, True, auto=True) vf = {'vf': self.VF_INDEX, 'link_state': 0} self.mock_ip_device.link.set_vf_feature.assert_called_once_with(vf) + # state=False, auto=True --> link_state=disable + self.mock_ip_device.link.set_vf_feature.reset_mock() + self.pci_wrapper.set_vf_state(self.VF_INDEX, False, auto=True) + vf = {'vf': self.VF_INDEX, 'link_state': 2} + self.mock_ip_device.link.set_vf_feature.assert_called_once_with(vf) + def test_set_vf_spoofcheck(self): self.pci_wrapper.set_vf_spoofcheck(self.VF_INDEX, True) vf = {'vf': self.VF_INDEX, 'spoofchk': 1} diff --git a/releasenotes/notes/sriov-vf-state-disable-has-precedence-2adecdf959dc0f9e.yaml b/releasenotes/notes/sriov-vf-state-disable-has-precedence-2adecdf959dc0f9e.yaml new file mode 100644 index 00000000000..6ab968a767e --- /dev/null +++ b/releasenotes/notes/sriov-vf-state-disable-has-precedence-2adecdf959dc0f9e.yaml @@ -0,0 +1,7 @@ +--- +security: + - | + A ML2/SR-IOV port with status=DOWN will always set the VF link state to + "disable", regardless of the ``propagate_uplink_status`` port field value. + The port disabling, to stop any transmission, has precedence over the + link state "auto" value. From 87879f59d94c875f1c220769b7283fbebbbb889f Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 3 Sep 2024 10:31:24 +0000 Subject: [PATCH 042/184] Protect the "standardattr" retrieval from a concurrent deletion The method ``_extend_tags_dict`` can be called from a "list" operation. If one resource and its "standardattr" register is deleted concurrently, the "standard_attr" field retrieval will fail. The "list" operation is protected with a READER transaction context; however this is failing with the DB PostgreSQL backend. Closes-Bug: #2078787 Change-Id: I55142ce21cec8bd8e2d6b7b8b20c0147873699da (cherry picked from commit c7d07b7421034c2722fb0d0cfd2371e052928b97) --- neutron/services/tag/tag_plugin.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/neutron/services/tag/tag_plugin.py b/neutron/services/tag/tag_plugin.py index 23f3cb9ed1a..3c163b5c743 100644 --- a/neutron/services/tag/tag_plugin.py +++ b/neutron/services/tag/tag_plugin.py @@ -47,7 +47,17 @@ def __new__(cls, *args, **kwargs): def _extend_tags_dict(response_data, db_data): if not directory.get_plugin(tagging.TAG_PLUGIN_TYPE): return - tags = [tag_db.tag for tag_db in db_data.standard_attr.tags] + try: + tags = [tag_db.tag for tag_db in db_data.standard_attr.tags] + except AttributeError: + # NOTE(ralonsoh): this method can be called from a "list" + # operation. If one resource and its "standardattr" register is + # deleted concurrently, the "standard_attr" field retrieval will + # fail. + # The "list" operation is protected with a READER transaction + # context; however this is failing with the DB PostgreSQL backend. + # https://bugs.launchpad.net/neutron/+bug/2078787 + tags = [] response_data['tags'] = tags @db_api.CONTEXT_READER From e034dc84343842a2e4c23378ee57de8cec832c5c Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Fri, 30 Aug 2024 11:50:55 +0200 Subject: [PATCH 043/184] Fix port_hardware_offload_type ML2 extension This patch fixes 2 issues related to that port_hardware_offload_type extension: 1. API extension is now not supported by the ML2 plugin directly so if ml2 extension is not loaded Neutron will not report that API extension is available, 2. Fix error 500 when creating port with hardware_offload_type attribute set but when binding:profile is not set (is of type Sentinel). Conflicts: neutron/plugins/ml2/plugin.py Closes-bug: #2078432 Closes-bug: #2078434 Change-Id: Ib0038dd39d8d210104ee8a70e4519124f09292da (cherry picked from commit fbb7c9ae3d672796b72b796c53f89865ea6b3763) --- neutron/db/port_hardware_offload_type_db.py | 2 +- neutron/plugins/ml2/plugin.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/neutron/db/port_hardware_offload_type_db.py b/neutron/db/port_hardware_offload_type_db.py index 21447e82f6d..9e1975e57ca 100644 --- a/neutron/db/port_hardware_offload_type_db.py +++ b/neutron/db/port_hardware_offload_type_db.py @@ -41,7 +41,7 @@ def _process_create_port(self, context, data, result): if hw_type not in capabilities: capabilities.append(hw_type) data[portbindings.PROFILE]['capabilities'] = capabilities - except KeyError: + except (AttributeError, KeyError): data[portbindings.PROFILE] = {'capabilities': [hw_type]} def _extend_port_dict(self, port_db, result): diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index d6d3ff9ec73..c8a891a9cb6 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -43,7 +43,6 @@ from neutron_lib.api.definitions import network_mtu_writable as mtuw_apidef from neutron_lib.api.definitions import port as port_def from neutron_lib.api.definitions import port_device_profile as pdp_def -from neutron_lib.api.definitions import port_hardware_offload_type as phot_def from neutron_lib.api.definitions import port_mac_address_override from neutron_lib.api.definitions import port_mac_address_regenerate from neutron_lib.api.definitions import port_numa_affinity_policy as pnap_def @@ -246,7 +245,6 @@ class Ml2Plugin(db_base_plugin_v2.NeutronDbPluginV2, port_mac_address_override.ALIAS, sg_default_rules_ext.ALIAS, sg_rules_default_sg.ALIAS, - phot_def.ALIAS, ] # List of agent types for which all binding_failed ports should try to be From c648015d4433d2db42dfefd5e0bd7d69e6c6169b Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Mon, 9 Sep 2024 18:01:41 +0530 Subject: [PATCH 044/184] [Stable Only] Switch to branched linux bridge job Change-Id: I57e43a828462d72859d6830f26007849874a58d7 --- zuul.d/job-templates.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zuul.d/job-templates.yaml b/zuul.d/job-templates.yaml index 259e6930476..8b53e95874c 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -106,7 +106,7 @@ - openstack-tox-py39-with-oslo-master: timeout: 3600 irrelevant-files: *irrelevant-files - - neutron-tempest-plugin-linuxbridge + - neutron-tempest-plugin-linuxbridge-2024-1 experimental: jobs: *neutron-periodic-jobs From 21d7fa4760411e08c47c20cf53554f95e3899e47 Mon Sep 17 00:00:00 2001 From: LIU Yulong Date: Fri, 28 Jun 2024 18:08:39 +0800 Subject: [PATCH 045/184] Always get local vlan from port other_config For openvswitch security group, due to some extreme case, if ofport is processed once, the openvswitch security driver will cache some old ofport informations with different local vlan from current assignment. So this patch changes the local_vlan get method to the port other_config, this value should be managed by ovs_agent properly, we can rely on that. Closes-Bug: #2071451 Change-Id: I7ad7df72807c95571ef3156c99072852d1c4f494 (cherry picked from commit ae587c34ab59a5717630eded2fab84413f3c1742) --- .../linux/openvswitch_firewall/firewall.py | 30 +++++++++++-------- .../openvswitch_firewall/test_firewall.py | 15 ++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/neutron/agent/linux/openvswitch_firewall/firewall.py b/neutron/agent/linux/openvswitch_firewall/firewall.py index 8e7c3f5df8e..a739f57bad8 100644 --- a/neutron/agent/linux/openvswitch_firewall/firewall.py +++ b/neutron/agent/linux/openvswitch_firewall/firewall.py @@ -705,6 +705,22 @@ def get_ofport(self, port): port_id = port['device'] return self.sg_port_map.ports.get(port_id) + def _create_of_port(self, port, ovs_port): + # Should always try to get the local vlan tag from + # the OVSDB Port other_config, since the ovs-agent's + # LocalVlanManager always allocated/updated it and then + # set_db_attribute to Port other_config before this. + port_vlan_id = self._get_port_vlan_tag(ovs_port.port_name) + segment_id = self._get_port_segmentation_id( + ovs_port.port_name) + network_type = self._get_port_network_type( + ovs_port.port_name) + physical_network = self._get_port_physical_network( + ovs_port.port_name) + return OFPort(port, ovs_port, port_vlan_id, + segment_id, + network_type, physical_network) + def get_or_create_ofport(self, port): """Get ofport specified by port['device'], checking and reflecting ofport changes. @@ -715,22 +731,12 @@ def get_or_create_ofport(self, port): try: of_port = self.sg_port_map.ports[port_id] except KeyError: - port_vlan_id = self._get_port_vlan_tag(ovs_port.port_name) - segment_id = self._get_port_segmentation_id( - ovs_port.port_name) - network_type = self._get_port_network_type( - ovs_port.port_name) - physical_network = self._get_port_physical_network( - ovs_port.port_name) - of_port = OFPort(port, ovs_port, port_vlan_id, - segment_id, - network_type, physical_network) + of_port = self._create_of_port(port, ovs_port) self.sg_port_map.create_port(of_port, port) else: if of_port.ofport != ovs_port.ofport: self.sg_port_map.remove_port(of_port) - of_port = OFPort(port, ovs_port, of_port.vlan_tag, - of_port.segment_id) + of_port = self._create_of_port(port, ovs_port) self.sg_port_map.create_port(of_port, port) else: self.sg_port_map.update_port(of_port, port) diff --git a/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py b/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py index ee3359e98be..fb647b57f4c 100644 --- a/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py +++ b/neutron/tests/unit/agent/linux/openvswitch_firewall/test_firewall.py @@ -658,6 +658,21 @@ def test_get_or_create_ofport_changed(self): self.assertIn(of_port.id, self.firewall.sg_port_map.ports.keys()) self.assertEqual(port.ofport, 2) + def test_get_or_create_ofport_changed_and_local_vlan_changed(self): + port_dict = { + 'device': 'port-id', + 'security_groups': [123, 456]} + of_port = create_ofport(port_dict) + self.firewall.sg_port_map.ports[of_port.id] = of_port + fake_ovs_port = FakeOVSPort('port', 2, '00:00:00:00:00:00') + self.mock_bridge.br.get_vif_port_by_id.return_value = \ + fake_ovs_port + self.mock_bridge.br.db_get_val.return_value = {"tag": 10} + port = self.firewall.get_or_create_ofport(port_dict) + self.assertIn(of_port.id, self.firewall.sg_port_map.ports.keys()) + self.assertEqual(port.ofport, 2) + self.assertEqual(port.vlan_tag, 10) + def test_get_or_create_ofport_missing(self): port_dict = { 'device': 'port-id', From eca02394acc5969b0602277f285c22c8f986d69d Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Thu, 19 Sep 2024 18:32:11 +0530 Subject: [PATCH 046/184] Handle EndpointNotFound in nova notifier Currently if the nova endpoint do not exist exception is raised. Even the endpoint gets created notification keeps on failing until the session expires. If the endpoint not exist the session is not useful so marking it as invalid, this will ensure if endpoint is created later the notification do not fail. Closes-Bug: #2081174 Change-Id: I1f7fd1d1371ca0a3c4edb409cffd2177d44a1f23 (cherry picked from commit 7d1a20ed4d458c6682a52679b71b6bc8dea20d07) --- neutron/notifiers/nova.py | 3 +++ neutron/tests/unit/notifiers/test_nova.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/neutron/notifiers/nova.py b/neutron/notifiers/nova.py index 1269187ec98..9cb6c3880e2 100644 --- a/neutron/notifiers/nova.py +++ b/neutron/notifiers/nova.py @@ -281,6 +281,9 @@ def send_events(self, batched_events): try: response = novaclient.server_external_events.create( batched_events) + except ks_exceptions.EndpointNotFound: + LOG.exception("Nova endpoint not found, invalidating the session") + self.session.invalidate() except nova_exceptions.NotFound: LOG.debug("Nova returned NotFound for event: %s", batched_events) diff --git a/neutron/tests/unit/notifiers/test_nova.py b/neutron/tests/unit/notifiers/test_nova.py index 4456dd393c4..3567476549b 100644 --- a/neutron/tests/unit/notifiers/test_nova.py +++ b/neutron/tests/unit/notifiers/test_nova.py @@ -237,6 +237,16 @@ def test_no_notification_notify_nova_on_port_data_changes_false(self): {}, {}) self.assertFalse(send_events.called) + @mock.patch('novaclient.client.Client') + def test_nova_send_events_noendpoint_invalidate_session(self, mock_client): + create = mock_client().server_external_events.create + create.side_effect = ks_exc.EndpointNotFound + with mock.patch.object(self.nova_notifier.session, + 'invalidate', return_value=True) as mock_sess: + self.nova_notifier.send_events([]) + create.assert_called() + mock_sess.assert_called() + @mock.patch('novaclient.client.Client') def test_nova_send_events_returns_bad_list(self, mock_client): create = mock_client().server_external_events.create From 5ed1b572b08c609b5d8e88208df49291060a82fc Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 19 Sep 2024 14:00:57 +0000 Subject: [PATCH 047/184] Change the load method of SG rule "default_security_group" Since [1], the SG rule SQL view also retrieves the table "default_security_group", using a complex relationship [2]. When the number of SG rules of a SG is high (above 50 it is clearly noticeable the performance degradation), the API call can take several seconds. For example, for 100 SG rules it can take up to one minute. This patch changes the load method of the SG rule "default_security_group" relationship to "selectin". Benchmarks with a single default SG and 100 rules, doing "openstack security group show $sg": * 2023.2 (without this feature): around 0.05 seconds * master: between 45-50 seconds (1000x time increase) * loading method "selectin" or "dynamic": around 0.5 seconds. NOTE: this feature [1] was implemented in 2024.1. At this time, SQLAlchemy version was <2.0 and "selectin" method was not available. For this version, "dynamic" can be used instead. [1]https://review.opendev.org/q/topic:%22bug/2019960%22 [2]https://github.com/openstack/neutron/blob/08fff4087dc342be40db179fca0cd9bbded91053/neutron/db/models/securitygroup.py#L120-L121 Closes-Bug: #2081087 Change-Id: I46af1179f6905307c0d60b5c0fdee264a40a4eac (cherry picked from commit c1b05e29adf9d0d68c1ac636013a8a363a92eb85) --- neutron/db/models/securitygroup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/db/models/securitygroup.py b/neutron/db/models/securitygroup.py index 86e0e746444..cf9b103ed25 100644 --- a/neutron/db/models/securitygroup.py +++ b/neutron/db/models/securitygroup.py @@ -115,7 +115,7 @@ class DefaultSecurityGroup(model_base.BASEV2, model_base.HasProjectPrimaryKey): primaryjoin="SecurityGroup.id==DefaultSecurityGroup.security_group_id", ) security_group_rule = orm.relationship( - SecurityGroupRule, lazy='joined', + SecurityGroupRule, lazy='selectin', backref=orm.backref('default_security_group'), primaryjoin="foreign(SecurityGroupRule.security_group_id) == " "DefaultSecurityGroup.security_group_id", From 253aa97b343b945b919c7f363771bd72c3b0a9b0 Mon Sep 17 00:00:00 2001 From: Will Szumski Date: Mon, 10 Jun 2024 13:44:14 +0100 Subject: [PATCH 048/184] Correct logic error when associating FIP with OVN LB Fixes a logic error which meant that we didn't iterate over all logical switches when associating a FIP to an OVN loadbalancer. The symptom was that the FIP would show in neutron, but would not exist in OVN. Closes-Bug: #2068644 Change-Id: I6d1979dfb4d6f455ca419e64248087047fbf73d7 Co-Authored-By: Brian Haley (cherry picked from commit d8a4ad9167afd824a3f823d86a8fd33fb67c4abd) --- .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 2 +- ...fix-issue-with-ovn-loadbalancer-fip-4e4bda00cf019f71.yaml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/fix-issue-with-ovn-loadbalancer-fip-4e4bda00cf019f71.yaml diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index acd4c41a0e8..96abc466e64 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -989,7 +989,7 @@ def _handle_lb_fip_cmds(self, context, lb_lsp, if lb in item.load_balancer] if not ls_linked: - return + continue # Find out IP addresses and subnets of configured members. members_to_verify = [] diff --git a/releasenotes/notes/fix-issue-with-ovn-loadbalancer-fip-4e4bda00cf019f71.yaml b/releasenotes/notes/fix-issue-with-ovn-loadbalancer-fip-4e4bda00cf019f71.yaml new file mode 100644 index 00000000000..943aa596d1e --- /dev/null +++ b/releasenotes/notes/fix-issue-with-ovn-loadbalancer-fip-4e4bda00cf019f71.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Fixes an issue when associating floating IPs to OVN load balancers. See `LP#2068644 + `__ for more details. From 2bdba0193aad104a036e8613d7790831b13ebab5 Mon Sep 17 00:00:00 2001 From: elajkat Date: Tue, 10 Sep 2024 09:36:32 +0200 Subject: [PATCH 049/184] [CI] Functional: Increase Ulimit to 4096 Functional tests started to fail with "Too many open files" randomly, the default ulimit in OS is configured to 1024, increasing this to 4096 to avoid these random failures. Closes-Bug: #2080199 Change-Id: Iff86599678ebdd5189d5b56d11f3373c9b138562 (cherry picked from commit 6970f39a49b83f279b9e0479f7637d03a123a40e) --- roles/configure_functional_tests/tasks/main.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/roles/configure_functional_tests/tasks/main.yaml b/roles/configure_functional_tests/tasks/main.yaml index 1bd6c65d399..ee893c93129 100644 --- a/roles/configure_functional_tests/tasks/main.yaml +++ b/roles/configure_functional_tests/tasks/main.yaml @@ -47,6 +47,10 @@ fi configure_host_for_func_testing + echo "$USER soft nofile 4096" | sudo tee /etc/security/limits.d/99-user.conf executable: /bin/bash environment: "{{ override_env | default({})}}" + +- name: Reset ssh connection to pick up limits + meta: reset_connection From a3375a46a49b894464beb3fd52ae2a65a172c423 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Sat, 14 Sep 2024 16:17:18 +0000 Subject: [PATCH 050/184] [OVN] Check metadata HA proxy configuration before restart Since [1], the OVN Metadata agent has support for IPv6. If the agent is updated, the HA proxy instances need to be reconfigured and restarted. However, that needs to be done only once; the next time the OVN agent is restarted, if the HA proxy instances are updated (have IPv6 support), they won't be restarted. [1]https://review.opendev.org/c/openstack/neutron/+/894026 Conflicts: neutron/agent/linux/utils.py neutron/tests/unit/agent/dhcp/test_agent.py Closes-Bug: #2079996 Change-Id: Id0f678c7ffe162df42e18dfebb97dce677fc79fc (cherry picked from commit 7b7f8d986a4f818d289149c6960c9eb8b62b432d) --- neutron/agent/linux/utils.py | 14 +++ neutron/agent/metadata/driver_base.py | 118 +++++++++++++----- neutron/agent/ovn/metadata/agent.py | 8 -- neutron/common/utils.py | 13 ++ neutron/privileged/agent/linux/utils.py | 16 +++ .../functional/agent/linux/test_utils.py | 41 ++++++ neutron/tests/unit/agent/dhcp/test_agent.py | 12 +- neutron/tests/unit/agent/l3/test_agent.py | 8 +- .../tests/unit/agent/metadata/test_driver.py | 42 ++++--- .../unit/agent/ovn/metadata/test_agent.py | 4 +- .../unit/agent/ovn/metadata/test_driver.py | 29 ++--- 11 files changed, 231 insertions(+), 74 deletions(-) diff --git a/neutron/agent/linux/utils.py b/neutron/agent/linux/utils.py index a69ad200fc4..1d2dde3a7e0 100644 --- a/neutron/agent/linux/utils.py +++ b/neutron/agent/linux/utils.py @@ -35,6 +35,7 @@ from oslo_utils import fileutils import psutil +from neutron.common import utils from neutron.conf.agent import common as config from neutron.privileged.agent.linux import utils as priv_utils from neutron import wsgi @@ -400,6 +401,19 @@ def delete_if_exists(path, run_as_root=False): fileutils.delete_if_exists(path) +def read_if_exists(path: str, run_as_root=False) -> str: + """Return the content of a text file as a string + + The output includes the empty lines too. If the file does not exist, + returns an empty string. + It could be called with elevated permissions (root). + """ + if run_as_root: + return priv_utils.read_file(path) + else: + return utils.read_file(path) + + class UnixDomainHTTPConnection(httplib.HTTPConnection): """Connection class for HTTP over UNIX domain socket.""" def __init__(self, host, port=None, strict=None, timeout=None, diff --git a/neutron/agent/metadata/driver_base.py b/neutron/agent/metadata/driver_base.py index 8c8e8f46f72..6be9d129d39 100644 --- a/neutron/agent/metadata/driver_base.py +++ b/neutron/agent/metadata/driver_base.py @@ -46,7 +46,7 @@ """ -class HaproxyConfiguratorBase(object): +class HaproxyConfiguratorBase(object, metaclass=abc.ABCMeta): PROXY_CONFIG_DIR = None HEADER_CONFIG_TEMPLATE = None @@ -76,9 +76,27 @@ def __init__(self, network_id, router_id, unix_socket_path, host, port, # /var/log/haproxy.log on Debian distros, instead of to syslog. uuid = network_id or router_id self.log_tag = "haproxy-{}-{}".format(METADATA_SERVICE_NAME, uuid) + self._haproxy_cfg = '' + self._resource_id = None + self._create_config() - def create_config_file(self): - """Create the config file for haproxy.""" + @property + def haproxy_cfg(self) -> str: + return self._haproxy_cfg + + @property + def resource_id(self) -> str: + return self._resource_id + + def _create_config(self) -> None: + """Create the configuration for haproxy, stored locally + + This method creates a string with the HAProxy configuration, stored in + ``self._haproxy_cfg``. It also stores the resource ID (network, router) + in ``self._resource_id``. + + This method must be called once in the init method. + """ # Need to convert uid/gid into username/group try: username = pwd.getpwuid(int(self.user)).pw_name @@ -127,27 +145,49 @@ def create_config_file(self): cfg_info['res_type'] = 'Router' cfg_info['res_id'] = self.router_id cfg_info['res_type_del'] = 'Network' + self._resource_id = cfg_info['res_id'] + self._haproxy_cfg = comm_meta.get_haproxy_config( + cfg_info, self.rate_limiting_config, + self.HEADER_CONFIG_TEMPLATE, _UNLIMITED_CONFIG_TEMPLATE) - haproxy_cfg = comm_meta.get_haproxy_config(cfg_info, - self.rate_limiting_config, - self.HEADER_CONFIG_TEMPLATE, - _UNLIMITED_CONFIG_TEMPLATE) - - LOG.debug("haproxy_cfg = %s", haproxy_cfg) + def create_config_file(self): + """Read the configuration stored and write the configuration file""" + LOG.debug("haproxy_cfg = %s", self.haproxy_cfg) cfg_dir = self.get_config_path(self.state_path) # uuid has to be included somewhere in the command line so that it can # be tracked by process_monitor. - self.cfg_path = os.path.join(cfg_dir, "%s.conf" % cfg_info['res_id']) + self.cfg_path = os.path.join(cfg_dir, "%s.conf" % self.resource_id) if not os.path.exists(cfg_dir): os.makedirs(cfg_dir) with open(self.cfg_path, "w") as cfg_file: - cfg_file.write(haproxy_cfg) + cfg_file.write(self.haproxy_cfg) @classmethod def get_config_path(cls, state_path): return os.path.join(state_path or cfg.CONF.state_path, cls.PROXY_CONFIG_DIR) + def read_config_file(self) -> str: + """Return a string with the content of the configuration file""" + cfg_path = os.path.join(self.get_config_path(self.state_path), + '%s.conf' % self.resource_id) + return linux_utils.read_if_exists(str(cfg_path), run_as_root=True) + + def is_config_file_obsolete(self) -> bool: + """Compare the instance config and the config file content + + Returns False if both configurations match. This check skips the + "pidfile" line because that is provided just before the process is + started. + """ + def trim_config(haproxy_cfg: str) -> list[str]: + return [line for line in haproxy_cfg.split('\n') + if not line.lstrip().startswith('pidfile')] + + file_config = trim_config(self.read_config_file()) + current_config = trim_config(self.haproxy_cfg) + return file_config != current_config + @classmethod def cleanup_config_file(cls, uuid, state_path): """Delete config file created when metadata proxy was spawned.""" @@ -174,31 +214,39 @@ def _get_metadata_proxy_user_group(cls, conf): return user, group + @classmethod + def _get_haproxy_configurator(cls, bind_address, port, conf, + network_id=None, router_id=None, + bind_address_v6=None, + bind_interface=None, + pid_file=''): + metadata_proxy_socket = conf.metadata_proxy_socket + user, group = cls._get_metadata_proxy_user_group(conf) + configurator = cls.haproxy_configurator() + return configurator(network_id, + router_id, + metadata_proxy_socket, + bind_address, + port, + user, + group, + conf.state_path, + pid_file, + conf.metadata_rate_limiting, + bind_address_v6, + bind_interface) + @classmethod def _get_metadata_proxy_callback(cls, bind_address, port, conf, network_id=None, router_id=None, bind_address_v6=None, bind_interface=None): def callback(pid_file): - metadata_proxy_socket = conf.metadata_proxy_socket - user, group = cls._get_metadata_proxy_user_group(conf) - configurator = cls.haproxy_configurator() - haproxy = configurator(network_id, - router_id, - metadata_proxy_socket, - bind_address, - port, - user, - group, - conf.state_path, - pid_file, - conf.metadata_rate_limiting, - bind_address_v6, - bind_interface) + haproxy = cls._get_haproxy_configurator( + bind_address, port, conf, network_id, router_id, + bind_address_v6, bind_interface, pid_file) haproxy.create_config_file() - proxy_cmd = [HAPROXY_SERVICE, '-f', haproxy.cfg_path] - - return proxy_cmd + return [HAPROXY_SERVICE, '-f', haproxy.cfg_path] return callback @@ -238,6 +286,18 @@ def spawn_monitored_metadata_proxy(cls, monitor, ns_name, port, conf, # Do not use the address or interface when DAD fails bind_address_v6 = bind_interface = None + # If the HAProxy running instance configuration is different from + # the one passed in this call, the HAProxy is stopped. The new + # configuration will be written to the disk and a new instance + # started. + haproxy_cfg = cls._get_haproxy_configurator( + bind_address, port, conf, network_id=network_id, + router_id=router_id, bind_address_v6=bind_address_v6, + bind_interface=bind_interface) + if haproxy_cfg.is_config_file_obsolete(): + cls.destroy_monitored_metadata_proxy( + monitor, haproxy_cfg.resource_id, conf, ns_name) + uuid = network_id or router_id callback = cls._get_metadata_proxy_callback( bind_address, port, conf, diff --git a/neutron/agent/ovn/metadata/agent.py b/neutron/agent/ovn/metadata/agent.py index ba9589951a0..de48c64ef97 100644 --- a/neutron/agent/ovn/metadata/agent.py +++ b/neutron/agent/ovn/metadata/agent.py @@ -378,9 +378,6 @@ def __init__(self, conf): resource_type='metadata') self._sb_idl = None self._post_fork_event = threading.Event() - # We'll restart all haproxy instances upon start so that they honor - # any potential changes in their configuration. - self.restarted_metadata_proxy_set = set() self._chassis = None @property @@ -834,11 +831,6 @@ def provision_datapath(self, port_binding): # Ensure the correct checksum in the metadata traffic. self._ensure_datapath_checksum(namespace) - if net_name not in self.restarted_metadata_proxy_set: - metadata_driver.MetadataDriver.destroy_monitored_metadata_proxy( - self._process_monitor, net_name, self.conf, namespace) - self.restarted_metadata_proxy_set.add(net_name) - # Spawn metadata proxy if it's not already running. metadata_driver.MetadataDriver.spawn_monitored_metadata_proxy( self._process_monitor, namespace, n_const.METADATA_PORT, diff --git a/neutron/common/utils.py b/neutron/common/utils.py index 3ac808ba5aa..6f32788f5a4 100644 --- a/neutron/common/utils.py +++ b/neutron/common/utils.py @@ -1103,3 +1103,16 @@ def parse_permitted_ethertypes(permitted_ethertypes): continue return ret + + +def read_file(path: str) -> str: + """Return the content of a text file as a string + + The output includes the empty lines too. If the file does not exist, + returns an empty string. + """ + try: + with open(path) as file: + return file.read() + except FileNotFoundError: + return '' diff --git a/neutron/privileged/agent/linux/utils.py b/neutron/privileged/agent/linux/utils.py index 1839103d1f4..33268494414 100644 --- a/neutron/privileged/agent/linux/utils.py +++ b/neutron/privileged/agent/linux/utils.py @@ -15,12 +15,14 @@ import os from os import path import re +import typing from eventlet.green import subprocess from neutron_lib.utils import helpers from oslo_concurrency import processutils from oslo_utils import fileutils +from neutron.common import utils from neutron import privileged @@ -52,6 +54,20 @@ def delete_if_exists(_path, remove=os.unlink): fileutils.delete_if_exists(_path, remove=remove) +@privileged.default.entrypoint +def read_file(_path: str) -> str: + return utils.read_file(_path) + + +@privileged.default.entrypoint +def write_to_tempfile(content: bytes, + _path: typing.Optional[str] = None, + suffix: str = '', + prefix: str = 'tmp'): + return fileutils.write_to_tempfile(content, path=_path, suffix=suffix, + prefix=prefix) + + @privileged.default.entrypoint def execute_process(cmd, _process_input, addl_env): obj, cmd = _create_process(cmd, addl_env=addl_env) diff --git a/neutron/tests/functional/agent/linux/test_utils.py b/neutron/tests/functional/agent/linux/test_utils.py index b65233efd58..2ce5891fc13 100644 --- a/neutron/tests/functional/agent/linux/test_utils.py +++ b/neutron/tests/functional/agent/linux/test_utils.py @@ -15,10 +15,15 @@ import functools import os import signal +import tempfile + +from oslo_utils import fileutils +import testscenarios from neutron.agent.common import async_process from neutron.agent.linux import utils from neutron.common import utils as common_utils +from neutron.privileged.agent.linux import utils as priv_utils from neutron.tests.functional.agent.linux import test_async_process from neutron.tests.functional import base as functional_base @@ -172,3 +177,39 @@ def test_find_non_existing_process(self): with open('/proc/sys/kernel/pid_max', 'r') as fd: pid_max = int(fd.readline().strip()) self.assertEqual([], utils.find_child_pids(pid_max)) + + +class ReadIfExists(testscenarios.WithScenarios, + functional_base.BaseSudoTestCase): + scenarios = [ + ('root', {'run_as_root': True}), + ('non-root', {'run_as_root': False})] + + FILE = """Test file +line 2 + +line 4 + +""" + + @classmethod + def _write_file(cls, path='/tmp', run_as_root=False): + content = cls.FILE.encode('ascii') + if run_as_root: + return priv_utils.write_to_tempfile(content, _path=path) + else: + return fileutils.write_to_tempfile(content, path=path) + + def test_read_if_exists(self): + test_file_path = self._write_file(run_as_root=self.run_as_root) + content = utils.read_if_exists(test_file_path, + run_as_root=self.run_as_root) + file = self.FILE + self.assertEqual(file, content) + + def test_read_if_exists_no_file(self): + temp_dir = tempfile.TemporaryDirectory() + content = utils.read_if_exists( + os.path.join(temp_dir.name, 'non-existing-file'), + run_as_root=self.run_as_root) + self.assertEqual('', content) diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index d53cb5cbd8b..f7aede9b873 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -37,6 +37,7 @@ from neutron.agent.linux import interface from neutron.agent.linux import utils as linux_utils from neutron.agent.metadata import driver as metadata_driver +from neutron.agent.metadata import driver_base as metadata_driver_base from neutron.common import config as common_config from neutron.common.ovn import constants as ovn_const from neutron.common import utils @@ -841,6 +842,9 @@ def _enable_dhcp_helper(self, network, enable_isolated_metadata=False, mock.call(FAKE_NETWORK_UUID, cfg.CONF, ns_name=FAKE_NETWORK_DHCP_NS, callback=mock.ANY)) + mock.patch.object(metadata_driver_base.HaproxyConfiguratorBase, + 'is_config_file_obsolete', + return_value=False).start() self.plugin.get_network_info.return_value = network process_instance = mock.Mock(active=False) with mock.patch.object(metadata_driver.MetadataDriver, @@ -1036,7 +1040,9 @@ def test_enable_isolated_metadata_proxy(self): process_instance = mock.Mock(active=False) with mock.patch.object(metadata_driver.MetadataDriver, '_get_metadata_proxy_process_manager', - return_value=process_instance) as gmppm: + return_value=process_instance) as gmppm,\ + mock.patch.object(metadata_driver_base.MetadataDriverBase, + '_get_haproxy_configurator'): self.dhcp.enable_isolated_metadata_proxy(fake_network) gmppm.assert_called_with(FAKE_NETWORK_UUID, cfg.CONF, @@ -1135,7 +1141,9 @@ def test_enable_isolated_metadata_proxy_with_2_agents_network_ipv6(self): network.ports = [dhcp_port_this_host, dhcp_port_other_host] self._test_enable_isolated_metadata_proxy_ipv6(network) - def _test_disable_isolated_metadata_proxy(self, network): + @mock.patch.object(metadata_driver_base.HaproxyConfiguratorBase, + 'is_config_file_obsolete', return_value=False) + def _test_disable_isolated_metadata_proxy(self, network, *args): cfg.CONF.set_override('enable_metadata_network', True) method_path = ('neutron.agent.metadata.driver.MetadataDriver' '.destroy_monitored_metadata_proxy') diff --git a/neutron/tests/unit/agent/l3/test_agent.py b/neutron/tests/unit/agent/l3/test_agent.py index 0d51040a86e..b53014a4093 100644 --- a/neutron/tests/unit/agent/l3/test_agent.py +++ b/neutron/tests/unit/agent/l3/test_agent.py @@ -58,6 +58,7 @@ from neutron.agent.linux import ra from neutron.agent.linux import utils as linux_utils from neutron.agent.metadata import driver as metadata_driver +from neutron.agent.metadata import driver_base as metadata_driver_base from neutron.agent import rpc as agent_rpc from neutron.conf.agent import common as agent_config from neutron.conf.agent.l3 import config as l3_config @@ -298,7 +299,9 @@ def test_enqueue_state_change_metadata_disable(self): eventlet.sleep(self.conf.ha_vrrp_advert_int + 2) self.assertFalse(agent._update_metadata_proxy.call_count) - def test_enqueue_state_change_l3_extension(self): + @mock.patch.object(metadata_driver_base.MetadataDriverBase, + '_get_haproxy_configurator') + def test_enqueue_state_change_l3_extension(self, mock_haproxy_conf): self.conf.set_override('ha_vrrp_advert_int', 1) agent = l3_agent.L3NATAgent(HOSTNAME, self.conf) router_dict = {'id': 'router_id', 'enable_ndp_proxy': True} @@ -307,6 +310,9 @@ def test_enqueue_state_change_l3_extension(self): router_info.router = router_dict agent.router_info['router_id'] = router_info agent.l3_ext_manager.ha_state_change = mock.Mock() + haproxy_cfg = mock.Mock() + haproxy_cfg.is_config_file_obsolete.return_value = False + mock_haproxy_conf.return_value = haproxy_cfg with mock.patch('neutron.agent.linux.ip_lib.' 'IpAddrCommand.wait_until_address_ready') as mock_wait: mock_wait.return_value = True diff --git a/neutron/tests/unit/agent/metadata/test_driver.py b/neutron/tests/unit/agent/metadata/test_driver.py index 0fe5ba44ffe..cf9606888c6 100644 --- a/neutron/tests/unit/agent/metadata/test_driver.py +++ b/neutron/tests/unit/agent/metadata/test_driver.py @@ -112,6 +112,9 @@ def setUp(self): meta_conf.register_meta_conf_opts( meta_conf.METADATA_RATE_LIMITING_OPTS, cfg.CONF, group=meta_conf.RATE_LIMITING_GROUP) + self.mock_conf_obsolete = mock.patch.object( + driver_base.HaproxyConfiguratorBase, + 'is_config_file_obsolete').start() def test_after_router_updated_called_on_agent_process_update(self): with mock.patch.object(metadata_driver, 'after_router_updated') as f,\ @@ -153,7 +156,8 @@ def test_after_router_updated_should_not_call_add_metadata_rules(self): agent._process_updated_router(router) f.assert_not_called() - def _test_spawn_metadata_proxy(self, dad_failed=False, rate_limited=False): + def _test_spawn_metadata_proxy(self, dad_failed=False, rate_limited=False, + is_config_file_obsolete=False): router_id = _uuid() router_ns = 'qrouter-%s' % router_id service_name = 'haproxy' @@ -163,6 +167,11 @@ def _test_spawn_metadata_proxy(self, dad_failed=False, rate_limited=False): cfg.CONF.set_override('metadata_proxy_group', self.EGNAME) cfg.CONF.set_override('metadata_proxy_socket', self.METADATA_SOCKET) cfg.CONF.set_override('debug', True) + self.mock_conf_obsolete.return_value = is_config_file_obsolete + if is_config_file_obsolete: + self.mock_destroy_haproxy = mock.patch.object( + driver_base.MetadataDriverBase, + 'destroy_monitored_metadata_proxy').start() with mock.patch(ip_class_path) as ip_mock,\ mock.patch( @@ -274,6 +283,10 @@ def _test_spawn_metadata_proxy(self, dad_failed=False, rate_limited=False): self.delete_if_exists.assert_called_once_with( mock.ANY, run_as_root=True) + if is_config_file_obsolete: + self.mock_destroy_haproxy.assert_called_once_with( + agent.process_monitor, router_id, agent.conf, router_ns) + def test_spawn_metadata_proxy(self): self._test_spawn_metadata_proxy() @@ -294,6 +307,9 @@ def test_metadata_proxy_conf_parse_ip_versions(self): def test_spawn_metadata_proxy_dad_failed(self): self._test_spawn_metadata_proxy(dad_failed=True) + def test_spawn_metadata_proxy_no_matching_configurations(self): + self._test_spawn_metadata_proxy(is_config_file_obsolete=True) + @mock.patch.object(driver_base.LOG, 'error') def test_spawn_metadata_proxy_handles_process_exception(self, error_log): process_instance = mock.Mock(active=False) @@ -316,29 +332,21 @@ def test_spawn_metadata_proxy_handles_process_exception(self, error_log): def test_create_config_file_wrong_user(self): with mock.patch('pwd.getpwnam', side_effect=KeyError): - config = metadata_driver.HaproxyConfigurator(_uuid(), - mock.ANY, mock.ANY, - mock.ANY, mock.ANY, - self.EUNAME, - self.EGNAME, - mock.ANY, mock.ANY, - mock.ANY) self.assertRaises(comm_meta.InvalidUserOrGroupException, - config.create_config_file) + metadata_driver.HaproxyConfigurator, _uuid(), + mock.ANY, mock.ANY, mock.ANY, mock.ANY, + self.EUNAME, self.EGNAME, mock.ANY, mock.ANY, + mock.ANY) def test_create_config_file_wrong_group(self): with mock.patch('grp.getgrnam', side_effect=KeyError),\ mock.patch('pwd.getpwnam', return_value=test_utils.FakeUser(self.EUNAME)): - config = metadata_driver.HaproxyConfigurator(_uuid(), - mock.ANY, mock.ANY, - mock.ANY, mock.ANY, - self.EUNAME, - self.EGNAME, - mock.ANY, mock.ANY, - mock.ANY) self.assertRaises(comm_meta.InvalidUserOrGroupException, - config.create_config_file) + metadata_driver.HaproxyConfigurator, _uuid(), + mock.ANY, mock.ANY, mock.ANY, mock.ANY, + self.EUNAME, self.EGNAME, mock.ANY, mock.ANY, + mock.ANY) def test_destroy_monitored_metadata_proxy(self): mproxy_process = mock.Mock(active=False) diff --git a/neutron/tests/unit/agent/ovn/metadata/test_agent.py b/neutron/tests/unit/agent/ovn/metadata/test_agent.py index 296ff640465..61f3f8c2b7a 100644 --- a/neutron/tests/unit/agent/ovn/metadata/test_agent.py +++ b/neutron/tests/unit/agent/ovn/metadata/test_agent.py @@ -446,8 +446,7 @@ def test_provision_datapath(self): ip_wrap, 'add_veth', return_value=[ip_lib.IPDevice('ip1'), ip_lib.IPDevice('ip2')]) as add_veth,\ - mock.patch.object( - linux_utils, 'delete_if_exists') as mock_delete,\ + mock.patch.object(linux_utils, 'delete_if_exists'), \ mock.patch.object( driver.MetadataDriver, 'spawn_monitored_metadata_proxy') as spawn_mdp, \ @@ -488,7 +487,6 @@ def test_provision_datapath(self): self.assertCountEqual(expected_call, ip_addr_add_multiple.call_args.args[0]) # Check that metadata proxy has been spawned - mock_delete.assert_called_once_with(mock.ANY, run_as_root=True) spawn_mdp.assert_called_once_with( mock.ANY, nemaspace_name, 80, mock.ANY, bind_address=n_const.METADATA_V4_IP, network_id=net_name, diff --git a/neutron/tests/unit/agent/ovn/metadata/test_driver.py b/neutron/tests/unit/agent/ovn/metadata/test_driver.py index 263c8daca32..c52fdd65a98 100644 --- a/neutron/tests/unit/agent/ovn/metadata/test_driver.py +++ b/neutron/tests/unit/agent/ovn/metadata/test_driver.py @@ -105,7 +105,10 @@ def _test_spawn_metadata_proxy(self, rate_limited=False): mock.patch( 'neutron.agent.linux.ip_lib.' 'IpAddrCommand.wait_until_address_ready', - return_value=True): + return_value=True),\ + mock.patch.object(driver_base.HaproxyConfiguratorBase, + 'is_config_file_obsolete', + return_value=False): cfg_file = os.path.join( metadata_driver.HaproxyConfigurator.get_config_path( agent.conf.state_path), @@ -184,7 +187,9 @@ def test_spawn_metadata_proxy_handles_process_exception(self, error_log): with mock.patch.object(metadata_driver.MetadataDriver, '_get_metadata_proxy_process_manager', - return_value=process_instance): + return_value=process_instance),\ + mock.patch.object(driver_base.MetadataDriverBase, + '_get_haproxy_configurator'): process_monitor = mock.Mock() network_id = 123456 @@ -201,22 +206,18 @@ def test_spawn_metadata_proxy_handles_process_exception(self, error_log): def test_create_config_file_wrong_user(self): with mock.patch('pwd.getpwnam', side_effect=KeyError): - config = metadata_driver.HaproxyConfigurator(mock.ANY, mock.ANY, - mock.ANY, mock.ANY, - mock.ANY, self.EUNAME, - self.EGNAME, mock.ANY, - mock.ANY, mock.ANY) self.assertRaises(comm_meta.InvalidUserOrGroupException, - config.create_config_file) + metadata_driver.HaproxyConfigurator, mock.ANY, + mock.ANY, mock.ANY, mock.ANY, mock.ANY, + self.EUNAME, self.EGNAME, mock.ANY, mock.ANY, + mock.ANY) def test_create_config_file_wrong_group(self): with mock.patch('grp.getgrnam', side_effect=KeyError),\ mock.patch('pwd.getpwnam', return_value=test_utils.FakeUser(self.EUNAME)): - config = metadata_driver.HaproxyConfigurator(mock.ANY, mock.ANY, - mock.ANY, mock.ANY, - mock.ANY, self.EUNAME, - self.EGNAME, mock.ANY, - mock.ANY, mock.ANY) self.assertRaises(comm_meta.InvalidUserOrGroupException, - config.create_config_file) + metadata_driver.HaproxyConfigurator, mock.ANY, + mock.ANY, mock.ANY, mock.ANY, mock.ANY, + self.EUNAME, self.EGNAME, mock.ANY, mock.ANY, + mock.ANY) From 4cd5ed0dfeee01717f53288de8b56c6653fb2233 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 25 Sep 2024 07:17:07 +0000 Subject: [PATCH 051/184] Use the declarative attribute ``standard_attr_id`` In those Neutron objects and DB definitions where the declarative attribute ``standard_attr_id`` is defined, use it instead of accessing to the ``standard_attr`` child object. Closes-Bug: #2081945 Change-Id: Iadfbeff79c0200c3a6b90f785b910dc391f9deb3 (cherry picked from commit 144e140e750987a286e6adc74ff0ffad1da474d6) --- neutron/db/db_base_plugin_common.py | 6 +++--- neutron/db/l3_db.py | 2 +- neutron/db/securitygroups_db.py | 6 +++--- neutron/services/ovn_l3/service_providers/ovn.py | 4 ++-- neutron/tests/unit/fake_resources.py | 4 +++- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/neutron/db/db_base_plugin_common.py b/neutron/db/db_base_plugin_common.py index 5e2770ceb8f..3198b42d3ef 100644 --- a/neutron/db/db_base_plugin_common.py +++ b/neutron/db/db_base_plugin_common.py @@ -147,9 +147,9 @@ def _store_ip_allocation(context, ip_address, network_id, subnet_id, def _make_subnet_dict(self, subnet, fields=None, context=None): if isinstance(subnet, subnet_obj.Subnet): - standard_attr_id = subnet.db_obj.standard_attr.id + standard_attr_id = subnet.db_obj.standard_attr_id else: - standard_attr_id = subnet.standard_attr.id + standard_attr_id = subnet.standard_attr_id res = {'id': subnet['id'], 'name': subnet['name'], @@ -338,7 +338,7 @@ def _make_network_dict(self, network, fields=None, 'status': network['status'], 'subnets': [subnet['id'] for subnet in network['subnets']], - 'standard_attr_id': network.standard_attr.id} + 'standard_attr_id': network.standard_attr_id} res['shared'] = self._is_network_shared(context, network.rbac_entries) # Call auxiliary extend functions, if any if process_extensions: diff --git a/neutron/db/l3_db.py b/neutron/db/l3_db.py index 14060532f34..2149dc533ea 100644 --- a/neutron/db/l3_db.py +++ b/neutron/db/l3_db.py @@ -1245,7 +1245,7 @@ def _make_floatingip_dict(self, floatingip, fields=None, 'port_id': floatingip.fixed_port_id, 'fixed_ip_address': fixed_ip_address, 'status': floatingip.status, - 'standard_attr_id': floatingip.db_obj.standard_attr.id, + 'standard_attr_id': floatingip.db_obj.standard_attr_id, } # NOTE(mlavalle): The following assumes this mixin is used in a # class inheriting from CommonDbMixin, which is true for all existing diff --git a/neutron/db/securitygroups_db.py b/neutron/db/securitygroups_db.py index 4f2c1377bc5..2cce69c5a69 100644 --- a/neutron/db/securitygroups_db.py +++ b/neutron/db/securitygroups_db.py @@ -326,7 +326,7 @@ def _make_security_group_dict(self, security_group, fields=None): 'stateful': security_group['stateful'], 'tenant_id': security_group['tenant_id'], 'description': security_group['description'], - 'standard_attr_id': security_group.db_obj.standard_attr.id, + 'standard_attr_id': security_group.db_obj.standard_attr_id, 'shared': security_group['shared'], } if security_group.rules: @@ -498,7 +498,7 @@ def _make_default_security_group_rule_dict(self, rule_obj, fields=None): 'remote_address_group_id': rule_obj[ 'remote_address_group_id'], 'remote_group_id': rule_obj['remote_group_id'], - 'standard_attr_id': rule_obj.db_obj.standard_attr.id, + 'standard_attr_id': rule_obj.db_obj.standard_attr_id, 'description': rule_obj['description'], 'used_in_default_sg': rule_obj['used_in_default_sg'], 'used_in_non_default_sg': rule_obj['used_in_non_default_sg'] @@ -905,7 +905,7 @@ def _make_security_group_rule_dict(self, security_group_rule, fields=None): 'normalized_cidr': self._get_normalized_cidr_from_rule( sg_rule_db), 'remote_group_id': sg_rule_db.remote_group_id, - 'standard_attr_id': sg_rule_db.standard_attr.id, + 'standard_attr_id': sg_rule_db.standard_attr_id, 'belongs_to_default_sg': belongs_to_default_sg, } diff --git a/neutron/services/ovn_l3/service_providers/ovn.py b/neutron/services/ovn_l3/service_providers/ovn.py index 00ea368748c..105eda41259 100644 --- a/neutron/services/ovn_l3/service_providers/ovn.py +++ b/neutron/services/ovn_l3/service_providers/ovn.py @@ -61,7 +61,7 @@ def _process_router_create_precommit(self, resource, event, trigger, db_rev.create_initial_revision( context, router_id, ovn_const.TYPE_ROUTERS, - std_attr_id=router_db.standard_attr.id) + std_attr_id=router_db.standard_attr_id) @registry.receives(resources.ROUTER, [events.AFTER_CREATE]) def _process_router_create(self, resource, event, trigger, payload): @@ -167,7 +167,7 @@ def _create_floatingip_initial_revision(self, context, floatingip_db): return db_rev.create_initial_revision( context, floatingip_db.id, ovn_const.TYPE_FLOATINGIPS, - may_exist=True, std_attr_id=floatingip_db.standard_attr.id) + may_exist=True, std_attr_id=floatingip_db.standard_attr_id) @registry.receives(resources.FLOATING_IP, [events.PRECOMMIT_CREATE, events.PRECOMMIT_UPDATE]) diff --git a/neutron/tests/unit/fake_resources.py b/neutron/tests/unit/fake_resources.py index 436d2ebc4b5..7813a17af64 100644 --- a/neutron/tests/unit/fake_resources.py +++ b/neutron/tests/unit/fake_resources.py @@ -712,6 +712,7 @@ def create_one_fip(attrs=None): # Set default attributes. fake_uuid = uuidutils.generate_uuid() + standard_attr = FakeStandardAttribute() fip_attrs = { 'id': 'fip-id-' + fake_uuid, 'tenant_id': '', @@ -728,7 +729,8 @@ def create_one_fip(attrs=None): 'dns_domain': '', 'dns_name': '', 'project_id': '', - 'standard_attr': FakeStandardAttribute(), + 'standard_attr': standard_attr, + 'standard_attr_id': standard_attr.id, 'qos_policy_binding': FakeQosFIPPolicyBinding(), 'qos_network_policy_binding': FakeQosNetworkPolicyBinding(), } From 4f06f63911105405cf0c1a403a2b8184ee4db6a5 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 10 Oct 2024 08:49:44 +0000 Subject: [PATCH 052/184] Optimize the SG rule retrieval There are some operations where the SG DB object can be used instead of the SG OVO. That saves conversion time, including the conversion of the SG rule OVOs, that are child resources of the SG OVO. This optimization applies to the following methods: * SecurityGroupDbMixin.get_security_groups * SecurityGroupDbMixin.update_security_group (partially) The Nova query to retrieve the SG list in the "server list" command, has been benchmarked. The testing environment had a single SG with 250 SG rules. Call: "GET /networking/v2.0/security-groups?id=81f64aa4-2cea-46db-8fea-cd944f106aab &fields=id&fields=name HTTP/1.1" * Without this patch: around 1.25 seconds * With this patch: around 0.025 second (50x improvement). Closes-bug: #2083682 Change-Id: Ibd032ea77c5bfbc1fa80b3b3ee9ba7d5c36bb1bc (cherry picked from commit adbc3e23b7d2251cc7de088e2a757674a41c2f6a) --- neutron/db/securitygroups_db.py | 72 +++++++++++++++++++++------------ neutron/objects/base.py | 6 ++- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/neutron/db/securitygroups_db.py b/neutron/db/securitygroups_db.py index 2cce69c5a69..17a30bb17ba 100644 --- a/neutron/db/securitygroups_db.py +++ b/neutron/db/securitygroups_db.py @@ -44,6 +44,7 @@ from neutron.extensions import securitygroup as ext_sg from neutron.objects import base as base_obj from neutron.objects import ports as port_obj +from neutron.objects import rbac_db as rbac_db_obj from neutron.objects import securitygroup as sg_obj from neutron.objects import securitygroup_default_rules as sg_default_rules_obj from neutron import quota @@ -131,8 +132,8 @@ def create_security_group(self, context, security_group, default_sg=False): # be used here otherwise, SG will not be found and error 500 will # be returned through the API get_context = context.elevated() if default_sg else context - sg = sg_obj.SecurityGroup.get_object(get_context, id=sg.id) - secgroup_dict = self._make_security_group_dict(sg) + sg = self._get_security_group(get_context, sg.id) + secgroup_dict = self._make_security_group_dict(context, sg) self._registry_publish(resources.SECURITY_GROUP, events.PRECOMMIT_CREATE, exc_cls=ext_sg.SecurityGroupConflict, @@ -174,9 +175,10 @@ def get_security_groups(self, context, filters=None, fields=None, sg_objs = sg_obj.SecurityGroup.get_objects( context, _pager=pager, validate_filters=False, - fields=fields, **filters) + fields=fields, return_db_obj=True, **filters) - return [self._make_security_group_dict(obj, fields) for obj in sg_objs] + return [self._make_security_group_dict(context, obj, fields) + for obj in sg_objs] @db_api.retry_if_session_inactive() def get_security_groups_count(self, context, filters=None): @@ -195,8 +197,8 @@ def get_security_group(self, context, id, fields=None, tenant_id=None): try: with db_api.CONTEXT_READER.using(context): - ret = self._make_security_group_dict(self._get_security_group( - context, id, fields=fields), fields) + sg = self._get_security_group(context, id, fields=fields) + ret = self._make_security_group_dict(context, sg, fields) if (fields is None or len(fields) == 0 or 'security_group_rules' in fields): rules = self.get_security_group_rules( @@ -209,12 +211,21 @@ def get_security_group(self, context, id, fields=None, tenant_id=None): context.tenant_id = tmp_context_tenant_id return ret - def _get_security_group(self, context, id, fields=None): - sg = sg_obj.SecurityGroup.get_object(context, fields=fields, id=id) + @staticmethod + def _get_security_group(context, _id, fields=None): + sg = sg_obj.SecurityGroup.get_object(context, fields=fields, id=_id) if sg is None: - raise ext_sg.SecurityGroupNotFound(id=id) + raise ext_sg.SecurityGroupNotFound(id=_id) return sg + @staticmethod + def _get_security_group_db(context, _id, fields=None): + sg_db = sg_obj.SecurityGroup.get_object( + context, fields=fields, id=_id, return_db_obj=True) + if sg_db is None: + raise ext_sg.SecurityGroupNotFound(id=_id) + return sg_db + def _check_security_group(self, context, id, tenant_id=None): if tenant_id: tmp_context_tenant_id = context.tenant_id @@ -258,7 +269,7 @@ def delete_security_group(self, context, id): # consistency with deleted rules sg = self._get_security_group(context, id) sgr_ids = [r['id'] for r in sg.rules] - sec_group = self._make_security_group_dict(sg) + sec_group = self._make_security_group_dict(context, sg) self._registry_publish(resources.SECURITY_GROUP, events.PRECOMMIT_DELETE, exc_cls=ext_sg.SecurityGroupInUse, @@ -282,8 +293,8 @@ def update_security_group(self, context, id, security_group): if 'stateful' in s: with db_api.CONTEXT_READER.using(context): - sg = self._get_security_group(context, id) - if s['stateful'] != sg['stateful']: + sg_db = self._get_security_group_db(context, id) + if s['stateful'] != sg_db['stateful']: filters = {'security_group_id': [id]} ports = self._get_port_security_group_bindings(context, filters) @@ -299,11 +310,11 @@ def update_security_group(self, context, id, security_group): sg = self._get_security_group(context, id) if sg.name == 'default' and 'name' in s: raise ext_sg.SecurityGroupCannotUpdateDefault() - sg_dict = self._make_security_group_dict(sg) + sg_dict = self._make_security_group_dict(context, sg) original_security_group = sg_dict sg.update_fields(s) sg.update() - sg_dict = self._make_security_group_dict(sg) + sg_dict = self._make_security_group_dict(context, sg) self._registry_publish( resources.SECURITY_GROUP, events.PRECOMMIT_UPDATE, @@ -320,24 +331,33 @@ def update_security_group(self, context, id, security_group): return sg_dict - def _make_security_group_dict(self, security_group, fields=None): + def _make_security_group_dict(self, context, security_group, fields=None): + """Return the security group in a dictionary + + :param context: Neutron API request context. + :param security_group: DB object or OVO of the security group. + :param fields: list of fields to filter the returned dictionary. + :return: a dictionary with the security group definition. + """ + rules = security_group.rules or [] + if isinstance(security_group, sg_obj.SecurityGroup): + shared = security_group.shared + security_group = security_group.db_obj + else: + rbac_entries = security_group['rbac_entries'] + shared = rbac_db_obj.RbacNeutronDbObjectMixin.is_network_shared( + context, rbac_entries) res = {'id': security_group['id'], 'name': security_group['name'], 'stateful': security_group['stateful'], 'tenant_id': security_group['tenant_id'], 'description': security_group['description'], - 'standard_attr_id': security_group.db_obj.standard_attr_id, - 'shared': security_group['shared'], + 'standard_attr_id': security_group.standard_attr_id, + 'shared': shared, + 'security_group_rules': [self._make_security_group_rule_dict(r) + for r in rules], } - if security_group.rules: - res['security_group_rules'] = [ - self._make_security_group_rule_dict(r) - for r in security_group.rules - ] - else: - res['security_group_rules'] = [] - resource_extend.apply_funcs(ext_sg.SECURITYGROUPS, res, - security_group.db_obj) + resource_extend.apply_funcs(ext_sg.SECURITYGROUPS, res, security_group) return db_utils.resource_fields(res, fields) @staticmethod diff --git a/neutron/objects/base.py b/neutron/objects/base.py index c2c9601ef6e..acde5c1b04b 100644 --- a/neutron/objects/base.py +++ b/neutron/objects/base.py @@ -608,7 +608,7 @@ def db_context_reader(cls, context): return db_api.CONTEXT_READER.using(context) @classmethod - def get_object(cls, context, fields=None, **kwargs): + def get_object(cls, context, fields=None, return_db_obj=False, **kwargs): """Fetch a single object Return the first result of given context or None if the result doesn't @@ -620,6 +620,8 @@ def get_object(cls, context, fields=None, **kwargs): avoid loading synthetic fields when possible, and does not affect db queries. Default is None, which is the same as []. Example: ['id', 'name'] + :param return_db_obj: return the DB model object instead of loading + the OVO; that could save some time. :param kwargs: multiple keys defined by key=value pairs :return: single object of NeutronDbObject class or None """ @@ -633,6 +635,8 @@ def get_object(cls, context, fields=None, **kwargs): with cls.db_context_reader(context): db_obj = obj_db_api.get_object( cls, context, **cls.modify_fields_to_db(kwargs)) + if return_db_obj: + return db_obj if db_obj: return cls._load_object(context, db_obj, fields=fields) From fcbbff0eb04caebf70c0aead6aff2793ec4dc731 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 11 Oct 2024 06:09:33 +0000 Subject: [PATCH 053/184] "security_group_rules" is not a SG selectable field When building the security group dictionary, it is not needed to build the security group rules objects individually. These objects (OVO) are built along with the security group OVO and added in the result dictionary in ``_make_security_group_dict``. Related-Bug: #2083682 Change-Id: I66fbf8487b390f7685ef0a4e44c3f58b79cab05f (cherry picked from commit 232d1d26ea096c1e3b5f92b46029e67689185ae1) --- neutron/db/securitygroups_db.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/neutron/db/securitygroups_db.py b/neutron/db/securitygroups_db.py index 17a30bb17ba..fd21f981605 100644 --- a/neutron/db/securitygroups_db.py +++ b/neutron/db/securitygroups_db.py @@ -199,12 +199,6 @@ def get_security_group(self, context, id, fields=None, tenant_id=None): with db_api.CONTEXT_READER.using(context): sg = self._get_security_group(context, id, fields=fields) ret = self._make_security_group_dict(context, sg, fields) - if (fields is None or len(fields) == 0 or - 'security_group_rules' in fields): - rules = self.get_security_group_rules( - context_lib.get_admin_context(), - {'security_group_id': [id]}) - ret['security_group_rules'] = rules finally: if tenant_id: From c0aff4bd7f31560f9d331c89ef76c7fb4f48fc33 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 22 Oct 2024 14:15:13 +0000 Subject: [PATCH 054/184] Skip LSP host info update for trunk subports In ML2/OVN, the subports bindings are not updated with the host information. This patch skips the LSP update in that case. Currently the method ``update_lsp_host_info`` is stuck executing ``_wait_for_port_bindings_host``. During this time the subport can be deleted or removed from the trunk. That will clash with the newer operation that tries to remove the LSP port host info and is the cause of the related bug. Closes-Bug: #2085462 Change-Id: Ic68f9b5aa3b06bc4e1cbfbe577efc33b4b617b45 (cherry picked from commit 63d14a3ff225faa75a825991cf0b33b2fd745b9b) --- .../ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 5 +++++ .../drivers/ovn/mech_driver/ovsdb/test_ovn_client.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 96abc466e64..2b5adcad3c1 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -33,6 +33,7 @@ from neutron_lib.plugins import utils as p_utils from neutron_lib.services.logapi import constants as log_const from neutron_lib.services.qos import constants as qos_consts +from neutron_lib.services.trunk import constants as trunk_const from neutron_lib.utils import helpers from neutron_lib.utils import net as n_net from oslo_config import cfg @@ -292,6 +293,10 @@ def update_lsp_host_info(self, context, db_port, up=True): Defaults to True. """ cmd = [] + if db_port.device_owner == trunk_const.TRUNK_SUBPORT_OWNER: + # NOTE(ralonsoh): OVN subports don't have host ID information. + return + if up: if not db_port.port_bindings: return diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py index 58ab1ef5258..b536d869d89 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -27,6 +27,7 @@ from neutron_lib.api.definitions import l3 from neutron_lib import constants as const from neutron_lib.services.logapi import constants as log_const +from neutron_lib.services.trunk import constants as trunk_const from tenacity import wait_none @@ -251,6 +252,15 @@ def test_update_lsp_host_info_down(self): 'Logical_Switch_Port', port_id, 'external_ids', constants.OVN_HOST_ID_EXT_ID_KEY, if_exists=True) + def test_update_lsp_host_info_trunk_subport(self): + context = mock.MagicMock() + db_port = mock.Mock(id='fake-port-id', + device_owner=trunk_const.TRUNK_SUBPORT_OWNER) + + self.ovn_client.update_lsp_host_info(context, db_port) + self.nb_idl.db_remove.assert_not_called() + 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): context = mock.MagicMock() From 84780e9b3ee75df95162d945f024ba4d144a16c6 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Mon, 7 Oct 2024 13:06:59 +0300 Subject: [PATCH 055/184] [OVN] Update lsp host id when cr port is updated with chassis When a chassisredirect port is updated with chassis, the PortBindingChassisEvent event would only update the binding host id in the neutron database, while it is also usefull to keep the information in the OVN database up to date with the host information. Similar to change [1], but for router's gateway ports. [1] https://review.opendev.org/c/openstack/neutron/+/896883 Other plugins that connect to the OVN database can then also rely on the information stored in the OVN DB's Closes-Bug: #2083832 Change-Id: Ibe8bda2f81bda7a89e3a994db55cd394a18decb8 (cherry picked from commit 4b032bdbb2a6843b776c367486d1620ea6ae71a5) --- neutron/services/ovn_l3/plugin.py | 13 +++++++++- .../functional/services/ovn_l3/test_plugin.py | 24 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/neutron/services/ovn_l3/plugin.py b/neutron/services/ovn_l3/plugin.py index dafdaf9b66c..a32f2bd587c 100644 --- a/neutron/services/ovn_l3/plugin.py +++ b/neutron/services/ovn_l3/plugin.py @@ -250,7 +250,18 @@ def update_router_gateway_port_bindings(self, router, host): port = self._plugin.update_port( context, port['id'], {'port': {portbindings.HOST_ID: host}}) - + # Updates OVN NB database with hostname for lsp router + # gateway port + with self._nb_ovn.transaction(check_error=True) as txn: + ext_ids = ( + "external_ids", + {ovn_const.OVN_HOST_ID_EXT_ID_KEY: host}, + ) + txn.add( + self._nb_ovn.db_set( + "Logical_Switch_Port", port["id"], ext_ids + ) + ) if port['status'] != status: self._plugin.update_port_status(context, port['id'], status) diff --git a/neutron/tests/functional/services/ovn_l3/test_plugin.py b/neutron/tests/functional/services/ovn_l3/test_plugin.py index 7d9953d5a7a..efd0482a1ef 100644 --- a/neutron/tests/functional/services/ovn_l3/test_plugin.py +++ b/neutron/tests/functional/services/ovn_l3/test_plugin.py @@ -529,6 +529,12 @@ def fake_select(*args, **kwargs): # hosted in any chassis. self.assertGreaterEqual(plugin_select.call_count, 2) + def _find_port_binding(self, port_id): + cmd = self.sb_api.db_find_rows('Port_Binding', + ('logical_port', '=', port_id)) + rows = cmd.execute(check_error=True) + return rows[0] if rows else None + def test_router_gateway_port_binding_host_id(self): # Test setting chassis on chassisredirect port in Port_Binding table, # will update host_id of corresponding router gateway port @@ -551,14 +557,30 @@ def test_router_gateway_port_binding_host_id(self): may_exist=True).execute(check_error=True) def check_port_binding_host_id(port_id): + # Get port from Neutron DB port = core_plugin.get_ports( self.context, filters={'id': [port_id]})[0] - return port[portbindings.HOST_ID] == host_id + # Get port from OVN DB + bp = self._find_port_binding(port_id) + ovn_host_id = bp.external_ids.get(ovn_const.OVN_HOST_ID_EXT_ID_KEY) + return port[portbindings.HOST_ID] == host_id == ovn_host_id # Test if router gateway port updated with this chassis n_utils.wait_until_true(lambda: check_port_binding_host_id( gw_port_id)) + # Simulate failover to another chassis and check host_id in Neutron DB + # and external_ids:neutron:host_id in OVN DB are updated + chassis = idlutils.row_by_value( + self.sb_api.idl, "Chassis", "name", self.chassis2 + ) + host_id = chassis.hostname + self.sb_api.lsp_unbind(logical_port).execute(check_error=True) + self.sb_api.lsp_bind(logical_port, self.chassis2).execute( + check_error=True + ) + n_utils.wait_until_true(lambda: check_port_binding_host_id(gw_port_id)) + def _validate_router_ipv6_ra_configs(self, lrp_name, expected_ra_confs): lrp = idlutils.row_by_value(self.nb_api.idl, 'Logical_Router_Port', 'name', lrp_name) From a716e4d3c490383da9d66b7f7d014b491b8e371e Mon Sep 17 00:00:00 2001 From: kyu0 Date: Thu, 13 Jun 2024 12:46:54 +0900 Subject: [PATCH 056/184] Modify the default SG rule count logic when creating SG During the creation of SG, not to exceed the SG rule quota, the number of default SG rules that will be automatically created must be counted. It is always 2 (in case of the default SG, it is 4), but it is wrong since it depends on the default SG rules. Closes-Bug: #2067239 Change-Id: Ic86826b71c1160a6891f09ca1e40135049a8948a (cherry picked from commit 1a440dd61b04b37d0e2a9434e802f5a1ee3c198b) --- neutron/db/securitygroups_db.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/neutron/db/securitygroups_db.py b/neutron/db/securitygroups_db.py index fd21f981605..eed101ee2ea 100644 --- a/neutron/db/securitygroups_db.py +++ b/neutron/db/securitygroups_db.py @@ -110,8 +110,12 @@ def create_security_group(self, context, security_group, default_sg=False): return self.get_security_group(context, existing_def_sg_id) with db_api.CONTEXT_WRITER.using(context): - delta = len(ext_sg.sg_supported_ethertypes) - delta = delta * 2 if default_sg else delta + if default_sg: + delta = sg_default_rules_obj.SecurityGroupDefaultRule.count( + context, used_in_default_sg=True) + else: + delta = sg_default_rules_obj.SecurityGroupDefaultRule.count( + context, used_in_non_default_sg=True) quota.QUOTAS.quota_limit_check(context, tenant_id, security_group_rule=delta) From 6b861fac751b3ecdbd5118c578bf251987590d8d Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 30 Oct 2024 00:58:16 +0000 Subject: [PATCH 057/184] [OVN] Fix the revision number retrieval method The "ovn_revision_numbers" table has a unique constraint that is a combination of the "resource_uuid" and the "resource_type". There is a case where the resource_uuid can be the same for two registers. A router interface will create a single Neutron DB register ("ports") but it will require two OVN DB registers ("Logical_Switch_Port" and "Logical_Router_Ports"). In this case is needed to define the "resource_type" when retrieving the revision number. The exception "RevisionNumberNotDefined" will be thrown if only the "resource_uuid" is provided in the related case. Closes-Bug: #2085946 Change-Id: I12079de78773f7409503392d4791848aea90cb7b (cherry picked from commit a298a37fe7ee41d25db02fdde36e134b01ef5d9a) --- neutron/db/ovn_revision_numbers_db.py | 28 +++++++++++++++---- .../ovn/mech_driver/ovsdb/ovn_client.py | 3 +- .../ovn/mech_driver/ovsdb/test_maintenance.py | 3 +- .../unit/db/test_ovn_revision_numbers_db.py | 20 ++++++++++++- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/neutron/db/ovn_revision_numbers_db.py b/neutron/db/ovn_revision_numbers_db.py index c7e945cf1d0..3bc91c889cb 100644 --- a/neutron/db/ovn_revision_numbers_db.py +++ b/neutron/db/ovn_revision_numbers_db.py @@ -92,6 +92,12 @@ class UnknownResourceType(n_exc.NeutronException): message = 'Uknown resource type: %(resource_type)s' +# NOTE(ralonsoh): to be moved to neutron-lib +class RevisionNumberNotDefined(n_exc.NeutronException): + message = ('Unique revision number not found for %(resource_uuid)s, ' + 'the resource type is required in query') + + def _get_standard_attr_id(context, resource_uuid, resource_type): try: row = context.session.query(STD_ATTR_MAP[resource_type]).filter_by( @@ -155,14 +161,26 @@ def _ensure_revision_row_exist(context, resource, resource_type, std_attr_id): @db_api.retry_if_session_inactive() -def get_revision_row(context, resource_uuid): +@db_api.CONTEXT_READER +def get_revision_row(context, resource_uuid, resource_type=None): + """Retrieve the resource revision number + + Only the Neutron ports can have two revision number registers, one for the + Logical_Switch_Port and another for the Logical_Router_Port, if this port + is a router interface. It is not strictly needed to filter by resource_type + if the resource is not a port. + """ try: - with db_api.CONTEXT_READER.using(context): - return context.session.query( - ovn_models.OVNRevisionNumbers).filter_by( - resource_uuid=resource_uuid).one() + filters = {'resource_uuid': resource_uuid} + if resource_type: + filters['resource_type'] = resource_type + return context.session.query( + ovn_models.OVNRevisionNumbers).filter_by( + **filters).one() except exc.NoResultFound: pass + except exc.MultipleResultsFound: + raise RevisionNumberNotDefined(resource_uuid=resource_uuid) @db_api.retry_if_session_inactive() diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 2b5adcad3c1..890ba143485 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -838,7 +838,8 @@ def delete_port(self, context, port_id, port_object=None): # to allow at least one maintenance cycle before we delete the # revision number so that the port doesn't stale and eventually # gets deleted by the maintenance task. - rev_row = db_rev.get_revision_row(context, port_id) + rev_row = db_rev.get_revision_row( + context, port_id, resource_type=ovn_const.TYPE_PORTS) time_ = (timeutils.utcnow() - datetime.timedelta( seconds=ovn_const.DB_CONSISTENCY_CHECK_INTERVAL + 30)) if rev_row and rev_row.created_at >= time_: diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index 00ec7932099..5c631ee2c0a 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -380,7 +380,8 @@ def test_port(self): # Assert the revision number no longer exists self.assertIsNone(db_rev.get_revision_row( self.context, - neutron_obj['id'])) + neutron_obj['id'], + resource_type=ovn_const.TYPE_PORTS)) def test_subnet_global_dhcp4_opts(self): obj_name = 'globaltestsubnet' diff --git a/neutron/tests/unit/db/test_ovn_revision_numbers_db.py b/neutron/tests/unit/db/test_ovn_revision_numbers_db.py index 744c6e62553..9dd714c9221 100644 --- a/neutron/tests/unit/db/test_ovn_revision_numbers_db.py +++ b/neutron/tests/unit/db/test_ovn_revision_numbers_db.py @@ -24,6 +24,7 @@ from neutron.api import extensions from neutron.common import config +from neutron.common.ovn import constants as ovn_const from neutron.db.models import ovn as ovn_models from neutron.db import ovn_revision_numbers_db as ovn_rn_db import neutron.extensions @@ -32,7 +33,6 @@ from neutron.tests.unit.extensions import test_l3 from neutron.tests.unit.extensions import test_securitygroup - EXTENSIONS_PATH = ':'.join(neutron.extensions.__path__) PLUGIN_CLASS = ( 'neutron.tests.unit.db.test_ovn_revision_numbers_db.TestMaintenancePlugin') @@ -123,6 +123,24 @@ def test_create_initial_revision_may_exist_duplicated_entry(self): self.fail("create_initial_revision shouldn't raise " "DBDuplicateEntry when may_exist is True") + def test_get_revision_row_ports(self): + res = self._create_port(self.fmt, self.net['id']) + port = self.deserialize(self.fmt, res)['port'] + with db_api.CONTEXT_WRITER.using(self.ctx): + for resource_type in (ovn_const.TYPE_PORTS, + ovn_const.TYPE_ROUTER_PORTS): + self._create_initial_revision(port['id'], resource_type) + + for resource_type in (ovn_const.TYPE_PORTS, + ovn_const.TYPE_ROUTER_PORTS): + row = ovn_rn_db.get_revision_row( + self.ctx, port['id'], resource_type=resource_type) + self.assertEqual(resource_type, row.resource_type) + self.assertEqual(port['id'], row.resource_uuid) + + self.assertRaises(ovn_rn_db.RevisionNumberNotDefined, + ovn_rn_db.get_revision_row, self.ctx, port['id']) + class TestMaintenancePlugin(test_securitygroup.SecurityGroupTestPlugin, test_l3.TestL3NatBasePlugin): From b2cc9f09ad7df523861d3321f6ca856e341f0e68 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 30 Oct 2024 18:08:15 +0000 Subject: [PATCH 058/184] [OVN] Check LSP.up status before setting the port host info Before executing updating the Logical_Swith_Port host information, it is needed to check the current status of the port. If it doesn't match with the event calling this update, the host information is not updated. Closes-Bug: #2085543 Change-Id: I92afb190375caf27c815f9fe1cb627e87c49d4ca (cherry picked from commit c0bdb0c8a33286acb4d44ad865f0000309fc79b6) --- .../ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py | 12 ++++++++++++ .../drivers/ovn/mech_driver/ovsdb/test_ovn_client.py | 1 + 2 files changed, 13 insertions(+) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 890ba143485..ab56acaef65 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -297,7 +297,14 @@ def update_lsp_host_info(self, context, db_port, up=True): # NOTE(ralonsoh): OVN subports don't have host ID information. return + port_up = self._nb_idl.lsp_get_up(db_port.id).execute( + check_error=True) if up: + if not port_up: + LOG.warning('Logical_Switch_Port %s host information not ' + 'updated, the port state is down') + return + if not db_port.port_bindings: return @@ -319,6 +326,11 @@ def update_lsp_host_info(self, context, db_port, up=True): self._nb_idl.db_set( 'Logical_Switch_Port', db_port.id, ext_ids)) else: + if port_up: + LOG.warning('Logical_Switch_Port %s host information not ' + 'removed, the port state is up') + return + cmd.append( self._nb_idl.db_remove( 'Logical_Switch_Port', db_port.id, 'external_ids', diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py index b536d869d89..5c836d60938 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -245,6 +245,7 @@ def test_update_lsp_host_info_down(self): context = mock.MagicMock() port_id = 'fake-port-id' db_port = mock.Mock(id=port_id) + self.nb_idl.lsp_get_up.return_value.execute.return_value = False self.ovn_client.update_lsp_host_info(context, db_port, up=False) From f9e56eb2a405a9e37adced2e05116d05b6368c94 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Tue, 1 Oct 2024 15:17:50 +0200 Subject: [PATCH 059/184] Add logging details of the http response in the unit tests In unit tests where neutron resources are created by "fake" http requests, it was always only raised webob.exc.HTTPClientError in case when response from the neutron server was >= webob.exc.HTTPClientError.code, without any explanation what was real issue in the response. To make it hopefully easier to debug failures in such tests in the future this patch adds explanation with original response from the server to such HTTPClientError exception. Related-bug: #2081868 Change-Id: Ice15dd05d95422236e5901865865d77925adc44d (cherry picked from commit 4af0d333aa8732a1092ca02e01a4949a103b7371) --- .../tests/unit/db/test_db_base_plugin_v2.py | 26 +++++++++---------- neutron/tests/unit/db/test_l3_db.py | 4 +-- .../unit/extensions/test_address_group.py | 3 +-- .../unit/extensions/test_address_scope.py | 3 +-- .../tests/unit/extensions/test_local_ip.py | 9 +++---- .../extensions/test_network_segment_range.py | 18 +++++-------- neutron/tests/unit/extensions/test_segment.py | 5 +--- .../unit/plugins/ml2/test_port_binding.py | 9 +++---- .../unit/services/qos/test_qos_plugin.py | 9 +++---- 9 files changed, 32 insertions(+), 54 deletions(-) diff --git a/neutron/tests/unit/db/test_db_base_plugin_v2.py b/neutron/tests/unit/db/test_db_base_plugin_v2.py index 0a5a2592fe8..e67fa1a076a 100644 --- a/neutron/tests/unit/db/test_db_base_plugin_v2.py +++ b/neutron/tests/unit/db/test_db_base_plugin_v2.py @@ -290,6 +290,14 @@ def _reader_req(self, method, resource, data=None, fmt=None, id=None, '', tenant_id, roles=['reader']) return req + def _check_http_response(self, res): + # Things can go wrong - raise HTTP exc with res code only + # so it can be caught by unit tests + if res.status_int >= webob.exc.HTTPClientError.code: + res.charset = 'utf8' + raise webob.exc.HTTPClientError(explanation=str(res), + code=res.status_int) + def new_create_request(self, resource, data, fmt=None, id=None, subresource=None, context=None, tenant_id=None, as_admin=False, as_service=False): @@ -590,10 +598,7 @@ def _make_network(self, fmt, name, admin_state_up, as_admin=False, as_admin=as_admin, **kwargs) # TODO(salvatore-orlando): do exception handling in this test module # in a uniform way (we do it differently for ports, subnets, and nets - # Things can go wrong - raise HTTP exc with res code only - # so it can be caught by unit tests - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(fmt, res) def _make_subnet(self, fmt, network, gateway, cidr, subnetpool_id=None, @@ -618,10 +623,7 @@ def _make_subnet(self, fmt, network, gateway, cidr, subnetpool_id=None, ipv6_ra_mode=ipv6_ra_mode, ipv6_address_mode=ipv6_address_mode, as_admin=as_admin) - # Things can go wrong - raise HTTP exc with res code only - # so it can be caught by unit tests - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(fmt, res) def _make_v6_subnet(self, network, ra_addr_mode, ipv6_pd=False): @@ -649,18 +651,14 @@ def _make_subnetpool(self, fmt, prefixes, admin=False, tenant_id=None, **kwargs) # Things can go wrong - raise HTTP exc with res code only # so it can be caught by unit tests - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(fmt, res) def _make_port(self, fmt, net_id, expected_res_status=None, as_admin=False, **kwargs): res = self._create_port(fmt, net_id, expected_res_status, is_admin=as_admin, **kwargs) - # Things can go wrong - raise HTTP exc with res code only - # so it can be caught by unit tests - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(fmt, res) def _create_qos_rule(self, fmt, qos_policy_id, rule_type, max_kbps=None, diff --git a/neutron/tests/unit/db/test_l3_db.py b/neutron/tests/unit/db/test_l3_db.py index 5d230b9ad72..5a74f3b6a65 100644 --- a/neutron/tests/unit/db/test_l3_db.py +++ b/neutron/tests/unit/db/test_l3_db.py @@ -33,7 +33,6 @@ from neutron_lib.plugins import utils as plugin_utils from oslo_utils import uuidutils import testtools -import webob.exc from neutron.db import extraroute_db from neutron.db import l3_db @@ -1049,8 +1048,7 @@ def _create_external_network(self, name=None, **kwargs): self.fmt, name, True, arg_list=(extnet_apidef.EXTERNAL,), as_admin=True, **kwargs) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(self.fmt, res) def test_update_router_gw_notify(self): diff --git a/neutron/tests/unit/extensions/test_address_group.py b/neutron/tests/unit/extensions/test_address_group.py index e3339b03208..a38a548ad0f 100644 --- a/neutron/tests/unit/extensions/test_address_group.py +++ b/neutron/tests/unit/extensions/test_address_group.py @@ -52,8 +52,7 @@ def _create_address_group(self, **kwargs): self._tenant_id)) req.environ['neutron.context'] = neutron_context res = req.get_response(self.ext_api) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return res def _test_create_address_group(self, expected=None, **kwargs): diff --git a/neutron/tests/unit/extensions/test_address_scope.py b/neutron/tests/unit/extensions/test_address_scope.py index 7e33980686d..9d133ff1a0a 100644 --- a/neutron/tests/unit/extensions/test_address_scope.py +++ b/neutron/tests/unit/extensions/test_address_scope.py @@ -72,8 +72,7 @@ def _make_address_scope(self, fmt, ip_version, admin=False, tenant_id=None, res = self._create_address_scope(fmt, ip_version, admin=admin, tenant_id=tenant_id, **kwargs) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(fmt, res) @contextlib.contextmanager diff --git a/neutron/tests/unit/extensions/test_local_ip.py b/neutron/tests/unit/extensions/test_local_ip.py index e15b31e8216..db9ae231f17 100644 --- a/neutron/tests/unit/extensions/test_local_ip.py +++ b/neutron/tests/unit/extensions/test_local_ip.py @@ -48,16 +48,14 @@ def _create_local_ip(self, **kwargs): req = self.new_create_request('local-ips', local_ip, tenant_id=self._tenant_id, as_admin=True) res = req.get_response(self.ext_api) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(self.fmt, res) def _update_local_ip(self, lip_id, data): update_req = self.new_update_request( 'local-ips', data, lip_id, tenant_id=self._tenant_id) res = update_req.get_response(self.ext_api) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(self.fmt, res) def _create_local_ip_association(self, local_ip_id, fixed_port_id, @@ -71,8 +69,7 @@ def _create_local_ip_association(self, local_ip_id, fixed_port_id, subresource='port_associations', tenant_id=self._tenant_id) res = req.get_response(self.ext_api) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(self.fmt, res) @contextlib.contextmanager diff --git a/neutron/tests/unit/extensions/test_network_segment_range.py b/neutron/tests/unit/extensions/test_network_segment_range.py index 9bf4ca36607..ffbe5fd817e 100644 --- a/neutron/tests/unit/extensions/test_network_segment_range.py +++ b/neutron/tests/unit/extensions/test_network_segment_range.py @@ -69,8 +69,7 @@ def _create_network_segment_range(self, fmt, expected_res_status=None, def network_segment_range(self, **kwargs): res = self._create_network_segment_range(self.fmt, **kwargs) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(self.fmt, res) def _test_create_network_segment_range(self, expected=None, **kwargs): @@ -165,7 +164,7 @@ def test_create_network_segment_range_unsupported_network_type(self): self._test_create_network_segment_range, network_type='foo-network-type') self.assertEqual(webob.exc.HTTPClientError.code, exc.code) - self.assertIn('The server could not comply with the request', + self.assertIn('foo-network-type is not in valid_values', exc.explanation) def test_create_network_segment_range_no_physical_network(self): @@ -215,16 +214,14 @@ def test_create_network_segment_range_failed_with_vlan_minimum_id(self): self._test_create_network_segment_range, minimum=0) self.assertEqual(webob.exc.HTTPClientError.code, exc.code) - self.assertIn('The server could not comply with the request', - exc.explanation) + self.assertIn('Invalid input for minimum', exc.explanation) def test_create_network_segment_range_failed_with_vlan_maximum_id(self): exc = self.assertRaises(webob.exc.HTTPClientError, self._test_create_network_segment_range, minimum=4095) self.assertEqual(webob.exc.HTTPServerError.code, exc.code) - self.assertIn('The server could not comply with the request', - exc.explanation) + self.assertIn('Invalid network VLAN range', exc.explanation) def test_create_network_segment_range_failed_with_tunnel_minimum_id(self): tunnel_type = [constants.TYPE_VXLAN, @@ -237,8 +234,7 @@ def test_create_network_segment_range_failed_with_tunnel_minimum_id(self): physical_network=None, minimum=0) self.assertEqual(webob.exc.HTTPClientError.code, exc.code) - self.assertIn('The server could not comply with the request', - exc.explanation) + self.assertIn('Invalid input for minimum', exc.explanation) def test_create_network_segment_range_failed_with_tunnel_maximum_id(self): expected_res = [(constants.TYPE_VXLAN, 2 ** 24), @@ -252,10 +248,10 @@ def test_create_network_segment_range_failed_with_tunnel_maximum_id(self): maximum=max_id) if network_type == constants.TYPE_GRE: self.assertEqual(webob.exc.HTTPClientError.code, exc.code) + self.assertIn('Invalid input for maximum', exc.explanation) else: self.assertEqual(webob.exc.HTTPServerError.code, exc.code) - self.assertIn('The server could not comply with the request', - exc.explanation) + self.assertIn('Invalid network tunnel range', exc.explanation) def test_update_network_segment_range_set_name(self): network_segment_range = self._test_create_network_segment_range() diff --git a/neutron/tests/unit/extensions/test_segment.py b/neutron/tests/unit/extensions/test_segment.py index 6643aa71579..acffbf967aa 100644 --- a/neutron/tests/unit/extensions/test_segment.py +++ b/neutron/tests/unit/extensions/test_segment.py @@ -123,10 +123,7 @@ def _create_segment(self, fmt, expected_res_status=None, **kwargs): def _make_segment(self, fmt, **kwargs): res = self._create_segment(fmt, **kwargs) - if res.status_int >= webob.exc.HTTPClientError.code: - res.charset = 'utf8' - raise webob.exc.HTTPClientError( - code=res.status_int, explanation=str(res)) + self._check_http_response(res) return self.deserialize(fmt, res) def segment(self, **kwargs): diff --git a/neutron/tests/unit/plugins/ml2/test_port_binding.py b/neutron/tests/unit/plugins/ml2/test_port_binding.py index f8afe9f5dce..1cc97002da1 100644 --- a/neutron/tests/unit/plugins/ml2/test_port_binding.py +++ b/neutron/tests/unit/plugins/ml2/test_port_binding.py @@ -379,8 +379,7 @@ def _create_port_binding(self, fmt, port_id, host, tenant_id=None, def _make_port_binding(self, fmt, port_id, host, **kwargs): res = self._create_port_binding(fmt, port_id, host, **kwargs) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(fmt, res) def _update_port_binding(self, fmt, port_id, host, **kwargs): @@ -393,8 +392,7 @@ def _update_port_binding(self, fmt, port_id, host, **kwargs): def _do_update_port_binding(self, fmt, port_id, host, **kwargs): res = self._update_port_binding(fmt, port_id, host, **kwargs) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(fmt, res) def _activate_port_binding(self, port_id, host, raw_response=True): @@ -408,8 +406,7 @@ def _activate_port_binding(self, port_id, host, raw_response=True): def _check_code_and_serialize(self, response, raw_response): if raw_response: return response - if response.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=response.status_int) + self._check_http_response(response) return self.deserialize(self.fmt, response) def _list_port_bindings(self, port_id, params=None, raw_response=True): diff --git a/neutron/tests/unit/services/qos/test_qos_plugin.py b/neutron/tests/unit/services/qos/test_qos_plugin.py index 0aa377f378d..a36adf772c4 100644 --- a/neutron/tests/unit/services/qos/test_qos_plugin.py +++ b/neutron/tests/unit/services/qos/test_qos_plugin.py @@ -1929,8 +1929,7 @@ def _update_rule(self, rule_type, rule_id, **kwargs): request = self.new_update_request(resource, data, rule_id, self.fmt, as_admin=True) res = request.get_response(self.ext_api) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(self.fmt, res) def _show_rule(self, rule_type, rule_id): @@ -1939,8 +1938,7 @@ def _show_rule(self, rule_type, rule_id): request = self.new_show_request(resource, rule_id, self.fmt, as_admin=True) res = request.get_response(self.ext_api) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) return self.deserialize(self.fmt, res) def _delete_rule(self, rule_type, rule_id): @@ -1949,8 +1947,7 @@ def _delete_rule(self, rule_type, rule_id): request = self.new_delete_request(resource, rule_id, self.fmt, as_admin=True) res = request.get_response(self.ext_api) - if res.status_int >= webob.exc.HTTPClientError.code: - raise webob.exc.HTTPClientError(code=res.status_int) + self._check_http_response(res) @mock.patch.object(qos_plugin.QoSPlugin, "update_policy_rule") def test_update_rule(self, update_policy_rule_mock): From 311fc8ea4a5cff12f5ff5cd973f1d6c21346f71d Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 31 Oct 2024 23:33:58 +0000 Subject: [PATCH 060/184] [OVN] Create the SG rules revision number registers During a security group creation, the default security group rules are also added. This patch is creating the security group rules revision number registers and bumping them to their first revision. Closes-Bug: #2086205 Change-Id: Idc6ad29bcac23c2397e32f290addfd1877b8b3e0 (cherry picked from commit e0ee8bd7726a24747ee5028cb31f9b62cfcfcc29) --- .../drivers/ovn/mech_driver/mech_driver.py | 5 +++++ .../ovn/mech_driver/ovsdb/ovn_client.py | 3 +++ .../ovsdb/test_ovn_db_resources.py | 20 +++++++++++++++++++ .../tests/unit/db/test_db_base_plugin_v2.py | 16 +++++++++++++++ .../ovn/mech_driver/test_mech_driver.py | 8 ++++++-- 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index 23504c98abf..23cbd776afd 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -404,6 +404,11 @@ def _create_security_group_precommit(self, resource, event, trigger, context, security_group['id'], ovn_const.TYPE_SECURITY_GROUPS, std_attr_id=security_group['standard_attr_id']) + for sg_rule in security_group['security_group_rules']: + ovn_revision_numbers_db.create_initial_revision( + context, sg_rule['id'], + ovn_const.TYPE_SECURITY_GROUP_RULES, + std_attr_id=sg_rule['standard_attr_id']) def _create_security_group(self, resource, event, trigger, payload): context = payload.context diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index ab56acaef65..f6f437475ca 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -2510,6 +2510,9 @@ def create_security_group(self, context, security_group): self.is_allow_stateless_supported()) db_rev.bump_revision( context, security_group, ovn_const.TYPE_SECURITY_GROUPS) + for sg_rule in security_group['security_group_rules']: + db_rev.bump_revision( + context, sg_rule, ovn_const.TYPE_SECURITY_GROUP_RULES) def _add_port_to_drop_port_group(self, port, txn): txn.add(self._nb_idl.pg_add_ports(ovn_const.OVN_DROP_PORT_GROUP_NAME, diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_resources.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_resources.py index 023a61e6e4b..76f0bf262cc 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_resources.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_resources.py @@ -26,6 +26,7 @@ from neutron.common.ovn import utils from neutron.common import utils as n_utils from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf as ovn_config +from neutron.db import ovn_revision_numbers_db as rev_db from neutron.tests.functional import base @@ -922,6 +923,25 @@ def test_port_security_port_group(self): self._verify_port_acls(port_id, expected_acls_with_sg_ps_enabled) +class TestSecurityGroups(base.TestOVNFunctionalBase): + + def test_security_group_creation_and_deletion(self): + sg = self._make_security_group(self.fmt)['security_group'] + rev_num = rev_db.get_revision_row(self.context, sg['id']) + self.assertEqual(1, rev_num.revision_number) + for sg_rule in sg['security_group_rules']: + rev_num = rev_db.get_revision_row(self.context, sg_rule['id']) + self.assertEqual(0, rev_num.revision_number) + + self._delete('security-groups', sg['id']) + self.assertIsNone(rev_db.get_revision_row(self.context, sg['id'])) + # NOTE(ralonsoh): the deletion of the revision numbers of the SG rules + # will be fixed in a follow-up patch. + # for sg_rule in sg['security_group_rules']: + # self.assertIsNone(rev_db.get_revision_row(self.context, + # sg_rule['id'])) + + class TestDNSRecords(base.TestOVNFunctionalBase): _extension_drivers = ['port_security', 'dns'] diff --git a/neutron/tests/unit/db/test_db_base_plugin_v2.py b/neutron/tests/unit/db/test_db_base_plugin_v2.py index e67fa1a076a..a9b9adfd0e8 100644 --- a/neutron/tests/unit/db/test_db_base_plugin_v2.py +++ b/neutron/tests/unit/db/test_db_base_plugin_v2.py @@ -661,6 +661,22 @@ def _make_port(self, fmt, net_id, expected_res_status=None, self._check_http_response(res) return self.deserialize(fmt, res) + def _make_security_group(self, fmt, name=None, expected_res_status=None, + project_id=None, is_admin=False): + name = name or 'sg-{}'.format(uuidutils.generate_uuid()) + project_id = project_id or self._tenant_id + data = {'security_group': {'name': name, + 'description': name, + 'project_id': project_id}} + sg_req = self.new_create_request('security-groups', data, fmt, + tenant_id=project_id, + as_admin=is_admin) + sg_res = sg_req.get_response(self.api) + if expected_res_status: + self.assertEqual(expected_res_status, sg_res.status_int) + self._check_http_response(sg_res) + return self.deserialize(fmt, sg_res) + def _create_qos_rule(self, fmt, qos_policy_id, rule_type, max_kbps=None, max_burst_kbps=None, dscp_mark=None, min_kbps=None, direction=constants.EGRESS_DIRECTION, diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 400909c2425..5b342ab86ef 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -291,8 +291,12 @@ def _test__create_security_group( for c in self.nb_ovn.pg_acl_add.call_args_list: self.assertEqual(expected, c[1]["action"]) - mock_bump.assert_called_once_with( - mock.ANY, self.fake_sg, ovn_const.TYPE_SECURITY_GROUPS) + calls = [mock.call(mock.ANY, self.fake_sg, + ovn_const.TYPE_SECURITY_GROUPS)] + for sg_rule in self.fake_sg['security_group_rules']: + calls.append(mock.call(mock.ANY, sg_rule, + ovn_const.TYPE_SECURITY_GROUP_RULES)) + mock_bump.assert_has_calls(calls) def test__create_security_group_stateful_supported(self): self._test__create_security_group(True, True) From 467c5af5a8aea814816b67dcdf0d237cb3e491a9 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Wed, 6 Nov 2024 16:08:58 +0100 Subject: [PATCH 061/184] [Fullstack] Use only one keepalived-state-change thread in L3 agent By default number of threads used by the neutron-keepalived-state-change service is set to "1 + / 2" which in CI results with "4". This is definitely not needed for the fullstack tests where L3 agent is spawned for the single test and don't need to handle more than one router ever. To safe some CPU resources this patch sets this config option to '1' in fullstack tests. Related-bug: #2083609 Change-Id: I18cfb18abe481f47db870f210188e1a570844077 (cherry picked from commit df177b15db2b92b6e8050f812a8ee0b3fcfd460f) --- neutron/tests/fullstack/resources/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/neutron/tests/fullstack/resources/config.py b/neutron/tests/fullstack/resources/config.py index 1873b603fd9..6a0dc0c316a 100644 --- a/neutron/tests/fullstack/resources/config.py +++ b/neutron/tests/fullstack/resources/config.py @@ -426,6 +426,7 @@ def __init__(self, env_desc, host_desc, temp_dir, integration_bridge=None): self.config['DEFAULT'].update({ 'debug': 'True', 'test_namespace_suffix': self._generate_namespace_suffix(), + 'ha_keepalived_state_change_server_threads': '1', }) self.config.update({ 'agent': {'use_helper_for_ns_read': 'False'} From a0a25b785380f0b61d999ff5faea890658d1adb3 Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Tue, 1 Oct 2024 16:54:18 -0400 Subject: [PATCH 062/184] Set distributed flag to NB_Global The patch introduces a new maintenance routine that always sets NB_Global.external_ids:fip-distributed value in Northbound OVN DB to the same value that enable_distributed_floating_ip config option has. This is useful for projects that do not use RPC and rely on data only in the OVN database. Conflicts: neutron/common/ovn/constants.py neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py Closes-Bug: #2083456 Change-Id: I7f30e6e030292b762dc9fc785c494c0dc215c749 Signed-off-by: Jakub Libosvar (cherry picked from commit 1300110ccb9963e48a7c19e70599194d5c7da92c) --- neutron/common/ovn/constants.py | 1 + .../ovn/mech_driver/ovsdb/maintenance.py | 15 ++++++ .../ovn/mech_driver/ovsdb/test_maintenance.py | 48 +++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index 5c0aa8951ba..78334dd534f 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -56,6 +56,7 @@ METADATA_LIVENESS_CHECK_EXT_ID_KEY = 'neutron:metadata_liveness_check_at' OVN_PORT_BINDING_PROFILE = portbindings.PROFILE OVN_HOST_ID_EXT_ID_KEY = 'neutron:host_id' +OVN_FIP_DISTRIBUTED_KEY = 'neutron:fip-distributed' MIGRATING_ATTR = 'migrating_to' OVN_ROUTER_PORT_OPTION_KEYS = ['router-port', 'nat-addresses', diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index c6ab5fbbd33..8a34af8576c 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -1300,6 +1300,21 @@ def remove_invalid_gateway_chassis_from_unbound_lrp(self): raise periodics.NeverAgain() + @has_lock_periodic( + periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, + spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, + run_immediately=True) + def set_fip_distributed_flag(self): + """Set the NB_Global.external_ids:fip-distributed flag.""" + distributed = ovn_conf.is_ovn_distributed_floating_ip() + LOG.debug( + "Setting fip-distributed flag in NB_Global to %s", distributed) + self._nb_idl.db_set( + 'NB_Global', '.', external_ids={ + ovn_const.OVN_FIP_DISTRIBUTED_KEY: str(distributed)}).execute( + check_error=True) + raise periodics.NeverAgain() + class HashRingHealthCheckPeriodics(object): diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index 5c631ee2c0a..36671829207 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -1270,6 +1270,54 @@ def test_remove_invalid_gateway_chassis_from_unbound_lrp(self): lr = self.nb_api.lookup('Logical_Router', utils.ovn_name(router['id'])) self.assertEqual([], lr.ports[0].gateway_chassis) + def _get_nb_global_external_ids(self): + return self.nb_api.db_get( + 'NB_Global', '.', 'external_ids').execute(check_error=True) + + def test_set_fip_distributed_flag(self): + ovn_config.cfg.CONF.set_override( + 'enable_distributed_floating_ip', True, 'ovn') + nb_global_ext_id = self._get_nb_global_external_ids() + self.assertNotIn(ovn_const.OVN_FIP_DISTRIBUTED_KEY, nb_global_ext_id) + + self.assertRaises( + periodics.NeverAgain, self.maint.set_fip_distributed_flag) + + nb_global_ext_id = self._get_nb_global_external_ids() + self.assertEqual( + "True", nb_global_ext_id[ovn_const.OVN_FIP_DISTRIBUTED_KEY]) + + def _test_set_fip_distributed_flag_change( + self, original_value, config_value): + ovn_config.cfg.CONF.set_override( + 'enable_distributed_floating_ip', config_value, 'ovn') + self.nb_api.db_set( + 'NB_Global', '.', external_ids={ + ovn_const.OVN_FIP_DISTRIBUTED_KEY: str(original_value)} + ).execute(check_error=True) + nb_global_ext_id = self._get_nb_global_external_ids() + self.assertEqual( + str(original_value), + nb_global_ext_id[ovn_const.OVN_FIP_DISTRIBUTED_KEY]) + + self.assertRaises( + periodics.NeverAgain, self.maint.set_fip_distributed_flag) + + nb_global_ext_id = self._get_nb_global_external_ids() + self.assertEqual( + str(config_value), + nb_global_ext_id[ovn_const.OVN_FIP_DISTRIBUTED_KEY]) + + def test_set_fip_distributed_flag_changed(self): + self._test_set_fip_distributed_flag_change( + original_value=False, + config_value=True) + + def test_set_fip_distributed_flag_unchanged(self): + self._test_set_fip_distributed_flag_change( + original_value=True, + config_value=True) + class TestLogMaintenance(_TestMaintenanceHelper, test_log_driver.LogApiTestCaseBase): From 72ce15b15dc700876a67abe673461ff4ff403ad7 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 25 Nov 2024 07:25:14 +0000 Subject: [PATCH 063/184] [stable-only] Drop skip-level jobs in the CI These jobs are currently broken due to a partial migration to um/2023.1. Because the older branch is now in unmantained mode, these jobs are not longer executed in this stable branch. Related-Bug: #2089522 Change-Id: Id17831323822b4c2aa89442c8bfc6b089809b9b8 --- zuul.d/grenade.yaml | 32 -------------------------------- zuul.d/job-templates.yaml | 17 ----------------- zuul.d/project.yaml | 1 - 3 files changed, 50 deletions(-) diff --git a/zuul.d/grenade.yaml b/zuul.d/grenade.yaml index 2d1fe227c37..2dd6c747e2d 100644 --- a/zuul.d/grenade.yaml +++ b/zuul.d/grenade.yaml @@ -381,35 +381,3 @@ PHYSICAL_NETWORK: public ENABLE_CHASSIS_AS_GW: false OVN_DBS_LOG_LEVEL: dbg - -- job: - name: neutron-ovs-grenade-multinode-skip-level - parent: neutron-ovs-grenade-multinode - description: | - Grenade job that skips a release, validating that deployers can skip - specific releases as prescribed by our process. - vars: - # Move this forward when master changes to a new skip-level-allowed - # target release. Right now, this is Antelope (2023.1) because master is - # Caracal (2024.1). - # When master is E (2025.1), this should become Caracal (2024.1), - # and so forth. - grenade_from_branch: stable/2023.1 - grenade_localrc: - NOVA_ENABLE_UPGRADE_WORKAROUND: True - -- job: - name: neutron-ovn-grenade-multinode-skip-level - parent: neutron-ovn-grenade-multinode - description: | - Grenade job that skips a release, validating that deployers can skip - specific releases as prescribed by our process. - vars: - # Move this forward when master changes to a new skip-level-allowed - # target release. Right now, this is Antelope (2023.1) because master is - # Caracal (2024.1). - # When master is E (2025.1), this should become Caracal (2024.1), - # and so forth. - grenade_from_branch: stable/2023.1 - grenade_localrc: - NOVA_ENABLE_UPGRADE_WORKAROUND: True diff --git a/zuul.d/job-templates.yaml b/zuul.d/job-templates.yaml index 8b53e95874c..e92da972579 100644 --- a/zuul.d/job-templates.yaml +++ b/zuul.d/job-templates.yaml @@ -109,20 +109,3 @@ - neutron-tempest-plugin-linuxbridge-2024-1 experimental: jobs: *neutron-periodic-jobs - -- project-template: - name: neutron-skip-level-jobs - # During a SLURP release, these jobs are executed in the check queue, - # otherwise periodic/experimental. SLURP releases are 2024.1, 2025.1, etc. - check: - jobs: - - neutron-ovs-grenade-multinode-skip-level - - neutron-ovn-grenade-multinode-skip-level - #periodic: - # jobs: - # - neutron-ovs-grenade-multinode-skip-level - # - neutron-ovn-grenade-multinode-skip-level - #experimental: - # jobs: - # - neutron-ovs-grenade-multinode-skip-level - # - neutron-ovn-grenade-multinode-skip-level diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index 133fe3de7fc..ccdd4cac449 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -15,7 +15,6 @@ - neutron-experimental-jobs - neutron-periodic-jobs - neutron-tox-override-jobs - - neutron-skip-level-jobs check: jobs: - neutron-functional-with-uwsgi From 5941e2faa59ee1616b501ad8563334d94409ccd2 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 12 Nov 2024 10:00:08 +0000 Subject: [PATCH 064/184] [stable-only] Change the agent AZ query by agent type In PostgreSQL, it is needed to add the field used in the query field ("select") clause in the "group_by" one. This patch changes the scope of the query to only select the filtered fields (availability zones and agent type). Because the PostgreSQL support has been dropped in master branch (Epoxy, 2025.1), this patch is only for stable branches. Closes-Bug: #2086787 Change-Id: Ifb5ab94ca68a9ab84407b54ac632164860b7a3a8 (cherry picked from commit ebafc58e692c1533137be2fcc8a8027e262f84ce) --- neutron/db/agents_db.py | 4 +- neutron/objects/agent.py | 17 ++-- .../tests/functional/objects/test_agent.py | 85 +++++++++++++++++++ 3 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 neutron/tests/functional/objects/test_agent.py diff --git a/neutron/db/agents_db.py b/neutron/db/agents_db.py index e282173684b..3b5e5cd2ab8 100644 --- a/neutron/db/agents_db.py +++ b/neutron/db/agents_db.py @@ -78,10 +78,8 @@ def get_availability_zones_by_agent_type(context, agent_type, availability_zones): """Get list of availability zones based on agent type""" - - agents = agent_obj.Agent.get_agents_by_availability_zones_and_agent_type( + return agent_obj.Agent.get_availability_zones_by_agent_type( context, agent_type=agent_type, availability_zones=availability_zones) - return set(agent.availability_zone for agent in agents) class AgentAvailabilityZoneMixin(az_ext.AvailabilityZonePluginBase): diff --git a/neutron/objects/agent.py b/neutron/objects/agent.py index 62b7daea14b..6b4d1730053 100644 --- a/neutron/objects/agent.py +++ b/neutron/objects/agent.py @@ -154,15 +154,18 @@ def get_ha_agents(cls, context, network_id=None, router_id=None): @classmethod @db_api.CONTEXT_READER - def get_agents_by_availability_zones_and_agent_type( + def get_availability_zones_by_agent_type( cls, context, agent_type, availability_zones): - query = context.session.query(agent_model.Agent).filter_by( - agent_type=agent_type).group_by( - agent_model.Agent.availability_zone) + query = context.session.query( + agent_model.Agent.availability_zone, + agent_model.Agent.agent_type) query = query.filter( - agent_model.Agent.availability_zone.in_(availability_zones)).all() - agents = [cls._load_object(context, record) for record in query] - return agents + agent_model.Agent.availability_zone.in_(availability_zones), + agent_model.Agent.agent_type == agent_type) + agents = query.group_by( + agent_model.Agent.availability_zone, + agent_model.Agent.agent_type).all() + return [agent[0] for agent in agents] @classmethod def get_objects_by_agent_mode(cls, context, agent_mode=None, **kwargs): diff --git a/neutron/tests/functional/objects/test_agent.py b/neutron/tests/functional/objects/test_agent.py new file mode 100644 index 00000000000..2cd8f0a8de4 --- /dev/null +++ b/neutron/tests/functional/objects/test_agent.py @@ -0,0 +1,85 @@ +# Copyright 2021 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 collections import defaultdict + +from neutron_lib import context as n_context +from neutron_lib.db import api as db_api +from oslo_utils import timeutils +from oslo_utils import uuidutils + +from neutron.objects import agent as agent_obj +from neutron.tests.unit import testlib_api + + +class _AgentSql(testlib_api.SqlTestCase): + + def setUp(self): + super().setUp() + self.context = n_context.get_admin_context() + + @db_api.CONTEXT_WRITER + def _create_agent(self, context, agent_type, az, host=None): + host = host or uuidutils.generate_uuid() + agent = agent_obj.Agent(context, agent_type=agent_type, + availability_zone=az, host=host, + binary=uuidutils.generate_uuid(), + topic=uuidutils.generate_uuid(), + admin_state_up=True, + created_at=timeutils.utcnow(), + started_at=timeutils.utcnow(), + heartbeat_timestamp=timeutils.utcnow(), + configurations='{}', + load=0, + ) + agent.create() + + def test_get_agents_by_availability_zones_and_agent_type(self): + self.agents = defaultdict(dict) + agent_types = ('dhcp', 'ovs', 'l3agent') + azs = ('az1', 'az2', 'az3') + for type_ in agent_types: + for az in azs: + # Create up to 5 agents per AZ and agent type. That will check + # the query GROUP BY clause. + for _ in range(5): + self._create_agent(self.context, type_, az) + + method = agent_obj.Agent.get_availability_zones_by_agent_type + for type_ in agent_types: + for az in azs: + res_azs = method(self.context, type_, [az]) + self.assertEqual(1, len(res_azs)) + self.assertEqual(az, res_azs[0]) + + # Non-existing types, correct AZs + for type_ in ('type1', 'type2'): + for az in azs: + res_azs = method(self.context, type_, [az]) + self.assertEqual(0, len(res_azs)) + + # Correct types, non-existing AZs + for type_ in agent_types: + for az in ('az23', 'az42'): + res_azs = method(self.context, type_, [az]) + self.assertEqual(0, len(res_azs)) + + +class TestAgentMySQL(testlib_api.MySQLTestCaseMixin, _AgentSql): + pass + + +class TestAgentPostgreSQL(testlib_api.PostgreSQLTestCaseMixin, _AgentSql): + pass From 3eac5e4fd797146db9b4cc1d173de5245ca68aa9 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 15 Nov 2024 11:08:19 +0000 Subject: [PATCH 065/184] Filter out the floating IPs when removing a shared RBAC When a RBAC with action=access_as_shared is removed from a network, it is checked first that there are no elements (ports) in this network that could no longer exist due to the RBAC permissions reduction. The floating IP related ports, that have project_id='' by definition, should be removed from this check. These ports can be created due to a RBAC with action=access_as_external. If a floating IP port is present in the network, it should not block the RBAC with action=access_as_shared removal. Closes-Bug: #2075529 Change-Id: I7e31c21c04dc1ef26f5f05537ca0d2cb8f5ca505 (cherry picked from commit 90d836bc420ccd309196ece7908b41b9e2c4f766) --- neutron/db/db_base_plugin_v2.py | 4 + neutron/tests/functional/db/test_network.py | 122 +++++++++++++++----- 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index bb4654d6d47..0d833295a02 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -276,6 +276,10 @@ def ensure_no_tenant_ports_on_network(self, context, network_id, allowed_tenants.append(net_tenant_id) ports = ports.filter( ~models_v2.Port.tenant_id.in_(allowed_tenants)) + # Filter any port with project_id=''. These ports are related + # to floating IPs, router ports (gateway, SNAT, FIP agent, HA + # interface). + ports = ports.filter(models_v2.Port.project_id.notin_([''])) else: # if there is a wildcard rule, we can return early because it # allows any ports diff --git a/neutron/tests/functional/db/test_network.py b/neutron/tests/functional/db/test_network.py index cebede36b2b..20cfee10de3 100644 --- a/neutron/tests/functional/db/test_network.py +++ b/neutron/tests/functional/db/test_network.py @@ -20,10 +20,11 @@ from oslo_config import cfg from oslo_utils import uuidutils -from neutron.db import rbac_db_models +from neutron.db import l3_dvrscheduler_db from neutron.objects import network as network_obj from neutron.plugins.ml2 import plugin as ml2_plugin from neutron import quota +from neutron.services.l3_router import l3_router_plugin from neutron.tests.unit import testlib_api @@ -36,6 +37,9 @@ def setUp(self): DB_PLUGIN_KLASS = 'neutron.plugins.ml2.plugin.Ml2Plugin' self.setup_coreplugin(DB_PLUGIN_KLASS) self.plugin = ml2_plugin.Ml2Plugin() + self.mock_notify_l3_agent = mock.patch.object( + l3_dvrscheduler_db, '_notify_l3_agent_new_port').start() + self.plugin_l3 = l3_router_plugin.L3RouterPlugin() self.ctx = context.Context(user_id=None, tenant_id=None, is_admin=True, @@ -92,14 +96,26 @@ def _create_port(self, tenant_id, network_id, port_id): 'fixed_ips': constants.ATTR_NOT_SPECIFIED} return self.plugin.create_port(self.ctx, {'port': port}) + def _create_floating_ip(self, tenant_id, network_id): + fip = {'tenant_id': tenant_id, + 'floating_network_id': network_id} + return self.plugin_l3.create_floatingip(self.ctx, {'floatingip': fip}) + + def _create_rbac(self, project_id, network_id, action, target_project): + rbac = {'project_id': project_id, + 'object_id': network_id, + 'object_type': 'network', + 'target_project': target_project, + 'action': action} + return self.plugin.create_rbac_policy(self.ctx, {'rbac_policy': rbac}) + + def _delete_rbac(self, rbac_id): + return self.plugin.delete_rbac_policy(self.ctx, rbac_id) + def _list_networks(self, ctx): return self.plugin.get_networks(ctx) - def _check_rbac(self, network_id, is_none, external): - if external: - action = rbac_db_models.ACCESS_EXTERNAL - else: - action = rbac_db_models.ACCESS_SHARED + def _check_rbac(self, network_id, is_none, action): rbac = network_obj.NetworkRBAC.get_object( self.ctx, object_id=network_id, action=action, target_project='*') if is_none: @@ -116,10 +132,12 @@ def test_network_owner(self): 'net-shared': (uuidutils.generate_uuid(), True)} for uuid, shared in tenant_1.values(): self._create_network(self.tenant_1, uuid, shared) - self._check_rbac(uuid, is_none=(not shared), external=False) + self._check_rbac(uuid, is_none=(not shared), + action=constants.ACCESS_SHARED) for uuid, shared in tenant_2.values(): self._create_network(self.tenant_2, uuid, shared) - self._check_rbac(uuid, is_none=(not shared), external=False) + self._check_rbac(uuid, is_none=(not shared), + action=constants.ACCESS_SHARED) ctx_1 = context.Context(user_id=None, tenant_id=self.tenant_1, @@ -144,66 +162,84 @@ def test_network_owner(self): def test_create_network_shared(self): self._create_network(self.tenant_1, self.network_id, True) - self._check_rbac(self.network_id, is_none=False, external=False) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) def test_create_network_not_shared(self): self._create_network(self.tenant_1, self.network_id, False) - self._check_rbac(self.network_id, is_none=True, external=False) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_SHARED) def test_create_network_not_shared_external(self): with mock.patch.object(resource_extend, 'apply_funcs'): self._create_network(self.tenant_1, self.network_id, False, external=True) - self._check_rbac(self.network_id, is_none=False, external=True) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_EXTERNAL) def test_update_network_to_shared(self): self._create_network(self.tenant_1, self.network_id, False) - self._check_rbac(self.network_id, is_none=True, external=False) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_SHARED) network_data = {'shared': True} self._update_network(self.network_id, network_data) - self._check_rbac(self.network_id, is_none=False, external=False) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) def test_update_network_to_no_shared_no_subnets(self): self._create_network(self.tenant_1, self.network_id, True) - self._check_rbac(self.network_id, is_none=False, external=False) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) network_data = {'shared': False} self._update_network(self.network_id, network_data) - self._check_rbac(self.network_id, is_none=True, external=False) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_SHARED) def test_update_network_shared_to_external(self): self._create_network(self.tenant_1, self.network_id, True) - self._check_rbac(self.network_id, is_none=False, external=False) - self._check_rbac(self.network_id, is_none=True, external=True) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_EXTERNAL) network_data = {extnet_apidef.EXTERNAL: True} self._update_network(self.network_id, network_data) - self._check_rbac(self.network_id, is_none=False, external=False) - self._check_rbac(self.network_id, is_none=False, external=True) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_EXTERNAL) def test_update_network_shared_to_internal(self): self._create_network(self.tenant_1, self.network_id, True, external=True) - self._check_rbac(self.network_id, is_none=False, external=False) - self._check_rbac(self.network_id, is_none=False, external=True) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_EXTERNAL) network_data = {extnet_apidef.EXTERNAL: False} self._update_network(self.network_id, network_data) - self._check_rbac(self.network_id, is_none=False, external=False) - self._check_rbac(self.network_id, is_none=True, external=True) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_EXTERNAL) def test_update_network_to_no_shared_tenant_subnet(self): self._create_network(self.tenant_1, self.network_id, True) - self._check_rbac(self.network_id, is_none=False, external=False) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) self._create_subnet(self.tenant_1, self.subnet_1_id, True) network_data = {'shared': False} self._update_network(self.network_id, network_data) - self._check_rbac(self.network_id, is_none=True, external=False) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_SHARED) def test_update_network_to_no_shared_no_tenant_subnet(self): self._create_network(self.tenant_1, self.network_id, True) - self._check_rbac(self.network_id, is_none=False, external=False) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) self._create_subnet(self.tenant_1, self.subnet_1_id, True) self._create_subnet(self.tenant_2, self.subnet_2_id, True, cidr='10.10.20/24') @@ -264,3 +300,37 @@ def test_ensure_no_share_port_tenant_2_in_tenant_2(self): self.plugin.ensure_no_tenant_ports_on_network, self.ctx, self.network_id, self.tenant_1, self.tenant_2) + + def _external_and_shared_network(self, project_id): + self._create_network(self.tenant_1, self.network_id, False, + external=True) + self._create_subnet(self.tenant_1, self.subnet_1_id, False) + self._create_floating_ip(project_id, self.network_id) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_EXTERNAL) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_SHARED) + + # Add a RBAC with action=access_as_shared + rbac_shared = self._create_rbac( + self.tenant_1, self.network_id, action=constants.ACCESS_SHARED, + target_project='*') + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_SHARED) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_EXTERNAL) + + # Delete the created RBAC with action=access_as_shared. The FIP does + # not interfere with the RBAC deletion because it can be created due + # to the RBAC action=access_as_external. + self._delete_rbac(rbac_shared['id']) + self._check_rbac(self.network_id, is_none=True, + action=constants.ACCESS_SHARED) + self._check_rbac(self.network_id, is_none=False, + action=constants.ACCESS_EXTERNAL) + + def test_external_network_update_shared_flag_own_project_fip(self): + self._external_and_shared_network(self.tenant_1) + + def test_external_network_update_shared_flag_other_project_fip(self): + self._external_and_shared_network(self.tenant_2) From 1f594ea5135b6a1025c2f337e11b9b956b1f06cc Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 22 Nov 2024 11:07:26 +0000 Subject: [PATCH 066/184] Add policy enforcer for QoS policy "tags" service plugin This resource was missing in [1]. This patch should be backported up to 2023.2. [1]https://review.opendev.org/q/I9f3e032739824f268db74c5a1b4f04d353742dbd Depends-On: https://review.opendev.org/c/openstack/neutron-tempest-plugin/+/936036 Conflicts: neutron/conf/policies/qos.py neutron/tests/unit/conf/policies/test_qos.py Related-Bug: #2037002 Change-Id: Ie6210f7dab4d54d734255d3ac2271cac99590f46 (cherry picked from commit 6aaf293ffd24555450ee9c416ec6b4890a91b40f) --- neutron/conf/policies/qos.py | 50 ++++++++ neutron/tests/unit/conf/policies/test_qos.py | 117 +++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/neutron/conf/policies/qos.py b/neutron/conf/policies/qos.py index c507a7bdb99..fc988140879 100644 --- a/neutron/conf/policies/qos.py +++ b/neutron/conf/policies/qos.py @@ -19,6 +19,25 @@ DEPRECATED_REASON = """ The QoS API now supports project scope and default roles. """ +RESOURCE_PATH = '/qos/policies/{id}' +TAGS_PATH = RESOURCE_PATH + '/tags' +TAG_PATH = RESOURCE_PATH + '/tags/{tag_id}' + +ACTION_GET_TAGS = [ + {'method': 'GET', 'path': TAGS_PATH}, + {'method': 'GET', 'path': TAG_PATH}, +] +ACTION_PUT_TAGS = [ + {'method': 'PUT', 'path': TAGS_PATH}, + {'method': 'PUT', 'path': TAG_PATH}, +] +ACTION_POST_TAGS = [ + {'method': 'POST', 'path': TAGS_PATH}, +] +ACTION_DELETE_TAGS = [ + {'method': 'DELETE', 'path': TAGS_PATH}, + {'method': 'DELETE', 'path': TAG_PATH}, +] rules = [ @@ -50,6 +69,16 @@ deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY) ), + policy.DocumentedRuleDefault( + name='get_policies_tags', + check_str=neutron_policy.policy_or( + base.ADMIN_OR_PROJECT_READER, + 'rule:shared_qos_policy' + ), + scope_types=['project'], + description='Get QoS policy tags', + operations=ACTION_GET_TAGS + ), policy.DocumentedRuleDefault( name='create_policy', check_str=base.ADMIN, @@ -67,6 +96,13 @@ deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY) ), + policy.DocumentedRuleDefault( + name='create_policies_tags', + check_str=base.ADMIN, + scope_types=['project'], + description='Create the QoS policy tags', + operations=ACTION_POST_TAGS, + ), policy.DocumentedRuleDefault( name='update_policy', check_str=base.ADMIN, @@ -84,6 +120,13 @@ deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY) ), + policy.DocumentedRuleDefault( + name='update_policies_tags', + check_str=base.ADMIN, + scope_types=['project'], + description='Update the QoS policy tags', + operations=ACTION_PUT_TAGS, + ), policy.DocumentedRuleDefault( name='delete_policy', check_str=base.ADMIN, @@ -101,6 +144,13 @@ deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY) ), + policy.DocumentedRuleDefault( + name='delete_policies_tags', + check_str=base.ADMIN, + scope_types=['project'], + description='Delete the QoS policy tags', + operations=ACTION_DELETE_TAGS + ), policy.DocumentedRuleDefault( name='get_rule_type', diff --git a/neutron/tests/unit/conf/policies/test_qos.py b/neutron/tests/unit/conf/policies/test_qos.py index b5ee683c981..b05bdcbfcb0 100644 --- a/neutron/tests/unit/conf/policies/test_qos.py +++ b/neutron/tests/unit/conf/policies/test_qos.py @@ -44,6 +44,14 @@ def test_get_policy(self): base_policy.InvalidScope, policy.enforce, self.context, 'get_policy', self.alt_target) + def test_get_policies_tags(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'get_policies_tags', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'get_policies_tags', self.alt_target) + def test_create_policy(self): self.assertRaises( base_policy.InvalidScope, @@ -52,6 +60,15 @@ def test_create_policy(self): base_policy.InvalidScope, policy.enforce, self.context, 'create_policy', self.alt_target) + def test_create_policies_tags(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'create_policies_tags', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'create_policies_tags', + self.alt_target) + def test_update_policy(self): self.assertRaises( base_policy.InvalidScope, @@ -60,6 +77,15 @@ def test_update_policy(self): base_policy.InvalidScope, policy.enforce, self.context, 'update_policy', self.alt_target) + def test_update_policies_tags(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'update_policies_tags', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'update_policies_tags', + self.alt_target) + def test_delete_policy(self): self.assertRaises( base_policy.InvalidScope, @@ -68,6 +94,15 @@ def test_delete_policy(self): base_policy.InvalidScope, policy.enforce, self.context, 'delete_policy', self.alt_target) + def test_delete_policies_tags(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'delete_policies_tags', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, self.context, 'delete_policies_tags', + self.alt_target) + class SystemMemberQosPolicyTests(SystemAdminQosPolicyTests): @@ -95,24 +130,51 @@ def test_get_policy(self): self.assertTrue( policy.enforce(self.context, 'get_policy', self.alt_target)) + def test_get_policies_tags(self): + self.assertTrue( + policy.enforce(self.context, 'get_policies_tags', self.target)) + self.assertTrue( + policy.enforce(self.context, 'get_policies_tags', self.alt_target)) + def test_create_policy(self): self.assertTrue( policy.enforce(self.context, 'create_policy', self.target)) self.assertTrue( policy.enforce(self.context, 'create_policy', self.alt_target)) + def test_create_policies_tags(self): + self.assertTrue( + policy.enforce(self.context, 'create_policies_tags', self.target)) + self.assertTrue( + policy.enforce(self.context, 'create_policies_tags', + self.alt_target)) + def test_update_policy(self): self.assertTrue( policy.enforce(self.context, 'update_policy', self.target)) self.assertTrue( policy.enforce(self.context, 'update_policy', self.alt_target)) + def test_update_policies_tags(self): + self.assertTrue( + policy.enforce(self.context, 'update_policies_tags', self.target)) + self.assertTrue( + policy.enforce(self.context, 'update_policies_tags', + self.alt_target)) + def test_delete_policy(self): self.assertTrue( policy.enforce(self.context, 'delete_policy', self.target)) self.assertTrue( policy.enforce(self.context, 'delete_policy', self.alt_target)) + def test_delete_policies_tags(self): + self.assertTrue( + policy.enforce(self.context, 'delete_policies_tags', self.target)) + self.assertTrue( + policy.enforce(self.context, 'delete_policies_tags', + self.alt_target)) + class ProjectMemberQosPolicyTests(AdminQosPolicyTests): @@ -127,6 +189,14 @@ def test_get_policy(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'get_policy', self.alt_target) + def test_get_policies_tags(self): + self.assertTrue( + policy.enforce(self.context, 'get_policies_tags', self.target)) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'get_policies_tags', + self.alt_target) + def test_create_policy(self): self.assertRaises( base_policy.PolicyNotAuthorized, @@ -135,6 +205,15 @@ def test_create_policy(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_policy', self.alt_target) + def test_create_policies_tags(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_policies_tags', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_policies_tags', + self.alt_target) + def test_update_policy(self): self.assertRaises( base_policy.PolicyNotAuthorized, @@ -143,6 +222,15 @@ def test_update_policy(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_policy', self.alt_target) + def test_update_policies_tags(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_policies_tags', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_policies_tags', + self.alt_target) + def test_delete_policy(self): self.assertRaises( base_policy.PolicyNotAuthorized, @@ -151,6 +239,15 @@ def test_delete_policy(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_policy', self.alt_target) + def test_delete_policies_tags(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'delete_policies_tags', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'delete_policies_tags', + self.alt_target) + class ProjectReaderQosPolicyTests(ProjectMemberQosPolicyTests): @@ -170,21 +267,41 @@ def test_get_policy(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'get_policy', self.target) + def test_get_policies_tags(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'get_policies_tags', self.target) + def test_create_policy(self): self.assertRaises( base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_policy', self.target) + def test_create_policies_tags(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'create_policies_tags', self.target) + def test_update_policy(self): self.assertRaises( base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_policy', self.target) + def test_update_policies_tags(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'update_policies_tags', self.target) + def test_delete_policy(self): self.assertRaises( base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_policy', self.target) + def test_delete_policies_tags(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, self.context, 'delete_policies_tags', self.target) + class QosRuleTypeAPITestCase(base.PolicyBaseTestCase): From e67217d5fa7a29aab29efd1a2bf62511f0077e18 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 20 Nov 2024 15:20:16 +0000 Subject: [PATCH 067/184] [OVN] Use the MySQL backend for the ``TestOvnNbSync`` tests The ``TestOvnNbSync`` test cases perform intensive operations on both the Neutron database and the OVN databases. These test are frequently hitting an issue with the Neutron database, that in the functional test is, by default, SQLite. When a port is being deleted, the database raises an exception with the following message: DELETE failed.: oslo_db.exception.DBReferenceError: (sqlite3.IntegrityError) FOREIGN KEY constraint failed [SQL: DELETE FROM ports WHERE ports.id = ?] [parameters: ('64720ac5-72a0-4e88-8193-fd54a97ccef3',)] This resource (port) and the one referring to it (floating IP), have been created and updated in previous API calls, thus the transactions to the database should be commited and finished. This patch is changing the database backend to MySQL, that should provide better transaction isolation. Closes-Bug: #2088423 Change-Id: If1da6c5992aa4635da5a4b5c6eaa06db56d693b4 (cherry picked from commit abb527d1e4ec8ac34f6e277089b59687f9c3307e) --- .../ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py index b6e4595e2ff..f6c613a8ad7 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py @@ -42,9 +42,11 @@ from neutron.tests.unit.api import test_extensions from neutron.tests.unit.extensions import test_extraroute from neutron.tests.unit.extensions import test_securitygroup +from neutron.tests.unit import testlib_api -class TestOvnNbSync(base.TestOVNFunctionalBase): +class TestOvnNbSync(base.TestOVNFunctionalBase, + testlib_api.MySQLTestCaseMixin): _extension_drivers = ['port_security', 'dns', 'qos', 'revision_plugin'] From 74eb4426dda690e29d0ed119dc68680427cab43f Mon Sep 17 00:00:00 2001 From: Jakub Libosvar Date: Tue, 5 Nov 2024 21:37:31 +0000 Subject: [PATCH 068/184] OVN metadata agent additional_chassis detection The patch changes how additional_chassis column support is handled in events. We cannot call to IDL from the match methods because the post fork event might not be set yet right after IDL was instantiated. If between the IDL instantiation and the post fork event set an event calling to IDL is processed, the match event method will wait indefinitely. This patch removes the call to IDL in the match method. Closes-Bug: #2086740 Change-Id: Ibc7d9b4dd196bed65cff73b79d78122f70aac1a7 Signed-off-by: Jakub Libosvar (cherry picked from commit d8884a99e03533533f7bacef598e9a6af592e3fa) --- neutron/agent/ovn/metadata/agent.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/neutron/agent/ovn/metadata/agent.py b/neutron/agent/ovn/metadata/agent.py index de48c64ef97..d563cf55e97 100644 --- a/neutron/agent/ovn/metadata/agent.py +++ b/neutron/agent/ovn/metadata/agent.py @@ -69,10 +69,12 @@ def wrapped(*args, **kwargs): return wrapped +# TODO(jlibosva): Remove the decorator after we depend on OVN version that has +# the schema containing the additional_chassis column def _match_only_if_additional_chassis_is_supported(f): @functools.wraps(f) def wrapped(self, row, old): - if not ovn_utils.is_additional_chassis_supported(self.agent.sb_idl): + if not hasattr(row, 'additional_chassis'): return False return f(self, row, old) return wrapped @@ -206,7 +208,9 @@ def _is_localport_ext_ids_update(self, row, old): def _is_new_chassis_set(self, row, old): self._log_msg = "Port %s in datapath %s bound to our chassis" try: - if ovn_utils.is_additional_chassis_supported(self.agent.sb_idl): + # TODO(jlibosva): Remove the check after we depend on OVN version + # that has the schema containing the additional_chassis column + if hasattr(row, 'additional_chassis'): try: # If the additional chassis used to be in the old version # the resources are already provisioned From f8dc7100c113c4dfa290da078102c14cc2b5c3fd Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 21 Nov 2024 11:55:37 +0000 Subject: [PATCH 069/184] Fix the tagging policy engine The service tagging policy engine should consider the parent resource or the upper parent resource project ID when checking the policies against the caller project ID. Before this patch, as introduced in [1], the target was incorrectly populated with the caller project ID instead of using the resource ID. [1]https://review.opendev.org/c/openstack/neutron/+/896509/13/neutron/extensions/tagging.py OSSA-2024-005 CVE-2024-53916 Conflitcs: neutron/extensions/tagging.py Closes-Bug: #2088986 Change-Id: Id7d0c8e7ba37993b1084519d05e7e2eac095b81b (cherry picked from commit fb75d3c4f185bb082f69c121090382d9eb803b94) (cherry picked from commit 93e86fa84175b525f5b1dc5df1651a44d60219ba) --- neutron/extensions/tagging.py | 198 ++++++++++++------ neutron/objects/subnet.py | 11 - neutron/tests/unit/extensions/test_tagging.py | 179 ++++++++++++++++ 3 files changed, 310 insertions(+), 78 deletions(-) create mode 100644 neutron/tests/unit/extensions/test_tagging.py diff --git a/neutron/extensions/tagging.py b/neutron/extensions/tagging.py index 432677ebe67..28fc8a419c1 100644 --- a/neutron/extensions/tagging.py +++ b/neutron/extensions/tagging.py @@ -12,8 +12,10 @@ # under the License. import abc +import collections import copy import functools +import itertools from neutron_lib.api.definitions import port from neutron_lib.api import extensions as api_extensions @@ -29,7 +31,15 @@ from neutron._i18n import _ from neutron.api import extensions from neutron.api.v2 import resource as api_resource -from neutron.objects import subnet +from neutron.objects import network as network_obj +from neutron.objects import network_segment_range as network_segment_range_obj +from neutron.objects import ports as ports_obj +from neutron.objects.qos import policy as policy_obj +from neutron.objects import router as router_obj +from neutron.objects import securitygroup as securitygroup_obj +from neutron.objects import subnet as subnet_obj +from neutron.objects import subnetpool as subnetpool_obj +from neutron.objects import trunk as trunk_obj from neutron import policy @@ -58,7 +68,26 @@ 'validate': {'type:list_of_unique_strings': MAX_TAG_LEN}, 'default': [], 'is_visible': True, 'is_filter': True } -RESOURCES_AND_PARENTS = {'subnets': ('network', subnet.Subnet.get_network_id)} +PARENTS = { + 'floatingips': router_obj.FloatingIP, + 'network_segment_ranges': network_segment_range_obj.NetworkSegmentRange, + 'networks': network_obj.Network, + 'policies': policy_obj.QosPolicy, + 'ports': ports_obj.Port, + 'routers': router_obj.Router, + 'security_groups': securitygroup_obj.SecurityGroup, + 'subnets': ('networks', subnet_obj.Subnet), + 'subnetpools': subnetpool_obj.SubnetPool, + 'trunks': trunk_obj.Trunk, +} +ResourceInfo = collections.namedtuple( + 'ResourceInfo', ['project_id', + 'parent_type', + 'parent_id', + 'upper_parent_type', + 'upper_parent_id', + ]) +EMPTY_RESOURCE_INFO = ResourceInfo(None, None, None, None, None) def _policy_init(f): @@ -107,44 +136,68 @@ def __init__(self): self.plugin = directory.get_plugin(TAG_PLUGIN_TYPE) self.supported_resources = TAG_SUPPORTED_RESOURCES - @staticmethod - def _get_target(ctx, res_id, p_res, p_res_id, tag_id=None): - target = {'id': res_id, - 'tenant_id': ctx.project_id, - 'project_id': ctx.project_id} - if p_res: - target[p_res + '_id'] = p_res_id - if tag_id: - target['tag_id'] = tag_id + def _get_target(self, res_info): + target = {'id': res_info.parent_id, + 'tenant_id': res_info.project_id, + 'project_id': res_info.project_id} + if res_info.upper_parent_type: + res_id = (self.supported_resources[res_info.upper_parent_type] + + '_id') + target[res_id] = res_info.upper_parent_id return target - @staticmethod - def _get_pparent_resource_and_id(context, resource, resource_id): - """Retrieve the parent of the resource and ID (e.g.: subnet->net)""" - parent, getter_id = RESOURCES_AND_PARENTS[resource] - parent_id = getter_id(context.elevated(), resource_id) - return parent, parent_id - - def _get_parent_resource_and_id(self, context, kwargs): - parent, parent_id = None, None - for key in kwargs: - for resource in self.supported_resources: - if key == self.supported_resources[resource] + '_id': - if resource in RESOURCES_AND_PARENTS.keys(): - parent, parent_id = self._get_pparent_resource_and_id( - context, resource, kwargs[key]) - return resource, kwargs[key], parent, parent_id - return None, None, None, None + def _get_resource_info(self, context, kwargs): + """Return the tag parent resource information + + Some parent resources, like the subnets, depend on other upper parent + resources (networks). In that case, it is needed to provide the upper + parent resource information. + + :param kwargs: dictionary with the parent resource ID, along with other + information not needed. It is formated as + {"resource_id": "id", ...} + :return: ``ResourceInfo`` named tuple with the parent and upper parent + information and the project ID (of the parent or upper + parent). + """ + for key, parent_type in itertools.product( + kwargs.keys(), self.supported_resources.keys()): + if key != self.supported_resources[parent_type] + '_id': + continue + + parent_id = kwargs[key] + parent_obj = PARENTS[parent_type] + if isinstance(parent_obj, tuple): + upper_parent_type = parent_obj[0] + parent_obj = parent_obj[1] + res_id = (self.supported_resources[upper_parent_type] + + '_id') + upper_parent_id = parent_obj.get_values( + context.elevated(), res_id, id=parent_id)[0] + else: + upper_parent_type = upper_parent_id = None + + try: + project_id = parent_obj.get_values( + context.elevated(), 'project_id', id=parent_id)[0] + except IndexError: + return EMPTY_RESOURCE_INFO + + return ResourceInfo(project_id, parent_type, parent_id, + upper_parent_type, upper_parent_id) + + # This should never be returned. + return EMPTY_RESOURCE_INFO @_policy_init def index(self, request, **kwargs): # GET /v2.0/{parent_resource}/{parent_resource_id}/tags ctx = request.context - res, res_id, p_res, p_res_id = self._get_parent_resource_and_id( - ctx, kwargs) - target = self._get_target(ctx, res_id, p_res, p_res_id) - policy.enforce(ctx, 'get_%s_%s' % (res, TAGS), target) - return self.plugin.get_tags(ctx, res, res_id) + rinfo = self._get_resource_info(ctx, kwargs) + target = self._get_target(rinfo) + policy.enforce(ctx, 'get_{}_{}'.format(rinfo.parent_type, TAGS), + target) + return self.plugin.get_tags(ctx, rinfo.parent_type, rinfo.parent_id) @_policy_init def show(self, request, id, **kwargs): @@ -152,11 +205,11 @@ def show(self, request, id, **kwargs): # id == tag validate_tag(id) ctx = request.context - res, res_id, p_res, p_res_id = self._get_parent_resource_and_id( - ctx, kwargs) - target = self._get_target(ctx, res_id, p_res, p_res_id, tag_id=id) - policy.enforce(ctx, 'get_%s_%s' % (res, TAGS), target) - return self.plugin.get_tag(ctx, res, res_id, id) + rinfo = self._get_resource_info(ctx, kwargs) + target = self._get_target(rinfo) + policy.enforce(ctx, 'get_{}_{}'.format(rinfo.parent_type, TAGS), + target) + return self.plugin.get_tag(ctx, rinfo.parent_type, rinfo.parent_id, id) def create(self, request, **kwargs): # not supported @@ -169,13 +222,16 @@ def update(self, request, id, **kwargs): # id == tag validate_tag(id) ctx = request.context - res, res_id, p_res, p_res_id = self._get_parent_resource_and_id( - ctx, kwargs) - target = self._get_target(ctx, res_id, p_res, p_res_id, tag_id=id) - policy.enforce(ctx, 'update_%s_%s' % (res, TAGS), target) - notify_tag_action(ctx, 'create.start', res, res_id, [id]) - result = self.plugin.update_tag(ctx, res, res_id, id) - notify_tag_action(ctx, 'create.end', res, res_id, [id]) + rinfo = self._get_resource_info(ctx, kwargs) + target = self._get_target(rinfo) + policy.enforce(ctx, 'update_{}_{}'.format(rinfo.parent_type, TAGS), + target) + notify_tag_action(ctx, 'create.start', rinfo.parent_type, + rinfo.parent_id, [id]) + result = self.plugin.update_tag(ctx, rinfo.parent_type, + rinfo.parent_id, id) + notify_tag_action(ctx, 'create.end', rinfo.parent_type, + rinfo.parent_id, [id]) return result @_policy_init @@ -184,14 +240,16 @@ def update_all(self, request, body, **kwargs): # body: {"tags": ["aaa", "bbb"]} validate_tags(body) ctx = request.context - res, res_id, p_res, p_res_id = self._get_parent_resource_and_id( - ctx, kwargs) - target = self._get_target(ctx, res_id, p_res, p_res_id) - policy.enforce(ctx, 'update_%s_%s' % (res, TAGS), target) - notify_tag_action(ctx, 'update.start', res, res_id, body['tags']) - result = self.plugin.update_tags(ctx, res, res_id, body) - notify_tag_action(ctx, 'update.end', res, res_id, - body['tags']) + rinfo = self._get_resource_info(ctx, kwargs) + target = self._get_target(rinfo) + policy.enforce(ctx, 'update_{}_{}'.format(rinfo.parent_type, TAGS), + target) + notify_tag_action(ctx, 'update.start', rinfo.parent_type, + rinfo.parent_id, body['tags']) + result = self.plugin.update_tags(ctx, rinfo.parent_type, + rinfo.parent_id, body) + notify_tag_action(ctx, 'update.end', rinfo.parent_type, + rinfo.parent_id, body['tags']) return result @_policy_init @@ -200,26 +258,32 @@ def delete(self, request, id, **kwargs): # id == tag validate_tag(id) ctx = request.context - res, res_id, p_res, p_res_id = self._get_parent_resource_and_id( - ctx, kwargs) - target = self._get_target(ctx, res_id, p_res, p_res_id, tag_id=id) - policy.enforce(ctx, 'delete_%s_%s' % (res, TAGS), target) - notify_tag_action(ctx, 'delete.start', res, res_id, [id]) - result = self.plugin.delete_tag(ctx, res, res_id, id) - notify_tag_action(ctx, 'delete.end', res, res_id, [id]) + rinfo = self._get_resource_info(ctx, kwargs) + target = self._get_target(rinfo) + policy.enforce(ctx, 'delete_{}_{}'.format(rinfo.parent_type, TAGS), + target) + notify_tag_action(ctx, 'delete.start', rinfo.parent_type, + rinfo.parent_id, [id]) + result = self.plugin.delete_tag(ctx, rinfo.parent_type, + rinfo.parent_id, id) + notify_tag_action(ctx, 'delete.end', rinfo.parent_type, + rinfo.parent_id, [id]) return result @_policy_init def delete_all(self, request, **kwargs): # DELETE /v2.0/{parent_resource}/{parent_resource_id}/tags ctx = request.context - res, res_id, p_res, p_res_id = self._get_parent_resource_and_id( - ctx, kwargs) - target = self._get_target(ctx, res_id, p_res, p_res_id) - policy.enforce(ctx, 'delete_%s_%s' % (res, TAGS), target) - notify_tag_action(ctx, 'delete_all.start', res, res_id) - result = self.plugin.delete_tags(ctx, res, res_id) - notify_tag_action(ctx, 'delete_all.end', res, res_id) + rinfo = self._get_resource_info(ctx, kwargs) + target = self._get_target(rinfo) + policy.enforce(ctx, 'delete_{}_{}'.format(rinfo.parent_type, TAGS), + target) + notify_tag_action(ctx, 'delete_all.start', rinfo.parent_type, + rinfo.parent_id) + result = self.plugin.delete_tags(ctx, rinfo.parent_type, + rinfo.parent_id) + notify_tag_action(ctx, 'delete_all.end', rinfo.parent_type, + rinfo.parent_id) return result diff --git a/neutron/objects/subnet.py b/neutron/objects/subnet.py index bafddf09e06..39d10a5391e 100644 --- a/neutron/objects/subnet.py +++ b/neutron/objects/subnet.py @@ -13,7 +13,6 @@ import netaddr from neutron_lib.api import validators from neutron_lib import constants as const -from neutron_lib.db import api as db_api from neutron_lib.db import model_query from neutron_lib.objects import common_types from neutron_lib.utils import net as net_utils @@ -23,7 +22,6 @@ from oslo_versionedobjects import fields as obj_fields from sqlalchemy import and_, or_ from sqlalchemy import orm -from sqlalchemy.orm import exc as orm_exc from sqlalchemy.sql import exists from neutron.db.models import dns as dns_models @@ -547,15 +545,6 @@ def get_subnet_segment_ids(cls, context, network_id, return [segment_id for (segment_id,) in query.all()] - @classmethod - @db_api.CONTEXT_READER - def get_network_id(cls, context, subnet_id): - try: - return context.session.query(cls.db_model.network_id).filter( - cls.db_model.id == subnet_id).one()[0] - except orm_exc.NoResultFound: - return None - @base.NeutronObjectRegistry.register class NetworkSubnetLock(base.NeutronDbObject): diff --git a/neutron/tests/unit/extensions/test_tagging.py b/neutron/tests/unit/extensions/test_tagging.py new file mode 100644 index 00000000000..ca8c7218ddc --- /dev/null +++ b/neutron/tests/unit/extensions/test_tagging.py @@ -0,0 +1,179 @@ +# Copyright 2024 Red Hat, Inc. +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import netaddr +from neutron_lib import constants as n_const +from neutron_lib import context +from neutron_lib.utils import net as net_utils +from oslo_utils import uuidutils + +from neutron.extensions import tagging +from neutron.objects import network as network_obj +from neutron.objects import network_segment_range as network_segment_range_obj +from neutron.objects import ports as ports_obj +from neutron.objects.qos import policy as policy_obj +from neutron.objects import router as router_obj +from neutron.objects import securitygroup as securitygroup_obj +from neutron.objects import subnet as subnet_obj +from neutron.objects import subnetpool as subnetpool_obj +from neutron.objects import trunk as trunk_obj +from neutron.tests.unit import testlib_api + + +class TaggingControllerDbTestCase(testlib_api.WebTestCase): + def setUp(self): + super().setUp() + self.user_id = uuidutils.generate_uuid() + self.project_id = uuidutils.generate_uuid() + self.ctx = context.Context(user_id=self.user_id, + tenant_id=self.project_id, + is_admin=False) + self.tc = tagging.TaggingController() + + def test_all_parents_have_a_reference(self): + tc_supported_resources = set(self.tc.supported_resources.keys()) + parent_resources = set(tagging.PARENTS.keys()) + self.assertEqual(tc_supported_resources, parent_resources) + + def _check_resource_info(self, parent_id, parent_type, + upper_parent_id=None, upper_parent_type=None): + p_id = self.tc.supported_resources[parent_type] + '_id' + res = self.tc._get_resource_info(self.ctx, {p_id: parent_id}) + reference = tagging.ResourceInfo( + self.project_id, parent_type, parent_id, + upper_parent_type, upper_parent_id) + self.assertEqual(reference, res) + + def test__get_resource_info_floatingips(self): + ext_net_id = uuidutils.generate_uuid() + fip_port_id = uuidutils.generate_uuid() + fip_id = uuidutils.generate_uuid() + network_obj.Network( + self.ctx, id=ext_net_id, project_id=self.project_id).create() + network_obj.ExternalNetwork( + self.ctx, project_id=self.project_id, + network_id=ext_net_id).create() + mac_str = next(net_utils.random_mac_generator( + ['ca', 'fe', 'ca', 'fe'])) + mac = netaddr.EUI(mac_str) + ports_obj.Port( + self.ctx, id=fip_port_id, project_id=self.project_id, + mac_address=mac, network_id=ext_net_id, admin_state_up=True, + status='UP', device_id='', device_owner='').create() + ip_address = netaddr.IPAddress('1.2.3.4') + router_obj.FloatingIP( + self.ctx, id=fip_id, project_id=self.project_id, + floating_network_id=ext_net_id, floating_port_id=fip_port_id, + floating_ip_address=ip_address).create() + self._check_resource_info(fip_id, 'floatingips') + + def test__get_resource_info_network_segment_ranges(self): + srange_id = uuidutils.generate_uuid() + network_segment_range_obj.NetworkSegmentRange( + self.ctx, id=srange_id, project_id=self.project_id, + shared=False, network_type=n_const.TYPE_GENEVE).create() + self._check_resource_info(srange_id, 'network_segment_ranges') + + def test__get_resource_info_networks(self): + net_id = uuidutils.generate_uuid() + network_obj.Network( + self.ctx, id=net_id, project_id=self.project_id).create() + self._check_resource_info(net_id, 'networks') + + def test__get_resource_info_policies(self): + qos_id = uuidutils.generate_uuid() + policy_obj.QosPolicy( + self.ctx, id=qos_id, project_id=self.project_id).create() + self._check_resource_info(qos_id, 'policies') + + def test__get_resource_info_ports(self): + net_id = uuidutils.generate_uuid() + port_id = uuidutils.generate_uuid() + network_obj.Network( + self.ctx, id=net_id, project_id=self.project_id).create() + mac_str = next(net_utils.random_mac_generator( + ['ca', 'fe', 'ca', 'fe'])) + mac = netaddr.EUI(mac_str) + ports_obj.Port( + self.ctx, id=port_id, project_id=self.project_id, + mac_address=mac, network_id=net_id, admin_state_up=True, + status='UP', device_id='', device_owner='').create() + self._check_resource_info(port_id, 'ports') + + def test__get_resource_info_routers(self): + router_id = uuidutils.generate_uuid() + router_obj.Router( + self.ctx, id=router_id, project_id=self.project_id).create() + self._check_resource_info(router_id, 'routers') + + def test__get_resource_info_security_groups(self): + sg_id = uuidutils.generate_uuid() + securitygroup_obj.SecurityGroup( + self.ctx, id=sg_id, project_id=self.project_id, + is_default=True).create() + self._check_resource_info(sg_id, 'security_groups') + + def test__get_resource_info_subnets(self): + net_id = uuidutils.generate_uuid() + subnet_id = uuidutils.generate_uuid() + network_obj.Network( + self.ctx, id=net_id, project_id=self.project_id).create() + cidr = netaddr.IPNetwork('1.2.3.0/24') + subnet_obj.Subnet( + self.ctx, id=subnet_id, project_id=self.project_id, + ip_version=n_const.IP_VERSION_4, cidr=cidr, + network_id=net_id).create() + self._check_resource_info(subnet_id, 'subnets', + upper_parent_id=net_id, + upper_parent_type='networks') + + def test__get_resource_info_subnetpools(self): + sp_id = uuidutils.generate_uuid() + subnetpool_obj.SubnetPool( + self.ctx, id=sp_id, project_id=self.project_id, + ip_version=n_const.IP_VERSION_4, default_prefixlen=26, + min_prefixlen=28, max_prefixlen=26).create() + self._check_resource_info(sp_id, 'subnetpools') + + def test__get_resource_info_trunks(self): + trunk_id = uuidutils.generate_uuid() + net_id = uuidutils.generate_uuid() + port_id = uuidutils.generate_uuid() + network_obj.Network( + self.ctx, id=net_id, project_id=self.project_id).create() + mac_str = next(net_utils.random_mac_generator( + ['ca', 'fe', 'ca', 'fe'])) + mac = netaddr.EUI(mac_str) + ports_obj.Port( + self.ctx, id=port_id, project_id=self.project_id, + mac_address=mac, network_id=net_id, admin_state_up=True, + status='UP', device_id='', device_owner='').create() + trunk_obj.Trunk( + self.ctx, id=trunk_id, project_id=self.project_id, + port_id=port_id).create() + self._check_resource_info(trunk_id, 'trunks') + + def test__get_resource_info_parent_not_present(self): + missing_id = uuidutils.generate_uuid() + p_id = self.tc.supported_resources['trunks'] + '_id' + res = self.tc._get_resource_info(self.ctx, {p_id: missing_id}) + self.assertEqual(tagging.EMPTY_RESOURCE_INFO, res) + + def test__get_resource_info_wrong_resource(self): + missing_id = uuidutils.generate_uuid() + res = self.tc._get_resource_info(self.ctx, + {'wrong_resource_id': missing_id}) + self.assertEqual(tagging.EMPTY_RESOURCE_INFO, res) From 49b90710cedb0d601a10dd0799f56bd7732a9d31 Mon Sep 17 00:00:00 2001 From: yatinkarel Date: Mon, 16 Dec 2024 09:59:52 +0530 Subject: [PATCH 070/184] Revert "[HA] Do not add initial state change delay in HA router" This reverts commit c20f2e5136fd241f4be5c37403ab1ed54cdaefb5. The fix of bug #1945512 reintroduced bug #1837635 as after the initial backup state ha router can transition to 'primary' state on multiple hosts and due to this delay multiple routers get into 'active' ha_state even if one of the host quickly transition to backup after the primary state. The issue got visible since ha router fullstack tests are added as part of [1]. [1] https://review.opendev.org/c/openstack/neutron/+/917429 Related-Bug: #1837635 Related-Bug: #1945512 Related-Bug: #2083609 Change-Id: I83b53a07362861da98b8361dafd95e94e5048322 (cherry picked from commit a3689956dde80b9639a3e805257ac02e1044a4c2) Conflicts: neutron/agent/l3/ha.py neutron/tests/unit/agent/l3/test_agent.py --- neutron/agent/l3/ha.py | 20 +----------------- neutron/tests/unit/agent/l3/test_agent.py | 25 +---------------------- 2 files changed, 2 insertions(+), 43 deletions(-) diff --git a/neutron/agent/l3/ha.py b/neutron/agent/l3/ha.py index f0369193818..c6cf90cfb2e 100644 --- a/neutron/agent/l3/ha.py +++ b/neutron/agent/l3/ha.py @@ -17,9 +17,6 @@ import threading import eventlet -from neutron_lib.callbacks import events -from neutron_lib.callbacks import registry -from neutron_lib.callbacks import resources from neutron_lib import constants from oslo_log import log as logging from oslo_utils import fileutils @@ -79,7 +76,6 @@ def run(self): server.wait() -@registry.has_registry_receivers class AgentMixin(object): def __init__(self, host): self._init_ha_conf_path() @@ -91,13 +87,6 @@ def __init__(self, host): eventlet.spawn(self._start_keepalived_notifications_server) self._transition_states = {} self._transition_state_mutex = threading.Lock() - self._initial_state_change_per_router = set() - - def initial_state_change(self, router_id): - initial_state = router_id not in self._initial_state_change_per_router - if initial_state: - self._initial_state_change_per_router.add(router_id) - return initial_state def _get_router_info(self, router_id): try: @@ -106,13 +95,6 @@ def _get_router_info(self, router_id): LOG.info('Router %s is not managed by this agent. It was ' 'possibly deleted concurrently.', router_id) - @registry.receives(resources.ROUTER, [events.AFTER_DELETE]) - def _delete_router(self, resource, event, trigger, payload): - try: - self._initial_state_change_per_router.remove(payload.resource_id) - except KeyError: - pass - def check_ha_state_for_router(self, router_id, current_state): ri = self._get_router_info(router_id) if not ri: @@ -166,7 +148,7 @@ def enqueue_state_change(self, router_id, state): def _enqueue_state_change(self, router_id, state): # NOTE(ralonsoh): move 'primary' and 'backup' constants to n-lib - if state == 'primary' and not self.initial_state_change(router_id): + if state == 'primary': eventlet.sleep(self.conf.ha_vrrp_advert_int) transition_state = self._update_transition_state(router_id) if transition_state != state: diff --git a/neutron/tests/unit/agent/l3/test_agent.py b/neutron/tests/unit/agent/l3/test_agent.py index b53014a4093..d0b05851c20 100644 --- a/neutron/tests/unit/agent/l3/test_agent.py +++ b/neutron/tests/unit/agent/l3/test_agent.py @@ -274,15 +274,7 @@ def test_enqueue_state_change_from_none_to_backup(self): self._enqueue_state_change_transitions(['backup'], 1) def test_enqueue_state_change_from_none_to_primary_to_backup(self): - # First transition (to primary), won't have a delay. - self._enqueue_state_change_transitions(['primary', 'backup'], 2) - - def test_enqueue_state_change_from_none_to_primary_to_backup_twice(self): - # Second transition to primary will have a delay and will be overridden - # by the second transition to backup; that means the transition from - # backup (second state) to primary (third state) is dismissed. - self._enqueue_state_change_transitions( - ['primary', 'backup', 'primary', 'backup'], 2) + self._enqueue_state_change_transitions(['primary', 'backup'], 0) def test_enqueue_state_change_from_none_to_backup_to_primary(self): self._enqueue_state_change_transitions(['backup', 'primary'], 2) @@ -2630,21 +2622,6 @@ def test_removed_from_agent(self): agent.router_removed_from_agent(None, {'router_id': FAKE_ID}) self.assertEqual(1, agent._queue.add.call_count) - @mock.patch.object(metadata_driver.MetadataDriver, - 'destroy_monitored_metadata_proxy') - def test__router_removed(self, *args): - agent = l3_agent.L3NATAgent(HOSTNAME, self.conf) - ri = mock.Mock(router={'id': 'router_id'}) - agent._initial_state_change_per_router.add('router_id') - self.assertEqual({'router_id'}, agent._initial_state_change_per_router) - for _ in range(2): - # The second call will trigger a KeyError exception in - # AgentMixin._delete_router that should be dismissed. - agent.router_info['router_id'] = mock.ANY - agent.pd = mock.Mock(routers={'router_id': {'subnets': []}}) - agent._router_removed(ri, 'router_id') - self.assertEqual(set([]), agent._initial_state_change_per_router) - def test_added_to_agent(self): agent = l3_agent.L3NATAgent(HOSTNAME, self.conf) agent._queue = mock.Mock() From d028ffab17b21ff51dd1a960c1cd397f49f4263a Mon Sep 17 00:00:00 2001 From: Kien Nguyen Tuan Date: Wed, 4 Dec 2024 11:10:47 +0700 Subject: [PATCH 071/184] Use the correct input for OVN agent deletion The key [1] is a list which is unhashable, therefore, it can not be passed as dict key [2]. It causes TypeError: unhashable type: 'list' exception. [1] https://github.com/openstack/neutron/blob/24e70ea166b517df78ca720b279e2520720de1e7/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py#L332 [2] https://github.com/openstack/neutron/blob/24e70ea166b517df78ca720b279e2520720de1e7/neutron/plugins/ml2/drivers/ovn/agent/neutron_agent.py#L281 Closes-bug: #2091071 Change-Id: I064d9b3e6cb72562a16030e1b31de45ddc8f487c (cherry picked from commit ce6e2d87c52a11ec76a0f6fb7deb25a3dc26cf86) --- .../plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py index 81a1ca3b0d1..1a5e0f83e75 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py @@ -327,7 +327,7 @@ def match_fn(self, event, row, old=None): return False def run(self, event, row, old): - n_agent.AgentCache().delete([row.external_ids['delete_agent']]) + n_agent.AgentCache().delete(row.external_ids['delete_agent']) class ChassisAgentWriteEvent(ChassisAgentEvent): From a0d4d136bdb242821ce2e4ceae55fd55810eced5 Mon Sep 17 00:00:00 2001 From: Pavlo Shchelokovskyy Date: Mon, 6 Nov 2023 19:20:34 +0000 Subject: [PATCH 072/184] Allow network owner reader to get subnets This patch is a follow up of [1]. "get_subnet" is a read-only operation; it should be possible for network readers to retrieve the subnet too. NOTE: the release note refers to this patch and [1]. Because [1] has been backported up to 2023.2, this patch should be backported too. [1]https://review.opendev.org/q/Iae2e3a31eb65d68dc0d3d0f9dd9fc8cf83260769 Conflicts: neutron/conf/policies/subnet.py neutron/tests/unit/conf/policies/test_subnet.py Related-Bug: #2038646 Change-Id: I05aeebe1db8ea7cadb292e60b5b322069a557c16 (cherry picked from commit ad71c927b30ed2a111b82c0109fad8f1c7e60375) --- neutron/conf/policies/subnet.py | 5 +- .../tests/unit/conf/policies/test_subnet.py | 157 ++++++++++++++++++ ...net_policies_updated-ec1ddc477757b441.yaml | 7 + 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/subnet_policies_updated-ec1ddc477757b441.yaml diff --git a/neutron/conf/policies/subnet.py b/neutron/conf/policies/subnet.py index 5052e674763..7eaa29736f1 100644 --- a/neutron/conf/policies/subnet.py +++ b/neutron/conf/policies/subnet.py @@ -97,7 +97,7 @@ check_str=neutron_policy.policy_or( base.PROJECT_READER, 'rule:shared', - base.ADMIN_OR_NET_OWNER_MEMBER, + base.ADMIN_OR_NET_OWNER_READER, ), scope_types=['project'], description='Get a subnet', @@ -128,7 +128,8 @@ check_str=neutron_policy.policy_or( base.PROJECT_READER, 'rule:shared', - base.ADMIN_OR_NET_OWNER_MEMBER, + 'rule:external_network', + base.ADMIN_OR_NET_OWNER_READER, ), scope_types=['project'], description='Get the subnet tags', diff --git a/neutron/tests/unit/conf/policies/test_subnet.py b/neutron/tests/unit/conf/policies/test_subnet.py index a8fb6f8fc80..b08c3f4653c 100644 --- a/neutron/tests/unit/conf/policies/test_subnet.py +++ b/neutron/tests/unit/conf/policies/test_subnet.py @@ -57,6 +57,13 @@ def setUp(self): 'tenant_id': self.alt_project_id, 'network_id': self.alt_network['id'], 'ext_parent_network_id': self.alt_network['id']} + # This is the case where the network belongs to the project but not + # the subnet. + self.alt_target_own_net = { + 'project_id': self.alt_project_id, + 'tenant_id': self.alt_project_id, + 'network_id': self.network['id'], + 'ext_parent_network_id': self.network['id']} def get_network(context, id, fields=None): return networks.get(id) @@ -87,6 +94,10 @@ def test_create_subnet(self): base_policy.InvalidScope, policy.enforce, self.context, 'create_subnet', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'create_subnet', self.alt_target_own_net) def test_create_subnet_segment_id(self): self.assertRaises( @@ -102,6 +113,10 @@ def test_create_subnet_segment_id(self): base_policy.InvalidScope, policy.enforce, self.context, 'create_subnet:segment_id', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'create_subnet:segment_id', self.alt_target_own_net) def test_create_subnet_service_types(self): self.assertRaises( @@ -117,6 +132,11 @@ def test_create_subnet_service_types(self): base_policy.InvalidScope, policy.enforce, self.context, 'create_subnet:service_types', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'create_subnet:service_types', + self.alt_target_own_net) def test_get_subnet(self): self.assertRaises( @@ -131,6 +151,10 @@ def test_get_subnet(self): base_policy.InvalidScope, policy.enforce, self.context, 'get_subnet', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'get_subnet', self.alt_target_own_net) def test_get_subnet_segment_id(self): self.assertRaises( @@ -145,6 +169,10 @@ def test_get_subnet_segment_id(self): base_policy.InvalidScope, policy.enforce, self.context, 'get_subnet:segment_id', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'get_subnet:segment_id', self.alt_target_own_net) def test_get_subnets_tags(self): self.assertRaises( @@ -159,6 +187,10 @@ def test_get_subnets_tags(self): base_policy.InvalidScope, policy.enforce, self.context, 'get_subnets_tags', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'get_subnets_tags', self.alt_target_own_net) def test_update_subnet(self): self.assertRaises( @@ -173,6 +205,10 @@ def test_update_subnet(self): base_policy.InvalidScope, policy.enforce, self.context, 'update_subnet', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_subnet', self.alt_target_own_net) def test_update_subnet_segment_id(self): self.assertRaises( @@ -188,6 +224,10 @@ def test_update_subnet_segment_id(self): base_policy.InvalidScope, policy.enforce, self.context, 'update_subnet:segment_id', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_subnet:segment_id', self.alt_target_own_net) def test_update_subnet_service_types(self): self.assertRaises( @@ -203,6 +243,11 @@ def test_update_subnet_service_types(self): base_policy.InvalidScope, policy.enforce, self.context, 'update_subnet:service_types', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_subnet:service_types', + self.alt_target_own_net) def test_update_subnets_tags(self): self.assertRaises( @@ -217,6 +262,10 @@ def test_update_subnets_tags(self): base_policy.InvalidScope, policy.enforce, self.context, 'update_subnets_tags', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_subnets_tags', self.alt_target_own_net) def test_delete_subnet(self): self.assertRaises( @@ -231,6 +280,10 @@ def test_delete_subnet(self): base_policy.InvalidScope, policy.enforce, self.context, 'delete_subnet', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'delete_subnet', self.alt_target_own_net) def test_delete_subnets_tags(self): self.assertRaises( @@ -245,6 +298,10 @@ def test_delete_subnets_tags(self): base_policy.InvalidScope, policy.enforce, self.context, 'delete_subnets_tags', self.alt_target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'delete_subnets_tags', self.alt_target_own_net) class SystemMemberTests(SystemAdminTests): @@ -275,6 +332,9 @@ def test_create_subnet(self): self.target_net_alt_target)) self.assertTrue( policy.enforce(self.context, 'create_subnet', self.alt_target)) + self.assertTrue( + policy.enforce(self.context, 'create_subnet', + self.alt_target_own_net)) def test_create_subnet_segment_id(self): self.assertTrue( @@ -287,6 +347,10 @@ def test_create_subnet_segment_id(self): self.assertTrue( policy.enforce( self.context, 'create_subnet:segment_id', self.alt_target)) + self.assertTrue( + policy.enforce( + self.context, 'create_subnet:segment_id', + self.alt_target_own_net)) def test_create_subnet_service_types(self): self.assertTrue( @@ -299,6 +363,10 @@ def test_create_subnet_service_types(self): self.assertTrue( policy.enforce( self.context, 'create_subnet:service_types', self.alt_target)) + self.assertTrue( + policy.enforce( + self.context, 'create_subnet:service_types', + self.alt_target_own_net)) def test_get_subnet(self): self.assertTrue( @@ -308,6 +376,9 @@ def test_get_subnet(self): self.target_net_alt_target)) self.assertTrue( policy.enforce(self.context, 'get_subnet', self.alt_target)) + self.assertTrue( + policy.enforce(self.context, 'get_subnet', + self.alt_target_own_net)) def test_get_subnet_segment_id(self): self.assertTrue( @@ -318,6 +389,10 @@ def test_get_subnet_segment_id(self): self.assertTrue( policy.enforce( self.context, 'get_subnet:segment_id', self.alt_target)) + self.assertTrue( + policy.enforce( + self.context, 'get_subnet:segment_id', + self.alt_target_own_net)) def test_get_subnets_tags(self): self.assertTrue( @@ -327,6 +402,9 @@ def test_get_subnets_tags(self): self.target_net_alt_target)) self.assertTrue( policy.enforce(self.context, 'get_subnets_tags', self.alt_target)) + self.assertTrue( + policy.enforce(self.context, 'get_subnets_tags', + self.alt_target_own_net)) def test_update_subnet(self): self.assertTrue( @@ -336,6 +414,9 @@ def test_update_subnet(self): self.target_net_alt_target)) self.assertTrue( policy.enforce(self.context, 'update_subnet', self.alt_target)) + self.assertTrue( + policy.enforce(self.context, 'update_subnet', + self.alt_target_own_net)) def test_update_subnet_segment_id(self): self.assertTrue( @@ -348,6 +429,10 @@ def test_update_subnet_segment_id(self): self.assertTrue( policy.enforce( self.context, 'update_subnet:segment_id', self.alt_target)) + self.assertTrue( + policy.enforce( + self.context, 'update_subnet:segment_id', + self.alt_target_own_net)) def test_update_subnet_service_types(self): self.assertTrue( @@ -370,6 +455,9 @@ def test_update_subnets_tags(self): self.assertTrue( policy.enforce(self.context, 'update_subnets_tags', self.alt_target)) + self.assertTrue( + policy.enforce(self.context, 'update_subnets_tags', + self.alt_target_own_net)) def test_delete_subnet(self): self.assertTrue( @@ -379,6 +467,9 @@ def test_delete_subnet(self): self.target_net_alt_target)) self.assertTrue( policy.enforce(self.context, 'delete_subnet', self.alt_target)) + self.assertTrue( + policy.enforce(self.context, 'delete_subnet', + self.alt_target_own_net)) def test_delete_subnets_tags(self): self.assertTrue( @@ -389,6 +480,9 @@ def test_delete_subnets_tags(self): self.assertTrue( policy.enforce(self.context, 'delete_subnets_tags', self.alt_target)) + self.assertTrue( + policy.enforce(self.context, 'delete_subnets_tags', + self.alt_target_own_net)) class ProjectMemberTests(AdminTests): @@ -408,6 +502,9 @@ def test_create_subnet(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_subnet', self.alt_target) + self.assertTrue( + policy.enforce(self.context, 'create_subnet', + self.alt_target_own_net)) def test_create_subnet_segment_id(self): self.assertRaises( @@ -423,6 +520,10 @@ def test_create_subnet_segment_id(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_subnet:segment_id', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_subnet:segment_id', self.alt_target_own_net) def test_create_subnet_service_types(self): self.assertRaises( @@ -438,6 +539,11 @@ def test_create_subnet_service_types(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_subnet:service_types', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_subnet:service_types', + self.alt_target_own_net) def test_get_subnet(self): self.assertTrue( @@ -449,6 +555,9 @@ def test_get_subnet(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'get_subnet', self.alt_target) + self.assertTrue( + policy.enforce(self.context, 'get_subnet', + self.alt_target_own_net)) def test_get_subnet_segment_id(self): self.assertRaises( @@ -463,6 +572,10 @@ def test_get_subnet_segment_id(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'get_subnet:segment_id', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'get_subnet:segment_id', self.alt_target_own_net) def test_get_subnets_tags(self): self.assertTrue( @@ -474,6 +587,9 @@ def test_get_subnets_tags(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'get_subnets_tags', self.alt_target) + self.assertTrue( + policy.enforce(self.context, 'get_subnets_tags', + self.alt_target_own_net)) def test_update_subnet(self): self.assertTrue( @@ -485,6 +601,9 @@ def test_update_subnet(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_subnet', self.alt_target) + self.assertTrue( + policy.enforce(self.context, 'update_subnet', + self.alt_target_own_net)) def test_update_subnet_segment_id(self): self.assertRaises( @@ -500,6 +619,10 @@ def test_update_subnet_segment_id(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_subnet:segment_id', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_subnet:segment_id', self.alt_target_own_net) def test_update_subnet_service_types(self): self.assertRaises( @@ -515,6 +638,11 @@ def test_update_subnet_service_types(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_subnet:service_types', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_subnet:service_types', + self.alt_target_own_net) def test_update_subnets_tags(self): self.assertTrue( @@ -526,6 +654,9 @@ def test_update_subnets_tags(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_subnets_tags', self.alt_target) + self.assertTrue( + policy.enforce(self.context, 'update_subnets_tags', + self.alt_target_own_net)) def test_delete_subnet(self): self.assertTrue( @@ -537,6 +668,9 @@ def test_delete_subnet(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_subnet', self.alt_target) + self.assertTrue( + policy.enforce(self.context, 'delete_subnet', + self.alt_target_own_net)) def test_delete_subnets_tags(self): self.assertTrue( @@ -548,6 +682,9 @@ def test_delete_subnets_tags(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_subnets_tags', self.alt_target) + self.assertTrue( + policy.enforce(self.context, 'delete_subnets_tags', + self.alt_target_own_net)) class ProjectReaderTests(ProjectMemberTests): @@ -569,6 +706,10 @@ def test_create_subnet(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_subnet', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_subnet', self.alt_target_own_net) def test_update_subnet(self): self.assertRaises( @@ -583,6 +724,10 @@ def test_update_subnet(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_subnet', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_subnet', self.alt_target_own_net) def test_update_subnets_tags(self): self.assertRaises( @@ -597,6 +742,10 @@ def test_update_subnets_tags(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'update_subnets_tags', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_subnets_tags', self.alt_target_own_net) def test_delete_subnet(self): self.assertRaises( @@ -611,6 +760,10 @@ def test_delete_subnet(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_subnet', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'delete_subnet', self.alt_target_own_net) def test_delete_subnets_tags(self): self.assertRaises( @@ -625,6 +778,10 @@ def test_delete_subnets_tags(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_subnets_tags', self.alt_target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'delete_subnets_tags', self.alt_target_own_net) class ServiceRoleTests(SubnetAPITestCase): diff --git a/releasenotes/notes/subnet_policies_updated-ec1ddc477757b441.yaml b/releasenotes/notes/subnet_policies_updated-ec1ddc477757b441.yaml new file mode 100644 index 00000000000..16bef408423 --- /dev/null +++ b/releasenotes/notes/subnet_policies_updated-ec1ddc477757b441.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Subnet policies have been updated to allow other users to operate on them. + Network owners and readers can now retrieve the subnet and project members + can now update and delete the subnet. For more information, see bug + `2038646 `_. From 15d853920413b11df6de871b0f5fb2e68f7f5d56 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Mon, 23 Dec 2024 10:28:24 -0500 Subject: [PATCH 073/184] ovn: Disable activation-strategy=rarp for DPDK ports When vhosuser* is used, qemu won't send RARP; instead, it will request guest's virtio to announce the guest. Which ultimately generates GARP (for IPv4) and NA (for IPv6) addresses. This results in port not being activated until it's too late (only when nova updates neutron via api). It's better to have activation-strategy disabled until ovn-controller learns how to activate with GARPs and NAs. Related-Bug: #2092407 Change-Id: I71e6ec0d87adec629262c5a488bc9739f78ad6f8 (cherry picked from commit e16ab24cd8c418deb7af9ed4dff24f36be39231a) --- .../ovn/mech_driver/ovsdb/ovn_client.py | 6 ++++- .../ovn/mech_driver/test_mech_driver.py | 24 ++++++++++++++++++- ...-rarp-for-dpdk-ports-7f9164e57c578b95.yaml | 9 +++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/disable-activation-strategy-rarp-for-dpdk-ports-7f9164e57c578b95.yaml diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index f6f437475ca..a81079c8109 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -495,7 +495,11 @@ def _get_port_options(self, port): # Block traffic on destination host until libvirt sends # a RARP packet from it to inform network about the new # location of the port - options['activation-strategy'] = 'rarp' + # TODO(ihrachys) Remove this once OVN properly supports + # activation of DPDK ports (bug 2092407) + if (port[portbindings.VIF_TYPE] != + portbindings.VIF_TYPE_VHOST_USER): + options['activation-strategy'] = 'rarp' # Virtual ports can not be bound by using the requested-chassis # mechanism, ovn-controller will create the Port_Binding entry diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 5b342ab86ef..5a0b0a6df07 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -2032,7 +2032,8 @@ def test__get_port_options_migrating_additional_chassis_present(self): portbindings.HOST_ID: 'fake-src', portbindings.PROFILE: { ovn_const.MIGRATING_ATTR: 'fake-dest', - } + }, + portbindings.VIF_TYPE: portbindings.VIF_TYPE_OVS, } with mock.patch.object( self.mech_driver._ovn_client._sb_idl, 'is_col_present', @@ -2042,6 +2043,27 @@ def test__get_port_options_migrating_additional_chassis_present(self): self.assertEqual('fake-src,fake-dest', options.options['requested-chassis']) + def test__get_port_options_migrating_vhostuser(self): + port = { + 'id': 'virt-port', + 'mac_address': '00:00:00:00:00:00', + 'device_owner': 'device_owner', + 'network_id': 'foo', + 'fixed_ips': [], + portbindings.HOST_ID: 'fake-src', + portbindings.PROFILE: { + ovn_const.MIGRATING_ATTR: 'fake-dest', + }, + portbindings.VIF_TYPE: portbindings.VIF_TYPE_VHOST_USER, + } + with mock.patch.object( + self.mech_driver._ovn_client._sb_idl, 'is_col_present', + return_value=True): + options = self.mech_driver._ovn_client._get_port_options(port) + self.assertNotIn('activation-strategy', options.options) + self.assertEqual('fake-src,fake-dest', + options.options['requested-chassis']) + def test__get_port_options_not_migrating_additional_chassis_present(self): port = { 'id': 'virt-port', diff --git a/releasenotes/notes/disable-activation-strategy-rarp-for-dpdk-ports-7f9164e57c578b95.yaml b/releasenotes/notes/disable-activation-strategy-rarp-for-dpdk-ports-7f9164e57c578b95.yaml new file mode 100644 index 00000000000..aaf45718a18 --- /dev/null +++ b/releasenotes/notes/disable-activation-strategy-rarp-for-dpdk-ports-7f9164e57c578b95.yaml @@ -0,0 +1,9 @@ +--- +fixes: + - | + For OVN DPDK ports, live migration activation strategy that expects a RARP + frame sent by QEMU is no longer used. This is because for DPDK ports, QEMU + does not send a RARP frame, which affects the time to recover network + connectivity for DPDK ports after live migration is complete. Note that + because of the change, some low number of duplicate packets from these + ports may be observed during live migration. From e5fa6c4ce2b15ba3053ab239a2b895c666d12d1b Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Wed, 15 Jan 2025 13:38:29 -0500 Subject: [PATCH 074/184] functional: Handle ovsdb monitor returning inserts in different checks The test case should be satisfied as long as monitor receives the events; it doesn't matter if the insert events were received at the same call to process_events. Closes-Bug: #2095034 Change-Id: Ib5b78c3bdb9f4efbbc9bad9a45be45fb97da3c1d (cherry picked from commit 956803819854546992a620cd6eb44acfd291fb79) --- .../functional/agent/common/test_ovsdb_monitor.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/neutron/tests/functional/agent/common/test_ovsdb_monitor.py b/neutron/tests/functional/agent/common/test_ovsdb_monitor.py index 75087252892..73a5d11900a 100644 --- a/neutron/tests/functional/agent/common/test_ovsdb_monitor.py +++ b/neutron/tests/functional/agent/common/test_ovsdb_monitor.py @@ -65,10 +65,20 @@ def test_interface_monitor_filtering(self): p2 = self.useFixture(net_helpers.OVSPortFixture(br_2)) ports_expected = {p1.port.name, p2.port.name} + + def process_new_events(mon, ports_expected): + remaining = self._check_port_events( + mon, ports_expected=ports_expected) + + # Next time check only the ports not seen yet + ports_expected.clear() # Python doesn't support {:} syntax + ports_expected.update(remaining) + + return bool(ports_expected) # True if there are remaining ports + try: common_utils.wait_until_true( - lambda: not self._check_port_events( - mon_no_filter, ports_expected=ports_expected), + lambda: not process_new_events(mon_no_filter, ports_expected), timeout=5) except common_utils.WaitTimeout: self.fail('Interface monitor not filtered did not received an ' From af4de9919acabdf10419b9f26bbf68beceb71d51 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Wed, 8 Jan 2025 11:05:29 +0100 Subject: [PATCH 075/184] Make sure that policy enforcer is initialized before use In some cases policy.enforce() or policy.check() functions were called without calling policy.init() before and that could lead to errors like mentioned in the related bug. To avoid that and to make it less error prone in future, this patch moves call of the policy.init() method directly to the enforce() and check() methods and removes it from all other places. Other modules should not need to carry about policy initialization at all. Conflicts: neutron/extensions/tagging.py Closes-Bug: #2092659 Change-Id: Ic11992ba3ed91980189efbacdc2a54fba64fcf7c (cherry picked from commit 08afd36e549892e35be7356547fd7b525cae0bb3) (cherry picked from commit c94b09f14af56a5dc1e14d9707444060e77f1ea8) --- neutron/api/v2/base.py | 11 ----------- neutron/extensions/quotasv2.py | 1 - neutron/extensions/tagging.py | 15 --------------- neutron/pecan_wsgi/hooks/policy_enforcement.py | 2 -- neutron/policy.py | 2 ++ 5 files changed, 2 insertions(+), 29 deletions(-) diff --git a/neutron/api/v2/base.py b/neutron/api/v2/base.py index 0660b177147..49f094ecf19 100644 --- a/neutron/api/v2/base.py +++ b/neutron/api/v2/base.py @@ -228,8 +228,6 @@ def __getattr__(self, name): @db_api.retry_db_errors def _handle_action(request, id, **kwargs): arg_list = [request.context, id] - # Ensure policy engine is initialized - policy.init() # Fetch the resource and verify if the user can access it try: parent_id = kwargs.get(self._parent_id_name) @@ -367,8 +365,6 @@ def _item(self, request, id, do_authz=False, field_list=None, def index(self, request, **kwargs): """Returns a list of the requested entity.""" parent_id = kwargs.get(self._parent_id_name) - # Ensure policy engine is initialized - policy.init() return self._items(request, True, parent_id) @db_api.retry_db_errors @@ -381,8 +377,6 @@ def show(self, request, id, **kwargs): field_list, added_fields = self._do_field_list( api_common.list_args(request, "fields")) parent_id = kwargs.get(self._parent_id_name) - # Ensure policy engine is initialized - policy.init() return {self._resource: self._view(request.context, self._item(request, @@ -459,8 +453,6 @@ def _create(self, request, body, **kwargs): items = body[self._collection] else: items = [body] - # Ensure policy engine is initialized - policy.init() # Store requested resource amounts grouping them by tenant # This won't work with multiple resources. However because of the # current structure of this controller there will hardly be more than @@ -582,7 +574,6 @@ def _delete(self, request, id, **kwargs): action = self._plugin_handlers[self.DELETE] # Check authz - policy.init() parent_id = kwargs.get(self._parent_id_name) obj = self._item(request, id, parent_id=parent_id) try: @@ -653,8 +644,6 @@ def _update(self, request, id, body, **kwargs): if (value.get('required_by_policy') or value.get('primary_key') or 'default' not in value)] - # Ensure policy engine is initialized - policy.init() parent_id = kwargs.get(self._parent_id_name) # If the parent_id exist, we should get orig_obj with # self._parent_id_name field. diff --git a/neutron/extensions/quotasv2.py b/neutron/extensions/quotasv2.py index c360835005d..7627d5c063a 100644 --- a/neutron/extensions/quotasv2.py +++ b/neutron/extensions/quotasv2.py @@ -48,7 +48,6 @@ def validate_policy(context, policy_name): - policy.init() policy.enforce(context, policy_name, target={'project_id': context.project_id}, diff --git a/neutron/extensions/tagging.py b/neutron/extensions/tagging.py index 28fc8a419c1..1ab52ca76f1 100644 --- a/neutron/extensions/tagging.py +++ b/neutron/extensions/tagging.py @@ -14,7 +14,6 @@ import abc import collections import copy -import functools import itertools from neutron_lib.api.definitions import port @@ -90,14 +89,6 @@ EMPTY_RESOURCE_INFO = ResourceInfo(None, None, None, None, None) -def _policy_init(f): - @functools.wraps(f) - def func(self, *args, **kwargs): - policy.init() - return f(self, *args, **kwargs) - return func - - class TagResourceNotFound(exceptions.NotFound): message = _("Resource %(resource)s %(resource_id)s could not be found.") @@ -189,7 +180,6 @@ def _get_resource_info(self, context, kwargs): # This should never be returned. return EMPTY_RESOURCE_INFO - @_policy_init def index(self, request, **kwargs): # GET /v2.0/{parent_resource}/{parent_resource_id}/tags ctx = request.context @@ -199,7 +189,6 @@ def index(self, request, **kwargs): target) return self.plugin.get_tags(ctx, rinfo.parent_type, rinfo.parent_id) - @_policy_init def show(self, request, id, **kwargs): # GET /v2.0/{parent_resource}/{parent_resource_id}/tags/{tag} # id == tag @@ -216,7 +205,6 @@ def create(self, request, **kwargs): # POST /v2.0/{parent_resource}/{parent_resource_id}/tags raise webob.exc.HTTPNotFound("not supported") - @_policy_init def update(self, request, id, **kwargs): # PUT /v2.0/{parent_resource}/{parent_resource_id}/tags/{tag} # id == tag @@ -234,7 +222,6 @@ def update(self, request, id, **kwargs): rinfo.parent_id, [id]) return result - @_policy_init def update_all(self, request, body, **kwargs): # PUT /v2.0/{parent_resource}/{parent_resource_id}/tags # body: {"tags": ["aaa", "bbb"]} @@ -252,7 +239,6 @@ def update_all(self, request, body, **kwargs): rinfo.parent_id, body['tags']) return result - @_policy_init def delete(self, request, id, **kwargs): # DELETE /v2.0/{parent_resource}/{parent_resource_id}/tags/{tag} # id == tag @@ -270,7 +256,6 @@ def delete(self, request, id, **kwargs): rinfo.parent_id, [id]) return result - @_policy_init def delete_all(self, request, **kwargs): # DELETE /v2.0/{parent_resource}/{parent_resource_id}/tags ctx = request.context diff --git a/neutron/pecan_wsgi/hooks/policy_enforcement.py b/neutron/pecan_wsgi/hooks/policy_enforcement.py index 20aa758ebab..f165719aa92 100644 --- a/neutron/pecan_wsgi/hooks/policy_enforcement.py +++ b/neutron/pecan_wsgi/hooks/policy_enforcement.py @@ -93,7 +93,6 @@ def before(self, state): return collection = state.request.context.get('collection') needs_prefetch = state.request.method in ('PUT', 'DELETE') - policy.init() action = controller.plugin_handlers[ pecan_constants.ACTION_MAP[state.request.method]] @@ -169,7 +168,6 @@ def after(self, state): return if not data or (resource not in data and collection not in data): return - policy.init() is_single = resource in data action_type = pecan_constants.ACTION_MAP[state.request.method] if action_type == 'get': diff --git a/neutron/policy.py b/neutron/policy.py index 1e972147250..c35af40366c 100644 --- a/neutron/policy.py +++ b/neutron/policy.py @@ -484,6 +484,7 @@ def check(context, action, target, plugin=None, might_not_exist=False, # personas will be supported if not cfg.CONF.oslo_policy.enforce_new_defaults and context.is_admin: return True + init() if might_not_exist and not (_ENFORCER.rules and action in _ENFORCER.rules): return True match_rule, target, credentials = _prepare_check(context, @@ -520,6 +521,7 @@ def enforce(context, action, target, plugin=None, pluralized=None): # personas will be supported if not cfg.CONF.oslo_policy.enforce_new_defaults and context.is_admin: return True + init() rule, target, context = _prepare_check(context, action, target, pluralized) try: result = _ENFORCER.enforce(rule, target, context, action=action, From 2b1db5efd8b53f33dadee299765ce1a4be7b7169 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Tue, 24 Sep 2024 15:57:44 +0200 Subject: [PATCH 076/184] [Functional tests] Add logging router interfaces in metadata IPv6 tests This is added to help understand the root cause of the issue with communication with metadata server like e.g. described in the related bug. Related-bug: #2079048 Change-Id: I5f6cfc4f8f25a82ca703c0d2b36c2de92fc1f20d (cherry picked from commit 770ce6150a80da6df518d966cc9598978cf57278) --- .../agent/l3/test_metadata_proxy.py | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/neutron/tests/functional/agent/l3/test_metadata_proxy.py b/neutron/tests/functional/agent/l3/test_metadata_proxy.py index 8d99edfd765..f2557ddd1ed 100644 --- a/neutron/tests/functional/agent/l3/test_metadata_proxy.py +++ b/neutron/tests/functional/agent/l3/test_metadata_proxy.py @@ -17,16 +17,20 @@ import netaddr from neutron_lib import constants +from oslo_log import log as logging import webob import webob.dec import webob.exc +from neutron.agent.linux import ip_lib from neutron.agent.linux import utils from neutron.tests.common import machine_fixtures from neutron.tests.common import net_helpers from neutron.tests.functional.agent.l3 import framework from neutron.tests.functional.agent.linux import helpers +LOG = logging.getLogger(__name__) + METADATA_REQUEST_TIMEOUT = 60 METADATA_REQUEST_SLEEP = 5 TOO_MANY_REQUESTS_CODE = '429' @@ -100,7 +104,14 @@ def _setup_for_ipv6(self, machine, qr_lla): interface,)) return interface - def _query_metadata_proxy(self, machine, ipv6=False, interface=None): + def _log_router_interfaces_configuration(self, router): + router_ip_wrapper = ip_lib.IPWrapper(router.ns_name) + ip_a_output = router_ip_wrapper.netns.execute(["ip", "addr"]) + LOG.debug("Interfaces in the router namespace (%s): %s", + router.ns_name, ip_a_output) + + def _query_metadata_proxy(self, machine, ipv6=False, interface=None, + router=None): cmd = self._get_command(machine, ipv6, interface) i = 0 CONNECTION_REFUSED_TIMEOUT = METADATA_REQUEST_TIMEOUT // 2 @@ -113,6 +124,9 @@ def _query_metadata_proxy(self, machine, ipv6=False, interface=None): time.sleep(METADATA_REQUEST_SLEEP) i += METADATA_REQUEST_SLEEP else: + if router: + self._log_router_interfaces_configuration(router) + self.fail('metadata proxy unreachable ' 'on %s before timeout' % cmd[-1]) @@ -139,15 +153,16 @@ def _create_resources(self): router_ifs = router_info[constants.INTERFACE_KEY] qr_lla = str( netaddr.EUI(router_ifs[0]['mac_address']).ipv6_link_local()) - return machine, qr_lla + return machine, qr_lla, router def _test_access_to_metadata_proxy(self, ipv6=False): - machine, qr_lla = self._create_resources() + machine, qr_lla, router = self._create_resources() interface = self._setup_for_ipv6(machine, qr_lla) if ipv6 else None # Query metadata proxy firstline = self._query_metadata_proxy(machine, ipv6=ipv6, - interface=interface) + interface=interface, + router=router) # Check status code self.assertIn(str(webob.exc.HTTPOk.code), firstline.split()) @@ -158,21 +173,23 @@ def _set_up_for_rate_limiting_test(self, ipv6=False): if ipv6: self.conf.set_override('ip_versions', [6], 'metadata_rate_limiting') - machine, qr_lla = self._create_resources() + machine, qr_lla, router = self._create_resources() interface = self._setup_for_ipv6(machine, qr_lla) if ipv6 else None - return machine, interface + return machine, interface, router def _test_rate_limiting(self, limit, machine, ipv6=False, interface=None, - exceed=True): + exceed=True, router=None): # The first "limit" requests should succeed for _ in range(limit): firstline = self._query_metadata_proxy(machine, ipv6=ipv6, - interface=interface) + interface=interface, + router=router) self.assertIn(str(webob.exc.HTTPOk.code), firstline.split()) if exceed: firstline = self._query_metadata_proxy(machine, ipv6=ipv6, - interface=interface) + interface=interface, + router=router) self.assertIn(TOO_MANY_REQUESTS_CODE, firstline.split()) def test_access_to_metadata_proxy(self): @@ -184,14 +201,16 @@ def test_access_to_metadata_proxy_ipv6(self): def test_metadata_proxy_rate_limiting(self): self.conf.set_override('base_query_rate_limit', 2, 'metadata_rate_limiting') - machine, _ = self._set_up_for_rate_limiting_test() + machine, _, _ = self._set_up_for_rate_limiting_test() self._test_rate_limiting(2, machine) def test_metadata_proxy_rate_limiting_ipv6(self): self.conf.set_override('base_query_rate_limit', 2, 'metadata_rate_limiting') - machine, interface = self._set_up_for_rate_limiting_test(ipv6=True) - self._test_rate_limiting(2, machine, ipv6=True, interface=interface) + machine, interface, router = self._set_up_for_rate_limiting_test( + ipv6=True) + self._test_rate_limiting(2, machine, ipv6=True, interface=interface, + router=router) def test_metadata_proxy_burst_rate_limiting(self): self.conf.set_override('base_query_rate_limit', 10, @@ -202,7 +221,7 @@ def test_metadata_proxy_burst_rate_limiting(self): 'metadata_rate_limiting') self.conf.set_override('burst_window_duration', 5, 'metadata_rate_limiting') - machine, _ = self._set_up_for_rate_limiting_test() + machine, _, _ = self._set_up_for_rate_limiting_test() # Since the number of metadata requests don't exceed the base or the # burst query rate limit, all of them should get "OK" response @@ -222,7 +241,7 @@ def test_metadata_proxy_base_and_burst_rate_limiting(self): 'metadata_rate_limiting') self.conf.set_override('burst_window_duration', 5, 'metadata_rate_limiting') - machine, _ = self._set_up_for_rate_limiting_test() + machine, _, _ = self._set_up_for_rate_limiting_test() # Since the number of metadata requests don't exceed the base or the # burst query rate limit, all of them should get "OK" response @@ -238,7 +257,7 @@ def test_metadata_proxy_rate_limiting_invalid_ip_versions(self): 'metadata_rate_limiting') self.conf.set_override('ip_versions', [4, 6], 'metadata_rate_limiting') - machine, _ = self._set_up_for_rate_limiting_test() + machine, _, _ = self._set_up_for_rate_limiting_test() # Since we are passing an invalid ip_versions configuration, rate # limiting will not be configuerd and more than 2 requests should # succeed From 7c9c2122501341fbe5dc4bef775c0eba75887f5f Mon Sep 17 00:00:00 2001 From: Arefiev Anton Date: Mon, 11 Mar 2024 11:53:48 +0200 Subject: [PATCH 077/184] Clean up state VRRP PID file Change Id62bf18067d0b144c3e8825c7603cc1e51dca052 removes explicit PID files clean up for keepalived and brings regression as there is no 'process enable' for VRRP. Always delete stale PID file if exists Related-Bug: 1561046 Change-Id: I95a004a3acbe6a9160a19053a37fc0dd2b1875a5 (cherry picked from commit d3a8c9ca0f668cfefc271d7db01dbf0badbbecec) --- neutron/agent/linux/keepalived.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/neutron/agent/linux/keepalived.py b/neutron/agent/linux/keepalived.py index 5dfafdcdb0f..c29074605ef 100644 --- a/neutron/agent/linux/keepalived.py +++ b/neutron/agent/linux/keepalived.py @@ -27,6 +27,7 @@ from neutron._i18n import _ from neutron.agent.linux import external_process +from neutron.agent.linux import utils as linux_utils from neutron.cmd import runtime_checks as checks from neutron.common import utils @@ -504,8 +505,12 @@ def callback(pid_file): # will be orphan and prevent keepalived process to be spawned. # A check here will let the l3-agent to kill the orphan process # and spawn keepalived successfully. + # Also removes stale pid file if vrrp_pm.active: - vrrp_pm.disable() + vrrp_pm.disable(delete_pid_file=False) + + linux_utils.delete_if_exists(self.get_vrrp_pid_file_name(pid_file), + run_as_root=vrrp_pm.run_as_root) cmd = ['keepalived', '-P', '-f', config_path, From 08851aa54aa40ac207322301ea7e82acdd0ce23b Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Sun, 10 Mar 2024 15:47:09 +0000 Subject: [PATCH 078/184] [OVN] Add the network type to the ``Logical_Switch`` register Now the ``Logical_Switch`` register (that represents an OVN network), stored the network type in the "external_ids" field. Related-Bug: #2056558 Change-Id: I9e55a7412d841b7b59602c56c3a4e2f9c954aeed (cherry picked from commit f82c650c8c1bfb0b4283b8624d8b6f32f1f8d188) --- .../ovn/mech_driver/ovsdb/maintenance.py | 25 +++++++++++++++++ .../ovn/mech_driver/ovsdb/ovn_client.py | 24 ++++++++++------- .../ovn/mech_driver/ovsdb/test_maintenance.py | 15 +++++++++++ .../ovn/mech_driver/test_mech_driver.py | 6 +++++ .../tests/unit/services/ovn_l3/test_plugin.py | 27 ++++++++++++++++++- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index 8a34af8576c..72ce31c9399 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -40,6 +40,7 @@ from neutron.db import l3_attrs_db from neutron.db import ovn_hash_ring_db as hash_ring_db from neutron.db import ovn_revision_numbers_db as revision_numbers_db +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 servicetype as servicetype_obj @@ -1315,6 +1316,30 @@ def set_fip_distributed_flag(self): check_error=True) raise periodics.NeverAgain() + # TODO(ralonsoh): Remove this method in the E cycle (SLURP release) + @has_lock_periodic(spacing=600, run_immediately=True) + def set_network_type(self): + """Add the network type to the Logical_Switch registers""" + context = n_context.get_admin_context() + net_segments = network_obj.NetworkSegment.get_objects(context) + net_segments = {seg.network_id: seg.network_type + for seg in net_segments} + cmds = [] + for ls in self._nb_idl.ls_list().execute(check_error=True): + if ovn_const.OVN_NETTYPE_EXT_ID_KEY not in ls.external_ids: + net_id = ls.name.replace('neutron-', '') + external_ids = { + ovn_const.OVN_NETTYPE_EXT_ID_KEY: net_segments[net_id]} + cmds.append(self._nb_idl.db_set( + 'Logical_Switch', ls.uuid, ('external_ids', external_ids))) + + if cmds: + with self._nb_idl.transaction(check_error=True) as txn: + for cmd in cmds: + txn.add(cmd) + + raise periodics.NeverAgain() + class HashRingHealthCheckPeriodics(object): diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index a81079c8109..0581ab84414 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -1687,18 +1687,20 @@ def _get_reside_redir_for_gateway_port(self, device_id): for net in networks) else 'false' return reside_redir_ch - def _gen_router_port_options(self, port, network=None): + def _gen_router_port_options(self, port): options = {} admin_context = n_context.get_admin_context() - if network is None: - network = self._plugin.get_network(admin_context, - port['network_id']) + ls_name = utils.ovn_name(port['network_id']) + ls = self._nb_idl.ls_get(ls_name).execute(check_error=True) + network_type = ls.external_ids[ovn_const.OVN_NETTYPE_EXT_ID_KEY] + network_mtu = int( + ls.external_ids[ovn_const.OVN_NETWORK_MTU_EXT_ID_KEY]) # For VLAN type networks we need to set the # "reside-on-redirect-chassis" option so the routing for this # logical router port is centralized in the chassis hosting the # distributed gateway port. # https://github.com/openvswitch/ovs/commit/85706c34d53d4810f54bec1de662392a3c06a996 - if network.get(pnet.NETWORK_TYPE) == const.TYPE_VLAN: + if network_type == const.TYPE_VLAN: reside_redir_ch = self._get_reside_redir_for_gateway_port( port['device_id']) options[ovn_const.LRP_OPTIONS_RESIDE_REDIR_CH] = reside_redir_ch @@ -1720,10 +1722,10 @@ def _gen_router_port_options(self, port, network=None): admin_context, filters={'id': network_ids}) if ovn_conf.is_ovn_emit_need_to_frag_enabled(): for net in networks: - if net['mtu'] > network['mtu']: + if net['mtu'] > network_mtu: options[ ovn_const.OVN_ROUTER_PORT_GW_MTU_OPTION] = str( - network['mtu']) + network_mtu) break if ovn_conf.is_ovn_distributed_floating_ip(): # NOTE(ltomasbo): For VLAN type networks connected through @@ -2040,7 +2042,11 @@ def _gen_network_parameters(self, network): ovn_const.OVN_REV_NUM_EXT_ID_KEY: str( utils.get_revision_number(network, ovn_const.TYPE_NETWORKS)), ovn_const.OVN_AZ_HINTS_EXT_ID_KEY: - ','.join(common_utils.get_az_hints(network))}} + ','.join(common_utils.get_az_hints(network)), + # NOTE(ralonsoh): it is not considered the case of multiple + # segments. + ovn_const.OVN_NETTYPE_EXT_ID_KEY: network.get(pnet.NETWORK_TYPE), + }} # Enable IGMP snooping if igmp_snooping_enable is enabled in Neutron vlan_transparent = ( @@ -2095,7 +2101,7 @@ def set_gateway_mtu(self, context, prov_net, txn=None): commands = [] for port in ports: lrp_name = utils.ovn_lrouter_port_name(port['id']) - options = self._gen_router_port_options(port, prov_net) + options = self._gen_router_port_options(port) commands.append(self._nb_idl.update_lrouter_port( lrp_name, if_exists=True, options=options)) self._transaction(commands, txn=txn) diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index 36671829207..9c373d34f79 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -1318,6 +1318,21 @@ def test_set_fip_distributed_flag_unchanged(self): original_value=True, config_value=True) + def test_set_network_type(self): + net1 = self._create_network(uuidutils.generate_uuid()) + ls_name = utils.ovn_name(net1['id']) + self.nb_api.db_remove( + 'Logical_Switch', ls_name, 'external_ids', + ovn_const.OVN_NETTYPE_EXT_ID_KEY).execute(check_error=True) + ls = self.nb_api.lookup('Logical_Switch', ls_name) + self.assertIsNone(ls.external_ids.get( + ovn_const.OVN_NETTYPE_EXT_ID_KEY)) + + self.assertRaises(periodics.NeverAgain, self.maint.set_network_type) + ls = self.nb_api.lookup('Logical_Switch', ls_name) + self.assertEqual(net1[provnet_apidef.NETWORK_TYPE], + ls.external_ids.get(ovn_const.OVN_NETTYPE_EXT_ID_KEY)) + class TestLogMaintenance(_TestMaintenanceHelper, test_log_driver.LogApiTestCaseBase): diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 5a0b0a6df07..e2a58fe8353 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -2614,6 +2614,12 @@ def _test_update_network_fragmentation(self, new_mtu, expected_opts, grps, network['network']['mtu'] = new_mtu fake_ctx = mock.MagicMock(current=network['network']) fake_ctx.plugin_context.session.is_active = False + external_ids = { + ovn_const.OVN_NETTYPE_EXT_ID_KEY: const.TYPE_GENEVE, + ovn_const.OVN_NETWORK_MTU_EXT_ID_KEY: str(new_mtu), + } + self.nb_ovn.ls_get.return_value.execute.return_value = ( + mock.Mock(external_ids=external_ids)) self.mech_driver.update_network_postcommit(fake_ctx) diff --git a/neutron/tests/unit/services/ovn_l3/test_plugin.py b/neutron/tests/unit/services/ovn_l3/test_plugin.py index 4fcb6b58a6f..859a1fff59f 100644 --- a/neutron/tests/unit/services/ovn_l3/test_plugin.py +++ b/neutron/tests/unit/services/ovn_l3/test_plugin.py @@ -339,6 +339,12 @@ def setUp(self): 'neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.ovn_client.' 'OVNClient._get_router_gw_ports', return_value=self.fake_ext_gw_ports) + ext_ids = { + ovn_const.OVN_NETTYPE_EXT_ID_KEY: constants.TYPE_GENEVE, + ovn_const.OVN_NETWORK_MTU_EXT_ID_KEY: 9000, + } + self.l3_inst._nb_ovn.ls_get.return_value.execute.return_value = ( + mock.Mock(external_ids=ext_ids)) def test__plugin_driver(self): # No valid mech drivers should raise an exception. @@ -744,6 +750,12 @@ def test_add_router_interface_vlan_network(self, ari, grps, gn): fake_network_vlan = self.fake_network fake_network_vlan[pnet.NETWORK_TYPE] = constants.TYPE_VLAN gn.return_value = fake_network_vlan + ext_ids = { + ovn_const.OVN_NETTYPE_EXT_ID_KEY: constants.TYPE_VLAN, + ovn_const.OVN_NETWORK_MTU_EXT_ID_KEY: 1500, + } + self.l3_inst._nb_ovn.ls_get.return_value.execute.return_value = ( + mock.Mock(external_ids=ext_ids)) payload = self._create_payload_for_router_interface(router_id) self.ovn_drv._process_add_router_interface(resources.ROUTER_INTERFACE, @@ -1915,13 +1927,20 @@ def test_add_router_interface_need_to_frag_enabled( ari.return_value = self.fake_router_interface_info grps.return_value = [interface_info] self.get_router.return_value = self.fake_router_with_ext_gw - network_attrs = {'id': 'prov-net', 'mtu': 1200} + mtu = 1200 + network_attrs = {'id': 'prov-net', 'mtu': mtu} prov_net = fake_resources.FakeNetwork.create_one_network( attrs=network_attrs).info() self.fake_router_port['device_owner'] = ( constants.DEVICE_OWNER_ROUTER_GW) gn.return_value = prov_net gns.return_value = [self.fake_network] + ext_ids = { + ovn_const.OVN_NETTYPE_EXT_ID_KEY: constants.TYPE_GENEVE, + ovn_const.OVN_NETWORK_MTU_EXT_ID_KEY: mtu, + } + self.l3_inst._nb_ovn.ls_get.return_value.execute.return_value = ( + mock.Mock(external_ids=ext_ids)) payload = self._create_payload_for_router_interface(router_id) self.ovn_drv._process_add_router_interface(resources.ROUTER_INTERFACE, @@ -2119,6 +2138,12 @@ def setUp(self): self.l3_inst._nb_ovn.db_get.return_value.execute.return_value = ext_ids self.l3_inst._nb_ovn.lookup.return_value = mock.Mock( external_ids=ext_ids) + ext_ids = { + ovn_const.OVN_NETTYPE_EXT_ID_KEY: constants.TYPE_GENEVE, + ovn_const.OVN_NETWORK_MTU_EXT_ID_KEY: 9000, + } + self.l3_inst._nb_ovn.ls_get.return_value.execute.return_value = ( + mock.Mock(external_ids=ext_ids)) # Note(dongj): According to bug #1657693, status of an unassociated # floating IP is set to DOWN. Revise expected_status to DOWN for related From 6d9f6d07e422c07bb0182239e1ec180935352454 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Wed, 31 Jul 2024 14:58:18 +0200 Subject: [PATCH 079/184] [OVN] Set reside-on-chassis-redirect also for FLAT networks This ovn option for the Logical Router Port was added to be set with [1] but FLAT networks are basically working in the same way and should have this option set in the same way. [1] https://review.opendev.org/c/openstack/neutron/+/871252 Conflicts: neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py Related-Bug: #2073987 Change-Id: I3203678c1ca6fb778c993b6084bab171a312ec28 (cherry picked from commit 9fd1f58394efd083cfe9a194601e30b827af56a5) --- .../ovn/mech_driver/ovsdb/maintenance.py | 11 +-- .../ovn/mech_driver/ovsdb/ovn_client.py | 4 +- .../ovn/mech_driver/ovsdb/test_maintenance.py | 67 ++++++++++++++++++- .../ovn/mech_driver/ovsdb/test_ovn_client.py | 63 ++++++++++++++++- .../ovn/mech_driver/ovsdb/test_maintenance.py | 12 ++-- 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index 72ce31c9399..241eff5b904 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -748,16 +748,17 @@ def check_redirect_type_router_gateway_ports(self): periodic_run_limit=ovn_const.MAINTENANCE_TASK_RETRY_LIMIT, spacing=ovn_const.MAINTENANCE_ONE_RUN_TASK_SPACING, run_immediately=True) - def check_vlan_distributed_ports(self): - """Check VLAN distributed ports + def check_provider_distributed_ports(self): + """Check provider (VLAN and FLAT) distributed ports Check for the option "reside-on-redirect-chassis" value for - distributed VLAN ports. + distributed ports which belongs to the FLAT or VLAN networks. """ context = n_context.get_admin_context() cmds = [] - # Get router ports belonging to VLAN networks + # Get router ports belonging to VLAN or FLAT networks vlan_nets = self._ovn_client._plugin.get_networks( - context, {pnet.NETWORK_TYPE: [n_const.TYPE_VLAN]}) + context, {pnet.NETWORK_TYPE: [n_const.TYPE_VLAN, + n_const.TYPE_FLAT]}) vlan_net_ids = [vn['id'] for vn in vlan_nets] router_ports = self._ovn_client._plugin.get_ports( context, {'network_id': vlan_net_ids, diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 0581ab84414..3eb059ad71d 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -1695,12 +1695,12 @@ def _gen_router_port_options(self, port): network_type = ls.external_ids[ovn_const.OVN_NETTYPE_EXT_ID_KEY] network_mtu = int( ls.external_ids[ovn_const.OVN_NETWORK_MTU_EXT_ID_KEY]) - # For VLAN type networks we need to set the + # For provider networks (VLAN, FLAT types) we need to set the # "reside-on-redirect-chassis" option so the routing for this # logical router port is centralized in the chassis hosting the # distributed gateway port. # https://github.com/openvswitch/ovs/commit/85706c34d53d4810f54bec1de662392a3c06a996 - if network_type == const.TYPE_VLAN: + if network_type in [const.TYPE_VLAN, const.TYPE_FLAT]: reside_redir_ch = self._get_reside_redir_for_gateway_port( port['device_id']) options[ovn_const.LRP_OPTIONS_RESIDE_REDIR_CH] = reside_redir_ch diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index 9c373d34f79..6b767c1d06c 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -16,6 +16,7 @@ from unittest import mock from oslo_config import cfg +from oslo_utils import strutils from futurist import periodics from neutron_lib.api.definitions import external_net as extnet_apidef @@ -68,12 +69,17 @@ def _find_network_row_by_name(self, name): ovn_const.OVN_NETWORK_NAME_EXT_ID_KEY) == name): return row - def _create_network(self, name, external=False, provider=None): + def _create_network(self, name, external=False, provider=None, + net_type=None): data = {'network': {'name': name, extnet_apidef.EXTERNAL: external}} + if net_type: + data['network'][provnet_apidef.NETWORK_TYPE] = net_type if provider: - data['network'][provnet_apidef.NETWORK_TYPE] = 'flat' + net_type = net_type or 'flat' + data['network'][provnet_apidef.NETWORK_TYPE] = net_type data['network'][provnet_apidef.PHYSICAL_NETWORK] = provider + req = self.new_create_request('networks', data, self.fmt, as_admin=True) res = req.get_response(self.api) @@ -1122,6 +1128,63 @@ def test_remove_duplicated_chassis_registers_no_ch_private_register(self): # "Chassis_Private" register was missing. self.assertEqual(2, len(chassis_result)) + def _test_check_provider_distributed_ports( + self, is_distributed_fip, net_type, expected_value=None): + cfg.CONF.set_override( + 'enable_distributed_floating_ip', is_distributed_fip, group='ovn') + net_args = {'net_type': net_type} + if net_type == n_const.TYPE_FLAT: + net_args['provider'] = 'datacentre' + net = self._create_network( + 'net_distributed_ports_test', **net_args) + subnet = self._create_subnet('subnet_distributed_ports_test', + net['id']) + router = self._create_router('router_distributed_ports_test') + self._add_router_interface(router['id'], subnet['id']) + + # Lets make sure that reside-on-chassis-redirect is not set for the LRP + lr = self.nb_api.lookup('Logical_Router', + utils.ovn_name(router['id'])) + lrp = lr.ports[0] + self.nb_api.db_remove( + 'Logical_Router_Port', + lrp.name, + 'options', + ovn_const.LRP_OPTIONS_RESIDE_REDIR_CH + ).execute() + + self.assertRaises(periodics.NeverAgain, + self.maint.check_provider_distributed_ports) + + lrp = self.nb_api.lookup('Logical_Router_Port', lrp.name) + if net_type in [n_const.TYPE_VLAN, n_const.TYPE_FLAT]: + self.assertEqual( + expected_value, + strutils.bool_from_string( + lrp.options[ovn_const.LRP_OPTIONS_RESIDE_REDIR_CH])) + else: + self.assertNotIn( + ovn_const.LRP_OPTIONS_RESIDE_REDIR_CH, + lrp.options) + + def test_check_provider_distributed_ports_dvr_vlan_net(self): + self._test_check_provider_distributed_ports(True, 'vlan', False) + + def test_check_provider_distributed_ports_non_dvr_vlan_net(self): + self._test_check_provider_distributed_ports(False, 'vlan', True) + + def test_check_provider_distributed_ports_dvr_flat_net(self): + self._test_check_provider_distributed_ports(True, 'flat', False) + + def test_check_provider_distributed_ports_non_dvr_flat_net(self): + self._test_check_provider_distributed_ports(False, 'flat', True) + + def test_check_provider_distributed_ports_dvr_geneve_net(self): + self._test_check_provider_distributed_ports(True, 'geneve') + + def test_check_provider_distributed_ports_non_dvr_geneve_net(self): + self._test_check_provider_distributed_ports(False, 'geneve') + def test_configure_nb_global(self): def options_intersect(options1, options2): return bool(set( diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py index f63de4f234f..779a17dc3c2 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -12,13 +12,26 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib.api.definitions import provider_net from neutron_lib import constants +from oslo_config import cfg +from oslo_utils import strutils +from neutron.common.ovn import constants as ovn_const +from neutron.common.ovn import utils as ovn_utils from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf as ovn_config from neutron.tests.functional import base +from neutron.tests.unit.api import test_extensions +from neutron.tests.unit.extensions import test_l3 -class TestOVNClient(base.TestOVNFunctionalBase): +class TestOVNClient(base.TestOVNFunctionalBase, + test_l3.L3NatTestCaseMixin): + + def setUp(self, **kwargs): + super().setUp(**kwargs) + ext_mgr = test_l3.L3TestExtensionManager() + self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr) def test_create_metadata_port(self): def check_metadata_port(enable_dhcp): @@ -84,3 +97,51 @@ def test_create_port(self): # command automatically checks for existing logical # switch ports ovn_client.create_port(self.context, port_data) + + def _test_router_reside_chassis_redirect( + self, is_distributed_fip, net_type, expected_value=None): + cfg.CONF.set_override( + 'enable_distributed_floating_ip', is_distributed_fip, group='ovn') + net_arg = { + provider_net.NETWORK_TYPE: net_type} + if net_type == constants.TYPE_FLAT: + net_arg[provider_net.PHYSICAL_NETWORK] = 'datacentre' + with self.network('test-ovn-client', as_admin=True, + arg_list=tuple(net_arg.keys()), **net_arg) as net: + with self.subnet(net) as subnet: + subnet_id = subnet['subnet']['id'] + with self.router() as router: + router_id = router['router']['id'] + self._router_interface_action( + 'add', router_id, subnet_id, None) + lr = self.nb_api.lookup('Logical_Router', + ovn_utils.ovn_name(router_id)) + lrp = lr.ports[0] + if net_type in [constants.TYPE_VLAN, constants.TYPE_FLAT]: + self.assertEqual( + expected_value, + strutils.bool_from_string( + lrp.options[ + ovn_const.LRP_OPTIONS_RESIDE_REDIR_CH])) + else: + self.assertNotIn( + ovn_const.LRP_OPTIONS_RESIDE_REDIR_CH, + lrp.options) + + def test_router_reside_chassis_redirect_dvr_vlan_net(self): + self._test_router_reside_chassis_redirect(True, 'vlan', False) + + def test_router_reside_chassis_redirect_non_dvr_vlan_net(self): + self._test_router_reside_chassis_redirect(False, 'vlan', True) + + def test_router_reside_chassis_redirect_dvr_flat_net(self): + self._test_router_reside_chassis_redirect(True, 'flat', False) + + def test_router_reside_chassis_redirect_non_dvr_flat_net(self): + self._test_router_reside_chassis_redirect(False, 'flat', True) + + def test_router_reside_chassis_redirect_dvr_geneve_net(self): + self._test_router_reside_chassis_redirect(True, 'geneve', False) + + def test_router_reside_chassis_redirect_non_dvr_geneve_net(self): + self._test_router_reside_chassis_redirect(False, 'geneve') diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py index eb024855ac6..83a7345712d 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_maintenance.py @@ -809,7 +809,7 @@ def test_check_redirect_type_router_gateway_ports_disable_redirect(self): 'provider:network_type': n_const.TYPE_GENEVE}] self._test_check_redirect_type_router_gateway_ports(networks, False) - def _test_check_vlan_distributed_ports(self, opt_value=None): + def _test_check_provider_distributed_ports(self, opt_value=None): fake_net0 = {'id': 'net0'} fake_net1 = {'id': 'net1'} fake_port0 = {'id': 'port0', 'device_id': 'device0'} @@ -831,18 +831,18 @@ def _test_check_vlan_distributed_ports(self, opt_value=None): # Invoke the periodic method, it meant to run only once at startup # so NeverAgain will be raised at the end self.assertRaises(periodics.NeverAgain, - self.periodic.check_vlan_distributed_ports) + self.periodic.check_provider_distributed_ports) - def test_check_vlan_distributed_ports_expected_value(self): - self._test_check_vlan_distributed_ports(opt_value='true') + def test_check_provider_distributed_ports_expected_value(self): + self._test_check_provider_distributed_ports(opt_value='true') # If the "reside-on-redirect-chassis" option value do match # the expected value, assert we do not update the database self.assertFalse( self.fake_ovn_client._nb_idl.db_set.called) - def test_check_vlan_distributed_ports_non_expected_value(self): - self._test_check_vlan_distributed_ports(opt_value='false') + def test_check_provider_distributed_ports_non_expected_value(self): + self._test_check_provider_distributed_ports(opt_value='false') # If the "reside-on-redirect-chassis" option value does not match # the expected value, assert we update the database From 1ffc079b93769199f20469fe6468f6c164860341 Mon Sep 17 00:00:00 2001 From: Konstantin Eremin Date: Thu, 30 Jan 2025 22:27:11 +0300 Subject: [PATCH 080/184] OVS: Set log level to INFO for unconfigured ofport cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log an ERROR if the VIF port has no ofport, which indicates that the port might not be able to transmit traffic. This can happen if the ovs-vswitchd service is down, but the ovsdb-server service is still running. In such cases, the port record might be created in the database, but it won't be processed by ovs-vswitchd, resulting in an empty list ofport value. I was unable to reproduce the ofport=[] scenario using the OpenStack CLI. However, I managed to achieve this by using the «ovs-vsctl add-port» command while the ovs-vswitchd service was stopped. Reusing this port in Neutron is impossible. I believe this situation is highly unlikely to occur in practice, but I decided to leave the check as it is, just in case. When the ofport is set to INVALID_OFPORT, it indicates that the port is in a transitional state and has not yet been fully configured. This is not an error condition but rather a normal part of the port's lifecycle, as it may take some time for the port to be initialized and assigned a valid ofport. Operators should be aware of this state for monitoring purposes, but no immediate action is required. Closes-Bug: #2095576 Change-Id: I0b7e7e8c506a9632aac911c1b4a0d19653dd5239 (cherry picked from commit a81aa17471a44545f71a044fb0008dc576be2f03) --- .../openvswitch/agent/ovs_neutron_agent.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py index c4b3d542766..09830fa2f53 100644 --- a/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/openvswitch/agent/ovs_neutron_agent.py @@ -1940,15 +1940,19 @@ def treat_vif_port(self, vif_port, port_id, network_id, network_type, physical_network, segmentation_id, admin_state_up, fixed_ips, device_owner, provisioning_needed): port_needs_binding = True - if (not vif_port.ofport or - vif_port.ofport == ovs_lib.INVALID_OFPORT): - # When this function is called for a port, the port should have - # an OVS ofport configured, as only these ports were considered - # for being treated. If that does not happen, it is a potential - # error condition of which operators should be aware - LOG.error("VIF port: %s has no ofport configured or is " - "invalid, and might not be able to transmit. " - "(ofport=%s)", vif_port.vif_id, vif_port.ofport) + if not vif_port.ofport: + # Log an error if the VIF port has no ofport, which indicates + # that the port might not be able to transmit traffic. + LOG.error("VIF port: %s has no ofport and might not " + "be able to transmit.", vif_port.vif_id) + elif vif_port.ofport == ovs_lib.INVALID_OFPORT: + # When the ofport is set to INVALID_OFPORT, it indicates that + # the port is in a transitional state and has not yet been fully + # configured. + LOG.info("VIF port: %s is in a transitional state and has not " + "yet been assigned a valid ofport. This is expected " + "during port initialization. (ofport=%s)", + vif_port.vif_id, vif_port.ofport) if admin_state_up: port_needs_binding = self.port_bound( vif_port, network_id, network_type, From f9dea6090cbbfcd9fd39254ef2d43e9b7c7735fc Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Date: Tue, 11 Feb 2025 17:52:00 +0000 Subject: [PATCH 081/184] Revert "Track all interfaces in Keepalived" This reverts commit bee07defacf61ccef19adbe54677b0a0a00ae65b. Reason for revert: The only interfaces tracked by keepalived are the HA interfaces. The fixed IP / floating IPs / routes are linked to the internal router interfaces or the gateway interface, that are interfaces not tracked by keepalived. These IP addresses / routes should have the suffix "no_track" in the configuration entry. This is commented in [1], when the keepalived VIP HA configuration was fixed, excluding any IP address other than the VIP of the HA interface, that are placed in the "virtual_ipaddress_excluded" and belong to no tracked interfaces. [1]https://github.com/openstack/neutron/blob/ad628353da68dcbf9e6ff25c47fd6753eee22683/neutron/agent/linux/keepalived.py#L271-L281 Change-Id: I4dfd89606042ba545559eb03d47fceee3b0895fc Closes-Bug: #2097770 (cherry picked from commit 438808f161044d7d2cb25b3a4dc783c181493da4) --- neutron/agent/linux/keepalived.py | 7 +- .../tests/functional/agent/l3/framework.py | 16 ++-- .../tests/unit/agent/linux/test_keepalived.py | 85 +++++-------------- 3 files changed, 33 insertions(+), 75 deletions(-) diff --git a/neutron/agent/linux/keepalived.py b/neutron/agent/linux/keepalived.py index c29074605ef..b3baa2c6eef 100644 --- a/neutron/agent/linux/keepalived.py +++ b/neutron/agent/linux/keepalived.py @@ -127,12 +127,11 @@ class KeepalivedVirtualRoute(object): """A virtual route entry of a keepalived configuration.""" def __init__(self, destination, nexthop, interface_name=None, - scope=None, track=True): + scope=None): self.destination = destination self.nexthop = nexthop self.interface_name = interface_name self.scope = scope - self.track = track def build_config(self): output = self.destination @@ -142,7 +141,7 @@ def build_config(self): output += ' dev %s' % self.interface_name if self.scope: output += ' scope %s' % self.scope - if not self.track and _is_keepalived_use_no_track_supported(): + if _is_keepalived_use_no_track_supported(): output += ' no_track' # NOTE(mstinsky): neutron and keepalived are adding the same routes on # primary routers. With this we ensure that both are adding the routes @@ -225,7 +224,7 @@ def set_authentication(self, auth_type, password): self.authentication = (auth_type, password) def add_vip(self, ip_cidr, interface_name, scope): - track = interface_name not in self.track_interfaces + track = interface_name in self.track_interfaces vip = KeepalivedVipAddress(ip_cidr, interface_name, scope, track=track) if vip not in self.vips: self.vips.append(vip) diff --git a/neutron/tests/functional/agent/l3/framework.py b/neutron/tests/functional/agent/l3/framework.py index 005cd0fc733..e375096c882 100644 --- a/neutron/tests/functional/agent/l3/framework.py +++ b/neutron/tests/functional/agent/l3/framework.py @@ -71,16 +71,16 @@ 169.254.0.1/24 dev %(ha_device_name)s } virtual_ipaddress_excluded { - %(floating_ip_cidr)s dev %(ex_device_name)s - %(external_device_cidr)s dev %(ex_device_name)s - %(internal_device_cidr)s dev %(internal_device_name)s - %(ex_port_ipv6)s dev %(ex_device_name)s scope link - %(int_port_ipv6)s dev %(internal_device_name)s scope link + %(floating_ip_cidr)s dev %(ex_device_name)s no_track + %(external_device_cidr)s dev %(ex_device_name)s no_track + %(internal_device_cidr)s dev %(internal_device_name)s no_track + %(ex_port_ipv6)s dev %(ex_device_name)s scope link no_track + %(int_port_ipv6)s dev %(internal_device_name)s scope link no_track } virtual_routes { - 0.0.0.0/0 via %(default_gateway_ip)s dev %(ex_device_name)s protocol static - 8.8.8.0/24 via 19.4.4.4 protocol static - %(extra_subnet_cidr)s dev %(ex_device_name)s scope link protocol static + 0.0.0.0/0 via %(default_gateway_ip)s dev %(ex_device_name)s no_track protocol static + 8.8.8.0/24 via 19.4.4.4 no_track protocol static + %(extra_subnet_cidr)s dev %(ex_device_name)s scope link no_track protocol static } }""" # noqa: E501 # pylint: disable=line-too-long diff --git a/neutron/tests/unit/agent/linux/test_keepalived.py b/neutron/tests/unit/agent/linux/test_keepalived.py index dfb6163d1bf..84b815d454d 100644 --- a/neutron/tests/unit/agent/linux/test_keepalived.py +++ b/neutron/tests/unit/agent/linux/test_keepalived.py @@ -87,7 +87,7 @@ def test_get_free_range_not_found(self): class KeepalivedConfBaseMixin(object): - def _get_config(self, track=True): + def _get_config(self): config = keepalived.KeepalivedConf() instance1 = keepalived.KeepalivedInstance('MASTER', 'eth0', 1, @@ -97,16 +97,16 @@ def _get_config(self, track=True): instance1.track_interfaces.append("eth0") vip_address1 = keepalived.KeepalivedVipAddress('192.168.1.0/24', - 'eth1', track=track) + 'eth1', track=False) vip_address2 = keepalived.KeepalivedVipAddress('192.168.2.0/24', - 'eth2', track=track) + 'eth2', track=False) vip_address3 = keepalived.KeepalivedVipAddress('192.168.3.0/24', - 'eth2', track=track) + 'eth2', track=False) vip_address_ex = keepalived.KeepalivedVipAddress('192.168.55.0/24', - 'eth10', track=track) + 'eth10', track=False) instance1.vips.append(vip_address1) instance1.vips.append(vip_address2) @@ -115,7 +115,7 @@ def _get_config(self, track=True): virtual_route = keepalived.KeepalivedVirtualRoute(n_consts.IPv4_ANY, "192.168.1.1", - "eth1", track=track) + "eth1") instance1.virtual_routes.gateway_routes = [virtual_route] instance2 = keepalived.KeepalivedInstance('MASTER', 'eth4', 2, @@ -124,7 +124,7 @@ def _get_config(self, track=True): instance2.track_interfaces.append("eth4") vip_address1 = keepalived.KeepalivedVipAddress('192.168.3.0/24', - 'eth6', track=track) + 'eth6', track=False) instance2.vips.append(vip_address1) instance2.vips.append(vip_address2) @@ -192,8 +192,7 @@ def test_config_generation(self): keepalived, '_is_keepalived_use_no_track_supported', return_value=True): config = self._get_config() - self.assertEqual(self.expected.replace(' no_track', ''), - config.get_config_str()) + self.assertEqual(self.expected, config.get_config_str()) def test_config_generation_no_track_not_supported(self): self._mock_no_track_supported.start().return_value = False @@ -208,7 +207,7 @@ def test_config_with_reset(self): with mock.patch.object( keepalived, '_is_keepalived_use_no_track_supported', return_value=True): - config = self._get_config(track=False) + config = self._get_config() self.assertEqual(self.expected, config.get_config_str()) config.reset() @@ -239,24 +238,20 @@ def test_state_exception(self): class KeepalivedInstanceRoutesTestCase(KeepalivedBaseTestCase): @classmethod - def _get_instance_routes(cls, track=True): + def _get_instance_routes(cls): routes = keepalived.KeepalivedInstanceRoutes() default_gw_eth0 = keepalived.KeepalivedVirtualRoute( - '0.0.0.0/0', '1.0.0.254', 'eth0', track=track) + '0.0.0.0/0', '1.0.0.254', 'eth0') default_gw_eth1 = keepalived.KeepalivedVirtualRoute( - '::/0', 'fe80::3e97:eff:fe26:3bfa/64', 'eth1', - track=track) + '::/0', 'fe80::3e97:eff:fe26:3bfa/64', 'eth1') routes.gateway_routes = [default_gw_eth0, default_gw_eth1] extra_routes = [ - keepalived.KeepalivedVirtualRoute( - '10.0.0.0/8', '1.0.0.1', track=track), - keepalived.KeepalivedVirtualRoute( - '20.0.0.0/8', '2.0.0.2', track=track)] + keepalived.KeepalivedVirtualRoute('10.0.0.0/8', '1.0.0.1'), + keepalived.KeepalivedVirtualRoute('20.0.0.0/8', '2.0.0.2')] routes.extra_routes = extra_routes extra_subnets = [ keepalived.KeepalivedVirtualRoute( - '30.0.0.0/8', None, 'eth0', scope='link', - track=track)] + '30.0.0.0/8', None, 'eth0', scope='link')] routes.extra_subnets = extra_subnets return routes @@ -282,7 +277,7 @@ def test_build_config(self): with mock.patch.object( keepalived, '_is_keepalived_use_no_track_supported', return_value=True): - routes = self._get_instance_routes(track=False) + routes = self._get_instance_routes() self.assertEqual(expected, '\n'.join(routes.build_config())) def _get_no_track_less_expected_config(self): @@ -295,19 +290,11 @@ def _get_no_track_less_expected_config(self): }""" return expected - def test_build_config_without_no_track(self): - with mock.patch.object( - keepalived, '_is_keepalived_use_no_track_supported', - return_value=True): - routes = self._get_instance_routes() - self.assertEqual(self._get_no_track_less_expected_config(), - '\n'.join(routes.build_config())) - def test_build_config_no_track_not_supported(self): with mock.patch.object( keepalived, '_is_keepalived_use_no_track_supported', return_value=False): - routes = self._get_instance_routes(track=False) + routes = self._get_instance_routes() self.assertEqual(self._get_no_track_less_expected_config(), '\n'.join(routes.build_config())) @@ -319,14 +306,12 @@ def test_get_primary_vip(self): ['169.254.192.0/18']) self.assertEqual('169.254.0.42/24', instance.get_primary_vip()) - def _test_remove_addresses_by_interface(self, track=True): - config = self._get_config(track=track) + def _test_remove_addresses_by_interface(self, no_track_value): + config = self._get_config() instance = config.get_instance(1) instance.remove_vips_vroutes_by_interface('eth2') instance.remove_vips_vroutes_by_interface('eth10') - no_track_value = ' no_track' if not track else '' - expected = KEEPALIVED_GLOBAL_CONFIG + textwrap.dedent(""" vrrp_instance VR_1 { state MASTER @@ -378,19 +363,13 @@ def test_remove_addresses_by_interface(self): with mock.patch.object( keepalived, '_is_keepalived_use_no_track_supported', return_value=True): - self._test_remove_addresses_by_interface() - - def test_remove_addresses_by_interface_with_no_track(self): - with mock.patch.object( - keepalived, '_is_keepalived_use_no_track_supported', - return_value=True): - self._test_remove_addresses_by_interface(track=False) + self._test_remove_addresses_by_interface(" no_track") def test_remove_address_by_interface_no_track_not_supported(self): with mock.patch.object( keepalived, '_is_keepalived_use_no_track_supported', return_value=False): - self._test_remove_addresses_by_interface() + self._test_remove_addresses_by_interface("") def test_build_config_no_vips(self): expected = textwrap.dedent("""\ @@ -457,16 +436,6 @@ def test_virtual_route_with_dev(self): return_value=True): route = keepalived.KeepalivedVirtualRoute( n_consts.IPv4_ANY, '1.2.3.4', 'eth0') - self.assertEqual( - '0.0.0.0/0 via 1.2.3.4 dev eth0 protocol static', - route.build_config()) - - def test_virtual_route_with_dev_supported_no_track(self): - with mock.patch.object( - keepalived, '_is_keepalived_use_no_track_supported', - return_value=True): - route = keepalived.KeepalivedVirtualRoute( - n_consts.IPv4_ANY, '1.2.3.4', 'eth0', track=False) self.assertEqual( '0.0.0.0/0 via 1.2.3.4 dev eth0 no_track protocol static', route.build_config()) @@ -480,21 +449,11 @@ def test_virtual_route_with_dev_no_track_not_supported(self): self.assertEqual('0.0.0.0/0 via 1.2.3.4 dev eth0 protocol static', route.build_config()) - def test_virtual_route_with_dev_no_track_not_supported_not_track(self): - with mock.patch.object( - keepalived, '_is_keepalived_use_no_track_supported', - return_value=False): - route = keepalived.KeepalivedVirtualRoute( - n_consts.IPv4_ANY, '1.2.3.4', 'eth0', track=False) - self.assertEqual('0.0.0.0/0 via 1.2.3.4 dev eth0 protocol static', - route.build_config()) - def test_virtual_route_without_dev(self): with mock.patch.object( keepalived, '_is_keepalived_use_no_track_supported', return_value=True): - route = keepalived.KeepalivedVirtualRoute( - '50.0.0.0/8', '1.2.3.4', track=False) + route = keepalived.KeepalivedVirtualRoute('50.0.0.0/8', '1.2.3.4') self.assertEqual('50.0.0.0/8 via 1.2.3.4 no_track protocol static', route.build_config()) From 69ddc84ac411fe7d9971514058a1309328f59107 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Thu, 13 Feb 2025 07:37:42 +0000 Subject: [PATCH 082/184] Add router ``PUT`` external gateways actions policies In [1], released in neutron-lib 3.6.0, and available since 2023.2 (Bobcat), three new actions were added to the router resource: * PUT add_external_gateways * PUT update_external_gateways * PUT remove_external_gateways [1]https://review.opendev.org/c/openstack/neutron-lib/+/870887 Closes-Bug: #2098109 Related-Bug: #2002687 Change-Id: Idc502903fe6a45c9a18798b8d76036a8a1b7236a (cherry picked from commit 179807f417b50928af7e9821b843ca044e5e3216) --- neutron/conf/policies/router.py | 21 ++++ .../tests/unit/conf/policies/test_router.py | 113 ++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/neutron/conf/policies/router.py b/neutron/conf/policies/router.py index 566d2ea0861..659264954b1 100644 --- a/neutron/conf/policies/router.py +++ b/neutron/conf/policies/router.py @@ -312,6 +312,27 @@ description='Update the router tags', operations=ACTION_PUT_TAGS, ), + policy.DocumentedRuleDefault( + name='add_external_gateways', + check_str=base.ADMIN, + scope_types=['project'], + description=('Add router external gateways'), + operations=ACTION_PUT, + ), + policy.DocumentedRuleDefault( + name='update_external_gateways', + check_str=base.ADMIN, + scope_types=['project'], + description=('Update router external gateways'), + operations=ACTION_PUT, + ), + policy.DocumentedRuleDefault( + name='remove_external_gateways', + check_str=base.ADMIN, + scope_types=['project'], + description=('Remove router external gateways'), + operations=ACTION_PUT, + ), policy.DocumentedRuleDefault( name='delete_router', diff --git a/neutron/tests/unit/conf/policies/test_router.py b/neutron/tests/unit/conf/policies/test_router.py index f29fde7353b..a5a342742ae 100644 --- a/neutron/tests/unit/conf/policies/test_router.py +++ b/neutron/tests/unit/conf/policies/test_router.py @@ -292,6 +292,36 @@ def test_update_routers_tags(self): policy.enforce, self.context, 'update_routers_tags', self.alt_target) + def test_add_external_gateways(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'add_external_gateways', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'add_external_gateways', self.alt_target) + + def test_update_external_gateways(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_external_gateways', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'update_external_gateways', self.alt_target) + + def test_remove_external_gateways(self): + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'remove_external_gateways', self.target) + self.assertRaises( + base_policy.InvalidScope, + policy.enforce, + self.context, 'remove_external_gateways', self.alt_target) + def test_delete_router(self): self.assertRaises( base_policy.InvalidScope, @@ -534,6 +564,29 @@ def test_update_routers_tags(self): policy.enforce(self.context, 'update_routers_tags', self.alt_target)) + def test_add_external_gateways(self): + self.assertTrue( + policy.enforce(self.context, 'add_external_gateways', self.target)) + self.assertTrue( + policy.enforce(self.context, 'add_external_gateways', + self.alt_target)) + + def test_update_external_gateways(self): + self.assertTrue( + policy.enforce(self.context, 'update_external_gateways', + self.target)) + self.assertTrue( + policy.enforce(self.context, 'update_external_gateways', + self.alt_target)) + + def test_remove_external_gateways(self): + self.assertTrue( + policy.enforce(self.context, 'remove_external_gateways', + self.target)) + self.assertTrue( + policy.enforce(self.context, 'remove_external_gateways', + self.alt_target)) + def test_delete_router(self): self.assertTrue( policy.enforce(self.context, 'delete_router', self.target)) @@ -794,6 +847,36 @@ def test_update_routers_tags(self): policy.enforce, self.context, 'update_routers_tags', self.alt_target) + def test_add_external_gateways(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'add_external_gateways', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'add_external_gateways', self.alt_target) + + def test_update_external_gateways(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_external_gateways', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_external_gateways', self.alt_target) + + def test_remove_external_gateways(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'remove_external_gateways', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'remove_external_gateways', self.alt_target) + def test_delete_router(self): self.assertTrue( policy.enforce(self.context, 'delete_router', self.target)) @@ -913,6 +996,36 @@ def test_update_routers_tags(self): policy.enforce, self.context, 'update_routers_tags', self.alt_target) + def test_add_external_gateways(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'add_external_gateways', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'add_external_gateways', self.alt_target) + + def test_update_external_gateways(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_external_gateways', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'update_external_gateways', self.alt_target) + + def test_remove_external_gateways(self): + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'remove_external_gateways', self.target) + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'remove_external_gateways', self.alt_target) + def test_delete_router(self): self.assertRaises( base_policy.PolicyNotAuthorized, From fded233c60d9184b84ec9d3fd9d760ad91e4a6cf Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Date: Fri, 14 Feb 2025 06:31:55 +0000 Subject: [PATCH 083/184] Revert "Add router ``PUT`` external gateways actions policies" This reverts commit 706569c73211567b24a1fca8fc92a953d2eee4e6. Reason for revert: The master branch patch is not merged and this fix is wrong. Change-Id: Iaebc8a729cddde954bac06e53052703a7857fb7a --- neutron/conf/policies/router.py | 21 ---- .../tests/unit/conf/policies/test_router.py | 113 ------------------ 2 files changed, 134 deletions(-) diff --git a/neutron/conf/policies/router.py b/neutron/conf/policies/router.py index 659264954b1..566d2ea0861 100644 --- a/neutron/conf/policies/router.py +++ b/neutron/conf/policies/router.py @@ -312,27 +312,6 @@ description='Update the router tags', operations=ACTION_PUT_TAGS, ), - policy.DocumentedRuleDefault( - name='add_external_gateways', - check_str=base.ADMIN, - scope_types=['project'], - description=('Add router external gateways'), - operations=ACTION_PUT, - ), - policy.DocumentedRuleDefault( - name='update_external_gateways', - check_str=base.ADMIN, - scope_types=['project'], - description=('Update router external gateways'), - operations=ACTION_PUT, - ), - policy.DocumentedRuleDefault( - name='remove_external_gateways', - check_str=base.ADMIN, - scope_types=['project'], - description=('Remove router external gateways'), - operations=ACTION_PUT, - ), policy.DocumentedRuleDefault( name='delete_router', diff --git a/neutron/tests/unit/conf/policies/test_router.py b/neutron/tests/unit/conf/policies/test_router.py index a5a342742ae..f29fde7353b 100644 --- a/neutron/tests/unit/conf/policies/test_router.py +++ b/neutron/tests/unit/conf/policies/test_router.py @@ -292,36 +292,6 @@ def test_update_routers_tags(self): policy.enforce, self.context, 'update_routers_tags', self.alt_target) - def test_add_external_gateways(self): - self.assertRaises( - base_policy.InvalidScope, - policy.enforce, - self.context, 'add_external_gateways', self.target) - self.assertRaises( - base_policy.InvalidScope, - policy.enforce, - self.context, 'add_external_gateways', self.alt_target) - - def test_update_external_gateways(self): - self.assertRaises( - base_policy.InvalidScope, - policy.enforce, - self.context, 'update_external_gateways', self.target) - self.assertRaises( - base_policy.InvalidScope, - policy.enforce, - self.context, 'update_external_gateways', self.alt_target) - - def test_remove_external_gateways(self): - self.assertRaises( - base_policy.InvalidScope, - policy.enforce, - self.context, 'remove_external_gateways', self.target) - self.assertRaises( - base_policy.InvalidScope, - policy.enforce, - self.context, 'remove_external_gateways', self.alt_target) - def test_delete_router(self): self.assertRaises( base_policy.InvalidScope, @@ -564,29 +534,6 @@ def test_update_routers_tags(self): policy.enforce(self.context, 'update_routers_tags', self.alt_target)) - def test_add_external_gateways(self): - self.assertTrue( - policy.enforce(self.context, 'add_external_gateways', self.target)) - self.assertTrue( - policy.enforce(self.context, 'add_external_gateways', - self.alt_target)) - - def test_update_external_gateways(self): - self.assertTrue( - policy.enforce(self.context, 'update_external_gateways', - self.target)) - self.assertTrue( - policy.enforce(self.context, 'update_external_gateways', - self.alt_target)) - - def test_remove_external_gateways(self): - self.assertTrue( - policy.enforce(self.context, 'remove_external_gateways', - self.target)) - self.assertTrue( - policy.enforce(self.context, 'remove_external_gateways', - self.alt_target)) - def test_delete_router(self): self.assertTrue( policy.enforce(self.context, 'delete_router', self.target)) @@ -847,36 +794,6 @@ def test_update_routers_tags(self): policy.enforce, self.context, 'update_routers_tags', self.alt_target) - def test_add_external_gateways(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'add_external_gateways', self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'add_external_gateways', self.alt_target) - - def test_update_external_gateways(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'update_external_gateways', self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'update_external_gateways', self.alt_target) - - def test_remove_external_gateways(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'remove_external_gateways', self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'remove_external_gateways', self.alt_target) - def test_delete_router(self): self.assertTrue( policy.enforce(self.context, 'delete_router', self.target)) @@ -996,36 +913,6 @@ def test_update_routers_tags(self): policy.enforce, self.context, 'update_routers_tags', self.alt_target) - def test_add_external_gateways(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'add_external_gateways', self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'add_external_gateways', self.alt_target) - - def test_update_external_gateways(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'update_external_gateways', self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'update_external_gateways', self.alt_target) - - def test_remove_external_gateways(self): - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'remove_external_gateways', self.target) - self.assertRaises( - base_policy.PolicyNotAuthorized, - policy.enforce, - self.context, 'remove_external_gateways', self.alt_target) - def test_delete_router(self): self.assertRaises( base_policy.PolicyNotAuthorized, From f0050dc154e17c88698a91e3488669b3627b5e94 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 13 Nov 2024 22:12:17 +0000 Subject: [PATCH 084/184] Catch when the process does not exist when killing it In ``ProcessManager.disable``, it could happen that the process to be stopped is no longer present in the system. In that case, catch this exception and dismiss it. If the goal of the ``disable`` method is to stop the process, it should not fail in the case of not being present anymore. [Stable Only] Also backport [1] to make pep8 happy for py38. [1] https://review.opendev.org/941154 Closes-Bug: #2088154 Change-Id: I5c6f7648d69e3a939445273f8d94241818538fc9 (cherry picked from commit 0c29e730db2629c084de0c114a0d1e8e6939ac25) --- neutron/agent/linux/external_process.py | 26 ++++--- .../agent/linux/test_external_process.py | 69 +++++++++++++++++++ .../unit/agent/linux/test_external_process.py | 19 ++--- 3 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 neutron/tests/functional/agent/linux/test_external_process.py diff --git a/neutron/agent/linux/external_process.py b/neutron/agent/linux/external_process.py index f58f186d049..a3a49fa0150 100644 --- a/neutron/agent/linux/external_process.py +++ b/neutron/agent/linux/external_process.py @@ -17,9 +17,11 @@ import os.path import eventlet +from neutron_lib import exceptions as n_exc from oslo_concurrency import lockutils from oslo_config import cfg from oslo_log import log as logging +from oslo_utils import excutils from oslo_utils import fileutils import psutil @@ -116,6 +118,20 @@ def reload_cfg(self): else: self.disable('HUP', delete_pid_file=False) + def _kill_process(self, cmd, pid): + try: + ip_wrapper = ip_lib.IPWrapper(namespace=self.namespace) + ip_wrapper.netns.execute(cmd, addl_env=self.cmd_addl_env, + run_as_root=self.run_as_root, + privsep_exec=True) + except n_exc.ProcessExecutionError as exc: + with excutils.save_and_reraise_exception() as ctxt: + if ('No such process' in str(exc) or + 'Cannot open network namespace' in str(exc)): + LOG.debug('Process %s not present when "kill" command ' + 'sent', pid) + ctxt.reraise = False + def disable(self, sig='9', get_stop_command=None, delete_pid_file=True): pid = self.pid delete_pid_file = delete_pid_file or sig == '9' @@ -123,15 +139,9 @@ def disable(self, sig='9', get_stop_command=None, delete_pid_file=True): if self.active: if get_stop_command: cmd = get_stop_command(self.get_pid_file_name()) - ip_wrapper = ip_lib.IPWrapper(namespace=self.namespace) - ip_wrapper.netns.execute(cmd, addl_env=self.cmd_addl_env, - run_as_root=self.run_as_root, - privsep_exec=True) else: cmd = self.get_kill_cmd(sig, pid) - utils.execute(cmd, addl_env=self.cmd_addl_env, - run_as_root=self.run_as_root, - privsep_exec=True) + self._kill_process(cmd, pid) if delete_pid_file: utils.delete_if_exists(self.get_pid_file_name(), @@ -150,7 +160,7 @@ def get_kill_cmd(self, sig, pid): kill_file = "%s-kill" % self.service kill_file_path = os.path.join(self.kill_scripts_path, kill_file) if os.path.isfile(kill_file_path): - return [kill_file_path, sig, pid] + return [kill_file_path, str(sig), pid] return ['kill', '-%s' % (sig), pid] def get_pid_file_name(self): diff --git a/neutron/tests/functional/agent/linux/test_external_process.py b/neutron/tests/functional/agent/linux/test_external_process.py new file mode 100644 index 00000000000..b3dcc4a2b58 --- /dev/null +++ b/neutron/tests/functional/agent/linux/test_external_process.py @@ -0,0 +1,69 @@ +# Copyright (c) 2024 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import signal +import tempfile + +from oslo_config import cfg + +from neutron.agent.common import async_process +from neutron.agent.linux import external_process as ep +from neutron.agent.linux import utils as agent_utils +from neutron.common import utils as common_utils +from neutron.tests.common import net_helpers +from neutron.tests.functional import base as functional_base + + +class ProcessManagerTestCase(functional_base.BaseSudoTestCase): + + def _create_sleep_process(self, time=None): + if time is None: + cmd = ['sleep', 'infinity'] + else: + cmd = ['sleep', str(time)] + process = async_process.AsyncProcess(cmd) + process.start() + + with tempfile.NamedTemporaryFile('w+', delete=False) as pid_file: + pid_file.write(process.pid) + os.chmod(pid_file.name, 0o777) + uuid = 'sleep infinity' + namespace = self.useFixture(net_helpers.NamespaceFixture()).name + return ep.ProcessManager(cfg.CONF, uuid, namespace, + pid_file=pid_file.name) + + def test__kill_process(self): + pm = self._create_sleep_process() + self.assertTrue(pm.active) + + pm._kill_process(pm.get_kill_cmd(int(signal.SIGKILL), pm.pid), pm.pid) + # Delete the PID file used by ``pm.active``. + agent_utils.delete_if_exists(pm.get_pid_file_name()) + self.assertFalse(pm.active) + + def test__kill_process_process_not_present(self): + pm = self._create_sleep_process(time=0) + # "sleep 0" should end immediately, but we add an active wait of 3 + # seconds just to avoid any race condition. + try: + common_utils.wait_until_true(lambda: not pm.active, timeout=3) + except common_utils.WaitTimeout: + self.fail('The process "sleep 0" (PID: %s) did not finish' % + pm.pid) + + # '_kill_process' should not raise any exception. + pm._kill_process(pm.get_kill_cmd(int(signal.SIGKILL), pm.pid), pm.pid) + self.assertFalse(pm.active) diff --git a/neutron/tests/unit/agent/linux/test_external_process.py b/neutron/tests/unit/agent/linux/test_external_process.py index 9e8c6f56fb6..1344fb63a2a 100644 --- a/neutron/tests/unit/agent/linux/test_external_process.py +++ b/neutron/tests/unit/agent/linux/test_external_process.py @@ -24,6 +24,7 @@ import psutil from neutron.agent.linux import external_process as ep +from neutron.agent.linux import ip_lib from neutron.common import utils as common_utils from neutron.tests import base @@ -269,10 +270,11 @@ def test_disable_no_namespace(self): active.__get__ = mock.Mock(return_value=True) manager = ep.ProcessManager(self.conf, 'uuid') - with mock.patch.object(ep, 'utils') as utils: + with mock.patch.object(ip_lib.IpNetnsCommand, 'execute') as \ + mock_execute: manager.disable() env = {ep.PROCESS_TAG: ep.DEFAULT_SERVICE_NAME + '-uuid'} - utils.assert_has_calls([ + mock_execute.assert_has_calls([ mock.call.execute(['kill', '-9', 4], addl_env=env, run_as_root=False, @@ -286,10 +288,11 @@ def test_disable_namespace(self): manager = ep.ProcessManager(self.conf, 'uuid', namespace='ns') - with mock.patch.object(ep, 'utils') as utils: + with mock.patch.object(ip_lib.IpNetnsCommand, 'execute') as \ + mock_execute: manager.disable() env = {ep.PROCESS_TAG: ep.DEFAULT_SERVICE_NAME + '-uuid'} - utils.assert_has_calls([ + mock_execute.assert_has_calls([ mock.call.execute( ['kill', '-9', 4], addl_env=env, run_as_root=True, privsep_exec=True)]) @@ -331,12 +334,12 @@ def _test_disable_custom_kill_script(self, kill_script_exists, namespace, manager = ep.ProcessManager( self.conf, 'uuid', namespace=namespace, service=service_name) - with mock.patch.object(ep, 'utils') as utils, \ - mock.patch.object(os.path, 'isfile', - return_value=kill_script_exists): + with mock.patch.object(ip_lib.IpNetnsCommand, 'execute') as \ + execute_mock, mock.patch.object( + os.path, 'isfile', return_value=kill_script_exists): manager.disable() addl_env = {ep.PROCESS_TAG: service_name + '-uuid'} - utils.execute.assert_called_with( + execute_mock.assert_called_with( expected_cmd, addl_env=addl_env, run_as_root=bool(namespace), privsep_exec=True) From c5faf46e91554029b799fbb6493d63d1bc3f4396 Mon Sep 17 00:00:00 2001 From: Terry Wilson Date: Fri, 22 Nov 2024 00:00:42 +0000 Subject: [PATCH 085/184] Update Nova aggregates on changed host mappings When creating a subnet on a segment, Nova aggregates are updated with the host information. But when adding a compute node to an existing segment or modifying what segments a node is attached to, Nova was not updated with these changes for ML2/OVN. ML2/OVS has agent code which via report_state() will call create_or_update_agent() which causes the aggregates to eventually get updated via AGENT_AFTER_CREATE events, etc. ML2/OVN does not have "real" agents. It monkeypatches some agent methods to respond to the API requests itself--but it does not use the agents db--which is what create_or_update_agent() modifies. But it shouldn't be necessary to rely on updates from the agent in our case, as the segments code can see segment host mappings being updated and just directly handle notifying nova when those change. Closes-Bug: #2096941 Change-Id: I8112076f8acb821752941396e7aa39ecb1352ca3 (cherry picked from commit 2d8fe38ad5dbdc223560f2da661ca9e384f8221b) --- neutron/services/segments/db.py | 26 ++++++++++++++++----- neutron/services/segments/plugin.py | 36 +++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/neutron/services/segments/db.py b/neutron/services/segments/db.py index 0cdf31ba0de..e2590004f87 100644 --- a/neutron/services/segments/db.py +++ b/neutron/services/segments/db.py @@ -235,6 +235,17 @@ def update_segment_host_mapping(context, host, current_segment_ids): for segment_id in segment_ids: network.SegmentHostMapping( context, segment_id=segment_id, host=host).create() + if segment_ids: + registry.publish( + resources.SEGMENT_HOST_MAPPING, + events.AFTER_CREATE, + update_segment_host_mapping, + payload=events.DBEventPayload( + context, + metadata={ + 'host': host, + 'current_segment_ids': segment_ids})) + LOG.debug('Segments %s mapped to the host %s', segment_ids, host) stale_segment_ids = previous_segment_ids - current_segment_ids if stale_segment_ids: @@ -243,6 +254,15 @@ def update_segment_host_mapping(context, host, current_segment_ids): entry.delete() LOG.debug('Segment %s unmapped from host %s', entry.segment_id, entry.host) + registry.publish( + resources.SEGMENT_HOST_MAPPING, + events.AFTER_DELETE, + update_segment_host_mapping, + payload=events.DBEventPayload( + context, + metadata={ + 'host': host, + 'deleted_segment_ids': stale_segment_ids})) def get_hosts_mapped_with_segments(context, include_agent_types=None, @@ -352,12 +372,6 @@ def _update_segment_host_mapping_for_agent(resource, event, trigger, segment['id'] for segment in segments if check_segment_for_agent(segment, agent)} update_segment_host_mapping(context, host, current_segment_ids) - registry.publish(resources.SEGMENT_HOST_MAPPING, events.AFTER_CREATE, - plugin, payload=events.DBEventPayload( - context, - metadata={ - 'host': host, - 'current_segment_ids': current_segment_ids})) def _add_segment_host_mapping_for_segment(resource, event, trigger, diff --git a/neutron/services/segments/plugin.py b/neutron/services/segments/plugin.py index e6f303cdda7..bfb5dcba304 100644 --- a/neutron/services/segments/plugin.py +++ b/neutron/services/segments/plugin.py @@ -393,13 +393,18 @@ def _delete_nova_inventory(self, event): LOG.info('Segment %s resource provider not found; error: %s', event.segment_id, str(exc)) + @staticmethod + def _payload_segment_ids(payload, key): + # NOTE(twilson) My assumption is that this is to guarantee the subnets + # passed exist in at least one subnet + subnets = subnet_obj.Subnet.get_objects( + payload.context, segment_id=payload.metadata.get(key)) + return {s.segment_id for s in subnets} + @registry.receives(resources.SEGMENT_HOST_MAPPING, [events.AFTER_CREATE]) def _notify_host_addition_to_aggregate(self, resource, event, trigger, payload=None): - subnets = subnet_obj.Subnet.get_objects( - payload.context, - segment_id=payload.metadata.get('current_segment_ids')) - segment_ids = {s.segment_id for s in subnets} + segment_ids = self._payload_segment_ids(payload, 'current_segment_ids') self.batch_notifier.queue_event( Event(self._add_host_to_aggregate, segment_ids, host=payload.metadata.get('host'))) @@ -420,6 +425,29 @@ def _add_host_to_aggregate(self, event): 'routed network segment %(segment_id)s', {'host': event.host, 'segment_id': segment_id}) + @registry.receives(resources.SEGMENT_HOST_MAPPING, [events.AFTER_DELETE]) + def _notify_host_removal_from_aggregate(self, resource, event, trigger, + payload=None): + segment_ids = self._payload_segment_ids(payload, 'deleted_segment_ids') + self.batch_notifier.queue_event( + Event(self._remove_host_from_aggregate, + segment_ids, host=payload.metadata.get('host'))) + + def _remove_host_from_aggregate(self, event): + for segment_id in event.segment_ids: + aggregate_id = self._get_aggregate_id(segment_id) + if not aggregate_id: + LOG.info('When removing host %(host)s, aggregate not found ' + 'for routed network segment %(segment_id)s', + {'host': event.host, 'segment_id': segment_id}) + continue + try: + self.n_client.aggregates.remove_host(aggregate_id, event.host) + except nova_exc.NotFound: + LOG.info('Host %(host)s is not in aggregate for ' + 'routed network segment %(segment_ids)s', + {'host': event.host, 'segment_id': segment_id}) + @registry.receives(resources.PORT, [events.AFTER_CREATE, events.AFTER_DELETE]) def _notify_port_created_or_deleted(self, resource, event, trigger, From 9cdbf61a07e2f8b86101636cbf78ca1f826fe0b0 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 16 Aug 2024 22:22:24 +0000 Subject: [PATCH 086/184] Support nested SNAT for ml2/ovn When ovn_router_indirect_snat = True, ml2/ovn will set a catch-all snat rule for each external ip, instead of a snat rule per attached subnet. NB: This option is global to cluster and cannot be controlled per project or per router. NB2: this patch assumes that 0.0.0.0/0 snat rules are properly handled by OVN. Some (e.g. 22.03 and 24.03) OVN versions may have this scenario broken. See: https://issues.redhat.com/browse/FDP-744 for details. -- A long time ago, nested SNAT behavior was unconditionally enabled for ml2/ovs, see: https://bugs.launchpad.net/neutron/+bug/1386041 Since this behavior has potential security implications, and since it may not be desired in all environments, a new flag is introduced. Since OVN was deployed without nested SNAT enabled in multiple environments, the flag is set to False by default (meaning: no nested SNAT). In theory, instead of a config option, neutron could introduce a new API to allow users to control the behavior per router. This would require more work though. This granular API is left out of the patch. Interested parties are welcome to start a discussion about adding the new API as a new neutron extension to routers. -- Before this patch, there was an alternative implementation proposed that was not relying on 0.0.0.0/0 snat behavior implemented properly in OVN. The implementation was abandoned because it introduced non-negligible complexity in the neutron code and the OVN NB database. See: https://review.opendev.org/c/openstack/neutron/+/907504 -- Conflicts: neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py Closes-Bug: #2051935 Co-Authored-By: Brian Haley Change-Id: I28fae44edc122fae389916e25b3321550de001fd (cherry picked from commit dbf53b7bbfa27cb74b1d0b0e47629bf3e1403645) --- neutron/common/ovn/constants.py | 3 + .../conf/plugins/ml2/drivers/ovn/ovn_conf.py | 10 ++ .../ovn/mech_driver/ovsdb/ovn_client.py | 161 ++++++++++-------- .../ovn/mech_driver/ovsdb/ovn_db_sync.py | 10 +- .../ovn/mech_driver/test_mech_driver.py | 60 +++++-- .../ovn/mech_driver/ovsdb/test_ovn_client.py | 83 ++++++++- .../ovn/mech_driver/ovsdb/test_ovn_db_sync.py | 17 +- ...-nested-snat-for-ovn-e4aa3b9af66c905b.yaml | 13 ++ 8 files changed, 247 insertions(+), 110 deletions(-) create mode 100644 releasenotes/notes/support-nested-snat-for-ovn-e4aa3b9af66c905b.yaml diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index 78334dd534f..474c0f64edf 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -466,3 +466,6 @@ portbindings.VNIC_BAREMETAL, portbindings.VNIC_VIRTIO_FORWARDER, ] + +# OVN default SNAT CIDR +OVN_DEFAULT_SNAT_CIDR = '0.0.0.0/0' diff --git a/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py b/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py index 72b97798aa9..6336942b6aa 100644 --- a/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py +++ b/neutron/conf/plugins/ml2/drivers/ovn/ovn_conf.py @@ -221,6 +221,12 @@ default=0, help=_('The number of seconds to keep MAC_Binding entries in ' 'the OVN DB. 0 to disable aging.')), + cfg.BoolOpt('ovn_router_indirect_snat', + default=False, + help=_('Whether to configure SNAT for all nested subnets ' + 'connected to the router through any other routers, ' + 'similar to the default ML2/OVS behavior. Defaults to ' + '"False".')), ] nb_global_opts = [ @@ -380,3 +386,7 @@ def get_ovn_mac_binding_age_threshold(): def get_ovn_mac_binding_removal_limit(): return str(cfg.CONF.ovn_nb_global.mac_binding_removal_limit) + + +def is_ovn_router_indirect_snat_enabled(): + return cfg.CONF.ovn.ovn_router_indirect_snat diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 3eb059ad71d..27088a1028f 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -16,6 +16,7 @@ import collections import copy import datetime +import functools import netaddr from neutron_lib.api.definitions import l3 @@ -52,6 +53,8 @@ from neutron.common import utils as common_utils from neutron.conf.agent import ovs_conf from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf +from neutron.conf.plugins.ml2.drivers.ovn.ovn_conf \ + import is_ovn_router_indirect_snat_enabled as is_nested_snat from neutron.db import ovn_revision_numbers_db as db_rev from neutron.db import segments_db from neutron.objects import router @@ -65,6 +68,10 @@ LOG = log.getLogger(__name__) +def _has_separate_snat_per_subnet(router): + return utils.is_snat_enabled(router) and not is_nested_snat() + + OvnPortInfo = collections.namedtuple( "OvnPortInfo", [ @@ -1241,23 +1248,23 @@ def _get_gw_info(self, context, port_dict): else const.IPv6_ANY)) return gateways_info - def _delete_router_ext_gw(self, router, networks, txn): + def _delete_router_ext_gw(self, router_id, txn): context = n_context.get_admin_context() - if not networks: - networks = [] - router_id = router['id'] + cidrs = self._get_snat_cidrs_for_external_router(context, router_id) gw_lrouter_name = utils.ovn_name(router_id) deleted_ports = [] for gw_port in self._get_router_gw_ports(context, router_id): for gw_info in self._get_gw_info(context, gw_port): - if gw_info.ip_version == const.IP_VERSION_4: - for network in networks: - txn.add(self._nb_idl.delete_nat_rule_in_lrouter( - gw_lrouter_name, type='snat', logical_ip=network, - external_ip=gw_info.router_ip)) txn.add(self._nb_idl.delete_static_route( gw_lrouter_name, ip_prefix=gw_info.ip_prefix, nexthop=gw_info.gateway_ip)) + if gw_info.ip_version != const.IP_VERSION_4: + continue + for cidr in cidrs: + txn.add(self._nb_idl.delete_nat_rule_in_lrouter( + gw_lrouter_name, type='snat', + external_ip=gw_info.router_ip, + logical_ip=cidr)) txn.add(self._nb_idl.delete_lrouter_port( utils.ovn_lrouter_port_name(gw_port['id']), gw_lrouter_name)) @@ -1303,7 +1310,7 @@ def _get_nets_and_ipv6_ra_confs_for_router_port(self, context, port): return list(networks), ipv6_ra_configs - def _add_router_ext_gw(self, context, router, networks, txn): + def _add_router_ext_gw(self, context, router, txn): lrouter_name = utils.ovn_name(router['id']) router_default_route_ecmp_enabled = router.get( 'enable_default_route_ecmp', False) @@ -1341,9 +1348,9 @@ def _add_router_ext_gw(self, context, router, networks, txn): maintain_bfd=router_default_route_bfd_enabled, **columns)) - # 3. Add snat rules for tenant networks in lrouter if snat is enabled - if utils.is_snat_enabled(router) and networks: - self.update_nat_rules(router, networks, enable_snat=True, txn=txn) + # 3. Add necessary snat rule(s) in lrouter if snat is enabled + if utils.is_snat_enabled(router): + self.update_nat_rules(router['id'], enable_snat=True, txn=txn) return added_ports def _check_external_ips_changed(self, ovn_snats, @@ -1445,17 +1452,20 @@ def _get_v4_network_for_router_port(self, context, port): cidr = subnet['cidr'] return cidr - def _get_v4_network_of_all_router_ports(self, context, router_id, - ports=None): + def _get_v4_network_of_all_router_ports(self, context, router_id): networks = [] - ports = ports or self._get_router_ports(context, router_id) - for port in ports: + for port in self._get_router_ports(context, router_id): network = self._get_v4_network_for_router_port(context, port) if network: networks.append(network) - return networks + def _get_snat_cidrs_for_external_router(self, context, router_id): + if is_nested_snat(): + return [ovn_const.OVN_DEFAULT_SNAT_CIDR] + # nat rule per attached subnet per external ip + return self._get_v4_network_of_all_router_ports(context, router_id) + def _gen_router_ext_ids(self, router): return { ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: @@ -1484,12 +1494,9 @@ def create_router(self, context, router, add_external_gateway=True): # by the ovn_db_sync.py script, remove it after the database # synchronization work if add_external_gateway: - networks = self._get_v4_network_of_all_router_ports( - context, router['id']) - if (router.get(l3_ext_gw_multihoming.EXTERNAL_GATEWAYS) and - networks is not None): + if router.get(l3_ext_gw_multihoming.EXTERNAL_GATEWAYS): added_gw_ports = self._add_router_ext_gw( - context, router, networks, txn) + context, router, txn) self._qos_driver.create_router(txn, router) @@ -1517,7 +1524,6 @@ def update_router(self, context, new_router, router_object=None): l3_ext_gw_multihoming.EXTERNAL_GATEWAYS) ovn_snats = utils.get_lrouter_snats(ovn_router) - networks = self._get_v4_network_of_all_router_ports(context, router_id) try: check_rev_cmd = self._nb_idl.check_revision_number( router_name, new_router, ovn_const.TYPE_ROUTERS) @@ -1526,13 +1532,13 @@ def update_router(self, context, new_router, router_object=None): if gateway_new and not gateway_old: # Route gateway is set added_gw_ports = self._add_router_ext_gw( - context, new_router, networks, txn) + context, new_router, txn) elif gateway_old and not gateway_new: # router gateway is removed txn.add(self._nb_idl.delete_lrouter_ext_gw(router_name)) if router_object: deleted_gw_port_ids = self._delete_router_ext_gw( - router_object, networks, txn) + router_object['id'], txn) elif gateway_new and gateway_old: # Check if external gateway has changed, if yes, delete # the old gateway and add the new gateway @@ -1550,16 +1556,16 @@ def update_router(self, context, new_router, router_object=None): router_name)) if router_object: deleted_gw_port_ids = self._delete_router_ext_gw( - router_object, networks, txn) + router_object['id'], txn) added_gw_ports = self._add_router_ext_gw( - context, new_router, networks, txn) + context, new_router, txn) else: # Check if snat has been enabled/disabled and update new_snat_state = utils.is_snat_enabled(new_router) - if bool(ovn_snats) != new_snat_state and networks: + if bool(ovn_snats) != new_snat_state: self.update_nat_rules( - new_router, networks, - enable_snat=new_snat_state, txn=txn) + new_router['id'], enable_snat=new_snat_state, + txn=txn) update = {'external_ids': self._gen_router_ext_ids(new_router)} update['enabled'] = new_router.get('admin_state_up') or False @@ -1807,26 +1813,26 @@ def create_router_port(self, context, router_id, router_interface): gw_ports = self._get_router_gw_ports(context, router_id) if gw_ports: - cidr = None - for fixed_ip in port['fixed_ips']: - subnet = self._plugin.get_subnet(context, - fixed_ip['subnet_id']) - if multi_prefix: - if 'subnet_id' in router_interface: - if subnet['id'] != router_interface['subnet_id']: - continue - if subnet['ip_version'] == const.IP_VERSION_4: - cidr = subnet['cidr'] - if ovn_conf.is_ovn_emit_need_to_frag_enabled(): for gw_port in gw_ports: provider_net = self._plugin.get_network( context, gw_port['network_id']) self.set_gateway_mtu(context, provider_net) - if utils.is_snat_enabled(router) and cidr: - self.update_nat_rules(router, networks=[cidr], - enable_snat=True, txn=txn) + if _has_separate_snat_per_subnet(router): + for fixed_ip in port['fixed_ips']: + subnet = self._plugin.get_subnet( + context, fixed_ip['subnet_id']) + if (multi_prefix and + 'subnet_id' in router_interface and + subnet['id'] != router_interface['subnet_id']): + continue + if subnet['ip_version'] == const.IP_VERSION_4: + self.update_nat_rules( + router['id'], cidrs=[subnet['cidr']], + enable_snat=True, txn=txn) + break # TODO(ihar): handle multiple ipv4 ips? + if ovn_conf.is_ovn_distributed_floating_ip(): router_gw_ports = self._get_router_gw_ports(context, router_id) @@ -1959,19 +1965,17 @@ def delete_router_port(self, context, port_id, subnet_ids=None): context, gw_port['network_id']) self.set_gateway_mtu(context, provider_net, txn=txn) - cidr = None - for sid in subnet_ids: - try: - subnet = self._plugin.get_subnet(context, sid) - except n_exc.SubnetNotFound: - continue - if subnet['ip_version'] == const.IP_VERSION_4: - cidr = subnet['cidr'] - break - - if utils.is_snat_enabled(router) and cidr: - self.update_nat_rules( - router, networks=[cidr], enable_snat=False, txn=txn) + if _has_separate_snat_per_subnet(router): + for sid in subnet_ids: + try: + subnet = self._plugin.get_subnet(context, sid) + except n_exc.SubnetNotFound: + continue + if subnet['ip_version'] == const.IP_VERSION_4: + self.update_nat_rules( + router['id'], cidrs=[subnet['cidr']], + enable_snat=False, txn=txn) + break # TODO(ihar): handle multiple ipv4 ips? if ovn_conf.is_ovn_distributed_floating_ip(): router_gw_ports = self._get_router_gw_ports(context, router_id) @@ -1990,20 +1994,35 @@ def delete_router_port(self, context, port_id, subnet_ids=None): db_rev.bump_revision( context, port, ovn_const.TYPE_ROUTER_PORTS) - def update_nat_rules(self, router, networks, enable_snat, txn=None): - """Update the NAT rules in a logical router.""" + def _iter_ipv4_gw_addrs(self, context, router_id): + yield from ( + gw_info.router_ip + for gw_port in self._get_router_gw_ports(context, router_id) + for gw_info in self._get_gw_info(context, gw_port) + if gw_info.ip_version != const.IP_VERSION_6 + ) + + def update_nat_rules(self, router_id, enable_snat, cidrs=None, txn=None): + if enable_snat: + idl_func = self._nb_idl.add_nat_rule_in_lrouter + else: + idl_func = self._nb_idl.delete_nat_rule_in_lrouter + func = functools.partial( + idl_func, utils.ovn_name(router_id), type='snat') + context = n_context.get_admin_context() - func = (self._nb_idl.add_nat_rule_in_lrouter if enable_snat else - self._nb_idl.delete_nat_rule_in_lrouter) - gw_lrouter_name = utils.ovn_name(router['id']) - # Update NAT rules only for IPv4 subnets - commands = [func(gw_lrouter_name, type='snat', logical_ip=network, - external_ip=gw_info.router_ip) - for gw_port in self._get_router_gw_ports(context, - router['id']) - for gw_info in self._get_gw_info(context, gw_port) - if gw_info.ip_version != const.IP_VERSION_6 - for network in networks] + cidrs = ( + cidrs or + self._get_snat_cidrs_for_external_router(context, router_id) + ) + commands = [ + func(logical_ip=cidr, external_ip=router_ip) + for router_ip in self._iter_ipv4_gw_addrs(context, router_id) + for cidr in cidrs + ] + if not commands: + return + self._transaction(commands, txn=txn) def create_provnet_port(self, network_id, segment, txn=None): diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py index 8637688f456..2e29f110f4e 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py @@ -540,12 +540,12 @@ def sync_routers_and_rports(self, ctx): if gw_info.ip_version == constants.IP_VERSION_6: continue if gw_info.router_ip and utils.is_snat_enabled(router): - networks = self._ovn_client.\ - _get_v4_network_of_all_router_ports( - ctx, router['id']) - for network in networks: + cidrs = self._ovn_client.\ + _get_snat_cidrs_for_external_router(ctx, + router['id']) + for cidr in cidrs: db_extends[router['id']]['snats'].append({ - 'logical_ip': network, + 'logical_ip': cidr, 'external_ip': gw_info.router_ip, 'type': 'snat'}) diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index e942eb6cec6..ff317ce0146 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -1291,14 +1291,6 @@ def _update_router(self, router_id, router_dict): res = req.get_response(self.api) return self.deserialize(self.fmt, res)['router'] - -class TestNATRuleGatewayPort(_TestRouter): - - def deserialize(self, content_type, response): - ctype = 'application/%s' % content_type - data = self._deserializers[ctype].deserialize(response.body)['body'] - return data - def _process_router_interface(self, action, router_id, subnet_id): req = self.new_action_request( 'routers', {'subnet_id': subnet_id}, router_id, @@ -1309,6 +1301,14 @@ def _process_router_interface(self, action, router_id, subnet_id): def _add_router_interface(self, router_id, subnet_id): return self._process_router_interface('add', router_id, subnet_id) + +class TestNATRuleGatewayPort(_TestRouter): + + def deserialize(self, content_type, response): + ctype = 'application/%s' % content_type + data = self._deserializers[ctype].deserialize(response.body)['body'] + return data + def _create_port(self, name, net_id, security_groups=None, device_owner=None): data = {'port': {'name': name, @@ -1383,7 +1383,7 @@ def test_create_floatingip(self): class TestRouterGWPort(_TestRouter): - def test_create_and_delete_router_gw_port(self): + def _test_create_and_delete_router_gw_port(self, nested_snat=False): ext_net = self._make_network( self.fmt, 'ext_networktest', True, as_admin=True, arg_list=('router:external', @@ -1403,18 +1403,46 @@ def test_create_and_delete_router_gw_port(self): uuidutils.generate_uuid(), external_gateway_info=external_gateway_info) + inner_network = self._make_network( + self.fmt, 'inner_network', True)['network'] + subnet_cidr = '192.168.0.0/24' + res = self._create_subnet(self.fmt, inner_network['id'], + '192.168.0.0/24', gateway_ip='192.168.0.1', + allocation_pools=[{'start': '192.168.0.2', + 'end': '192.168.0.253'}], + enable_dhcp=False) + inner_subnet = self.deserialize(self.fmt, res)['subnet'] + self._add_router_interface(router['id'], inner_subnet['id']) + # Check GW LRP. lr = self._ovn_client._nb_idl.lookup('Logical_Router', utils.ovn_name(router['id'])) - for lrp in lr.ports: - if lrp.external_ids[ovn_const.OVN_ROUTER_IS_EXT_GW] == str(True): - break - else: - self.fail('Logical Router %s does not have a gateway port' % - utils.ovn_name(router['id'])) + + def _find_ext_gw_lrp(lr): + for lrp in lr.ports: + if (lrp.external_ids[ovn_const.OVN_ROUTER_IS_EXT_GW] == + str(True)): + return lrp + + self.assertIsNotNone(_find_ext_gw_lrp(lr)) + + nats = lr.nat + self.assertEqual(1, len(nats)) + expected_logical_ip = ( + ovn_const.OVN_DEFAULT_SNAT_CIDR if nested_snat else subnet_cidr + ) + self.assertEqual(expected_logical_ip, nats[0].logical_ip) # Remove LR GW port and check. self._update_router(router['id'], {'external_gateway_info': {}}) lr = self._ovn_client._nb_idl.lookup('Logical_Router', utils.ovn_name(router['id'])) - self.assertEqual([], lr.ports) + self.assertEqual([], lr.nat) + self.assertIsNone(_find_ext_gw_lrp(lr)) + + def test_create_and_delete_router_gw_port(self): + self._test_create_and_delete_router_gw_port() + + def test_create_and_delete_router_gw_port_nested_snat(self): + ovn_conf.cfg.CONF.set_override('ovn_router_indirect_snat', True, 'ovn') + self._test_create_and_delete_router_gw_port(nested_snat=True) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py index 5c836d60938..06cc7c6e062 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -15,6 +15,9 @@ from unittest import mock +from neutron_lib import context as ncontext +from oslo_config import cfg + from neutron.common.ovn import constants from neutron.conf.plugins.ml2 import config as ml2_conf from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf @@ -32,6 +35,53 @@ from tenacity import wait_none +class Test_has_separate_snat_per_subnet(base.BaseTestCase): + + def setUp(self): + super().setUp() + ovn_conf.register_opts() + + def test_snat_on_nested_off(self): + fake_router = { + 'id': 'fake-id', + l3.EXTERNAL_GW_INFO: { + 'enable_snat': True, + }, + } + # ovn_router_indirect_snat default is False + self.assertTrue(ovn_client._has_separate_snat_per_subnet(fake_router)) + + def test_snat_off_nested_off(self): + fake_router = { + 'id': 'fake-id', + l3.EXTERNAL_GW_INFO: { + 'enable_snat': False, + }, + } + # ovn_router_indirect_snat default is False + self.assertFalse(ovn_client._has_separate_snat_per_subnet(fake_router)) + + def test_snat_on_nested_on(self): + fake_router = { + 'id': 'fake-id', + l3.EXTERNAL_GW_INFO: { + 'enable_snat': True, + }, + } + cfg.CONF.set_override('ovn_router_indirect_snat', True, 'ovn') + self.assertFalse(ovn_client._has_separate_snat_per_subnet(fake_router)) + + def test_snat_off_nested_on(self): + fake_router = { + 'id': 'fake-id', + l3.EXTERNAL_GW_INFO: { + 'enable_snat': False, + }, + } + cfg.CONF.set_override('ovn_router_indirect_snat', True, 'ovn') + self.assertFalse(ovn_client._has_separate_snat_per_subnet(fake_router)) + + class TestOVNClientBase(base.BaseTestCase): def setUp(self): @@ -67,7 +117,6 @@ def test__add_router_ext_gw_default_route(self): 'id': 'fake-router-id', 'gw_port_id': 'fake-port-id', } - networks = mock.MagicMock() txn = mock.MagicMock() self.ovn_client._get_router_gw_ports = mock.MagicMock() gw_port = fakes.FakePort().create_one_port( @@ -80,8 +129,7 @@ def test__add_router_ext_gw_default_route(self): self.ovn_client._get_router_gw_ports.return_value = [gw_port] self.assertEqual( [self.get_plugin().get_port()], - self.ovn_client._add_router_ext_gw(mock.Mock(), router, networks, - txn)) + self.ovn_client._add_router_ext_gw(mock.Mock(), router, txn)) self.nb_idl.add_static_route.assert_called_once_with( 'neutron-' + router['id'], ip_prefix='0.0.0.0/0', @@ -111,7 +159,6 @@ def test__add_router_ext_gw_default_route_ecmp(self): 'gw_port_id': 'fake-port-id', 'enable_default_route_ecmp': True, } - networks = mock.MagicMock() txn = mock.MagicMock() self.ovn_client._get_router_gw_ports = mock.MagicMock() gw_port1 = fakes.FakePort().create_one_port( @@ -132,8 +179,7 @@ def test__add_router_ext_gw_default_route_ecmp(self): gw_port1, gw_port2] self.assertEqual( [self.get_plugin().get_port(), self.get_plugin().get_port()], - self.ovn_client._add_router_ext_gw(mock.Mock(), router, - networks, txn)) + self.ovn_client._add_router_ext_gw(mock.Mock(), router, txn)) self.nb_idl.add_static_route.assert_has_calls([ mock.call('neutron-' + router['id'], ip_prefix='0.0.0.0/0', @@ -172,7 +218,6 @@ def test__add_router_ext_gw_no_default_route(self): }, 'gw_port_id': 'fake-port-id', } - networks = mock.MagicMock() txn = mock.MagicMock() self.ovn_client._get_router_gw_ports = mock.MagicMock() gw_port = fakes.FakePort().create_one_port( @@ -185,8 +230,7 @@ def test__add_router_ext_gw_no_default_route(self): self.ovn_client._get_router_gw_ports.return_value = [gw_port] self.assertEqual( [self.get_plugin().get_port()], - self.ovn_client._add_router_ext_gw(mock.Mock(), router, networks, - txn)) + self.ovn_client._add_router_ext_gw(mock.Mock(), router, txn)) self.nb_idl.add_static_route.assert_not_called() def test_update_lsp_host_info_up(self): @@ -303,6 +347,27 @@ def test__wait_for_port_bindings_host_fail(self, mock_get_port): mock.call(context, port_id)] mock_get_port.assert_has_calls(expected_calls) + def test__get_snat_cidrs_for_external_router_nested_snat_off(self): + ctx = ncontext.Context() + per_subnet_cidrs = ['10.0.0.0/24', '20.0.0.0/24'] + with mock.patch.object( + self.ovn_client, '_get_v4_network_of_all_router_ports', + return_value=per_subnet_cidrs): + cidrs = self.ovn_client._get_snat_cidrs_for_external_router( + ctx, 'fake-id') + self.assertEqual(per_subnet_cidrs, cidrs) + + def test__get_snat_cidrs_for_external_router_nested_snat_on(self): + ctx = ncontext.Context() + cfg.CONF.set_override('ovn_router_indirect_snat', True, 'ovn') + per_subnet_cidrs = ['10.0.0.0/24', '20.0.0.0/24'] + with mock.patch.object( + self.ovn_client, '_get_v4_network_of_all_router_ports', + return_value=per_subnet_cidrs): + cidrs = self.ovn_client._get_snat_cidrs_for_external_router( + ctx, 'fake-id') + self.assertEqual([constants.OVN_DEFAULT_SNAT_CIDR], cidrs) + class TestOVNClientFairMeter(TestOVNClientBase, test_log_driver.TestOVNDriverBase): diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py index 67f078ed478..0cb6ecdab25 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py @@ -378,7 +378,7 @@ def _fake_get_gw_info(self, ctx, port): ip_prefix=const.IPv4_ANY)] }.get(port['id'], []) - def _fake_get_v4_network_of_all_router_ports(self, ctx, router_id): + def _fake_get_snat_cidrs_for_external_router(self, ctx, router_id): return {'r1': ['172.16.0.0/24', '172.16.2.0/24'], 'r2': ['192.168.2.0/24']}.get(router_id, []) @@ -448,15 +448,14 @@ def get_segments(self, filters): l3_plugin._get_sync_interfaces = mock.Mock() l3_plugin._get_sync_interfaces.return_value = ( self.get_sync_router_ports) - ovn_nb_synchronizer._ovn_client = mock.Mock() - ovn_nb_synchronizer._ovn_client.\ - _get_nets_and_ipv6_ra_confs_for_router_port.return_value = ( + ovn_client = mock.Mock() + ovn_nb_synchronizer._ovn_client = ovn_client + ovn_client._get_nets_and_ipv6_ra_confs_for_router_port.return_value = ( self.lrport_networks, {'fixed_ips': {}}) - ovn_nb_synchronizer._ovn_client._get_v4_network_of_all_router_ports. \ - side_effect = self._fake_get_v4_network_of_all_router_ports - ovn_nb_synchronizer._ovn_client._get_gw_info = mock.Mock() - ovn_nb_synchronizer._ovn_client._get_gw_info.side_effect = ( - self._fake_get_gw_info) + ovn_client._get_snat_cidrs_for_external_router.side_effect = ( + self._fake_get_snat_cidrs_for_external_router) + ovn_client._get_gw_info = mock.Mock() + ovn_client._get_gw_info.side_effect = self._fake_get_gw_info # end of router-sync block l3_plugin.get_floatingips = mock.Mock() l3_plugin.get_floatingips.return_value = self.floating_ips diff --git a/releasenotes/notes/support-nested-snat-for-ovn-e4aa3b9af66c905b.yaml b/releasenotes/notes/support-nested-snat-for-ovn-e4aa3b9af66c905b.yaml new file mode 100644 index 00000000000..930859bae2a --- /dev/null +++ b/releasenotes/notes/support-nested-snat-for-ovn-e4aa3b9af66c905b.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + A new ML2 OVN driver configuration option ``ovn_router_indirect_snat`` was + added. When set to True, all external gateways will enable SNAT for all + nested networks that are indirectly connected to gateways (through other + routers). This option mimics the `router` service plugin behavior used with + ML2 Open vSwitch and some other backends. +other: + - | + When ``ovn_router_indirect_snat`` option is used, for some OVN releases, + floating IP connectivity may be broken. See more details at: + https://issues.redhat.com/browse/FDP-744 From 0dcdd9323826099526a6c3db02fb9d2ad8d76665 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 3 Mar 2025 11:24:51 +0000 Subject: [PATCH 087/184] [QoS] QoS rule check also considers the direction The method ``check_bandwidth_rule_conflict`` now takes into account the rules direction when checking the compatibility between bandwidth limit and minimum bandwidth rules. Closes-Bug: #2100853 Change-Id: I21244595f501e35919a97fd048b643f951b18500 (cherry picked from commit b658c52d83fb144faee97007781fc6a16bfdb2fc) --- neutron/objects/qos/qos_policy_validator.py | 5 ++ .../objects/qos/test_qos_policy_validator.py | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 neutron/tests/unit/objects/qos/test_qos_policy_validator.py diff --git a/neutron/objects/qos/qos_policy_validator.py b/neutron/objects/qos/qos_policy_validator.py index b7acfadb0d8..1a36eb9cece 100644 --- a/neutron/objects/qos/qos_policy_validator.py +++ b/neutron/objects/qos/qos_policy_validator.py @@ -25,10 +25,15 @@ def check_bandwidth_rule_conflict(policy, rule_data): doesn't conflict with the existing rules. Raises an exception if conflict is identified. """ + direction = rule_data.get('direction') for rule in policy.rules: if rule.rule_type == qos_consts.RULE_TYPE_DSCP_MARKING: # Skip checks if Rule is DSCP continue + if direction and rule.direction != direction: + # Rule check must be done within the same direction. + # DSCP rules have no direction. + continue if rule.rule_type == qos_consts.RULE_TYPE_MINIMUM_BANDWIDTH: if "max_kbps" in rule_data and ( int(rule.min_kbps) > int(rule_data["max_kbps"])): diff --git a/neutron/tests/unit/objects/qos/test_qos_policy_validator.py b/neutron/tests/unit/objects/qos/test_qos_policy_validator.py new file mode 100644 index 00000000000..7b08f9fa71a --- /dev/null +++ b/neutron/tests/unit/objects/qos/test_qos_policy_validator.py @@ -0,0 +1,53 @@ +# Copyright (c) 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 neutron_lib import constants as lib_consts +from neutron_lib import context +from neutron_lib.exceptions import qos as qos_exc +from neutron_lib.services.qos import constants as qos_consts + +from neutron.objects.qos import policy +from neutron.objects.qos import qos_policy_validator +from neutron.objects.qos import rule +from neutron.tests.unit import testlib_api + + +class TestCheckBandwidthRuleConflict(testlib_api.SqlTestCase): + + def setUp(self): + super().setUp() + self.context = context.get_admin_context() + self.qos_policy = policy.QosPolicy(self.context) + self.qos_policy.create() + self.max_bw_egress = 10000 + self.max_bw_rule = rule.QosBandwidthLimitRule( + self.context, qos_policy_id=self.qos_policy.id, + max_kbps=self.max_bw_egress, + direction=lib_consts.EGRESS_DIRECTION) + self.max_bw_rule.create() + self.qos_policy.rules = [self.max_bw_rule] + + def test_check_bandwidth_rule_conflict_different_direction(self): + rule_data = {qos_consts.DIRECTION: lib_consts.INGRESS_DIRECTION, + qos_consts.MIN_KBPS: self.max_bw_egress + 1} + qos_policy_validator.check_bandwidth_rule_conflict( + self.qos_policy, rule_data) + + def test_check_bandwidth_rule_conflict_same_direction(self): + rule_data = {qos_consts.DIRECTION: lib_consts.EGRESS_DIRECTION, + qos_consts.MIN_KBPS: self.max_bw_egress + 1} + self.assertRaises(qos_exc.QoSRuleParameterConflict, + qos_policy_validator.check_bandwidth_rule_conflict, + self.qos_policy, rule_data) From a1d5b3959febe62bce012e10ee4a72018028736f Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 13 Dec 2024 14:53:57 +0000 Subject: [PATCH 088/184] [OVN] Use the MySQL backend for the ``TestOvnNbSync`` tests (2) In [1] it was tried to define MySQL as backend for the ``TestOvnNbSync`` tests. This patch finishes this wrong attempt and checks the result. [1]https://review.opendev.org/c/openstack/neutron/+/935804 Closes-Bug: #2088423 Change-Id: Ibaeaa36dbbb9def10a163b4ee071cb432db5a383 (cherry picked from commit fa1dfd81c31beefb8eec9967580edf12305978e4) --- .../ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py index f6c613a8ad7..14a9dc41dd2 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py @@ -26,6 +26,7 @@ from oslo_utils import uuidutils from ovsdbapp.backend.ovs_idl import idlutils from ovsdbapp import constants as ovsdbapp_const +from sqlalchemy.dialects.mysql import dialect as mysql_dialect from neutron.common.ovn import acl as acl_utils from neutron.common.ovn import constants as ovn_const @@ -45,13 +46,14 @@ from neutron.tests.unit import testlib_api -class TestOvnNbSync(base.TestOVNFunctionalBase, - testlib_api.MySQLTestCaseMixin): +class TestOvnNbSync(testlib_api.MySQLTestCaseMixin, + base.TestOVNFunctionalBase): _extension_drivers = ['port_security', 'dns', 'qos', 'revision_plugin'] def setUp(self, *args): super(TestOvnNbSync, self).setUp(maintenance_worker=True) + self.assertEqual(mysql_dialect.name, self.db.engine.dialect.name) ovn_config.cfg.CONF.set_override('dns_domain', 'ovn.test') ext_mgr = test_extraroute.ExtraRouteTestExtensionManager() self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr) From 7aac28f8bdc74a669507293e72b7b913aff95511 Mon Sep 17 00:00:00 2001 From: Alexey Stupnikov Date: Thu, 20 Feb 2025 18:45:28 +0100 Subject: [PATCH 089/184] [ovn][trivial] Add 'empty_string_filtering' extension to OVN Closes-bug: #2098996 Change-Id: I2b13e120b3c5961f78c169f6776e0dfb958fa83e (cherry picked from commit 3fbb88b47218d1fb18664ef72c0400ee796a6608) (cherry picked from commit 649de83b16b5c7963166bd1e2cc3a50bb3a18e72) --- neutron/common/ovn/extensions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/neutron/common/ovn/extensions.py b/neutron/common/ovn/extensions.py index 0122e438e9d..211b84dfc78 100644 --- a/neutron/common/ovn/extensions.py +++ b/neutron/common/ovn/extensions.py @@ -25,6 +25,7 @@ from neutron_lib.api.definitions import dns from neutron_lib.api.definitions import dns_domain_keywords from neutron_lib.api.definitions import dns_domain_ports +from neutron_lib.api.definitions import empty_string_filtering from neutron_lib.api.definitions import expose_port_forwarding_in_fip from neutron_lib.api.definitions import external_net from neutron_lib.api.definitions import extra_dhcp_opt @@ -140,6 +141,7 @@ default_subnetpools.ALIAS, dhcpagentscheduler.ALIAS, dns.ALIAS, + empty_string_filtering.ALIAS, external_net.ALIAS, extra_dhcp_opt.ALIAS, filter_validation.ALIAS, From 264ac86c86a18c931860589ab5e4be6d7163853e Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Mon, 20 Jan 2025 11:28:09 +0100 Subject: [PATCH 090/184] Don't change original target dict by the OwnerCheck policy rule OwnerCheck policy rule may create new field "parent_object:tenant_id", like e.g. "network:tenant_id" in case of the port object to validate NET_OWNER_RULE. This new field should be just temporary and should not be added to the target dict which is later returned by the API. Closes-Bug: #2095323 Change-Id: I8bf022bef81249a2ddf21993654fece7337bebb0 (cherry picked from commit 27cbd9821e770170db02d8f669b36b72fe58dac8) --- neutron/policy.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/neutron/policy.py b/neutron/policy.py index c35af40366c..564bc13bd3d 100644 --- a/neutron/policy.py +++ b/neutron/policy.py @@ -14,6 +14,7 @@ # under the License. from collections import abc +import copy import itertools import re import sys @@ -319,6 +320,7 @@ def _extract(self, resource_type, resource_id, field): return data[field] def __call__(self, target, creds, enforcer): + target_copy = copy.copy(target) if self.target_field not in target: # policy needs a plugin check # target field is in the form resource:field @@ -363,10 +365,10 @@ def do_split(separator): policy="%s:%s" % (self.kind, self.match), reason=err_reason) - target[self.target_field] = self._extract( + target_copy[self.target_field] = self._extract( parent_res, target[parent_foreign_key], parent_field) - match = self.match % target + match = self.match % target_copy if self.kind in creds: return match == str(creds[self.kind]) return False From 819298e1855909cdfc547458d38cf45f56e2e146 Mon Sep 17 00:00:00 2001 From: Slawek Kaplonski Date: Tue, 11 Mar 2025 11:48:57 +0100 Subject: [PATCH 091/184] [S-RBAC] Fix policies for the SG rules API This patch fixes default policies for the Security Group Rules API so that user of the project who isn't owner of the SG but only sees it as shared one, can't now create or delete rules in such SG. Additionally this patch lowers numer of retries when parent object's id is looked up in the DB by the OwnerCheck policy rule to just one. If it will fail twice with NotFound exception, then there is no need to repeat it more times. Closes-bug: #2101150 Change-Id: I23722d0ffabce0034548a5fa919980d02bacd91a (cherry picked from commit dfea81a4bf6aa62f56d101f8a0cb168a02338d5c) --- neutron/conf/policies/base.py | 13 ++++ neutron/conf/policies/security_group.py | 10 +-- neutron/policy.py | 13 ++-- .../unit/conf/policies/test_security_group.py | 68 +++++++++++++++++-- .../unit/extensions/test_securitygroup.py | 8 +-- neutron/tests/unit/test_policy.py | 13 ++-- 6 files changed, 99 insertions(+), 26 deletions(-) diff --git a/neutron/conf/policies/base.py b/neutron/conf/policies/base.py index 52751f5d6c8..7a9c71e6fb4 100644 --- a/neutron/conf/policies/base.py +++ b/neutron/conf/policies/base.py @@ -74,6 +74,19 @@ ADMIN_OR_NET_OWNER_READER = ( '(' + ADMIN + ') or (' + NET_OWNER_READER + ')') +# Those rules for the SG owner are needed for the policies related to the +# Security Group rules and are very similar to the parent owner rules defined +# above. We should probably deprecate SG_OWNER rules and use PARENT_OWNER +# instead but this can be done later +# TODO(slaweq): Deprecate SG_OWNER rules and replace them with PARENT_OWNER +# rules but for that, 'ext_parent_owner:tenant_id' needs to be added to the SG +# rule target dict +SG_OWNER_MEMBER = 'role:member and ' + RULE_SG_OWNER +SG_OWNER_READER = 'role:reader and ' + RULE_SG_OWNER +ADMIN_OR_SG_OWNER_MEMBER = ( + '(' + ADMIN + ') or (' + SG_OWNER_MEMBER + ')') +ADMIN_OR_SG_OWNER_READER = ( + '(' + ADMIN + ') or (' + SG_OWNER_READER + ')') rules = [ policy.RuleDefault( diff --git a/neutron/conf/policies/security_group.py b/neutron/conf/policies/security_group.py index dc9cbaf9833..048c92ea194 100644 --- a/neutron/conf/policies/security_group.py +++ b/neutron/conf/policies/security_group.py @@ -175,11 +175,9 @@ operations=SG_ACTION_DELETE_TAGS, ), - # TODO(amotoki): admin_or_owner is the right rule? - # Does an empty string make more sense for create_security_group_rule? policy.DocumentedRuleDefault( name='create_security_group_rule', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=base.ADMIN_OR_SG_OWNER_MEMBER, scope_types=['project'], description='Create a security group rule', operations=[ @@ -196,9 +194,7 @@ ), policy.DocumentedRuleDefault( name='get_security_group_rule', - check_str=neutron_policy.policy_or( - base.ADMIN_OR_PROJECT_READER, - base.RULE_SG_OWNER), + check_str=base.ADMIN_OR_SG_OWNER_READER, scope_types=['project'], description='Get a security group rule', operations=[ @@ -219,7 +215,7 @@ ), policy.DocumentedRuleDefault( name='delete_security_group_rule', - check_str=base.ADMIN_OR_PROJECT_MEMBER, + check_str=base.ADMIN_OR_SG_OWNER_MEMBER, scope_types=['project'], description='Delete a security group rule', operations=[ diff --git a/neutron/policy.py b/neutron/policy.py index 564bc13bd3d..13a6032612d 100644 --- a/neutron/policy.py +++ b/neutron/policy.py @@ -27,7 +27,6 @@ from neutron_lib.plugins import directory from neutron_lib.services import constants as service_const from oslo_config import cfg -from oslo_db import exception as db_exc from oslo_log import log as logging from oslo_policy import opts from oslo_policy import policy @@ -291,7 +290,8 @@ def __deepcopy__(self, memo): return OwnerCheck(self._orig_kind, self._orig_match) @cache.cache_method_results - def _extract(self, resource_type, resource_id, field): + def _extract(self, resource_type, resource_id, field, + retry_if_not_found=True): # NOTE(salv-orlando): This check currently assumes the parent # resource is handled by the core plugin. It might be worth # having a way to map resources to plugins so to make this @@ -308,12 +308,15 @@ def _extract(self, resource_type, resource_id, field): resource_id, fields=[field]) except exceptions.NotFound as e: - # NOTE(kevinbenton): a NotFound exception can occur if a + # NOTE(kevinbenton, slaweq): a NotFound exception can occur if a # list operation is happening at the same time as one of # the parents and its children being deleted. So we issue - # a RetryRequest so the API will redo the lookup and the + # retry to get it once again if we didn't yet. # problem items will be gone. - raise db_exc.RetryRequest(e) + if retry_if_not_found: + return self._extract(resource_type, resource_id, field, + retry_if_not_found=False) + raise e except Exception: with excutils.save_and_reraise_exception(): LOG.exception('Policy check error while calling %s!', f) diff --git a/neutron/tests/unit/conf/policies/test_security_group.py b/neutron/tests/unit/conf/policies/test_security_group.py index 49ec0fb5197..949ad8c77bf 100644 --- a/neutron/tests/unit/conf/policies/test_security_group.py +++ b/neutron/tests/unit/conf/policies/test_security_group.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy from unittest import mock from oslo_policy import policy as base_policy @@ -338,19 +339,35 @@ def setUp(self): super(SecurityGroupRuleAPITestCase, self).setUp() self.sg = { 'id': uuidutils.generate_uuid(), - 'project_id': self.project_id} + 'project_id': self.project_id, + 'tenant_id': self.project_id} + self.alt_sg = { + 'id': uuidutils.generate_uuid(), + 'project_id': self.alt_project_id, + 'tenant_id': self.alt_project_id} self.target = { 'project_id': self.project_id, + 'tenant_id': self.project_id, 'security_group_id': self.sg['id'], + 'ext_parent:tenant_id': self.sg['id'], 'ext_parent_security_group_id': self.sg['id']} self.alt_target = { 'project_id': self.alt_project_id, - 'security_group_id': self.sg['id'], - 'ext_parent_security_group_id': self.sg['id']} + 'tenant_id': self.alt_project_id, + 'security_group_id': self.alt_sg['id'], + 'ext_parent:tenant_id': self.alt_sg['id'], + 'ext_parent_security_group_id': self.alt_sg['id']} + + def get_security_group_mock(context, id, + fields=None, tenant_id=None): + if id == self.alt_sg['id']: + return self.alt_sg + return self.sg self.plugin_mock = mock.Mock() - self.plugin_mock.get_security_group.return_value = self.sg + self.plugin_mock.get_security_group.side_effect = ( + get_security_group_mock) mock.patch( 'neutron_lib.plugins.directory.get_plugin', return_value=self.plugin_mock).start() @@ -489,6 +506,17 @@ def test_create_security_group_rule(self): policy.enforce, self.context, 'create_security_group_rule', self.alt_target) + # Test for the SG_OWNER different then current user case: + target = copy.copy(self.target) + target['security_group_id'] = self.alt_sg['id'] + target['ext_parent:tenant_id'] = self.alt_sg['tenant_id'] + target['ext_parent_security_group_id'] = self.alt_sg['id'] + self.plugin_mock.get_security_group.return_value = self.alt_sg + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_security_group_rule', target) + def test_create_security_group_rule_default_sg(self): self.override_create_security_group_rule() self.assertRaises( @@ -513,11 +541,23 @@ def test_delete_security_group_rule(self): self.assertTrue( policy.enforce(self.context, 'delete_security_group_rule', self.target)) + self.plugin_mock.get_security_group.return_value = self.alt_sg self.assertRaises( base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_security_group_rule', self.alt_target) + # Test for the SG_OWNER different then current user case: + target = copy.copy(self.target) + target['security_group_id'] = self.alt_sg['id'] + target['ext_parent:tenant_id'] = self.alt_sg['tenant_id'] + target['ext_parent_security_group_id'] = self.alt_sg['id'] + self.plugin_mock.get_security_group.return_value = self.alt_sg + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'delete_security_group_rule', target) + def test_delete_security_group_rule_default_sg(self): self.override_delete_security_group_rule() self.assertRaises( @@ -545,6 +585,16 @@ def test_create_security_group_rule(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'create_security_group_rule', self.alt_target) + # Test for the SG_OWNER different then current user case: + target = copy.copy(self.target) + target['security_group_id'] = self.alt_sg['id'] + target['ext_parent:tenant_id'] = self.alt_sg['tenant_id'] + target['ext_parent_security_group_id'] = self.alt_sg['id'] + self.plugin_mock.get_security_group.return_value = self.alt_sg + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'create_security_group_rule', target) def test_delete_security_group_rule(self): self.assertRaises( @@ -555,6 +605,16 @@ def test_delete_security_group_rule(self): base_policy.PolicyNotAuthorized, policy.enforce, self.context, 'delete_security_group_rule', self.alt_target) + # Test for the SG_OWNER different then current user case: + target = copy.copy(self.target) + target['security_group_id'] = self.alt_sg['id'] + target['ext_parent:tenant_id'] = self.alt_sg['tenant_id'] + target['ext_parent_security_group_id'] = self.alt_sg['id'] + self.plugin_mock.get_security_group.return_value = self.alt_sg + self.assertRaises( + base_policy.PolicyNotAuthorized, + policy.enforce, + self.context, 'delete_security_group_rule', target) class ServiceRoleSecurityGroupRuleTests(SecurityGroupRuleAPITestCase): diff --git a/neutron/tests/unit/extensions/test_securitygroup.py b/neutron/tests/unit/extensions/test_securitygroup.py index dbb1eb26476..b830d48061c 100644 --- a/neutron/tests/unit/extensions/test_securitygroup.py +++ b/neutron/tests/unit/extensions/test_securitygroup.py @@ -1339,7 +1339,7 @@ def test_create_security_group_source_group_ip_and_ip_prefix(self): port_range_max, remote_ip_prefix, remote_group_id) - res = self._create_security_group_rule(self.fmt, rule) + res = self._create_security_group_rule(self.fmt, rule, as_admin=True) self.deserialize(self.fmt, res) self.assertEqual(webob.exc.HTTPBadRequest.code, res.status_int) @@ -1372,7 +1372,7 @@ def test_create_security_group_rule_bad_tenant(self): tenant_id='bad_tenant', set_context=True) self.deserialize(self.fmt, res) - self.assertEqual(webob.exc.HTTPNotFound.code, res.status_int) + self.assertEqual(webob.exc.HTTPForbidden.code, res.status_int) def test_create_security_group_rule_bad_tenant_remote_group_id(self): with self.security_group() as sg: @@ -1413,7 +1413,7 @@ def test_create_security_group_rule_bad_tenant_security_group_rule(self): tenant_id='bad_tenant', set_context=True) self.deserialize(self.fmt, res) - self.assertEqual(webob.exc.HTTPNotFound.code, res.status_int) + self.assertEqual(webob.exc.HTTPForbidden.code, res.status_int) def test_create_security_group_rule_bad_remote_group_id(self): name = 'webservers' @@ -2135,7 +2135,7 @@ def test_create_security_group_rule_with_invalid_tcp_or_udp_protocol(self): port_range_max, remote_ip_prefix, remote_group_id) - res = self._create_security_group_rule(self.fmt, rule) + res = self._create_security_group_rule(self.fmt, rule, as_admin=True) self.deserialize(self.fmt, res) self.assertEqual(webob.exc.HTTPBadRequest.code, res.status_int) diff --git a/neutron/tests/unit/test_policy.py b/neutron/tests/unit/test_policy.py index 54d1d677ffb..2f417c74a4d 100644 --- a/neutron/tests/unit/test_policy.py +++ b/neutron/tests/unit/test_policy.py @@ -26,7 +26,6 @@ from neutron_lib.plugins import constants as plugin_constants from neutron_lib.plugins import directory from oslo_config import cfg -from oslo_db import exception as db_exc from oslo_policy import fixture as op_fixture from oslo_policy import policy as oslo_policy from oslo_serialization import jsonutils @@ -696,14 +695,16 @@ def test_enforce_subattribute_as_list_forbiden(self): def test_retryrequest_on_notfound(self): failure = exceptions.NetworkNotFound(net_id='whatever') action = "create_port:mac" - with mock.patch.object(directory.get_plugin(), - 'get_network', side_effect=failure): + with mock.patch.object( + directory.get_plugin(), + 'get_network', side_effect=failure) as get_network_mock: target = {'network_id': 'whatever'} try: policy.enforce(self.context, action, target) - self.fail("Did not raise RetryRequest") - except db_exc.RetryRequest as e: - self.assertEqual(failure, e.inner_exc) + self.fail("Did not raise NotFound exception and retry " + "DB request.") + except exceptions.NetworkNotFound: + self.assertEqual(2, get_network_mock.call_count) def test_enforce_tenant_id_check_parent_resource_bw_compatibility(self): From 8202d2b918d387a6453dec722082d7c9d8d477f4 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 18 Dec 2024 15:10:47 +0100 Subject: [PATCH 092/184] [OVN] Isolate test_ovn_db_sync.TestOvnNbSync.* FTs Related-Bug: #2088423 Change-Id: Ia0309e9129df992498820b239d8a145c1400aa6a (cherry picked from commit 73720da72f6f0df04c46b82604a8abcf3e39d560) Conflicts: tox.ini (cherry picked from commit 2db5c105932298272bb637c3529e98b67cabb17e) --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 49cb4f89007..00acd81232b 100644 --- a/tox.ini +++ b/tox.ini @@ -78,8 +78,8 @@ setenv = {[testenv:dsvm-functional]setenv} deps = {[testenv:dsvm-functional]deps} commands = bash {toxinidir}/tools/deploy_rootwrap.sh {toxinidir} {envdir}/etc {envdir}/bin - stestr run --slowest --exclude-regex (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task) {posargs} - stestr run --slowest --combine --concurrency 1 (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task) {posargs} + stestr run --slowest --exclude-regex (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*) {posargs} + stestr run --slowest --combine --concurrency 1 (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*) {posargs} [testenv:dsvm-fullstack] setenv = {[testenv]setenv} From 22977243115750a22f36983d2b60f1b070d3563b Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 14 Mar 2025 02:14:41 +0000 Subject: [PATCH 093/184] Fast exit if "segments" plugin is not loaded The method ``auto_schedule_new_network_segments`` will be called when a new agent is created and the segment host mapping is updated. If the "segments" plugin is not loaded, the method ``auto_schedule_new_network_segments`` should fast exit. Closes-Bug: #2102609 Change-Id: I46d58e1f7f9f6b0fdb70f2298839ee5423722e11 (cherry picked from commit 98b006b7e061752ae0cd3148d9308e2070240c11) --- neutron/db/agentschedulers_db.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/neutron/db/agentschedulers_db.py b/neutron/db/agentschedulers_db.py index f3fb2a1d230..ab8ebf35bbc 100644 --- a/neutron/db/agentschedulers_db.py +++ b/neutron/db/agentschedulers_db.py @@ -492,6 +492,9 @@ def auto_schedule_new_network_segments(self, resource, event, trigger, if not cfg.CONF.network_auto_schedule: return segment_plugin = directory.get_plugin('segments') + if not segment_plugin: + return + dhcp_notifier = self.agent_notifiers.get(constants.AGENT_TYPE_DHCP) segment_ids = payload.metadata.get('current_segment_ids') segments = segment_plugin.get_segments( From 07956a15494efa8f6e3ada7da0f581eabe343180 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 14 Mar 2025 04:40:53 +0000 Subject: [PATCH 094/184] [OVN] Do not delete twice the agent from the cache When a delete agent command is issued, the API worker that attends the API call, it issues an OVN SB ``SB_Global`` event to inform any other worker about the deletion of the agent, thus they can delete the agent from their local OVN agent cache. The problem is that this event is also received by the worker that is sending it. Because the call that is generating the event is also deleting the agent [1], this worker tries to delete the agent twice; the second time, it generates a KeyError exception in the local OVN agent cache. The code added in [1] should be deleted. [1]https://review.opendev.org/c/openstack/neutron/+/883607/3/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py#1421 Closes-Bug: #2102645 Change-Id: Ie391af5a3b226cbd33f7162a3c970d636fcdf1da (cherry picked from commit d52c22bbac140170aad60b231d648102767cb2f4) --- neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index 23cbd776afd..e6f6471d785 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -1490,11 +1490,6 @@ def delete_agent(self, context, id, _driver=None): 'SB_Global', '.', 'external_ids', delete_agent=str(id), if_exists=True).execute(check_error=True) - try: - n_agent.AgentCache().delete(id) - except KeyError: - LOG.debug('OVN agent %s has been deleted concurrently', id) - def get_availability_zones(cls, context, _driver, filters=None, fields=None, sorts=None, limit=None, marker=None, From ea7802f9b9d5cae9926d1bfc0fb75f165bcb532a Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 18 Mar 2025 15:12:09 +0000 Subject: [PATCH 095/184] [stable-only] Fix the Neutron milestones list The patch [1] incorrectly added ``RELEASE_2023_1`` to the ``NEUTRON_MILESTONES`` list, instead of ``ZED`` The patch [2] incorrectly added ``RELEASE_2023_2`` to the ``NEUTRON_MILESTONES`` list, instead of ``RELEASE_2023_1`` The patch [3] didn't add a new release because `RELEASE_2023_2`` was already in the list. [1]https://review.opendev.org/c/openstack/neutron/+/859111 [2]https://review.opendev.org/c/openstack/neutron/+/876051 [3]https://review.opendev.org/c/openstack/neutron/+/895155 Change-Id: I37b1ba1dc00d9af479e93dfa2d587f23f1f4fc5e --- neutron/db/migration/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/neutron/db/migration/__init__.py b/neutron/db/migration/__init__.py index 8b05fb44cd5..7657015069b 100644 --- a/neutron/db/migration/__init__.py +++ b/neutron/db/migration/__init__.py @@ -57,6 +57,7 @@ WALLABY, XENA, YOGA, + ZED, RELEASE_2023_1, RELEASE_2023_2, # Do not add the milestone until the end of the release From 5233c006a2ccdbcfea18375005dd318558acbdea Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Wed, 19 Mar 2025 10:32:10 +0000 Subject: [PATCH 096/184] Remove the duplicated YOGA database milestone Closes-Bug: #2103597 Change-Id: Ic2d471978ad12ef69d6324bef006aadda0ab8c13 (cherry picked from commit 6c6759c3b4c86f0eaaeb32c19fa672bda7d9d312) --- .../expand/c181bb1d89e4_qos_minimum_packet_rate_rules.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/neutron/db/migration/alembic_migrations/versions/yoga/expand/c181bb1d89e4_qos_minimum_packet_rate_rules.py b/neutron/db/migration/alembic_migrations/versions/yoga/expand/c181bb1d89e4_qos_minimum_packet_rate_rules.py index 69d491c934a..b0eabb4c098 100644 --- a/neutron/db/migration/alembic_migrations/versions/yoga/expand/c181bb1d89e4_qos_minimum_packet_rate_rules.py +++ b/neutron/db/migration/alembic_migrations/versions/yoga/expand/c181bb1d89e4_qos_minimum_packet_rate_rules.py @@ -18,8 +18,6 @@ from neutron_lib.db import constants as db_const import sqlalchemy as sa -from neutron.db import migration - """qos_minimum_packet_rate_rules @@ -33,9 +31,6 @@ revision = 'c181bb1d89e4' down_revision = '1bb3393de75d' -# milestone identifier, used by neutron-db-manage -neutron_milestone = [migration.YOGA] - def upgrade(): op.create_table( From 006e8bf78200ef569e0c07c8a7f4af2fc93c5218 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 17 Feb 2025 16:18:41 +0000 Subject: [PATCH 097/184] [OVN] Isolate ``test_maintenance.Test*Maintenance`` FTs Conflicts: tox.ini Related-Bug: #2088423 Change-Id: I07e57f765c4eb6b1feef2571f4bf2f049297a029 (cherry picked from commit 8152b83384c45d48a7256dfb40a2eb06852d5295) (cherry picked from commit ab73b66c52cf0f50b5176a33f52deff86d2d9172) --- tox.ini | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 00acd81232b..0b321429394 100644 --- a/tox.ini +++ b/tox.ini @@ -76,10 +76,12 @@ commands = [testenv:dsvm-functional-gate] setenv = {[testenv:dsvm-functional]setenv} deps = {[testenv:dsvm-functional]deps} +test_regex = .*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*|.*TestMaintenance|.*TestLogMaintenance commands = bash {toxinidir}/tools/deploy_rootwrap.sh {toxinidir} {envdir}/etc {envdir}/bin - stestr run --slowest --exclude-regex (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*) {posargs} - stestr run --slowest --combine --concurrency 1 (.*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*) {posargs} + stestr run --slowest --exclude-regex ({[testenv:dsvm-functional-gate]test_regex}|neutron.tests.functional.agent.l3.*) {posargs} + stestr run --slowest --combine --concurrency 1 ({[testenv:dsvm-functional-gate]test_regex}) {posargs} + stestr run --slowest --combine --exclude-regex ({[testenv:dsvm-functional-gate]test_regex}) neutron.tests.functional.agent.l3 {posargs} [testenv:dsvm-fullstack] setenv = {[testenv]setenv} From 64aa6e7863e522588b15294ed6798375d5fc9d3d Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 18 Mar 2025 11:03:08 +0000 Subject: [PATCH 098/184] [OVN] Isolate ``test_ovsdb_monitor.TestNBDbMonitor*`` FTs This patch moves the following test classes to the 1 worker execution: * TestNBDbMonitor * TestNBDbMonitorOverTcp * TestNBDbMonitorOverSsl Conflicts: tox.ini Related-Bug: #2088423 Change-Id: I138eb78683aa1cbc565533e2ef011f1946f9bc58 (cherry picked from commit e86951ef491b5f2429f0c71a9e504ff078cd5363) (cherry picked from commit 04ddcdcd07831373733c98ddb7b3ff7d0c4bb61e) (cherry picked from commit 2454165a9b48eb3eed218ae5e96a928db3ba1db9) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 0b321429394..f35ef687398 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ commands = [testenv:dsvm-functional-gate] setenv = {[testenv:dsvm-functional]setenv} deps = {[testenv:dsvm-functional]deps} -test_regex = .*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*|.*TestMaintenance|.*TestLogMaintenance +test_regex = .*MySQL\.|.*PostgreSQL\.|.*test_get_all_devices|.*TestMetadataAgent\.|.*BaseOVSTestCase\.|.*test_periodic_sync_routers_task|.*TestOvnNbSync.*|.*TestMaintenance|.*TestLogMaintenance|.*TestNBDbMonitor.* 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} From 8f3f0cd43dc0efc233eca9c66af70fbfbe6b00f0 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 18 Mar 2025 11:14:25 +0000 Subject: [PATCH 099/184] [OVN][FT] Use MySQL backend for ``TestNBDbMonitor*`` classes This solution is similar to what was implemented in [1]. [1]https://review.opendev.org/q/topic:%22bug/2088423%22 Conflicts: neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py Related-Bug: #2088423 Change-Id: Icd734d0bc26e6a2f62f7f9a53d6885fb38fded62 (cherry picked from commit 0c345dce5b6ee024e54df4cbc128569d9d768cc6) (cherry picked from commit b62e7329a17d19bdc64ea950900e9c0883a18ae1) (cherry picked from commit fbff2ef269144b4ff820d08d0c8fcddbb0e9603b) --- .../ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py index f8f3522381a..8e21127e432 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py @@ -28,6 +28,7 @@ from oslo_utils import uuidutils from ovsdbapp.backend.ovs_idl import event from ovsdbapp.backend.ovs_idl import idlutils +from sqlalchemy.dialects.mysql import dialect as mysql_dialect import tenacity from neutron.common.ovn import constants as ovn_const @@ -43,6 +44,7 @@ from neutron.tests.functional.resources import process from neutron.tests.unit.api import test_extensions from neutron.tests.unit.extensions import test_l3 +from neutron.tests.unit import testlib_api class WaitForDataPathBindingCreateEvent(event.WaitEvent): @@ -85,10 +87,12 @@ class GlobalTestEvent(DistributedLockTestEvent): GLOBAL = True -class TestNBDbMonitor(base.TestOVNFunctionalBase): +class TestNBDbMonitor(testlib_api.MySQLTestCaseMixin, + base.TestOVNFunctionalBase): def setUp(self): super(TestNBDbMonitor, self).setUp() + self.assertEqual(mysql_dialect.name, self.db.engine.dialect.name) self.chassis = self.add_fake_chassis('ovs-host1') self.l3_plugin = directory.get_plugin(plugin_constants.L3) self.net = self._make_network(self.fmt, 'net1', True) From 3e18de2a13820441c63ae0bf6d920c65ca45f4e5 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Wed, 11 Sep 2024 14:20:51 +0200 Subject: [PATCH 100/184] tox: Default to SAP upper-constraints Out of convenience for running the tests locally, we should by default pick our own "upper-constraints.txt" and not upstream's. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f35ef687398..1155561d8c8 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,7 @@ passenv = TRACE_FAILONLY TOX_ENV_SRC_MODULES usedevelop = True deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} + -c{env:TOX_CONSTRAINTS_FILE:https://raw.githubusercontent.com/sapcc/requirements/stable/2024.1-m3/upper-constraints.txt} -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt hacking>=6.1.0,<6.2.0 # Apache-2.0 From 57281015428ed4e0b4d76555f56851c910c56445 Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Fri, 23 Oct 2020 10:13:40 +0200 Subject: [PATCH 101/184] added custom requirements --- custom-requirements.txt | 44 +++++++++++++++++++++++++++++++++++++++++ test-requirements.txt | 4 ++++ 2 files changed, 48 insertions(+) create mode 100644 custom-requirements.txt diff --git a/custom-requirements.txt b/custom-requirements.txt new file mode 100644 index 00000000000..b588da32a1e --- /dev/null +++ b/custom-requirements.txt @@ -0,0 +1,44 @@ +# jaeger osprofiler +jaeger-client + +# init reaper +dumb-init + +# sentry client +raven + +# agent checks for neutron +openstack-agent-checks + +# uwsgi plugins +uwsgi-dogstatsd +uwsgi-shortmsecs + +# for memcached based oslo.cache +python-memcached +pymemcache + +#mysql +pymysql + +# neutron-lib SAPCC specific branch +git+https://github.com/sapcc/neutron-lib.git@stable/yoga-m3#egg=neutron-lib + +# 3rd party middleware +git+https://github.com/sapcc/openstack-watcher-middleware.git#egg=watcher-middleware +git+https://github.com/sapcc/openstack-audit-middleware.git@master#egg=audit-middleware +git+https://github.com/sapcc/openstack-manhole-middleware.git@main#egg=manhole-middleware +git+https://github.com/sapcc/openstack-uwsgi-middleware.git@main#egg=uwsgi-middleware + +# Networking Drivers +-e git+https://github.com/sapcc/asr1k-neutron-l3@stable/yoga-m3#egg=asr1k-neutron-l3 +-e git+https://github.com/sapcc/networking-aci.git@stable/yoga-m3#egg=networking_aci[acicobra] +-e git+https://github.com/sapcc/networking-manila.git@stable/yoga-m3#egg=networking_manila +-e git+https://github.com/sapcc/networking-f5.git@stable/yoga-m3#egg=networking_f5 +-e git+https://github.com/sapcc/networking-ucsm-bm.git@stable/yoga-m3#egg=networking-ucsm-bm +-e git+https://github.com/sapcc/networking-arista.git@stable/yoga-m3#egg=networking_arista +-e git+https://github.com/sapcc/networking-nsx-t.git@stable/yoga-m3#egg=networking_nsxv3 +-e git+https://github.com/sapcc/networking-bgpvpn@stable/yoga-m3#egg=networking-bgpvpn +-e git+https://github.com/sapcc/networking-interconnection@stable/yoga-m3#egg=networking_interconnection +-e git+https://github.com/sapcc/networking-ccloud@stable/yoga-m3#egg=networking_ccloud + diff --git a/test-requirements.txt b/test-requirements.txt index cdf72fd136a..17e6791e528 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -10,3 +10,7 @@ stestr>=1.0.0 # Apache-2.0 ddt>=1.0.1 # MIT # Needed to run DB commands in virtualenvs PyMySQL>=0.7.6 # MIT License + +# neutron-lib SAPCC specific branch +git+https://github.com/sapcc/neutron-lib.git@stable/yoga-m3#egg=neutron-lib + From bc4c28798140f6d2b2b1df91846d3f79b687307a Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Mon, 4 Mar 2019 16:30:54 +0100 Subject: [PATCH 102/184] added ccloud flavored auto-allocate tune auto-allocation, fallback to default network --- neutron/services/auto_allocate/db.py | 9 +++------ neutron/tests/unit/services/auto_allocate/test_db.py | 2 ++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/neutron/services/auto_allocate/db.py b/neutron/services/auto_allocate/db.py index 1d6d77519e8..2889cc08633 100644 --- a/neutron/services/auto_allocate/db.py +++ b/neutron/services/auto_allocate/db.py @@ -187,13 +187,10 @@ def _build_topology(self, context, tenant_id, default_external_network): raise e.error def _check_requirements(self, context, tenant_id): - """Raise if requirements are not met.""" + """Raise if requirements are not met. + CCloud: don't check for subnetpools + """ self._get_default_external_network(context) - try: - self._get_supported_subnetpools(context) - except n_exc.NotFound: - raise exceptions.AutoAllocationFailure( - reason=_("No default subnetpools defined")) return {'id': 'dry-run=pass', 'tenant_id': tenant_id, 'project_id': tenant_id} diff --git a/neutron/tests/unit/services/auto_allocate/test_db.py b/neutron/tests/unit/services/auto_allocate/test_db.py index 2ff3cf7955a..b12529e5985 100644 --- a/neutron/tests/unit/services/auto_allocate/test_db.py +++ b/neutron/tests/unit/services/auto_allocate/test_db.py @@ -12,6 +12,7 @@ # limitations under the License. from unittest import mock +from unittest import skip from neutron_lib.api.definitions import constants as api_const from neutron_lib.callbacks import events @@ -336,6 +337,7 @@ def test__check_requirements_fail_on_missing_ext_net(self): self.assertRaises(exceptions.AutoAllocationFailure, self.mixin._check_requirements, self.ctx, 'foo_tenant') + @skip("Pools checks disabled in CCloud for flavored auto-allocate") def test__check_requirements_fail_on_missing_pools(self): with mock.patch.object( self.mixin, '_get_default_external_network'),\ From ac6e1b00d7843d6aa9abb59c998299929ba50ca4 Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Fri, 29 Mar 2019 13:50:54 +0100 Subject: [PATCH 103/184] allow only subnets with prefixlen <= 28 --- neutron/db/db_base_plugin_v2.py | 2 +- .../tests/unit/db/test_db_base_plugin_v2.py | 14 ++++++-------- .../drivers/neutrondb_ipam/test_driver.py | 19 ++++++++++++------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index 0d833295a02..54d784e39d0 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -682,7 +682,7 @@ def _validate_subnet(self, context, s, cur_subnet=None, is_pd=False): if has_cidr and s.get('enable_dhcp') and not dhcp_was_enabled: error_message = _("Subnet has a prefix length that is " "incompatible with DHCP service enabled") - if ((ip_ver == 4 and net.prefixlen > 30) or + if ((ip_ver == 4 and net.prefixlen > 28) or (ip_ver == 6 and net.prefixlen > 126)): raise exc.InvalidInput(error_message=error_message) diff --git a/neutron/tests/unit/db/test_db_base_plugin_v2.py b/neutron/tests/unit/db/test_db_base_plugin_v2.py index a9b9adfd0e8..43b76febbb0 100644 --- a/neutron/tests/unit/db/test_db_base_plugin_v2.py +++ b/neutron/tests/unit/db/test_db_base_plugin_v2.py @@ -2567,7 +2567,7 @@ def test_ip_allocation_for_ipv6_2_subnet_slaac_mode(self): def test_range_allocation(self): with self.subnet(gateway_ip='10.0.0.3', - cidr='10.0.0.0/29') as subnet: + cidr='10.0.0.0/28') as subnet: kwargs = {"fixed_ips": [{'subnet_id': subnet['subnet']['id']}, {'subnet_id': subnet['subnet']['id']}, {'subnet_id': subnet['subnet']['id']}, @@ -2578,18 +2578,17 @@ def test_range_allocation(self): port = self.deserialize(self.fmt, res) ips = port['port']['fixed_ips'] self.assertEqual(5, len(ips)) - alloc = ['10.0.0.1', '10.0.0.2', '10.0.0.4', '10.0.0.5', - '10.0.0.6'] + alloc = ['10.0.0.%s' % i for i in range(1, 15) if i != 3] for ip in ips: self.assertIn(ip['ip_address'], alloc) self.assertEqual(ip['subnet_id'], subnet['subnet']['id']) alloc.remove(ip['ip_address']) - self.assertEqual(0, len(alloc)) + self.assertEqual(8, len(alloc)) self._delete('ports', port['port']['id']) with self.subnet(gateway_ip='11.0.0.6', - cidr='11.0.0.0/29') as subnet: + cidr='11.0.0.0/28') as subnet: kwargs = {"fixed_ips": [{'subnet_id': subnet['subnet']['id']}, {'subnet_id': subnet['subnet']['id']}, {'subnet_id': subnet['subnet']['id']}, @@ -2600,14 +2599,13 @@ def test_range_allocation(self): port = self.deserialize(self.fmt, res) ips = port['port']['fixed_ips'] self.assertEqual(5, len(ips)) - alloc = ['11.0.0.1', '11.0.0.2', '11.0.0.3', '11.0.0.4', - '11.0.0.5'] + alloc = ['11.0.0.%s' % i for i in range(1, 15) if i != 6] for ip in ips: self.assertIn(ip['ip_address'], alloc) self.assertEqual(ip['subnet_id'], subnet['subnet']['id']) alloc.remove(ip['ip_address']) - self.assertEqual(0, len(alloc)) + self.assertEqual(8, len(alloc)) self._delete('ports', port['port']['id']) def test_requested_invalid_fixed_ips(self): diff --git a/neutron/tests/unit/ipam/drivers/neutrondb_ipam/test_driver.py b/neutron/tests/unit/ipam/drivers/neutrondb_ipam/test_driver.py index e5df5e58349..bc32b5dcf1b 100644 --- a/neutron/tests/unit/ipam/drivers/neutrondb_ipam/test_driver.py +++ b/neutron/tests/unit/ipam/drivers/neutrondb_ipam/test_driver.py @@ -352,11 +352,14 @@ def test_allocate_specific_address_in_use_fails(self): addr_req) def test_allocate_any_address_exhausted_pools_fails(self): + target_ip_count = 13 # Same as above, the ranges will be recalculated always ipam_subnet = self._create_and_allocate_ipam_subnet( - '192.168.0.0/30', ip_version=constants.IP_VERSION_4)[0] - ipam_subnet.allocate(ipam_req.AnyAddressRequest) - # The second address generation request on a /30 for v4 net must fail + '192.168.0.0/28', ip_version=constants.IP_VERSION_4)[0] + ip_addresses = ipam_subnet.bulk_allocate( + ipam_req.BulkAddressRequest(target_ip_count)) + self.assertEqual(target_ip_count, len(ip_addresses)) + # The next address generation request on a /28 for v4 net must fail self.assertRaises(ipam_exc.IpAddressGenerationFailure, ipam_subnet.allocate, ipam_req.AnyAddressRequest) @@ -399,11 +402,13 @@ def test_bulk_allocate_multiple_address_pools(self): ipam_req.BulkAddressRequest(2)) def test_prefernext_allocate_multiple_address_pools(self): + target_ip_count = 13 ipam_subnet = self._create_and_allocate_ipam_subnet( - '192.168.0.0/30', ip_version=constants.IP_VERSION_4)[0] - - ipam_subnet.allocate(ipam_req.PreferNextAddressRequest()) - # The second address generation request on a /30 for v4 net must fail + '192.168.0.0/28', ip_version=constants.IP_VERSION_4)[0] + ip_addresses = ipam_subnet.bulk_allocate( + ipam_req.BulkAddressRequest(target_ip_count)) + self.assertEqual(target_ip_count, len(ip_addresses)) + # The next address generation request on a /28 for v4 net must fail self.assertRaises(ipam_exc.IpAddressGenerationFailure, ipam_subnet.allocate, ipam_req.PreferNextAddressRequest) From 3b2987bcc129cf29a8cce59cc761766c822c8d1a Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Fri, 12 Apr 2019 12:33:50 +0200 Subject: [PATCH 104/184] Pre-join Port.fixed_ips when filtering for ip/subnet Since manually pre-join `Port.fixed_ips`, we can filter for `IPAllocation.ip_address` and `IPAllocation.subnet_id` directly. This makes the query use direct filtering instead of using a `EXISTS( SELECT 1` construct and thus increases the speed drastically for a big ports table. Patch for SQL query optimization, patch courtesy of Johannes Kulik --- neutron/db/db_base_plugin_v2.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index 54d784e39d0..12f26a46ba1 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -1676,6 +1676,7 @@ def _get_ports_query(self, context, filters=None, lazy_fields=None, *args, **kwargs) ip_addresses = fixed_ips.get('ip_address') subnet_ids = fixed_ips.get('subnet_id') + ip_addresses_s = fixed_ips.get('ip_address_substr') if vif_type is not None: query = query.filter(Port.port_bindings.any(vif_type=vif_type)) if mac_address: @@ -1683,12 +1684,12 @@ def _get_ports_query(self, context, filters=None, lazy_fields=None, for x in mac_address] query = query.filter( func.lower(Port.mac_address).in_(sanitized_macs)) + if ip_addresses or subnet_ids or ip_addresses_s: + query = query.join(Port.fixed_ips) if ip_addresses: - query = query.filter( - Port.fixed_ips.any(IPAllocation.ip_address.in_(ip_addresses))) + query = query.filter(IPAllocation.ip_address.in_(ip_addresses)) if subnet_ids: - query = query.filter( - Port.fixed_ips.any(IPAllocation.subnet_id.in_(subnet_ids))) + query = query.filter(IPAllocation.subnet_id.in_(subnet_ids)) if limit: query = query.limit(limit) query = query.distinct() From 536e97ef2ae8d622da5b08c2e8374098a495cc13 Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Mon, 15 Apr 2019 19:03:55 +0200 Subject: [PATCH 105/184] Revert Switch isolated metadata proxy to bind to 169.254.169.254 Bernard Cafarelli 2018-09-06 10:48 Our metadata check also asks dhcp agents itself --- neutron/agent/dhcp/agent.py | 2 +- neutron/tests/unit/agent/dhcp/test_agent.py | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/neutron/agent/dhcp/agent.py b/neutron/agent/dhcp/agent.py index d298d4f36f1..31b7c1f73da 100644 --- a/neutron/agent/dhcp/agent.py +++ b/neutron/agent/dhcp/agent.py @@ -828,7 +828,7 @@ def enable_isolated_metadata_proxy(self, network): metadata_driver.MetadataDriver.spawn_monitored_metadata_proxy( self._process_monitor, network.namespace, constants.METADATA_PORT, - self.conf, bind_address=constants.METADATA_V4_IP, **kwargs) + self.conf, **kwargs) def disable_isolated_metadata_proxy(self, network): if (self.conf.enable_metadata_network and diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index f7aede9b873..013aa9143ef 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -675,7 +675,6 @@ def test_dhcp_metadata_destroy(self): dhcp.configure_dhcp_for_network(fake_network) md_cls.spawn_monitored_metadata_proxy.assert_called_once_with( mock.ANY, mock.ANY, mock.ANY, mock.ANY, - bind_address=const.METADATA_V4_IP, network_id=fake_network.id) md_cls.reset_mock() dhcp.disable_dhcp_helper(fake_network.id) @@ -694,7 +693,6 @@ def test_agent_start_restarts_metadata_proxy(self): mock.ANY, fake_network.id, mock.ANY, fake_network.namespace) md_cls.spawn_monitored_metadata_proxy.assert_called_once_with( mock.ANY, mock.ANY, mock.ANY, mock.ANY, - bind_address=const.METADATA_V4_IP, network_id=fake_network.id) def test_report_state_revival_logic(self): @@ -1068,12 +1066,10 @@ def _test_enable_isolated_metadata_proxy(self, network): '.spawn_monitored_metadata_proxy') with mock.patch(method_path) as spawn: self.dhcp.enable_isolated_metadata_proxy(network) - metadata_ip = const.METADATA_V4_IP spawn.assert_called_once_with(self.dhcp._process_monitor, network.namespace, const.METADATA_PORT, cfg.CONF, - bind_address=metadata_ip, router_id='forzanapoli') def test_enable_isolated_metadata_proxy_with_metadata_network(self): @@ -1100,7 +1096,6 @@ def _test_enable_isolated_metadata_proxy_ipv6(self, network): network.namespace, const.METADATA_PORT, cfg.CONF, - bind_address='169.254.169.254', network_id=network.id, bind_interface='fake-interface', bind_address_v6='fe80::a9fe:a9fe') From c8245c0b25db8fb6c85eaa23a7d1b95088e32387 Mon Sep 17 00:00:00 2001 From: Johannes Kulik Date: Thu, 27 Jun 2019 16:05:01 +0200 Subject: [PATCH 106/184] Add explicit policy-enforcement for port.security_groups Our customers want to be able to prohibit certain colleagues from changing the security_groups. For this, we have to tell neutron to explicitly enforce policy for `Port.security_groups`. After that, neutron checks for `update_port:security_groups` and `create_port:security_groups` in addition to `update_port` and `create_port`. --- neutron/extensions/securitygroup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/neutron/extensions/securitygroup.py b/neutron/extensions/securitygroup.py index 8bf11d9de54..6eb7cc2579b 100644 --- a/neutron/extensions/securitygroup.py +++ b/neutron/extensions/securitygroup.py @@ -294,6 +294,7 @@ def _validate_name_not_default(data, max_len=db_const.NAME_FIELD_SIZE): 'convert_to': converters.convert_none_to_empty_list, 'validate': {'type:uuid_list': None}, + 'enforce_policy': True, 'default': const.ATTR_NOT_SPECIFIED}}} # Register the configuration options From 5f68a6b4972f47baaf14d5a5fa5d99d1f07f1fc5 Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Fri, 12 Feb 2021 16:18:20 +0100 Subject: [PATCH 107/184] fix pagination for loggable_resources api endpoint --- neutron/services/logapi/logging_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/services/logapi/logging_plugin.py b/neutron/services/logapi/logging_plugin.py index c40862758f7..358f34670ee 100644 --- a/neutron/services/logapi/logging_plugin.py +++ b/neutron/services/logapi/logging_plugin.py @@ -140,5 +140,5 @@ def get_loggable_resources(self, context, filters=None, fields=None, sorts=None, limit=None, marker=None, page_reverse=False): """Get supported logging types""" - return [{'type': type_} + return [{'id': None, 'type': type_} for type_ in self.supported_logging_types] From f57534f0cc9a2d371e1bde59dc1e98f5a41835d3 Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Sat, 17 Apr 2021 11:36:07 +0300 Subject: [PATCH 108/184] Allow creating subnets without subnetpool for the same network. This patch allows adding a new subnet without subnetpool to a network that already has several subnets with subnetpools. This config supported by SAP infrastructure. --- neutron/db/ipam_backend_mixin.py | 4 --- .../unit/extensions/test_address_scope.py | 27 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/neutron/db/ipam_backend_mixin.py b/neutron/db/ipam_backend_mixin.py index 9c245ae6898..d78a9586dc6 100644 --- a/neutron/db/ipam_backend_mixin.py +++ b/neutron/db/ipam_backend_mixin.py @@ -288,10 +288,6 @@ def _validate_network_subnetpools(self, network, subnet_ip_version, subnet.ip_version == new_subnetpool.ip_version and not network_scope): raise exc.NetworkSubnetPoolAffinityError() - else: - if (subnet.subnetpool_id and - subnet.ip_version == subnet_ip_version): - raise exc.NetworkSubnetPoolAffinityError() def validate_allocation_pools(self, ip_pools, subnet_cidr): """Validate IP allocation pools. diff --git a/neutron/tests/unit/extensions/test_address_scope.py b/neutron/tests/unit/extensions/test_address_scope.py index 9d133ff1a0a..4b5452869ee 100644 --- a/neutron/tests/unit/extensions/test_address_scope.py +++ b/neutron/tests/unit/extensions/test_address_scope.py @@ -629,6 +629,33 @@ def test_block_update_subnetpool_network_affinity(self): self.assertEqual(webob.exc.HTTPBadRequest.code, res.status_int) + def test_create_second_subnet_without_subnetpool_same_network(self): + with self.address_scope(constants.IP_VERSION_4, + name='scope-a') as addr_scope: + addr_scope = addr_scope['address_scope'] + + with self.subnetpool( + ['10.10.0.0/16'], + name='subnetpool_a', + tenant_id=addr_scope['tenant_id'], + default_prefixlen=24, + address_scope_id=addr_scope['id']) as subnetpool: + subnetpool = subnetpool['subnetpool'] + + with self.network( + tenant_id=addr_scope['tenant_id']) as network: + with self.subnet(cidr=None, + network=network, + ip_version=constants.IP_VERSION_4, + subnetpool_id=subnetpool['id']): + res = self._create_subnet( + self.fmt, + cidr='192.168.16.0/24', + net_id=network['network']['id'], + tenant_id=addr_scope['tenant_id']) + self.assertEqual(webob.exc.HTTPCreated.code, + res.status_int) + def test_ipv6_pd_add_non_pd_subnet_to_same_network(self): with self.address_scope(constants.IP_VERSION_6, name='foo-address-scope') as addr_scope: From ac5435a83f8f15869d43cc577872531f8dfb1523 Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Mon, 7 Jun 2021 12:27:50 +0200 Subject: [PATCH 109/184] get_detailed_tenant_quotas: Check for existing resource before accessing it from dict Upgrading Neutron from lbaasv2 to non-lbaasv2 breaks quota detail if theres been already quotas set for example 'healthmonitor'. This is caused becaused the quota table has been already populated with (now deprecated) lbaasv2 quota limits which are unconditionally used to populate the quota detail dictionary result - which is missing this key. This commit add a check for the existence of the key before accessing the dictionary. --- neutron/db/quota/driver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/neutron/db/quota/driver.py b/neutron/db/quota/driver.py index 4023ebab9c6..f69a6ba2875 100644 --- a/neutron/db/quota/driver.py +++ b/neutron/db/quota/driver.py @@ -108,7 +108,8 @@ def get_detailed_project_quotas(self, context, resources, project_id): quota_objs = quota_obj.Quota.get_objects(context, project_id=project_id) for item in quota_objs: - project_quota_ext[item['resource']]['limit'] = item['limit'] + if item['resource'] in project_quota_ext: + project_quota_ext[item['resource']]['limit'] = item['limit'] return project_quota_ext @staticmethod From d65dc96cb6570df11f77e772993ed4b66018525f Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Tue, 6 Jul 2021 17:37:39 +0300 Subject: [PATCH 110/184] Fix check for network address scopes affinity. (#14) This commit fixes incorrect check for network address_scope affinity. Before this changes the function _check_subnetpool_address_scope_network_affinity compared lists of subnets related to subnet pools, and it works for upstream because they use 1 to 1 relation for subnet -> subnet_pool inside one network. But in our infrastructure different subnet pools in one network are valid configurations, so we have to check exactly address_scope inside each subnet_pool and they should be the same. This commit adds +1 database query per request for POST/PUT actions for subnet pool API, which in our reality will not add a significant load. --- neutron/db/db_base_plugin_v2.py | 37 +++++++++++-------- .../unit/extensions/test_address_scope.py | 13 ++++++- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index 12f26a46ba1..b2260750c7c 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -1257,7 +1257,7 @@ def _validate_address_scope_id(self, context, address_scope_id, ip_version=as_ip_version) self._check_subnetpool_address_scope_network_affinity( - context, subnetpool_id, ip_version) + context, subnetpool_id, ip_version, address_scope_id) subnetpools = subnetpool_obj.SubnetPool.get_objects( context, address_scope_id=address_scope_id) @@ -1272,17 +1272,17 @@ def _validate_address_scope_id(self, context, address_scope_id, def _check_subnetpool_address_scope_network_affinity(self, context, subnetpool_id, - ip_version): + ip_version, + address_scope_id): """Check whether updating a subnet pool's address scope is allowed. - Identify the subnets that would be re-scoped - Identify the networks that would be affected by re-scoping - Find all subnets associated with the affected networks - - Perform set difference (all - to_be_rescoped) - - If the set difference yields non-zero result size, re-scoping the - subnet pool will leave subnets in different address scopes and result - in address scope / network affinity violations so raise an exception to - block the operation. + - Compare address scopes for all of subnet pools related to subnets in + each network. + If the network has (or will have) different address scopes this check + will raise an exception to block the operation. """ # TODO(tidwellr) potentially lots of subnets here, optimize this code @@ -1298,15 +1298,20 @@ def _check_subnetpool_address_scope_network_affinity(self, context, context, network_id=affected_source_network_ids, ip_version=ip_version) - all_affected_subnet_ids = set( - [subnet.id for subnet in all_network_subnets]) - - # Use set difference to identify the subnets that would be - # violating address scope affinity constraints if the subnet - # pool's address scope was changed. - violations = all_affected_subnet_ids.difference(rescoped_subnet_ids) - if violations: - raise addr_scope_exc.NetworkAddressScopeAffinityError() + affected_pool_ids = set( + [s.subnetpool_id for s in all_network_subnets if s.subnetpool_id]) + + subnet_pools = subnetpool_obj.SubnetPool.get_objects( + context, + id=affected_pool_ids) + affected_scopes = {sp.id: sp.address_scope_id for sp in subnet_pools} + + for pool in affected_pool_ids: + # address scopes should be the same in all networks raleted to + # the address scope. + scope = affected_scopes.get(pool) + if scope and scope != address_scope_id: + raise addr_scope_exc.NetworkAddressScopeAffinityError() def _check_subnetpool_update_allowed(self, context, subnetpool_id, address_scope_id): diff --git a/neutron/tests/unit/extensions/test_address_scope.py b/neutron/tests/unit/extensions/test_address_scope.py index 4b5452869ee..8fb93812a58 100644 --- a/neutron/tests/unit/extensions/test_address_scope.py +++ b/neutron/tests/unit/extensions/test_address_scope.py @@ -576,7 +576,7 @@ def test_create_two_subnets_different_subnetpools_same_network(self): self.assertEqual(1, subnets_pool_a_count) self.assertEqual(1, subnets_pool_b_count) - def test_block_update_subnetpool_network_affinity(self): + def test_block_update_address_scope_network_affinity(self): with self.address_scope(constants.IP_VERSION_4, name='scope-a') as scope_a,\ self.address_scope(constants.IP_VERSION_4, @@ -618,13 +618,22 @@ def test_block_update_subnetpool_network_affinity(self): ip_version=constants.IP_VERSION_4, tenant_id=scope_a['tenant_id']) + api = self._api_for_resource('subnetpools') + # Attempt to update subnetpool_b's prefixes and avoid + # failure. + data = {'subnetpool': {'prefixes': ['10.20.0.0/16', + '10.100.0.0/24']}} + req = self.new_update_request('subnetpools', data, + subnetpool_b['id']) + res = req.get_response(api) + self.assertEqual(webob.exc.HTTPOk.code, + res.status_int) # Attempt to update subnetpool_b's address scope and # assert failure. data = {'subnetpool': {'address_scope_id': scope_b['id']}} req = self.new_update_request('subnetpools', data, subnetpool_b['id']) - api = self._api_for_resource('subnetpools') res = req.get_response(api) self.assertEqual(webob.exc.HTTPBadRequest.code, res.status_int) From f0d4e347a7c67d0a034a83762a1f50114dde04ef Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Wed, 7 Jul 2021 12:36:09 +0200 Subject: [PATCH 111/184] dns_integration: fix attribute error for deleted subnet race condition In case the port notification is handled after it related subnet was deleted, an attribute error was rised. This commit will gracefully handle this case. --- neutron/plugins/ml2/extensions/dns_integration.py | 2 +- .../plugins/ml2/extensions/test_dns_integration.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/extensions/dns_integration.py b/neutron/plugins/ml2/extensions/dns_integration.py index 7a52af21ee2..98fd119b291 100644 --- a/neutron/plugins/ml2/extensions/dns_integration.py +++ b/neutron/plugins/ml2/extensions/dns_integration.py @@ -456,7 +456,7 @@ def _filter_by_subnet(context, fixed_ips): # single get_objects call instead subnet = subnet_obj.Subnet.get_object( context, id=ip['subnet_id']) - if subnet.get('dns_publish_fixed_ip'): + if subnet and subnet.get('dns_publish_fixed_ip'): filter_fixed_ips = True subnet_filtered.append(str(ip['ip_address'])) if filter_fixed_ips: diff --git a/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py b/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py index 1d1f9057b7f..cc94909046a 100644 --- a/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py +++ b/neutron/tests/unit/plugins/ml2/extensions/test_dns_integration.py @@ -477,6 +477,16 @@ def test_create_port_dns_name_field_missing(self, *mocks): } self.plugin.create_port(self.context, port_request) + def test_filter_subnet_after_subnet_deleted(self, *mocks): + fake_ip = '192.168.0.1' + fake_fixed_ips = [{ + 'subnet_id': uuidutils.generate_uuid(), + 'ip_address': fake_ip + }] + filtered_ips = dns_integration._filter_by_subnet(self.context, + fake_fixed_ips) + self.assertEqual(filtered_ips, [fake_ip]) + def test_dns_driver_loaded_after_server_restart(self, *mocks): dns_integration.DNS_DRIVER = None port, dns_data_db = self._create_port_for_test() From e1ae88c9e1f24418a6158a135299b1438e9e3a40 Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Mon, 27 Jun 2022 14:16:28 +0200 Subject: [PATCH 112/184] [_create_db_port_obj] re-generate mac address if collision is detected --- neutron/db/db_base_plugin_v2.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index b2260750c7c..334d95005f5 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -1524,7 +1524,12 @@ def _create_db_port_obj(self, context, port_data): raise exc.MacAddressInUse(net_id=port_data['network_id'], mac=mac_address) else: - mac_address = self._generate_macs()[0] + while True: + mac_address = self._generate_macs()[0] + if not self._is_mac_in_use(context, port_data['network_id'], + mac_address): + break + db_port = models_v2.Port(mac_address=mac_address, **port_data) context.session.add(db_port) return db_port From aeffc6e04a28e3b468bc882a916e9b41960e8628 Mon Sep 17 00:00:00 2001 From: Sebastian Wagner Date: Tue, 28 Jun 2022 17:06:53 +0200 Subject: [PATCH 113/184] Relax check on subnet pools to just satisfy same address scope The current implementation does not allow subnets from different address scopes in a network. For DAPnets we need to relax that constraint so we allow subnets from the same address scope as well as subnets with no subnet pool (and hence no address scope) and subnets with the same address scope in a network. --- neutron/db/ipam_backend_mixin.py | 18 ++++++--------- .../tests/unit/db/test_db_base_plugin_v2.py | 14 ++++++----- .../tests/unit/db/test_ipam_backend_mixin.py | 23 ++++++++++++------- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/neutron/db/ipam_backend_mixin.py b/neutron/db/ipam_backend_mixin.py index d78a9586dc6..148d437acb4 100644 --- a/neutron/db/ipam_backend_mixin.py +++ b/neutron/db/ipam_backend_mixin.py @@ -257,10 +257,9 @@ def _validate_subnet_cidr(self, context, network, new_subnet_cidr): def _validate_network_subnetpools(self, network, subnet_ip_version, new_subnetpool, network_scope): - """Validate all subnets on the given network have been allocated from - the same subnet pool as new_subnetpool if no address scope is - used. If address scopes are used, validate that all subnets on the - given network participate in the same address scope. + """If address scopes are used, validate that all subnets on the + given network participate in the same address scope or have no + subnet pool set. """ # 'new_subnetpool' might just be the Prefix Delegation ID ipv6_pd_subnetpool = new_subnetpool == const.IPV6_PD_POOL_ID @@ -281,13 +280,10 @@ def _validate_network_subnetpools(self, network, subnet_ip_version, subnet.subnetpool_id != const.IPV6_PD_POOL_ID): raise exc.NetworkSubnetPoolAffinityError() else: - if new_subnetpool: - # In this case we have the new subnetpool object, so - # we can check the ID and IP version. - if (subnet.subnetpool_id != new_subnetpool.id and - subnet.ip_version == new_subnetpool.ip_version and - not network_scope): - raise exc.NetworkSubnetPoolAffinityError() + if (subnet.ip_version == const.IP_VERSION_6 and + subnet.subnetpool_id == const.IPV6_PD_POOL_ID and + not ipv6_pd_subnetpool): + raise exc.NetworkSubnetPoolAffinityError() def validate_allocation_pools(self, ip_pools, subnet_cidr): """Validate IP allocation pools. diff --git a/neutron/tests/unit/db/test_db_base_plugin_v2.py b/neutron/tests/unit/db/test_db_base_plugin_v2.py index 43b76febbb0..0d36c4b0250 100644 --- a/neutron/tests/unit/db/test_db_base_plugin_v2.py +++ b/neutron/tests/unit/db/test_db_base_plugin_v2.py @@ -7253,12 +7253,14 @@ def test__validate_network_subnetpools(self): network.subnets = [models_v2.Subnet(subnetpool_id='test_id', ip_version=constants.IP_VERSION_4)] new_subnetpool_id = None - self.assertRaises(lib_exc.NetworkSubnetPoolAffinityError, - self.plugin.ipam._validate_network_subnetpools, - network, - constants.IP_VERSION_4, - new_subnetpool_id, - None) + + # downstream patch + # call method without raising NetworkSubnetPoolAffinityError + self.plugin.ipam._validate_network_subnetpools( + network, + constants.IP_VERSION_4, + new_subnetpool_id, + None) def test_create_subnet_invalid_network_mtu_ipv4_returns_409(self): self.net_data['network']['mtu'] = common_constants.IPV4_MIN_MTU - 1 diff --git a/neutron/tests/unit/db/test_ipam_backend_mixin.py b/neutron/tests/unit/db/test_ipam_backend_mixin.py index 5588f2d75c9..b3b559a56c7 100644 --- a/neutron/tests/unit/db/test_ipam_backend_mixin.py +++ b/neutron/tests/unit/db/test_ipam_backend_mixin.py @@ -18,7 +18,6 @@ import netaddr from neutron_lib.api.definitions import portbindings from neutron_lib import constants -from neutron_lib import exceptions as exc from neutron_lib.exceptions import address_scope as addr_scope_exc from oslo_utils import uuidutils import webob.exc @@ -332,19 +331,27 @@ def test__validate_network_subnetpools_mismatch_address_scopes(self): subnetpool, address_scope) - def test__validate_network_subnetpools_subnetpool_mismatch(self): + def test__validate_network_subnetpools_new_subnetpool(self): subnet = mock.MagicMock(ip_version=constants.IP_VERSION_4) subnet.subnetpool_id = 'fake-subnetpool' network = mock.MagicMock(subnets=[subnet]) subnetpool = mock.MagicMock(id=uuidutils.generate_uuid()) subnetpool.ip_version = constants.IP_VERSION_4 + self.mixin._validate_network_subnetpools( + network, + constants.IP_VERSION_4, + subnetpool, + None) - self.assertRaises(exc.NetworkSubnetPoolAffinityError, - self.mixin._validate_network_subnetpools, - network, - constants.IP_VERSION_4, - subnetpool, - None) + def test__validate_network_subnetpools_new_subnet_no_subnetpool(self): + address_scope_id = "dummy-scope" + address_scope = mock.MagicMock() + address_scope.id.return_value = address_scope_id + self.mixin._validate_network_subnetpools( + mock.MagicMock(), + constants.IP_VERSION_4, + None, + address_scope) class TestPlugin(ml2_plugin.Ml2Plugin, segments_db.SegmentDbMixin): From ff200a19b059794d428ff7a2e1b529d03819d6f9 Mon Sep 17 00:00:00 2001 From: Sebastian Wagner Date: Tue, 2 Aug 2022 14:45:09 +0200 Subject: [PATCH 114/184] Mac address must only be globally unique on create The Cisco IOS XE platform drops any frames with a mac address that it has bound on a local interface, no matter which VRF they are in. OpenStack generates mac addresses randomly and checks only if the address is already used in the current network. We implemented global mac address duplicate checking in 1a58780b81, but had to notice that ironic baremetal deployments may generate 2 ports in different subnets with the same mac. However this is only done on an update and a mac address update is an admin-only action in our cloud. Hence we relax the check for update operations to only be network-wise unique while the create check must satisfy a globally unique constraint. --- neutron/db/db_base_plugin_common.py | 9 ++++++--- neutron/db/db_base_plugin_v2.py | 4 ++-- neutron/plugins/ml2/plugin.py | 17 +++++++++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/neutron/db/db_base_plugin_common.py b/neutron/db/db_base_plugin_common.py index 3198b42d3ef..44d64f2d098 100644 --- a/neutron/db/db_base_plugin_common.py +++ b/neutron/db/db_base_plugin_common.py @@ -110,9 +110,12 @@ def _generate_macs(mac_count=1): return [next(mac_maker) for x in range(mac_count)] @db_api.CONTEXT_READER - def _is_mac_in_use(self, context, network_id, mac_address): - return port_obj.Port.objects_exist(context, network_id=network_id, - mac_address=mac_address) + def _is_mac_in_use(self, context, network_id, mac_address, + globally_unique=False): + kwargs = dict(mac_address=mac_address) + if not globally_unique: + kwargs['network_id'] = network_id + return port_obj.Port.objects_exist(context, **kwargs) @staticmethod def _delete_ip_allocation(context, network_id, subnet_id, ip_address): diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index 334d95005f5..ae751650e8c 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -1520,14 +1520,14 @@ def _create_db_port_obj(self, context, port_data): mac_address = port_data.pop('mac_address', None) if mac_address: if self._is_mac_in_use(context, port_data['network_id'], - mac_address): + mac_address, globally_unique=True): raise exc.MacAddressInUse(net_id=port_data['network_id'], mac=mac_address) else: while True: mac_address = self._generate_macs()[0] if not self._is_mac_in_use(context, port_data['network_id'], - mac_address): + mac_address, globally_unique=True): break db_port = models_v2.Port(mac_address=mac_address, **port_data) diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index c8a891a9cb6..012c2ef2d31 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -1682,6 +1682,7 @@ def create_port_bulk(self, context, ports): def _create_port_bulk(self, context, port_list, network_cache): # TODO(njohnston): Break this up into smaller functions. port_data = [] + macs = self._generate_macs(len(port_list)) with db_api.CONTEXT_WRITER.using(context): for port in port_list: # Set up the port request dict @@ -1709,10 +1710,22 @@ def _create_port_bulk(self, context, port_list, network_cache): network = network_cache[network_id] + # Determine the MAC address + raw_mac_address = pdata.get('mac_address', + const.ATTR_NOT_SPECIFIED) + if raw_mac_address is const.ATTR_NOT_SPECIFIED: + raw_mac_address = macs.pop() + elif self._is_mac_in_use(context, network_id, raw_mac_address, + globally_unique=True): + raise exc.MacAddressInUse(net_id=network_id, + mac=raw_mac_address) + eui_mac_address = netaddr.EUI(raw_mac_address, + dialect=eui48.mac_unix_expanded) + port['port']['mac_address'] = str(eui_mac_address) + db_port_obj = ports_obj.Port( context, - mac_address=netaddr.EUI(port['port']['mac_address'], - dialect=eui48.mac_unix_expanded), + mac_address=eui_mac_address, id=port['port']['id'], **bulk_port_data) db_port_obj.create() From 73fd8987dbaf80e3dc045bee2c70ce728ae63c4c Mon Sep 17 00:00:00 2001 From: Maurice Escher Date: Wed, 9 Nov 2022 08:21:13 +0100 Subject: [PATCH 115/184] sapcc: tox.ini uses TOX_CONSTRAINTS_FILE UPPER_CONSTRAINTS_FILE is deprecated see https://zuul-ci.org/docs/zuul-jobs/python-roles.html#rolevar-tox.tox_constraints_file --- concourse_unit_test_task | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100755 concourse_unit_test_task diff --git a/concourse_unit_test_task b/concourse_unit_test_task new file mode 100755 index 00000000000..09041f9c805 --- /dev/null +++ b/concourse_unit_test_task @@ -0,0 +1,9 @@ +export DEBIAN_FRONTEND=noninteractive && \ +export TOX_CONSTRAINTS_FILE=https://raw.githubusercontent.com/sapcc/requirements/stable/yoga-m3/upper-constraints.txt && \ +apt-get update && \ +apt-get install -y build-essential python3-pip python3-dev git libpcre++-dev gettext sudo iproute2 && \ +pip install -U pip && \ +pip install tox "six>=1.14.0" && \ +git clone -b stable/yoga-m3 --single-branch https://github.com/sapcc/neutron.git --depth=1 && \ +cd neutron && \ +tox -e pep8,py38 From 29594e672b2a19261e254b7e9631ffdf97c8f8fe Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Wed, 30 Nov 2022 21:46:48 +0400 Subject: [PATCH 116/184] Remove neutron-lib from custom-requirements because it conflicts with upper-constraints Change-Id: If2fa3290ad55048b692e5a407be44be1ccf3a6ef --- custom-requirements.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index b588da32a1e..18b444ac96e 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -21,9 +21,6 @@ pymemcache #mysql pymysql -# neutron-lib SAPCC specific branch -git+https://github.com/sapcc/neutron-lib.git@stable/yoga-m3#egg=neutron-lib - # 3rd party middleware git+https://github.com/sapcc/openstack-watcher-middleware.git#egg=watcher-middleware git+https://github.com/sapcc/openstack-audit-middleware.git@master#egg=audit-middleware From 697550f209107c62b59975e4c1c5328a4907554b Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Wed, 30 Nov 2022 22:23:49 +0400 Subject: [PATCH 117/184] Remove neutron-lib from test-requirements because it conflicts with upper-constraints Change-Id: I5ee72355bdbc23b61bc7d63e5d5d6aee51f345f5 --- test-requirements.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/test-requirements.txt b/test-requirements.txt index 17e6791e528..07312ddc2b4 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -11,6 +11,3 @@ ddt>=1.0.1 # MIT # Needed to run DB commands in virtualenvs PyMySQL>=0.7.6 # MIT License -# neutron-lib SAPCC specific branch -git+https://github.com/sapcc/neutron-lib.git@stable/yoga-m3#egg=neutron-lib - From e4179c1ba91a4e63d230096ab3d6ff7402d78239 Mon Sep 17 00:00:00 2001 From: Dmitry Galkin Date: Thu, 9 Feb 2023 10:39:53 +0100 Subject: [PATCH 118/184] Merge pull request #38 from sapcc/do_not_fail_on_port_deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [CCC-34286] Do not fail on port deletion if dns_name and dns_port are… --- .../externaldns/drivers/designate/driver.py | 55 +++++++++++++++---- .../drivers/designate/test_driver.py | 12 ++-- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/neutron/services/externaldns/drivers/designate/driver.py b/neutron/services/externaldns/drivers/designate/driver.py index e826c74cfc9..2da94c686d8 100644 --- a/neutron/services/externaldns/drivers/designate/driver.py +++ b/neutron/services/externaldns/drivers/designate/driver.py @@ -35,7 +35,7 @@ LOG = log.getLogger(__name__) -def get_clients(context): +def get_clients(context, all_projects=False, edit_managed=False): global _SESSION if not _SESSION: @@ -46,7 +46,9 @@ def get_clients(context): client = d_client.Client(session=_SESSION, auth=auth) admin_auth = loading.load_auth_from_conf_options(CONF, 'designate') admin_client = d_client.Client(session=_SESSION, auth=admin_auth, - endpoint_override=CONF.designate.url) + endpoint_override=CONF.designate.url, + all_projects=all_projects, + edit_managed=edit_managed) return client, admin_client @@ -55,6 +57,10 @@ def get_all_projects_client(context): return d_client.Client(session=_SESSION, auth=auth, all_projects=True) +def get_all_projects_edit_managed_client(context): + return get_clients(context, all_projects=True, edit_managed=True) + + class Designate(driver.ExternalDNSService): """Driver for Designate.""" @@ -137,24 +143,53 @@ def _get_bytes_or_nybles_to_skip(self, in_addr_name): def delete_record_set(self, context, dns_domain, dns_name, records): client, admin_client = get_clients(context) + ids_to_delete = [] try: + # first try regular client: ids_to_delete = self._get_ids_ips_to_delete( dns_domain, '%s.%s' % (dns_name, dns_domain), records, client) - except dns_exc.DNSDomainNotFound: + except (dns_exc.DNSDomainNotFound, d_exc.Forbidden): # Try whether we have admin powers and can see all projects - client = get_all_projects_client(context) - ids_to_delete = self._get_ids_ips_to_delete( - dns_domain, '%s.%s' % (dns_name, dns_domain), records, client) + # and also handle managed records (to prevent leftover PTRs): + client, admin_client = get_all_projects_edit_managed_client( + context) + try: + ids_to_delete = self._get_ids_ips_to_delete( + dns_domain, + '%s.%s' % (dns_name, dns_domain), + records, + admin_client) + except d_exc.Forbidden: + LOG.error("Cannot determine Designate record ids for " + "deletion of: '%(name)s.%(dom)s'", + {'name': dns_name, 'dom': dns_domain}) + except dns_exc.DNSDomainNotFound: + LOG.debug("The domain '%s' not found in Designate", + dns_domain) for _id in ids_to_delete: - client.recordsets.delete(dns_domain, _id) + try: + client.recordsets.delete(dns_domain, _id) + except d_exc.Forbidden: + LOG.error("Cannot delete Designate record with id %(recid)s in" + " domain: %(dom)s", + {'recid': _id, 'dom': dns_domain}) + if not CONF.designate.allow_reverse_dns_lookup: return for record in records: - in_addr_name = netaddr.IPAddress(record).reverse_dns - in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) - admin_client.recordsets.delete(in_addr_zone_name, in_addr_name) + try: + in_addr_name = netaddr.IPAddress(record).reverse_dns + in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) + admin_client.recordsets.delete(in_addr_zone_name, + in_addr_name) + except (dns_exc.DNSDomainNotFound, d_exc.NotFound): + LOG.debug("No '%s' PTR record was found in Designate.", + in_addr_name) + except d_exc.Forbidden: + LOG.error("Cannot delete '%s' PTR record.", + in_addr_name) def _get_ids_ips_to_delete(self, dns_domain, name, records, designate_client): diff --git a/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py b/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py index 22e10203d66..eb379278854 100644 --- a/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py +++ b/neutron/tests/unit/services/externaldns/drivers/designate/test_driver.py @@ -246,12 +246,12 @@ def test_delete_record_set_zone_not_found(self): self.client.recordsets.list.side_effect = d_exc.NotFound self.all_projects_client.recordsets.list.side_effect = d_exc.NotFound - self.assertRaisesRegex( - dns_exc.DNSDomainNotFound, - 'Domain example.test. not found in the external DNS service', - self.driver.delete_record_set, self.context, 'example.test.', - 'test', ['192.168.0.10'] - ) + # custom patched delete_record_set method + # Not raising an exception when the domain is not found is + # expected behaviour + res = self.driver.delete_record_set(self.context, 'example.test.', + 'test', ['192.168.0.10']) + self.assertIsNone(res) def test_ipv4_ptr_is_misconfigured(self): self.assertRaises( From 4f66b2c08828b1b1b339f45ddb36b2d177cf6636 Mon Sep 17 00:00:00 2001 From: Johannes Kulik Date: Fri, 10 Mar 2023 13:15:17 +0100 Subject: [PATCH 119/184] tox4 compatibility The changes in this commit are the same we needed for Cinder: - removed skipsdist=True, which in tox 4 appears to prevent Neutron from being installed in the testenvs - use py_modules=[] to work around setuptools>=61 getting installed in the virtualenv and only later getting downgraded to the upper-constraints' version --- setup.py | 3 ++- tox.ini | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index cd35c3c35bf..5019c359d48 100644 --- a/setup.py +++ b/setup.py @@ -17,4 +17,5 @@ setuptools.setup( setup_requires=['pbr>=2.0.0'], - pbr=True) + pbr=True, + py_modules=[]) diff --git a/tox.ini b/tox.ini index 1155561d8c8..8d7d182c7b6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = docs,py3,pep8 minversion = 3.18.0 -skipsdist = False ignore_basepython_conflict = True [testenv] From 8009524c3c331444e4834c47d27ca378b3aa7804 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Wed, 8 Dec 2021 18:14:49 +0100 Subject: [PATCH 120/184] Restrict query of iface attrs in linuxbridge agent For a linuxbridge-agent the startup performance gets worse the more networks are scheduled onto the agent. One factor in this is that parsing and transporting all netlink attributes for all interfaces takes a long time. This makes functions like get_link_devices() pretty slow. The more interfaces there are the slower it gets. To prevent this an attribute filter is introduced to get_link_devices() and to make_serializable(). Only specified attributes are serialized for transport from each interface. As attributes can also be nested, this can save up a lot of processing time, especially when only the interface name is needed by the caller. get_devices_info() now allows interface attributes to be restricted to a subset as well. The function will determine which netlink attributes need to be requested. Co-authored-by: Johannes Kulik --- neutron/agent/linux/ip_lib.py | 46 +++++++++++++++++++--- neutron/privileged/agent/linux/__init__.py | 16 +++++--- neutron/privileged/agent/linux/ip_lib.py | 8 ++-- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/neutron/agent/linux/ip_lib.py b/neutron/agent/linux/ip_lib.py index 8ecf2a28116..6251d55e917 100644 --- a/neutron/agent/linux/ip_lib.py +++ b/neutron/agent/linux/ip_lib.py @@ -784,7 +784,7 @@ def exists(self, name): def vlan_in_use(segmentation_id, namespace=None): """Return True if VLAN ID is in use by an interface, else False.""" - interfaces = get_devices_info(namespace) + interfaces = get_devices_info(namespace, attrs=['vlan_id']) vlans = {interface.get('vlan_id') for interface in interfaces if interface.get('vlan_id')} return segmentation_id in vlans @@ -792,7 +792,7 @@ def vlan_in_use(segmentation_id, namespace=None): def vxlan_in_use(segmentation_id, namespace=None): """Return True if VXLAN VNID is in use by an interface, else False.""" - interfaces = get_devices_info(namespace) + interfaces = get_devices_info(namespace, attrs=['vxlan_id']) vxlans = {interface.get('vxlan_id') for interface in interfaces if interface.get('vxlan_id')} return segmentation_id in vxlans @@ -1429,7 +1429,7 @@ def get_devices_with_ip(namespace, name=None, **kwargs): if not link_args: ip_addresses = privileged.get_ip_addresses(namespace, **kwargs) else: - device = get_devices_info(namespace, **link_args) + device = get_devices_info(namespace, **link_args, attrs=[]) if not device: return retval ip_addresses = privileged.get_ip_addresses( @@ -1441,7 +1441,7 @@ def get_devices_with_ip(namespace, name=None, **kwargs): name = (linux_utils.get_attr(ip_address, 'IFA_LABEL') or devices.get(index)) if not name: - device = get_devices_info(namespace, index=index) + device = get_devices_info(namespace, index=index, attrs=['name']) if not device: continue name = device[0]['name'] @@ -1452,7 +1452,40 @@ def get_devices_with_ip(namespace, name=None, **kwargs): return retval -def get_devices_info(namespace, **kwargs): +def get_devices_info(namespace, attrs=None, **kwargs): + attr_map = { + 'name': ['IFLA_IFNAME'], + 'operstate': ['IFLA_OPERSTATE'], + 'linkmode': ['IFLA_LINKMODE'], + 'mtu': ['IFLA_MTU'], + 'promiscuity': ['IFLA_PROMISCUITY'], + 'mac': ['IFLA_ADDRESS'], + 'broadcast': ['IFLA_BROADCAST'], + 'parent_index': ['IFLA_LINK', 'IFLA_LINKINFO'], + 'parent_name': ['IFLA_LINK', 'IFLA_LINKINFO'], + 'kind': ['IFLA_LINKINFO', 'IFLA_INFO_KIND'], + 'vlan_id': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', + 'IFLA_VLAN_ID'], + 'vxlan_id': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', + 'IFLA_VXLAN_ID'], + 'vxlan_group': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', + 'IFLA_VXLAN_GROUP'], + 'vxlan_link_index': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', + 'IFLA_VXLAN_LINK'], + 'vxlan_link_name': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', + 'IFLA_VXLAN_LINK'], + } + attr_filter = set() + if attrs is not None: + for attr in attrs: + attr_filter.update(attr_map[attr]) + else: + for attr_list in attr_map.values(): + attr_filter.update(attr_list) + if 'IFLA_LINK' in attr_filter: + attr_filter.add('IFLA_IFNAME') + kwargs['attr_filter'] = list(attr_filter) + devices = privileged.get_link_devices(namespace, **kwargs) retval = {} for device in devices: @@ -1610,7 +1643,8 @@ def get_proto(proto_number): table = IP_RULE_TABLES.get(table, table) routes = privileged.list_ip_routes(namespace, ip_version, device=device, table=table, **kwargs) - devices = privileged.get_link_devices(namespace) + devices = privileged.get_link_devices(namespace, + attr_filter=['IFLA_IFNAME']) ret = [] for route in routes: cidr = linux_utils.get_attr(route, 'RTA_DST') diff --git a/neutron/privileged/agent/linux/__init__.py b/neutron/privileged/agent/linux/__init__.py index 103104c5fd1..409ede50150 100644 --- a/neutron/privileged/agent/linux/__init__.py +++ b/neutron/privileged/agent/linux/__init__.py @@ -34,7 +34,7 @@ def get_cdll(): return _CDLL -def make_serializable(value): +def make_serializable(value, attr_filter=None): """Make a pyroute2 object serializable This function converts 'netlink.nla_slot' object (key, value) in a list @@ -44,14 +44,18 @@ def _ensure_string(value): return value.decode() if isinstance(value, bytes) else value if isinstance(value, list): - return [make_serializable(item) for item in value] + return [make_serializable(item, attr_filter) for item in value + if attr_filter is None or + not isinstance(item, netlink.nla_slot) or + (attr_filter and item.name in attr_filter)] elif isinstance(value, netlink.nla_slot): - return [_ensure_string(value[0]), make_serializable(value[1])] + return [_ensure_string(value[0]), make_serializable(value[1], + attr_filter)] elif isinstance(value, netlink.nla_base): - return make_serializable(value.dump()) + return make_serializable(value.dump(), attr_filter) elif isinstance(value, dict): - return {_ensure_string(key): make_serializable(data) + return {_ensure_string(key): make_serializable(data, attr_filter) for key, data in value.items()} elif isinstance(value, tuple): - return tuple(make_serializable(item) for item in value) + return tuple(make_serializable(item, attr_filter) for item in value) return _ensure_string(value) diff --git a/neutron/privileged/agent/linux/ip_lib.py b/neutron/privileged/agent/linux/ip_lib.py index f889b795dd1..ba0f6a6b607 100644 --- a/neutron/privileged/agent/linux/ip_lib.py +++ b/neutron/privileged/agent/linux/ip_lib.py @@ -623,7 +623,7 @@ def list_netns(**kwargs): stop=tenacity.stop_after_delay(8), reraise=True) @privileged.default.entrypoint -def get_link_devices(namespace, **kwargs): +def get_link_devices(namespace, attr_filter=None, **kwargs): """List interfaces in a namespace :return: (list) interfaces in a namespace @@ -631,7 +631,8 @@ def get_link_devices(namespace, **kwargs): index = kwargs.pop('index') if 'index' in kwargs else 'all' try: with get_iproute(namespace) as ip: - return priv_linux.make_serializable(ip.get_links(index, **kwargs)) + return priv_linux.make_serializable(ip.get_links(index, **kwargs), + attr_filter=attr_filter) except OSError as e: if e.errno == errno.ENOENT: raise NetworkNamespaceNotFound(netns_name=namespace) @@ -644,7 +645,8 @@ def get_device_names(namespace, **kwargs): :return: a list of strings with the names of the interfaces in a namespace """ devices_attrs = [link['attrs'] for link - in get_link_devices(namespace, **kwargs)] + in get_link_devices(namespace, + attr_filter=['IFLA_IFNAME'], **kwargs)] device_names = [] for device_attrs in devices_attrs: for link_name in (link_attr[1] for link_attr in device_attrs From 9f3428ce9b79aa6fcc71f85c9739a4b76fa9bb07 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Tue, 7 May 2019 13:43:02 +0200 Subject: [PATCH 121/184] Improve performance for agent network scheduling Agent network scheduling is called on every get_active_networks_info() call, which is done in regular inverals by the dhcp agent. The scheduling part takes a significant amount of time due to get_dhcp_agents_hosting_networks() being called once for each network. To improve this we refactored the function in question so we are able to retrieve all agent network mappings in one call. This is joint work created together with Johannes Kulik --- neutron/db/agentschedulers_db.py | 16 +++++++++++----- neutron/scheduler/dhcp_agent_scheduler.py | 9 +++++++-- .../unit/scheduler/test_dhcp_agent_scheduler.py | 2 ++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/neutron/db/agentschedulers_db.py b/neutron/db/agentschedulers_db.py index ab8ebf35bbc..a640dbc8b79 100644 --- a/neutron/db/agentschedulers_db.py +++ b/neutron/db/agentschedulers_db.py @@ -359,7 +359,12 @@ def remove_networks_from_down_agents(self): LOG.exception("Exception encountered during network " "rescheduling") - def get_dhcp_agents_hosting_networks( + def get_dhcp_agents_hosting_networks(self, *args, **kwargs): + mapping = self.get_dhcp_agents_hosting_networks_mapping(*args, + **kwargs) + return [agent for agent, _ in mapping] # noqa + + def get_dhcp_agents_hosting_networks_mapping( self, context, network_ids, active=None, admin_state_up=None, hosts=None): if not network_ids: @@ -369,17 +374,18 @@ def get_dhcp_agents_hosting_networks( bindings = network.NetworkDhcpAgentBinding.get_objects( context, network_id=network_ids) # get the already fetched dhcp_agent objects - agent_objs = [binding.db_obj.dhcp_agent for binding in bindings] + agent_net_id = [(binding.db_obj.dhcp_agent, binding.db_obj.network_id) + for binding in bindings] # filter the dhcp_agent objects on admin_state_up if admin_state_up is not None: - agent_objs = [agent for agent in agent_objs + agent_net_id = [(agent, net_id) for agent, net_id in agent_net_id if agent.admin_state_up == admin_state_up] # filter the dhcp_agent objects on hosts if hosts: - agent_objs = [agent for agent in agent_objs + agent_net_id = [(agent, net_id) for agent, net_id in agent_net_id if agent.host in hosts] # finally filter if the agents are eligible - return [agent for agent in agent_objs + return [(agent, net_id) for (agent, net_id) in agent_net_id if self.is_eligible_agent(context, active, agent)] def add_network_to_dhcp_agent(self, context, id, network_id): diff --git a/neutron/scheduler/dhcp_agent_scheduler.py b/neutron/scheduler/dhcp_agent_scheduler.py index b414f12889b..87115a2105e 100644 --- a/neutron/scheduler/dhcp_agent_scheduler.py +++ b/neutron/scheduler/dhcp_agent_scheduler.py @@ -68,13 +68,18 @@ def auto_schedule_networks(self, plugin, context, host): segments_on_host = {s.segment_id for s in segment_host_mapping} + agent_net_id = plugin.get_dhcp_agents_hosting_networks_mapping( + context, list(net_ids.keys())) + agents_of_net = collections.defaultdict(list) + for agent, net_id in agent_net_id: + agents_of_net[net_id].append(agent) + for dhcp_agent in dhcp_agents: if agent_utils.is_agent_down(dhcp_agent.heartbeat_timestamp): LOG.warning('DHCP agent %s is not active', dhcp_agent.id) continue for net_id, is_routed_network in net_ids.items(): - agents = plugin.get_dhcp_agents_hosting_networks( - context, [net_id]) + agents = agents_of_net[net_id] segments_on_network = net_segment_ids[net_id] if is_routed_network: if len(segments_on_network & segments_on_host) == 0: diff --git a/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py b/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py index e1cc9b346ec..e81cad8a0a0 100644 --- a/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py +++ b/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py @@ -148,6 +148,8 @@ def test_auto_reschedule_vs_network_on_dead_agent(self): "segment_id": None}] plugin.get_network.return_value = self.network plugin.get_dhcp_agents_hosting_networks.return_value = dead_agent + plugin.get_dhcp_agents_hosting_networks_mapping.return_value = \ + [(dead_agent, self.network_id)] network_assigned_to_dead_agent = ( self._get_agent_binding_from_db(dead_agent)) self.assertEqual(1, len(network_assigned_to_dead_agent)) From 828ac1ef819d54e0935f1783d3965e1b0eea580e Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Thu, 22 Oct 2020 21:37:01 +0200 Subject: [PATCH 122/184] dynamic_segments: use segment_index=1 and always get all segments --- neutron/db/segments_db.py | 4 ++-- neutron/plugins/ml2/managers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/neutron/db/segments_db.py b/neutron/db/segments_db.py index 55ca52d6d8f..986aba5da19 100644 --- a/neutron/db/segments_db.py +++ b/neutron/db/segments_db.py @@ -77,12 +77,12 @@ def update_network_segment(context, segment_id, segmentation_id): {'id': segment_id, 'segmentation_id': segmentation_id}) -def get_network_segments(context, network_id, filter_dynamic=False): +def get_network_segments(context, network_id, filter_dynamic=None): return get_networks_segments( context, [network_id], filter_dynamic)[network_id] -def get_networks_segments(context, network_ids, filter_dynamic=False): +def get_networks_segments(context, network_ids, filter_dynamic=None): if not network_ids: return {} diff --git a/neutron/plugins/ml2/managers.py b/neutron/plugins/ml2/managers.py index 61bf0c0417e..0bed7da0f31 100644 --- a/neutron/plugins/ml2/managers.py +++ b/neutron/plugins/ml2/managers.py @@ -375,7 +375,7 @@ def allocate_dynamic_segment(self, context, network_id, segment): context, segment) segments_db.add_network_segment(context, network_id, dynamic_segment, - is_dynamic=True) + is_dynamic=True, segment_index=1) return dynamic_segment @db_api.retry_if_session_inactive() From ce5ce1e080ae08ec54c6e14c1b19d6dc080e0fc3 Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Fri, 17 Jul 2020 15:43:51 +0200 Subject: [PATCH 123/184] remove bond interfaces from bridge before adding new ones ensuring only one bond subinterface is assigned to the bridge at any time --- .../agent/linuxbridge_neutron_agent.py | 6 +++++ .../agent/test_linuxbridge_neutron_agent.py | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/drivers/linuxbridge/agent/linuxbridge_neutron_agent.py b/neutron/plugins/ml2/drivers/linuxbridge/agent/linuxbridge_neutron_agent.py index 3e049bd553f..99ac1311219 100644 --- a/neutron/plugins/ml2/drivers/linuxbridge/agent/linuxbridge_neutron_agent.py +++ b/neutron/plugins/ml2/drivers/linuxbridge/agent/linuxbridge_neutron_agent.py @@ -465,6 +465,12 @@ def ensure_bridge(self, bridge_name, interface=None, if bridge: bridge.delif(interface) + # Check if other bond interfaces are part of the bridge and + # remove them + for iface in bridge_device.get_interfaces(): + if iface.startswith('bond'): + bridge_device.delif(iface) + if not bridge_device.addif(interface): LOG.error("Unable to add %(interface)s to %(bridge_name)s", {'interface': interface, 'bridge_name': bridge_name}) diff --git a/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_linuxbridge_neutron_agent.py b/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_linuxbridge_neutron_agent.py index a05a1ac9a4f..f469fe24d2e 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_linuxbridge_neutron_agent.py +++ b/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_linuxbridge_neutron_agent.py @@ -525,27 +525,50 @@ def test_ensure_bridge(self): bridge_device.setfd.return_value = True bridge_device.disable_stp.return_value = True bridge_device.disable_ipv6.return_value = False - bridge_device.link.set_up.return_value = 0 + bridge_device.link.set_up.return_value = False + bridge_device.get_interfaces.return_value = [] self.assertEqual("br0", self.lbm.ensure_bridge("br0", None)) bridge_device.owns_interface.return_value = False + bridge_device.get_interfaces.return_value = [] self.lbm.ensure_bridge("br0", "eth0") upd_fn.assert_called_with("br0", "eth0") bridge_device.owns_interface.assert_called_with("eth0") de_fn.return_value = True bridge_device.delif.side_effect = Exception() + bridge_device.get_interfaces.return_value = [] self.lbm.ensure_bridge("br0", "eth0") bridge_device.owns_interface.assert_called_with("eth0") de_fn.return_value = True bridge_device.owns_interface.return_value = False + bridge_device.get_interfaces.return_value = [] get_if_br_fn.return_value = bridge_device_old bridge_device.addif.reset_mock() self.lbm.ensure_bridge("br0", "eth0") bridge_device_old.delif.assert_called_once_with('eth0') bridge_device.addif.assert_called_once_with('eth0') + def test_ensure_bridge_with_bond(self): + bridge_device = mock.Mock() + with mock.patch.object(ip_lib, + 'ensure_device_is_ready') as de_fn,\ + mock.patch.object(bridge_lib, "BridgeDevice", + return_value=bridge_device),\ + mock.patch.object(self.lbm, + 'update_interface_ip_details'),\ + mock.patch.object(bridge_lib, 'is_bridged_interface'),\ + mock.patch.object(bridge_lib.BridgeDevice, + 'get_interface_bridge'): + de_fn.return_value = True + bridge_device.owns_interface.return_value = False + bridge_device.get_interfaces.return_value = ['bond0'] + self.lbm.ensure_bridge("br0", "eth0") + bridge_device.owns_interface.assert_called_with("eth0") + bridge_device.delif.assert_called_once_with('bond0') + bridge_device.addif.assert_called_once_with('eth0') + def test_ensure_physical_in_bridge(self): self.assertFalse( self.lbm.ensure_physical_in_bridge("123", constants.TYPE_VLAN, From 423cda2cb5936fb357af6cf956e43ee5cb68e975 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Wed, 20 Apr 2022 13:02:21 +0200 Subject: [PATCH 124/184] Only set address scope when present for l3 sync data Upstream Neutron has the assumption that all subnets of a network are in the same subnet pool (and therefore same address scope), while our brand of Neutron requires all subnets in a network to be either in the same address scope or in no address scope at all. Neutron adds the address scope to each port in the l3 router sync data, but looks at all subnets present in the network. We now change this behavior to only add the address scope when the router port is actually in a subnet with this address scope. With this DAPNets (Directly Accessible Private Networks) and non-DAPNets can live in peace together. add missing key device_owner in test test__populate_ports_for_subnets_mixed_address_scopes For populating subnets for ports, it is checked whether the port is owned by a router. Runing the test with neutron yoga requires the key device_owner otherwise the test will fail with a KeyError. --- neutron/db/l3_db.py | 3 +-- neutron/tests/unit/db/test_l3_db.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/neutron/db/l3_db.py b/neutron/db/l3_db.py index 2149dc533ea..e419be02eb0 100644 --- a/neutron/db/l3_db.py +++ b/neutron/db/l3_db.py @@ -2036,9 +2036,7 @@ def _populate_mtu_and_subnets_for_ports(self, context, ports): scopes = {} for subnet in subnets_by_network[port['network_id']]: - scope = subnet['address_scope_id'] cidr = netaddr.IPNetwork(subnet['cidr']) - scopes[cidr.version] = scope # If this subnet is used by the port (has a matching entry # in the port's fixed_ips), then add this subnet to the @@ -2052,6 +2050,7 @@ def _populate_mtu_and_subnets_for_ports(self, context, ports): 'subnetpool_id': subnet['subnetpool_id']} for fixed_ip in port['fixed_ips']: if fixed_ip['subnet_id'] == subnet['id']: + scopes[cidr.version] = subnet['address_scope_id'] port['subnets'].append(subnet_info) prefixlen = cidr.prefixlen fixed_ip['prefixlen'] = prefixlen diff --git a/neutron/tests/unit/db/test_l3_db.py b/neutron/tests/unit/db/test_l3_db.py index 5a74f3b6a65..f6f3012e5b3 100644 --- a/neutron/tests/unit/db/test_l3_db.py +++ b/neutron/tests/unit/db/test_l3_db.py @@ -235,6 +235,43 @@ def test__populate_ports_for_subnets_gw_port(self, get_subnets_by_network, {k: subnets[2][k] for k in keys}) self.assertEqual([reference], ports) + @mock.patch.object(l3_db.L3_NAT_dbonly_mixin, + '_get_subnets_by_network_list') + def test__populate_ports_for_subnets_mixed_address_scopes( + self, get_subnets_by_network): + subnets = [{'id': mock.sentinel.subnet_id_a, + 'cidr': '10.180.0.0/24', + 'gateway_ip': mock.sentinel.gateway_ip_a, + 'dns_nameservers': mock.sentinel.dns_nameservers_a, + 'ipv6_ra_mode': mock.sentinel.ipv6_ra_mode_a, + 'subnetpool_id': mock.sentinel.subnetpool_id, + 'address_scope_id': mock.sentinel.address_scope_id}, + {'id': mock.sentinel.subnet_id_b, + 'cidr': '10.180.1.0/24', + 'gateway_ip': mock.sentinel.gateway_ip_b, + 'dns_nameservers': mock.sentinel.dns_nameservers_b, + 'ipv6_ra_mode': mock.sentinel.ipv6_ra_mode_b, + 'subnetpool_id': None, + 'address_scope_id': None}] + get_subnets_by_network.return_value = {'net_id': subnets} + + ports = [{'network_id': 'net_id', + 'id': 'port_id_a', + 'device_owner': n_const.DEVICE_OWNER_ROUTER_GW, + 'fixed_ips': [{'subnet_id': mock.sentinel.subnet_id_a}]}, + {'network_id': 'net_id', + 'id': 'port_id_b', + 'device_owner': n_const.DEVICE_OWNER_ROUTER_GW, + 'fixed_ips': [{'subnet_id': mock.sentinel.subnet_id_b}]}] + with mock.patch.object(directory, 'get_plugin') as get_p: + get_p().get_networks.return_value = [{'id': 'net_id', 'mtu': 1446}] + self.db._populate_mtu_and_subnets_for_ports(mock.sentinel.context, + ports) + + self.assertEqual(mock.sentinel.address_scope_id, + ports[0]['address_scopes'][n_const.IP_VERSION_4]) + self.assertIsNone(ports[1]['address_scopes'][n_const.IP_VERSION_4]) + def test__get_sync_floating_ips_no_query(self): """Basic test that no query is performed if no router ids are passed""" db = l3_db.L3_NAT_dbonly_mixin() From dbf50e7260f151944ddcd775072f75179eaf1aae Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Fri, 10 Mar 2023 13:18:38 +0100 Subject: [PATCH 125/184] Honor PYTHONWARNINGS env if run in WSGI mode Upgrading from openstack version ussuri to yoga, the location, where the python flag is being honored, changes. For reasons (details in [upstream commit](https://github.com/sapcc/neutron/commit/609508b7b946428c87f602258a6692a3b43076dc), the _get_application_ was removed where the python flag initially was set (refer to [commit](https://github.com/sapcc/neutron/commit/8ed303a3c04fa97ab59c04f6f7942f7034ef1d83)). Hence the place changes to _eventlet_api_server_. rewrite import due to pylinter error - C0415: Import outside toplevel --- neutron/server/api_eventlet.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/neutron/server/api_eventlet.py b/neutron/server/api_eventlet.py index 194514aad19..6cc1afd5ef5 100644 --- a/neutron/server/api_eventlet.py +++ b/neutron/server/api_eventlet.py @@ -13,6 +13,7 @@ # 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 os from oslo_config import cfg @@ -21,5 +22,8 @@ def eventlet_api_server(): + if os.environ.get('PYTHONWARNINGS') == 'ignore:Unverified HTTPS request': + import urllib3 # pylint: disable=import-outside-toplevel + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) profiler.setup('neutron-server', cfg.CONF.host) return config.load_paste_app('neutron') From 007b3d5e3c94f4cf5db88650613f2bbb966674a7 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Mon, 13 Mar 2023 11:38:56 +0100 Subject: [PATCH 126/184] fix flake 8 errors - E117 over-indented and E501 line too long --- neutron/agent/linux/ip_lib.py | 8 ++++---- neutron/privileged/agent/linux/ip_lib.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/neutron/agent/linux/ip_lib.py b/neutron/agent/linux/ip_lib.py index 6251d55e917..7cbd737606f 100644 --- a/neutron/agent/linux/ip_lib.py +++ b/neutron/agent/linux/ip_lib.py @@ -1470,10 +1470,10 @@ def get_devices_info(namespace, attrs=None, **kwargs): 'IFLA_VXLAN_ID'], 'vxlan_group': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', 'IFLA_VXLAN_GROUP'], - 'vxlan_link_index': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', - 'IFLA_VXLAN_LINK'], - 'vxlan_link_name': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', 'IFLA_INFO_DATA', - 'IFLA_VXLAN_LINK'], + 'vxlan_link_index': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', + 'IFLA_INFO_DATA', 'IFLA_VXLAN_LINK'], + 'vxlan_link_name': ['IFLA_LINKINFO', 'IFLA_INFO_KIND', + 'IFLA_INFO_DATA', 'IFLA_VXLAN_LINK'], } attr_filter = set() if attrs is not None: diff --git a/neutron/privileged/agent/linux/ip_lib.py b/neutron/privileged/agent/linux/ip_lib.py index ba0f6a6b607..1a826aeb40d 100644 --- a/neutron/privileged/agent/linux/ip_lib.py +++ b/neutron/privileged/agent/linux/ip_lib.py @@ -646,7 +646,8 @@ def get_device_names(namespace, **kwargs): """ devices_attrs = [link['attrs'] for link in get_link_devices(namespace, - attr_filter=['IFLA_IFNAME'], **kwargs)] + attr_filter=['IFLA_IFNAME'], + **kwargs)] device_names = [] for device_attrs in devices_attrs: for link_name in (link_attr[1] for link_attr in device_attrs From fd1afec7d0e2590dc9ca4d18152dce5f2d60890f Mon Sep 17 00:00:00 2001 From: Andrew Karpow Date: Mon, 15 Nov 2021 15:41:00 +0100 Subject: [PATCH 127/184] Add Guru Mediation Report support for neutron WSGI wrapper, triggered by SIGWINCH Upgrading openstack from version ussuri to yoga, the _get_application_ was removed. In order to enable guru mediation reporting, triggered by SIGWINCH, the setup moves to _eventlet_api_server. The removal goes back to [upstream commit](609508b). With [commit](a7c44263b7) guru mediation reporting was introduced in ussuri. --- neutron/server/api_eventlet.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/neutron/server/api_eventlet.py b/neutron/server/api_eventlet.py index 6cc1afd5ef5..79101d8ff30 100644 --- a/neutron/server/api_eventlet.py +++ b/neutron/server/api_eventlet.py @@ -14,16 +14,24 @@ # License for the specific language governing permissions and limitations # under the License. import os +import signal from oslo_config import cfg +from oslo_reports import guru_meditation_report as gmr from neutron.common import config from neutron.common import profiler +from neutron import version def eventlet_api_server(): if os.environ.get('PYTHONWARNINGS') == 'ignore:Unverified HTTPS request': import urllib3 # pylint: disable=import-outside-toplevel urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + _version_string = version.version_info.release_string() + gmr.TextGuruMeditation.setup_autorun(version=_version_string, + signum=signal.SIGWINCH) + profiler.setup('neutron-server', cfg.CONF.host) return config.load_paste_app('neutron') From e1ee4b495f7211cada37d73485d314eb9f9d8f0d Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Thu, 29 Jun 2023 19:03:53 +0200 Subject: [PATCH 128/184] Parsable logging for metadata service requests We want to get a better impression of what our users do with our metadata service and for this we want to improve the logging. The current logging logs part of the request (part by eventlet.wsgi, part by Neutron itself) or the complete ports request ("which ports do belong to the requester) as a multiline string representation of the request object. To make debugging this easier for us we now log an extra single line for each request, containing many informations, as the current OpenStack network, client ip, user-agent, request path etc., which can then be easily consumed by some log parser (fluentd + grok in our case). --- neutron/agent/metadata/agent.py | 21 +++++++++++++++++++ .../tests/unit/agent/metadata/test_agent.py | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/neutron/agent/metadata/agent.py b/neutron/agent/metadata/agent.py index 5c0a2062e8c..35a7daac969 100644 --- a/neutron/agent/metadata/agent.py +++ b/neutron/agent/metadata/agent.py @@ -259,6 +259,27 @@ def _proxy_request(self, instance_id, tenant_id, req): explanation = str(msg) return webob.exc.HTTPServiceUnavailable(explanation=explanation) + # Log the proxied request + result in a parsable way + LOG.info('Metadata request - method: %(method)s path: "%(path)s" ' + 'status: %(status)s client-ip: %(client_ip)s ' + 'project-id: %(project_id)s os-network-id: %(os_network_id)s ' + 'os-router-id: %(os_router_id)s ' + 'os-instance-id: %(os_instance_id)s ' + 'req-duration: %(req_duration)s ' + 'user-agent: "%(user_agent)s"', + { + 'method': req.method, + 'path': req.url[len(req.host_url):], + 'status': resp.status_code, + 'client_ip': headers['X-Forwarded-For'], + 'project_id': tenant_id, + 'os_network_id': req.headers.get('X-Neutron-Network-ID'), + 'os_router_id': req.headers.get('X-Neutron-Router-ID'), + 'os_instance_id': instance_id, + 'req_duration': resp.elapsed.total_seconds(), + 'user_agent': req.headers.get('User-Agent'), + }) + if resp.status_code == 200: req.response.content_type = resp.headers['content-type'] req.response.body = resp.content diff --git a/neutron/tests/unit/agent/metadata/test_agent.py b/neutron/tests/unit/agent/metadata/test_agent.py index eb03ef2e6ba..45be0a6ba86 100644 --- a/neutron/tests/unit/agent/metadata/test_agent.py +++ b/neutron/tests/unit/agent/metadata/test_agent.py @@ -412,7 +412,9 @@ def _proxy_request_test_helper(self, response_code=200, method='GET'): body = 'body' req = mock.Mock(path_info='/the_path', query_string='', headers=hdrs, - method=method, body=body) + method=method, body=body, + url='https://example.com/my/request', + host_url='https://example.com') resp = mock.MagicMock(status_code=response_code) resp.status.__str__.side_effect = AttributeError resp.content = 'content' From be0f6f26769d8d8e6eb726306c33069187caa998 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Fri, 30 Jun 2023 13:07:59 +0200 Subject: [PATCH 129/184] Remove X-Forwarded-For header in metadata haproxy When the user of our metadata service provides an X-Forwarded-For header in the request it will be forwarded to the neutron metadata service by haproxy, alongside with haproxy's own X-Forwarded-For header. On Neutron side this is then processed by eventlet.wsgi, which merges all values of headers that appear multiple times (separated by ','). The result of the merged addresses is then used by neutron in _get_instance_and_tenant_id(), where it is converted to an netaddr.IPAddress. As this is not a valid value, the AddrFormatError exception is raised. We do not want this exception to be raised, but even before that we don't want the user of a metadata service to be able to manipulate the X-Forwarded-For header in any way. Therefore we are now deleting this header in the haproxy frontend. --- neutron/agent/metadata/driver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/neutron/agent/metadata/driver.py b/neutron/agent/metadata/driver.py index 6389ca3ffd3..ba814690386 100644 --- a/neutron/agent/metadata/driver.py +++ b/neutron/agent/metadata/driver.py @@ -29,6 +29,7 @@ LOG = logging.getLogger(__name__) _HEADER_CONFIG_TEMPLATE = """ + http-request del-header X-Forwarded-For http-request del-header X-Neutron-%(res_type_del)s-ID http-request set-header X-Neutron-%(res_type)s-ID %(res_id)s """ From fd5645ca1aaa445fc74328b24ed941b35e1fff59 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Tue, 13 Jun 2023 12:14:49 +0200 Subject: [PATCH 130/184] add rate-limit-middleware as custom requirement --- custom-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index 18b444ac96e..acfdd5fc8fd 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -26,6 +26,7 @@ git+https://github.com/sapcc/openstack-watcher-middleware.git#egg=watcher-middle git+https://github.com/sapcc/openstack-audit-middleware.git@master#egg=audit-middleware git+https://github.com/sapcc/openstack-manhole-middleware.git@main#egg=manhole-middleware git+https://github.com/sapcc/openstack-uwsgi-middleware.git@main#egg=uwsgi-middleware +git+https://github.com/sapcc/openstack-rate-limit-middleware.git#egg=rate-limit-middleware # Networking Drivers -e git+https://github.com/sapcc/asr1k-neutron-l3@stable/yoga-m3#egg=asr1k-neutron-l3 @@ -38,4 +39,3 @@ git+https://github.com/sapcc/openstack-uwsgi-middleware.git@main#egg=uwsgi-middl -e git+https://github.com/sapcc/networking-bgpvpn@stable/yoga-m3#egg=networking-bgpvpn -e git+https://github.com/sapcc/networking-interconnection@stable/yoga-m3#egg=networking_interconnection -e git+https://github.com/sapcc/networking-ccloud@stable/yoga-m3#egg=networking_ccloud - From 5761812aeea5878b70857141ab8e520911dac409 Mon Sep 17 00:00:00 2001 From: Dmitry Galkin Date: Thu, 25 May 2023 17:00:39 +0200 Subject: [PATCH 131/184] Improve error handling for Designate dns integration. - Fallback to client allowing to edit managed PTR records. - Handle NotFound error from Designate client when deleting records. --- .../externaldns/drivers/designate/driver.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/neutron/services/externaldns/drivers/designate/driver.py b/neutron/services/externaldns/drivers/designate/driver.py index 2da94c686d8..9169bbcaaea 100644 --- a/neutron/services/externaldns/drivers/designate/driver.py +++ b/neutron/services/externaldns/drivers/designate/driver.py @@ -148,7 +148,7 @@ def delete_record_set(self, context, dns_domain, dns_name, records): # first try regular client: ids_to_delete = self._get_ids_ips_to_delete( dns_domain, '%s.%s' % (dns_name, dns_domain), records, client) - except (dns_exc.DNSDomainNotFound, d_exc.Forbidden): + except dns_exc.DNSDomainNotFound: # Try whether we have admin powers and can see all projects # and also handle managed records (to prevent leftover PTRs): client, admin_client = get_all_projects_edit_managed_client( @@ -158,22 +158,22 @@ def delete_record_set(self, context, dns_domain, dns_name, records): dns_domain, '%s.%s' % (dns_name, dns_domain), records, - admin_client) - except d_exc.Forbidden: - LOG.error("Cannot determine Designate record ids for " - "deletion of: '%(name)s.%(dom)s'", - {'name': dns_name, 'dom': dns_domain}) + client) except dns_exc.DNSDomainNotFound: LOG.debug("The domain '%s' not found in Designate", dns_domain) + except d_exc.Forbidden: + LOG.error("Cannot determine Designate record ids for " + "deletion of: '%(name)s.%(dom)s'", + {'name': dns_name, 'dom': dns_domain}) for _id in ids_to_delete: try: client.recordsets.delete(dns_domain, _id) - except d_exc.Forbidden: + except (d_exc.Forbidden, d_exc.NotFound) as exc: LOG.error("Cannot delete Designate record with id %(recid)s in" - " domain: %(dom)s", - {'recid': _id, 'dom': dns_domain}) + " domain: %(dom)s. Error: %(err)s", + {'recid': _id, 'dom': dns_domain, 'err': exc}) if not CONF.designate.allow_reverse_dns_lookup: return From d2fa6fcddb0481fc3cc54a2a47cb3b849946feaf Mon Sep 17 00:00:00 2001 From: Johannes Kulik Date: Thu, 6 Jul 2023 10:58:00 +0200 Subject: [PATCH 132/184] Add option for OwnerCheck's cache expiration The OwnerCheck makes it possible to define rules in the policy that allow the owner of a network to see all ports in the network - even if they belong to another project that has the network shared into it. To be able to do this policy check, OwnerCheck queries out the related Network object e.g. when listing ports and caches the result for 5s by default. Since the project_id/tenant_id of an object - or at least of a Network - does not change during its lifetime, expiring the cache this often will lead to more load on the DB without much benefit - we only get at small amount of RAM back. Therefore, we allow administrators to increase the expiration time with the config option `owner_check_cache_expiration_time`. Administrators should check if their policy only uses static attributes and if they do not have a huge amount of those objects when increasing this setting. --- neutron/conf/policy.py | 32 ++++++++++++++++++++++++++++++++ neutron/opts.py | 4 +++- neutron/policy.py | 6 +++++- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 neutron/conf/policy.py diff --git a/neutron/conf/policy.py b/neutron/conf/policy.py new file mode 100644 index 00000000000..5cac69cbf56 --- /dev/null +++ b/neutron/conf/policy.py @@ -0,0 +1,32 @@ +# Copyright 2023 SAP SE +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg + +from neutron._i18n import _ + + +OWNER_CHECK_OPTS = [ + cfg.IntOpt('owner_check_cache_expiration_time', + default=5, + min=1, + help=_("Seconds to cache the OwnerCheck object field lookup, " + "e.g. Network.tenant_id. Only increase this far out for " + "static values like on Network.")), +] + + +def register_owner_check_opts(cfg=cfg.CONF): + cfg.register_opts(OWNER_CHECK_OPTS) diff --git a/neutron/opts.py b/neutron/opts.py index eb3baf32ced..06aa2915b0d 100644 --- a/neutron/opts.py +++ b/neutron/opts.py @@ -50,6 +50,7 @@ import neutron.conf.plugins.ml2.drivers.mech_sriov.mech_sriov_conf import neutron.conf.plugins.ml2.drivers.openvswitch.mech_ovs_conf import neutron.conf.plugins.ml2.drivers.ovs_conf +import neutron.conf.policy import neutron.conf.quota import neutron.conf.service import neutron.conf.services.extdns_designate_driver @@ -185,7 +186,8 @@ def list_opts(): neutron.conf.common.core_opts, neutron.conf.wsgi.socket_opts, neutron.conf.service.SERVICE_OPTS, - neutron.conf.service.RPC_EXTRA_OPTS) + neutron.conf.service.RPC_EXTRA_OPTS, + neutron.conf.policy.OWNER_CHECK_OPTS) ), (neutron.conf.common.NOVA_CONF_SECTION, itertools.chain( diff --git a/neutron/policy.py b/neutron/policy.py index 13a6032612d..e68cd8ba4e9 100644 --- a/neutron/policy.py +++ b/neutron/policy.py @@ -19,6 +19,7 @@ import re import sys +from neutron.conf.policy import register_owner_check_opts from neutron_lib.api import attributes from neutron_lib.api.definitions import network as net_apidef from neutron_lib import constants @@ -61,6 +62,8 @@ enforce_scope=True, enforce_new_defaults=True) +register_owner_check_opts() + def reset(): global _ENFORCER @@ -278,7 +281,8 @@ def __init__(self, kind, match): raise exceptions.PolicyInitError( policy="%s:%s" % (kind, match), reason=err_reason) - self._cache = cache._get_memory_cache_region(expiration_time=5) + et = cfg.CONF.owner_check_cache_expiration_time + self._cache = cache._get_memory_cache_region(expiration_time=et) super(OwnerCheck, self).__init__(kind, match) # NOTE(slaweq): It seems we need to have it like that, otherwise we hit From 37710027ec1dc8f6bcdccd1cefb86e54abcddc5f Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Mon, 2 Oct 2023 17:32:13 +0200 Subject: [PATCH 133/184] Don't break metadata with missing content-type When sending an OPTIONS request to the metadata agent this request gets proxied to Nova, which then returns an empty HTTP response with content-length: 0 and no content-type key. This results in a KeyError, when we try to access this attribute. Using get() solves this and results in sending no content-type header to the client who requested this. In our environment this can be reproduced with: curl http://169.254.169.254/lol -X OPTIONS --- neutron/agent/metadata/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/agent/metadata/agent.py b/neutron/agent/metadata/agent.py index 35a7daac969..cffba59496f 100644 --- a/neutron/agent/metadata/agent.py +++ b/neutron/agent/metadata/agent.py @@ -281,7 +281,7 @@ def _proxy_request(self, instance_id, tenant_id, req): }) if resp.status_code == 200: - req.response.content_type = resp.headers['content-type'] + req.response.content_type = resp.headers.get('content-type') req.response.body = resp.content LOG.debug(str(resp)) return req.response From 97d7c57898786743ad5386d95fc9dc6168b66889 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Mon, 30 Oct 2023 14:36:45 +0100 Subject: [PATCH 134/184] Prefer default gw with ip on port on DHCP Agent When multiple subnets are configured in the same network, the DHCP agent will choose the first suitable subnet with a gateway ip set as default gateway, where the subnets are in a generally stable, but arbitrary order. In some cases only some of the subnets in a network actually have a router in it, meaning a DHCP agent might loose Internet access (used for example for DNS). Now we sort the subnets before selecting a default gateway. Subnets are sorted first by if their gateway ip is present on a port in the network and then by their created_at time, so we always have a stable order. Also, using the first created subnet seems like a good decision, as it will probably be the one with the least fluctuations. We won't filter for a router port, as the user could also provide a custom router via a VM in their network. In case no subnet is present on a port we will still use one of them as default route, just to preserve the old behavior. --- neutron/agent/linux/dhcp.py | 18 ++++++++++++++++ neutron/tests/unit/agent/dhcp/test_agent.py | 13 ++++++++++-- neutron/tests/unit/agent/linux/test_dhcp.py | 23 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index 98ad9df8264..f888e8ab45e 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -1536,7 +1536,19 @@ def _set_default_route_ip_version(self, network, device_name, ip_version): if gateway: gateway = gateway.get('gateway') + # sort subnets by if they have their gateway ip present on a port + # and then by subnet created time + subnets_extended = [] for subnet in network.subnets: + gw_ip_on_port = False + if subnet.gateway_ip: + gw_ip_on_port = any(fixed_ip.ip_address == subnet.gateway_ip + for port in network.ports + for fixed_ip in port.fixed_ips) + subnets_extended.append((subnet, gw_ip_on_port)) + subnets_extended.sort(key=lambda sn: (not sn[1], sn[0].created_at)) + + for subnet, gw_ip_on_port in subnets_extended: skip_subnet = ( subnet.ip_version != ip_version or not subnet.enable_dhcp or @@ -1564,6 +1576,12 @@ def _set_default_route_ip_version(self, network, device_name, ip_version): 'on net %(n)s to %(ip)s', {'n': network.id, 'ip': subnet.gateway_ip, 'version': ip_version}) + if not gw_ip_on_port: + LOG.warning('No port with gateway ip found for ' + 'IPv%(version)s gateway on ' + 'net %(n)s for %(ip)s', + {'n': network.id, 'ip': subnet.gateway_ip, + 'version': ip_version}) # Check for and remove the on-link route for the old # gateway being replaced, if it is outside the subnet diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index 013aa9143ef..65e80ac26f9 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -70,7 +70,8 @@ ip_version=const.IP_VERSION_4, subnetpool_id=FAKE_V4_SUBNETPOOL_ID, ipv6_ra_mode=None, ipv6_address_mode=None, - allocation_pools=fake_subnet1_allocation_pools) + allocation_pools=fake_subnet1_allocation_pools, + created_at='2023-10-30T15:21:46Z') fake_subnet2_allocation_pools = dhcp.DictModel(id='', start='172.9.8.2', end='172.9.8.254') @@ -81,7 +82,8 @@ gateway_ip='172.9.8.1', host_routes=[], dns_nameservers=[], ip_version=const.IP_VERSION_4, - allocation_pools=fake_subnet2_allocation_pools) + allocation_pools=fake_subnet2_allocation_pools, + created_at='2023-10-30T15:21:46Z') fake_subnet3 = dhcp.DictModel(id='bbbbbbbb-1111-2222-bbbbbbbbbbbb', network_id=FAKE_NETWORK_UUID, @@ -1827,11 +1829,13 @@ def test_cleanup_deleted_ports_loop_call(self): class FakePort1(object): def __init__(self): self.id = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee' + self.fixed_ips = [] class FakePort2(object): def __init__(self): self.id = 'ffffffff-ffff-ffff-ffff-ffffffffffff' + self.fixed_ips = [] class FakeV4Subnet(object): @@ -1842,6 +1846,7 @@ def __init__(self): self.gateway_ip = '192.168.0.1' self.enable_dhcp = True self.subnetpool_id = FAKE_V4_SUBNETPOOL_ID + self.created_at = "2023-10-30T15:21:46Z" class FakeV6Subnet(object): @@ -1852,12 +1857,14 @@ def __init__(self): self.gateway_ip = '2001:db8:0:1::1' self.enable_dhcp = True self.subnetpool_id = FAKE_V6_SUBNETPOOL_ID + self.created_at = "2023-10-30T15:21:46Z" class FakeV4SubnetOutsideGateway(FakeV4Subnet): def __init__(self): super(FakeV4SubnetOutsideGateway, self).__init__() self.gateway_ip = '192.168.1.1' + self.created_at = "2023-10-30T15:21:46Z" class FakeV6SubnetOutsideGateway(FakeV6Subnet): @@ -1873,6 +1880,7 @@ def __init__(self): self.cidr = '192.168.1.0/24' self.gateway_ip = None self.enable_dhcp = True + self.created_at = "2023-10-30T15:21:46Z" class FakeV6SubnetNoGateway(object): @@ -1882,6 +1890,7 @@ def __init__(self): self.cidr = '2001:db8:1:0::/64' self.gateway_ip = None self.enable_dhcp = True + self.created_at = "2023-10-30T15:21:46Z" class FakeV4Network(object): diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index 9eeaa608147..8997af4f68e 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -458,6 +458,7 @@ def __init__(self): self.host_routes = [FakeV4HostRoute()] self.dns_nameservers = ['8.8.8.8'] self.subnetpool_id = 'kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk' + self.created_at = "2023-10-27T05:21:46Z" class FakeV4Subnet2(FakeV4Subnet): @@ -467,6 +468,7 @@ def __init__(self): self.cidr = '192.168.1.0/24' self.gateway_ip = '192.168.1.1' self.host_routes = [] + self.created_at = "2023-10-20T05:21:46Z" class FakeV4SubnetSegmentID(FakeV4Subnet): @@ -597,6 +599,7 @@ def __init__(self): self.enable_dhcp = False self.host_routes = [] self.dns_nameservers = [] + self.created_at = "2023-10-26T05:21:46Z" class FakeV6SubnetDHCPStateful(Dictable): @@ -611,6 +614,7 @@ def __init__(self): self.ipv6_ra_mode = None self.ipv6_address_mode = constants.DHCPV6_STATEFUL self.subnetpool_id = 'mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm' + self.created_at = "2023-10-25T05:21:46Z" class FakeV6SubnetSlaac(object): @@ -826,6 +830,14 @@ def __init__(self): self.namespace = 'qdhcp-ns' +class FakeDualNetworkDualDHCPOneRouter(object): + def __init__(self): + self.id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + self.subnets = [FakeV4Subnet(), FakeV4Subnet2()] + self.ports = [FakePort1(), FakeRouterPort2()] + self.namespace = 'qdhcp-ns' + + class FakeDualNetworkDualDHCPOnLinkSubnetRoutesDisabled(object): def __init__(self): self.id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' @@ -3510,6 +3522,17 @@ def test__setup_reserved_dhcp_port_with_fake_remote_error(self): with testtools.ExpectedException(oslo_messaging.RemoteError): dh.setup_dhcp_port(fake_network, None) + def test_prefer_subnet_with_gateway_ip_on_port_as_default_gateway(self): + with mock.patch.object(dhcp.ip_lib, 'IPDevice') as mock_IPDevice: + device = mock.Mock() + mock_IPDevice.return_value = device + device.route.get_gateway.return_value = None + plugin = mock.Mock() + mgr = dhcp.DeviceManager(self.conf, plugin) + network = FakeDualNetworkDualDHCPOneRouter() + mgr._set_default_route(network, "LOL") + device.route.add_gateway.assert_called_with("192.168.1.1") + class TestDictModel(base.BaseTestCase): From da4eb2158c9076789cc5f9ae41f38742245f911e Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Fri, 22 Dec 2023 17:38:43 +0100 Subject: [PATCH 135/184] Prevent ext subnet gw IP being allocated as fips External subnets might have an external gateway IP outside of the subnets allocation pool. This IP is not allocated by the OpenStack IPAM, but is used as default gateway. A user might create a floating IP for this specific IP, which results in double usage of the IP. To prevent this we disallow allocating gateway IPs of external subnets as floating IPs. This commit only checks that on FIP creation the IP is not a gateway IP of any subnet it is allocated inside. We do not check this condition if the subnet's gateway IP is modified. This bug has been discussed with upstream, but was found to be invalid by them, because in the default configuration only admins can create a FIP with a predetermined IP address. For us this is different, but additionally I think we also shouldn't allow admins to do this, as in our infra this brings the network down. Discussion with upstream can be found here: https://bugs.launchpad.net/neutron/+bug/1959699 --- neutron/db/l3_db.py | 8 ++++++++ neutron/tests/unit/extensions/test_l3.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/neutron/db/l3_db.py b/neutron/db/l3_db.py index e419be02eb0..bf16b62a7e7 100644 --- a/neutron/db/l3_db.py +++ b/neutron/db/l3_db.py @@ -1485,6 +1485,14 @@ def _create_floatingip(self, context, floatingip, msg = _("Network %s does not contain any IPv4 subnet") % f_net_id raise n_exc.BadRequest(resource='floatingip', msg=msg) + if validators.is_attr_set(fip.get('floating_ip_address')): + for subnet in f_net_db.subnets: + if subnet.gateway_ip == fip['floating_ip_address']: + msg = _("Floating ip %s cannot be allocated, " + "as it is also the gateway ip of subnet %s") % ( + fip['floating_ip_address'], subnet.id) + raise n_exc.BadRequest(resource='floatingip', msg=msg) + # This external port is never exposed to the tenant. # it is used purely for internal system and admin use when # managing floating IPs. diff --git a/neutron/tests/unit/extensions/test_l3.py b/neutron/tests/unit/extensions/test_l3.py index 899bd122d7e..34e87d6f7cb 100644 --- a/neutron/tests/unit/extensions/test_l3.py +++ b/neutron/tests/unit/extensions/test_l3.py @@ -3536,6 +3536,19 @@ def test_create_floatingip_with_specific_ip_out_of_subnet(self): http_status=exc.HTTPBadRequest.code, as_admin=True) + def test_create_floatingip_with_specific_ip_gateway_of_subnet(self): + + with self.subnet(cidr='10.0.0.0/24', gateway_ip='10.0.0.1') as s: + network_id = s['subnet']['network_id'] + self._set_net_external(network_id) + req = self._make_floatingip(self.fmt, network_id, + floating_ip='10.0.0.1', + http_status=exc.HTTPBadRequest.code, + as_admin=True) + self.assertRegex(req['NeutronError']['message'], + "ip 10.0.0.1 cannot be allocated, as it is also " + f"the gateway ip of subnet {s['subnet']['id']}") + def test_create_floatingip_with_duplicated_specific_ip(self): with self.subnet(cidr='10.0.0.0/24') as s: From ec8936573d10f742333856f92b7f850422b576c7 Mon Sep 17 00:00:00 2001 From: Vassil Dimitrov Date: Mon, 11 Mar 2024 14:22:48 +0100 Subject: [PATCH 136/184] Introducing the edns_client_fingerprint option. 1. Motivation and goals The motivation is to assist the upstream DNS resolvers in identifying the clients using the neutron dnsmasq resolvers. The actual goal is monitoring of the DNS traffic for suspicious activity and uncovering compromised systems. 2. Implementation This is done by passing each client's IP address and network id to the upstream resolvers by adding that information as eDNS payload to the DNS query going out. If this boolean option is toggled then the following two command line options will be appended to dnsmasq: --add-cpe-id= --umbrella The former will add the network id to eDNS option 65074. The latter will add the client IP to eDNS option 20292. 3. Caveat The `--umbrella` option was introduced in dnsmasq 2.86. A dnsmasq version check is in place to make sure the option does not get activated if the currently installed version is older than that. In this case a warning will be logged and the fingerprinting will be reduced to the network id only. --- neutron/agent/linux/dhcp.py | 15 ++++++++ neutron/cmd/sanity/checks.py | 31 ++++++++++++++++ neutron/cmd/sanity_check.py | 15 ++++++++ neutron/conf/agent/dhcp.py | 6 ++- neutron/tests/unit/agent/linux/test_dhcp.py | 41 +++++++++++++++++++++ 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index f888e8ab45e..4d51a9f400e 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -41,6 +41,7 @@ from neutron.agent.linux import ip_lib from neutron.agent.linux import iptables_manager from neutron.cmd import runtime_checks as checks +from neutron.cmd.sanity import checks as sanity_checks from neutron.common.ovn import constants as ovn_constants from neutron.common.ovn import utils as ovn_utils from neutron.common import utils as common_utils @@ -450,6 +451,7 @@ class Dnsmasq(DhcpLocalProcess): _IS_DHCP_RELEASE6_SUPPORTED = None _IS_HOST_TAG_SUPPORTED = None + _IS_UMBRELLA_SUPPORTED = None @classmethod def check_version(cls): @@ -579,6 +581,12 @@ def _build_cmdline_callback(self, pid_file): cmd.append('--log-dhcp') cmd.append('--log-facility=%s' % log_filename) + # fingerprint the client (network id + client IP) + if self.conf.edns_client_fingerprint: + cmd.append('--add-cpe-id=%s' % self.network.id) + if self._is_dnsmasq_umbrella_supported(): + cmd.append('--umbrella') + return cmd def spawn_process(self): @@ -621,6 +629,13 @@ def _is_dnsmasq_host_tag_supported(self): return self._IS_HOST_TAG_SUPPORTED + def _is_dnsmasq_umbrella_supported(self): + if self._IS_UMBRELLA_SUPPORTED is None: + self._IS_UMBRELLA_SUPPORTED = ( + sanity_checks.dnsmasq_umbrella_supported()) + + return self._IS_UMBRELLA_SUPPORTED + def _release_lease(self, mac_address, ip, ip_version, client_id=None, server_id=None, iaid=None): """Release a DHCP lease.""" diff --git a/neutron/cmd/sanity/checks.py b/neutron/cmd/sanity/checks.py index ad56987eca5..d65481e538e 100644 --- a/neutron/cmd/sanity/checks.py +++ b/neutron/cmd/sanity/checks.py @@ -56,6 +56,7 @@ DNSMASQ_VERSION_DHCP_RELEASE6 = '2.76' DNSMASQ_VERSION_HOST_ADDR6_LIST = '2.81' DNSMASQ_VERSION_SEGFAULT_ISSUE = '2.86' +DNSMASQ_VERSION_UMBRELLA = '2.86' DIRECT_PORT_QOS_MIN_OVS_VERSION = '2.11' MINIMUM_DIBBLER_VERSION = '1.0.1' CONNTRACK_GRE_MODULE = 'nf_conntrack_proto_gre' @@ -244,6 +245,10 @@ def get_dnsmasq_version_with_host_addr6_list(): return DNSMASQ_VERSION_HOST_ADDR6_LIST +def get_dnsmasq_version_with_umbrella(): + return DNSMASQ_VERSION_UMBRELLA + + def get_ovs_version_for_qos_direct_port_support(): return DIRECT_PORT_QOS_MIN_OVS_VERSION @@ -322,6 +327,32 @@ def dhcp_release6_supported(): return priv_dhcp.dhcp_release6_supported() +def dnsmasq_umbrella_supported(): + try: + cmd = ['dnsmasq', '--version'] + env = {'LC_ALL': 'C'} + out = agent_utils.execute(cmd, addl_env=env) + m = re.search(r"version (\d+\.\d+)", out) + ver = versionutils.convert_version_to_tuple(m.group(1) if m else '0.0') + if ver >= versionutils.convert_version_to_tuple( + DNSMASQ_VERSION_UMBRELLA): + return True + + LOG.warning('Support for the `--umbrella` dnsmasq option ' + 'was introduced in dnsmasq version ' + '%(required)s. Found dnsmasq version %(current)s. ' + 'DNS client fingerprinting will not include client IPs.', + {'required': DNSMASQ_VERSION_UMBRELLA, + 'current': ver}) + except (OSError, RuntimeError, IndexError, ValueError) as e: + LOG.debug("Exception while checking dnsmasq for `--umbrella` option " + " support. " + "Exception: %s", e) + + # unsupported unless explicitly stated otherwise + return False + + def bridge_firewalling_enabled(): for proto in ('arp', 'ip', 'ip6'): knob = 'net.bridge.bridge-nf-call-%stables' % proto diff --git a/neutron/cmd/sanity_check.py b/neutron/cmd/sanity_check.py index e2e2347c4ab..603910623bc 100644 --- a/neutron/cmd/sanity_check.py +++ b/neutron/cmd/sanity_check.py @@ -288,6 +288,17 @@ def check_dhcp_release6(): return result +def check_dnsmasq_umbrella_supported(): + result = checks.dnsmasq_umbrella_supported() + if not result: + LOG.warning('The installed version of dnsmasq does not support ' + 'the `--umbrella` option. ' + 'Please update to at least version %s if you need ' + 'full DNS client fingerprinting.', + checks.get_dnsmasq_version_with_umbrella()) + return result + + def check_bridge_firewalling_enabled(): result = checks.bridge_firewalling_enabled() if not result: @@ -420,6 +431,9 @@ def check_ovn_sb_db_schema_chassis_private(): help=_('Check conntrack installation')), BoolOptCallback('dhcp_release6', check_dhcp_release6, help=_('Check dhcp_release6 installation')), + BoolOptCallback('dnsmasq_umbrella_supported', + check_dnsmasq_umbrella_supported, + help=_('Check dnsmasq support for `--umbrella` option')), BoolOptCallback('bridge_firewalling', check_bridge_firewalling_enabled, help=_('Check bridge firewalling'), default=False), @@ -483,6 +497,7 @@ def enable_tests_from_config(): if cfg.CONF.dhcp_driver == 'neutron.agent.linux.dhcp.Dnsmasq': cfg.CONF.set_default('dnsmasq_local_service_supported', True) cfg.CONF.set_default('dnsmasq_version', True) + cfg.CONF.set_default('dnsmasq_umbrella_supported', True) if cfg.CONF.l3_ha: cfg.CONF.set_default('keepalived_ipv6_support', True) cfg.CONF.set_default('ip_nonlocal_bind', True) diff --git a/neutron/conf/agent/dhcp.py b/neutron/conf/agent/dhcp.py index be472bf235a..f3684907cf1 100644 --- a/neutron/conf/agent/dhcp.py +++ b/neutron/conf/agent/dhcp.py @@ -112,7 +112,11 @@ help=_("Use broadcast in DHCP replies.")), cfg.BoolOpt('dnsmasq_enable_addr6_list', default=False, help=_("Enable dhcp-host entry with list of addresses when " - "port has multiple IPv6 addresses in the same subnet.")) + "port has multiple IPv6 addresses in the same " + "subnet.")), + cfg.BoolOpt('edns_client_fingerprint', default=False, + help=_("Add the network id and client IP as an eDNS payload " + "to each client DNS query sent to the DNS resolvers")), ] diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index 8997af4f68e..01280063e84 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -33,6 +33,7 @@ from neutron.agent.linux import dhcp from neutron.agent.linux import ip_lib from neutron.cmd import runtime_checks as checks +from neutron.cmd.sanity import checks as sanity_checks from neutron.common.ovn import constants as ovn_const from neutron.common import utils as common_utils from neutron.conf.agent import common as config @@ -1645,6 +1646,46 @@ def test_spawn_cfg_multiple_dns_server(self): '--server=9.9.9.9', '--domain=openstacklocal']) + def test_dnsmasq_umbrella_support_check(self): + with mock.patch('neutron.agent.linux.utils.execute') \ + as dnsmasq_version_mock: + + # no umbrella support in 2.85 and below + dnsmasq_version_mock.return_value = "version 2.85" + self.assertFalse(sanity_checks.dnsmasq_umbrella_supported()) + + # umbrella support introduced in 2.86 + dnsmasq_version_mock.return_value = "version 2.86" + self.assertTrue(sanity_checks.dnsmasq_umbrella_supported()) + + def test_spawn_cfg_edns_client_fingerprint_disabled(self): + self.conf.set_override('edns_client_fingerprint', False) + self._test_spawn(['--conf-file=', + '--domain=openstacklocal']) + + @mock.patch.object(sanity_checks, + 'dnsmasq_umbrella_supported', return_value=True) + def test_spawn_cfg_edns_client_fingerprint_with_umbrella(self, + mock_umbrella_supported): + self.conf.set_override('edns_client_fingerprint', True) + network = FakeDualNetwork() + self._test_spawn(['--conf-file=', + '--domain=openstacklocal', + '--add-cpe-id=%s' % network.id, + '--umbrella'], + network=network) + + @mock.patch.object(sanity_checks, + 'dnsmasq_umbrella_supported', return_value=False) + def test_spawn_cfg_edns_client_fingerprint_without_umbrella(self, + mock_umbrella_supported): + self.conf.set_override('edns_client_fingerprint', True) + network = FakeDualNetwork() + self._test_spawn(['--conf-file=', + '--domain=openstacklocal', + '--add-cpe-id=%s' % network.id], + network=network) + def test_spawn_cfg_enable_dnsmasq_log(self): self.conf.set_override('dnsmasq_base_log_dir', '/tmp') network = FakeV4Network() From 2def33a7df29f7e7e4b0034b751a3f38c64b062e Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Tue, 12 Mar 2024 19:34:17 +0100 Subject: [PATCH 137/184] Don't delete already deleted extra router routes When handling the deletion of extra routes we need to handle the case that the route is already deleted by another call in the time we have fetched the extra routes and try to delete it. This is a classic race condition when two calls try to update the routes of a router at the same time. The default MariaDB/MySQL transaction isolation level does not suffice to prevent this scenario. Directly deleting the route without fetching it solves this problem. Change-Id: Ie8238310569eb7c1c53296195800bef5c9cb92a3 Closes-Bug: #2057698 --- neutron/db/extraroute_db.py | 4 +- neutron/tests/unit/db/test_extraroute_db.py | 46 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/neutron/db/extraroute_db.py b/neutron/db/extraroute_db.py index a8af9cd00f2..7c346c7b43f 100644 --- a/neutron/db/extraroute_db.py +++ b/neutron/db/extraroute_db.py @@ -133,11 +133,11 @@ def _update_extra_routes(self, context, router, routes): LOG.debug('Removed routes are %s', removed) for route in removed: - l3_obj.RouterRoute.get_object( + l3_obj.RouterRoute.delete_objects( context, router_id=router['id'], destination=route['destination'], - nexthop=route['nexthop']).delete() + nexthop=route['nexthop']) return added, removed @staticmethod diff --git a/neutron/tests/unit/db/test_extraroute_db.py b/neutron/tests/unit/db/test_extraroute_db.py index 2bdf0c1aad1..34ff72eb354 100644 --- a/neutron/tests/unit/db/test_extraroute_db.py +++ b/neutron/tests/unit/db/test_extraroute_db.py @@ -22,6 +22,7 @@ from neutron_lib.plugins import directory from neutron.db import extraroute_db +from neutron.objects import router as l3_obj from neutron.tests.unit import testlib_api @@ -156,3 +157,48 @@ def test_remove_extra_routes(self): {"destination": "10.0.10.0/24", "nexthop": "10.0.0.10"}, ] self.assertEqual([], self._plugin._remove_extra_routes(old, remove)) + + def test_update_routes_where_route_vanishes_while_on_delete(self): + ctx = context.get_admin_context() + create_request = { + 'router': { + 'name': 'my router', + 'tenant_id': 'my tenant', + 'admin_state_up': True, + } + } + router = self._plugin.create_router(ctx, create_request) + self.assertCountEqual(router['routes'], []) + router_id = router['id'] + routes = [ + {'destination': '10.0.0.0/24', 'nexthop': '1.1.1.4'}, + {'destination': '10.1.0.0/24', 'nexthop': '1.1.1.3'}, + {'destination': '10.2.0.0/24', 'nexthop': '1.1.1.2'}, + ] + self._test_update_routes(ctx, router_id, router, routes) + routes = [ + {'destination': '10.0.0.0/24', 'nexthop': '1.1.1.4'}, + {'destination': '10.1.0.0/24', 'nexthop': '1.1.1.3'}, + ] + + def _remove_last_route(orig_func): + def _wrapper(ctx, router_id): + routes = orig_func(ctx, router_id) + + # forcefully delete route to 10.2.0.0/24 + ctx2 = context.get_admin_context() + l3_obj.RouterRoute.get_object( + ctx2, + router_id=router_id, + destination="10.2.0.0/24", + nexthop="1.1.1.2").delete() + return routes + + return _wrapper + + with mock.patch.object(self._plugin, '_get_extra_routes_by_router_id', + wraps=_remove_last_route( + self._plugin._get_extra_routes_by_router_id)) \ + as mock_get_routes: + self._test_update_routes(ctx, router_id, router, routes) + mock_get_routes.assert_called_once() From 3b6eec619f349f927534fc028e41c1a7472ddb8b Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Mon, 15 Apr 2024 15:39:27 +0200 Subject: [PATCH 138/184] Start logging plugin RPC via service framework Instead of the LoggingServiceDriverManager starting the RPC if any driver needs it, we now only start it when this is requested by neutron via start_rpc_listeners(). This is required when running neutron-server and neutron-rpc-server separately to run RPC only in neutron-rpc-server. Change-Id: I8d185cdc807e94098c137314bcaa2317a2f85ebe Partial-Bug: #2062009 --- neutron/services/logapi/drivers/manager.py | 4 +++- neutron/services/logapi/logging_plugin.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/neutron/services/logapi/drivers/manager.py b/neutron/services/logapi/drivers/manager.py index 55465c92d53..69a2cd620eb 100644 --- a/neutron/services/logapi/drivers/manager.py +++ b/neutron/services/logapi/drivers/manager.py @@ -70,7 +70,6 @@ def __init__(self): registry.publish(log_const.LOGGING_PLUGIN, events.AFTER_INIT, self) if self.rpc_required: - self._start_rpc_listeners() self.logging_rpc = server_rpc.LoggingApiNotification() @property @@ -92,6 +91,9 @@ def register_driver(self, driver): self._setup_resources_cb_handle() def _start_rpc_listeners(self): + if not self.rpc_required: + return [] + self._skeleton = server_rpc.LoggingApiSkeleton() return self._skeleton.conn.consume_in_threads() diff --git a/neutron/services/logapi/logging_plugin.py b/neutron/services/logapi/logging_plugin.py index 358f34670ee..c99e298d9a3 100644 --- a/neutron/services/logapi/logging_plugin.py +++ b/neutron/services/logapi/logging_plugin.py @@ -50,6 +50,9 @@ def supported_logging_types(self): # supported_logging_types are be dynamically loaded from log_drivers return self.driver_manager.supported_logging_types + def start_rpc_listeners(self): + return self.driver_manager._start_rpc_listeners() + def _clean_logs(self, context, sg_id=None, port_id=None): with db_api.CONTEXT_WRITER.using(context): sg_logs = log_db_api.get_logs_bound_sg( From 6e3dcd4d349de963d3a4a2830cd14460c21381de Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Mon, 15 Apr 2024 16:14:50 +0200 Subject: [PATCH 139/184] Start trunk plugin RPC via service framework Instead of each individual driver setting up the RPC server (and setting the _rpc_backend attribute on the TrunkPlugin) we now check in the TrunkPlugin if any driver requires the RPC backend to be started. Additionally, we only start it when this is requested by Neutron via start_rpc_listeners(). This is required when running neutron-server and neutron-rpc-server separately to run RPC only in neutron-rpc-server. As we still need the notifiers of ServerSideRpcBackend to be created/started, we separate TrunkSkeleton (which is the RPC server implementation) and ServerSideRpcBackend (which is essentially only a notifier). In case RPC is required by a driver, we always start the notifier, but the RPC server only when requested via start_rpc_listeners(). Change-Id: I2c6362b3320e534a6e65bd7701b5ac2feca42a49 Closes-Bug: #2015275 Closes-Bug: #2062009 --- neutron/services/trunk/drivers/base.py | 11 +++++----- neutron/services/trunk/plugin.py | 22 +++++++++++++------ neutron/services/trunk/rpc/backend.py | 3 +-- neutron/services/trunk/rpc/server.py | 7 +++++- .../services/trunk/rpc/test_server.py | 4 ++-- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/neutron/services/trunk/drivers/base.py b/neutron/services/trunk/drivers/base.py index 4ae4802f58f..25793f4c08c 100644 --- a/neutron/services/trunk/drivers/base.py +++ b/neutron/services/trunk/drivers/base.py @@ -19,8 +19,6 @@ from neutron_lib.callbacks import registry from neutron_lib.callbacks import resources -from neutron.services.trunk.rpc import backend - @registry.has_registry_receivers class DriverBase(object): @@ -82,7 +80,8 @@ def register(self, resource, event, trigger, payload=None): """ trigger.register_driver(self) - # Set up the server-side RPC backend if the driver is loaded, - # it is agent based, and the RPC backend is not already initialized. - if self.is_loaded and self.agent_type and not trigger.is_rpc_enabled(): - trigger.set_rpc_backend(backend.ServerSideRpcBackend()) + + @property + def rpc_required(self): + """True if this driver requires the RPC backend to be started""" + return self.is_loaded and self.agent_type diff --git a/neutron/services/trunk/plugin.py b/neutron/services/trunk/plugin.py index edb98ef5dea..881146bcf43 100644 --- a/neutron/services/trunk/plugin.py +++ b/neutron/services/trunk/plugin.py @@ -37,6 +37,8 @@ from neutron.objects import trunk as trunk_objects from neutron.services.trunk import drivers from neutron.services.trunk import exceptions as trunk_exc +from neutron.services.trunk.rpc import backend +from neutron.services.trunk.rpc import server from neutron.services.trunk import rules from neutron.services.trunk.seg_types import validators @@ -55,7 +57,8 @@ class TrunkPlugin(service_base.ServicePluginBase): __filter_validation_support = True def __init__(self): - self._rpc_backend = None + self._rpc_server = None + self._rpc_notifier = None self._drivers = [] self._segmentation_types = {} self._interfaces = set() @@ -64,6 +67,10 @@ def __init__(self): registry.subscribe(rules.enforce_port_deletion_rules, resources.PORT, events.BEFORE_DELETE) registry.publish(resources.TRUNK_PLUGIN, events.AFTER_INIT, self) + if any(drv.rpc_required for drv in self._drivers): + # create notifier backend + self._rpc_notifier = backend.ServerSideRpcBackend() + for driver in self._drivers: LOG.debug('Trunk plugin loaded with driver %s', driver.name) self.check_compatibility() @@ -91,6 +98,13 @@ def _extend_port_trunk_details(port_res, port_db): return port_res + def start_rpc_listeners(self): + if not any(drv.rpc_required for drv in self._drivers): + return [] + + self._rpc_server = server.TrunkSkeleton() + return self._rpc_server.rpc_servers + @staticmethod @resource_extend.extends([port_def.COLLECTION_NAME_BULK]) def _extend_port_trunk_details_bulk(ports_res, noop): @@ -155,12 +169,6 @@ def check_segmentation_compatibility(self): raise trunk_exc.SegmentationTypeValidatorNotFound( seg_type=seg_type) - def set_rpc_backend(self, backend): - self._rpc_backend = backend - - def is_rpc_enabled(self): - return self._rpc_backend is not None - def register_driver(self, driver): """Register driver with trunk plugin.""" if driver.agent_type: diff --git a/neutron/services/trunk/rpc/backend.py b/neutron/services/trunk/rpc/backend.py index f52c0c194ac..18d91edabc7 100644 --- a/neutron/services/trunk/rpc/backend.py +++ b/neutron/services/trunk/rpc/backend.py @@ -28,10 +28,9 @@ class ServerSideRpcBackend(object): def __init__(self): """Initialize an RPC backend for the Neutron Server.""" - self._skeleton = server.TrunkSkeleton() self._stub = server.TrunkStub() - LOG.debug("RPC backend initialized for trunk plugin") + LOG.debug("RPC notifier initialized for trunk plugin") for event_type in (events.AFTER_CREATE, events.AFTER_DELETE): registry.subscribe(self.process_event, diff --git a/neutron/services/trunk/rpc/server.py b/neutron/services/trunk/rpc/server.py index d8e45616df8..be5fd86a896 100644 --- a/neutron/services/trunk/rpc/server.py +++ b/neutron/services/trunk/rpc/server.py @@ -71,7 +71,12 @@ def __init__(self): self._connection = n_rpc.Connection() self._connection.create_consumer( constants.TRUNK_BASE_TOPIC, [self], fanout=False) - self._connection.consume_in_threads() + self._rpc_servers = self._connection.consume_in_threads() + LOG.debug("RPC backend initialized for trunk plugin") + + @property + def rpc_servers(self): + return self._rpc_servers @property def core_plugin(self): diff --git a/neutron/tests/functional/services/trunk/rpc/test_server.py b/neutron/tests/functional/services/trunk/rpc/test_server.py index 40ae1cae703..c9a05fb6348 100644 --- a/neutron/tests/functional/services/trunk/rpc/test_server.py +++ b/neutron/tests/functional/services/trunk/rpc/test_server.py @@ -26,13 +26,13 @@ class TrunkSkeletonTestCase(ml2_test_base.ML2TestFramework): def setUp(self): super(TrunkSkeletonTestCase, self).setUp() self.trunk_plugin = trunk_plugin.TrunkPlugin() + self.trunk_plugin.start_rpc_listeners() def test__handle_port_binding_set_device_owner(self): helpers.register_ovs_agent(host=helpers.HOST) with self.port() as subport: port = ( - self.trunk_plugin. - _rpc_backend._skeleton._handle_port_binding( + self.trunk_plugin._rpc_server._handle_port_binding( self.context, subport['port']['id'], mock.ANY, helpers.HOST)) self.assertEqual( From 4ff040a2bba0fc2333e1531a8c8c1b014d0c3822 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Wed, 24 Apr 2024 13:35:52 +0200 Subject: [PATCH 140/184] Remove ucsm driver from custom requirements The driver is no longer needed in our infra and therefore doesn't need to be part of our image anymore. --- custom-requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index acfdd5fc8fd..9850e793c34 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -33,7 +33,6 @@ git+https://github.com/sapcc/openstack-rate-limit-middleware.git#egg=rate-limit- -e git+https://github.com/sapcc/networking-aci.git@stable/yoga-m3#egg=networking_aci[acicobra] -e git+https://github.com/sapcc/networking-manila.git@stable/yoga-m3#egg=networking_manila -e git+https://github.com/sapcc/networking-f5.git@stable/yoga-m3#egg=networking_f5 --e git+https://github.com/sapcc/networking-ucsm-bm.git@stable/yoga-m3#egg=networking-ucsm-bm -e git+https://github.com/sapcc/networking-arista.git@stable/yoga-m3#egg=networking_arista -e git+https://github.com/sapcc/networking-nsx-t.git@stable/yoga-m3#egg=networking_nsxv3 -e git+https://github.com/sapcc/networking-bgpvpn@stable/yoga-m3#egg=networking-bgpvpn From 53bca3134769b37219b600c51ad37dc2bf1fc5a1 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Fri, 17 May 2024 17:20:43 +0200 Subject: [PATCH 141/184] Support on DHCP-agent for internal v6 networks When we have a network with stateful DHCPv6 we still need router advertisements to tell the hosts the prefix length of the current network (else all hosts will just have /128 routes). In cases where a network does not have a router (or don't use an l3 plugin with ra support) we do not have something in the network that sends out ras (radvd itself is launched on the l3 agent with Neutron's default implementation). Therefore we now let dnsmasq take over this task. With --enable-ra we enable dnsmasq's ra implementation. We use --ra-param=$iface,0,0 to deactivate that dnsmasq is advertising itself as default gateway. Params are (interface, use default ra-interval, router lifetime). --- neutron/agent/linux/dhcp.py | 10 ++++++++++ neutron/conf/agent/dhcp.py | 5 +++++ neutron/tests/unit/agent/linux/test_dhcp.py | 10 ++++++++++ 3 files changed, 25 insertions(+) diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index 4d51a9f400e..5b3cb504394 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -495,6 +495,7 @@ def _build_cmdline_callback(self, pid_file): ] possible_leases = 0 + enable_ra = False for subnet in self._get_all_subnets(self.network): mode = None # if a subnet is specified to have dhcp disabled @@ -511,6 +512,9 @@ def _build_cmdline_callback(self, pid_file): constants.DHCPV6_STATELESS] or not addr_mode and not ra_mode): mode = 'static' + if addr_mode == constants.DHCPV6_STATEFUL and \ + ra_mode in (constants.DHCPV6_STATEFUL, None): + enable_ra = True cidr = netaddr.IPNetwork(subnet.cidr) @@ -547,6 +551,12 @@ def _build_cmdline_callback(self, pid_file): cmd.append('--dhcp-lease-max=%d' % min(possible_leases, self.conf.dnsmasq_lease_max)) + if self.conf.enable_router_advertisements and enable_ra: + LOG.debug("Enabling ra in dnsmasq for network %s", self.network.id) + cmd.append('--enable-ra') + iface_name = self.interface_name or '*' + cmd.append(f'--ra-param={iface_name},0,0') + if self.conf.dhcp_renewal_time > 0: cmd.append('--dhcp-option-force=option:T1,%ds' % self.conf.dhcp_renewal_time) diff --git a/neutron/conf/agent/dhcp.py b/neutron/conf/agent/dhcp.py index f3684907cf1..4b545ed4e6f 100644 --- a/neutron/conf/agent/dhcp.py +++ b/neutron/conf/agent/dhcp.py @@ -117,6 +117,11 @@ cfg.BoolOpt('edns_client_fingerprint', default=False, help=_("Add the network id and client IP as an eDNS payload " "to each client DNS query sent to the DNS resolvers")), + cfg.BoolOpt('enable_router_advertisements', default=False, + help=_("Enable IPv6 router advertisements from dnsmasq. " + "This only supplements DHCPv6 by announcing the " + "network's prefix length and does not announce " + "a default gateway.")), ] diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index 01280063e84..4f7f4a22049 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -1742,6 +1742,16 @@ def test_spawn_cfg_with_dhcp_timers(self): self._test_spawn(['--conf-file=', '--domain=openstacklocal'], dhcp_t1=30, dhcp_t2=100) + def test_spawn_cfg_with_stateful_dhcpv6_and_ra_enabled(self): + self.conf.set_override('enable_router_advertisements', True) + network = FakeV6NetworkStatefulDHCPSameSubnetFixedIps() + + self._test_spawn(['--enable-ra', + '--ra-param=tap0,0,0', + '--conf-file=', + '--domain=openstacklocal', + ], network) + def _test_output_init_lease_file(self, timestamp): expected = [ '00:00:80:aa:bb:cc 192.168.0.2 * *', From ade891d9f0fd530bb1befcd2162d7f2e490e5922 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Fri, 19 Apr 2024 14:33:43 +0200 Subject: [PATCH 142/184] Disallow deletion of active port bindings Currently it is possible to delete a active binding via the port binding API extension. Deleting a active port binding leaves the port in a state of not being editable anymore. Each attempt editing the port results in a PortNotFound error. During a nova live migration a port entered the state described above. For some reasons nova was not able to set the binding of the new target host to active, thus the live migration rollback process started. During the rollback, the newly created binding got deleted (apparently already set to active) leaving the port with no active binding. Thus setting old binding information was not possible anymore. Fixing such a zombie requires to set the status of a binding to active (either via db directly or port binding extension) --- neutron/plugins/ml2/plugin.py | 7 ++++ neutron/tests/unit/plugins/ml2/test_plugin.py | 38 +++++++++++++------ .../unit/plugins/ml2/test_port_binding.py | 21 ++++++++++ 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index 012c2ef2d31..e5432bce011 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -2883,6 +2883,13 @@ def activate(self, context, host, port_id): @utils.transaction_guard @db_api.retry_if_session_inactive() def delete_port_binding(self, context, host, port_id): + port_db = self._get_port(context, port_id) + binding = self._get_binding_for_host(port_db.port_bindings, host) + if not binding: + raise exc.PortBindingNotFound(port_id=port_id, host=host) + if binding.status == const.ACTIVE: + raise exc.PortBindingInStatusActive(port_id=port_id, host=host) + ports_obj.PortBinding.delete_objects(context, host=host, port_id=port_id) diff --git a/neutron/tests/unit/plugins/ml2/test_plugin.py b/neutron/tests/unit/plugins/ml2/test_plugin.py index 2a9293f9839..b4c447d37d4 100644 --- a/neutron/tests/unit/plugins/ml2/test_plugin.py +++ b/neutron/tests/unit/plugins/ml2/test_plugin.py @@ -2275,21 +2275,37 @@ def test__device_to_port_id_UUID(self): self.context, port_id)) @mock.patch.object(ml2_db, 'clear_binding_levels') - @mock.patch.object(port_obj.PortBinding, 'delete_objects') def test_delete_port_binding_delete_binding_and_levels( self, - clear_bl_mock, - delete_port_binding_mock): - port_id = uuidutils.generate_uuid() + clear_bl_mock): host = 'fake-host' plugin = directory.get_plugin() - plugin.delete_port_binding(self.context, host, port_id) - clear_bl_mock.assert_called_once_with(self.context, - port_id=port_id, - host=host) - delete_port_binding_mock.assert_called_once_with(self.context, - host=host, - port_id=port_id) + # mock port and binding to skip conditions introduced + # for the deletion of port bindings + host_arg = {portbindings.HOST_ID: host} + with self.port(device_owner='compute:xyz', is_admin=True, + arg_list=(portbindings.HOST_ID,), + **host_arg) as port: + port_id = port['port']['id'] + # make binding in active so it can be deleted + with db_api.CONTEXT_WRITER.using(self.context): + port_binding = self.context.session.query( + models.PortBinding).filter( + models.PortBinding.port_id == port_id).one() + port_binding.status = constants.INACTIVE + # reset clear_binding_level made during creation + clear_bl_mock.reset_mock() + + with mock.patch.object(port_obj.PortBinding, + 'delete_objects') as delete_port_binding_mock: + plugin.delete_port_binding(self.context, host, port_id) + clear_bl_mock.assert_called_once_with(self.context, + port_id=port_id, + host=host) + delete_port_binding_mock.assert_called_with( + self.context, + host=host, + port_id=port_id) def test__validate_port_supports_multiple_bindings(self): plugin = directory.get_plugin() diff --git a/neutron/tests/unit/plugins/ml2/test_port_binding.py b/neutron/tests/unit/plugins/ml2/test_port_binding.py index 1cc97002da1..83abbc94206 100644 --- a/neutron/tests/unit/plugins/ml2/test_port_binding.py +++ b/neutron/tests/unit/plugins/ml2/test_port_binding.py @@ -667,6 +667,17 @@ def test_delete_non_existing_port_binding(self): response = self._delete_port_binding(port['id'], 'other-host') self.assertEqual(webob.exc.HTTPNotFound.code, response.status_int) + def test_delete_active_port_binding(self): + port, new_binding = self._create_port_and_binding() + with mock.patch.object(mechanism_test.TestMechanismDriver, + '_check_port_context'): + self._activate_port_binding( + port['id'], self.host, raw_response=False) + response = self._delete_port_binding(port['id'], self.host) + self.assertEqual(webob.exc.HTTPConflict.code, response.status_int) + self.assertEqual(exceptions.PortBindingInStatusActive.__name__, + response.json["NeutronError"]['type']) + def test_binding_fail_for_unknown_allocation(self): # The UUID is a random one - which of course is unknown to neutron # as a resource provider UUID. @@ -811,6 +822,16 @@ def test_bind_pf_port_with_mac_port_updated(self): ) # delete the remaining binding + # a custom patch prevents active port binding deletion + # make the remaining binding inactive first + + ctx = context.get_admin_context() + with db_api.CONTEXT_WRITER.using(ctx): + port_binding = ctx.session.query( + ml2_models.PortBinding).filter( + ml2_models.PortBinding.port_id == port['id']).one() + port_binding.status = const.INACTIVE + response = self._delete_port_binding(port['id'], host1) self.assertEqual(webob.exc.HTTPNoContent.code, response.status_int) # the MAC should not change as it is already the generated MAC From d934d129d2576abebd2978b76bb93df0f6707347 Mon Sep 17 00:00:00 2001 From: Johannes Kulik Date: Mon, 4 Jul 2022 09:24:10 +0200 Subject: [PATCH 143/184] Retry port bind on parallel segment deletion It can happen, that we clean up a network segment while there's already another port binding in progress. That port binding can then find the segment in the first steps, but is later unable to commit the binding to DB as a foreign key constraint fails. We catch the foreign key constraint failing (DBReferenceError) and raise a retryable RetryRequest instance instead, which the retry_db_errors() decorator around _bind_port_if_needed() should handle. --- neutron/plugins/ml2/plugin.py | 5 +- neutron/tests/unit/plugins/ml2/test_plugin.py | 184 ++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index e5432bce011..87f1589b644 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -785,8 +785,9 @@ def _commit_port_binding(self, orig_context, bind_context, if update_binding_levels: db.clear_binding_levels(plugin_context, port_id, cur_binding.host) - db.set_binding_levels(plugin_context, - bind_context._binding_levels) + with db_api.exc_to_retry(os_db_exception.DBReferenceError): + db.set_binding_levels(plugin_context, + bind_context._binding_levels) # Expire the "binding_levels" and fetch them into the port. plugin_context.session.flush() getattr(port_db, 'binding_levels') diff --git a/neutron/tests/unit/plugins/ml2/test_plugin.py b/neutron/tests/unit/plugins/ml2/test_plugin.py index b4c447d37d4..23f86a8f1c4 100644 --- a/neutron/tests/unit/plugins/ml2/test_plugin.py +++ b/neutron/tests/unit/plugins/ml2/test_plugin.py @@ -2780,6 +2780,190 @@ def test__bind_port_if_needed_concurrent_calls(self): # nothing due to the missing binding levels npu_mock.assert_called_once_with(ret_context) + def test__bind_port_if_needed_concurrent_calls_segment_cleanup(self): + """Parallel cleanup of network segments during a bind + + A parallel task in the background deletes the network segment we're + trying to bind our port against. This creates a `DBReferenceError` on + `set_binding_levels()`. + + We start with a Network having 2 segments, one base and one created by + our driver binding the first level. During the binding process, we + delete the second binding - which is referenced by the + PortBindingLevel, thus creating a DBReferenceError. + On retry, we check that the network of our context does not contain + that deleted segment again. This will trigger the driver binding the + first level to create a new segment - which we simulate here by + manually creating one and returning a PortBindingLevel for it. + """ + # create a network + # create a subnet + # create a port in the subnet - the port is not bound + # create an additional segment in the network + # check that our PortContext.network.network_segments contains our + # segments + # convince _bind_port_if_needed() to use these 2 segments in a binding + # during the binding process, delete the level-1 binding + # expect a DBReferenceError from db.set_binding_levels() + # expect a retry + # expect the PortContext.network.network_segments to contain only one + # segment (level 0), i.e. segments were fetched from the DB again + # expect db.set_binding_levels() to be called twice + with self.network() as n: + network = n + network_id = n['network']['id'] + with self.subnet(network=network) as sn: + subnet = sn + + host = 'fake_host' + host_arg = {portbindings.HOST_ID: host} + with self.port(subnet=subnet, is_admin=True, + arg_list=(portbindings.HOST_ID,), + **host_arg) as p: + port = p + + level_1_segment_1 = {driver_api.NETWORK_TYPE: 'vlan', + driver_api.PHYSICAL_NETWORK: 'physnet1', + driver_api.SEGMENTATION_ID: 1, + 'id': uuidutils.generate_uuid(), + 'network_id': network_id} + # used later on retry + level_1_segment_2 = {driver_api.NETWORK_TYPE: 'vlan', + driver_api.PHYSICAL_NETWORK: 'physnet1', + driver_api.SEGMENTATION_ID: 2, + 'id': uuidutils.generate_uuid(), + 'network_id': network_id} + segments_db.add_network_segment( + self.context, network['network']['id'], level_1_segment_1, + is_dynamic=True) + + plugin = directory.get_plugin() + port_db = plugin._get_port(self.context, port['port']['id']) + binding = p_utils.get_port_binding_by_status_and_host( + port_db.port_bindings, + constants.ACTIVE) + # Generates port context to be used before the bind. + port_context = driver_context.PortContext( + plugin, self.context, port['port'], + plugin.get_network(self.context, network_id), + binding, None) + + self.assertIsNotNone(next( + (x_ for x_ in port_context.network.network_segments + if x_['id'] == level_1_segment_1['id']), + None)) + + def _bind_port(port_context): + if _bind_port.call_count == 0: + # delete our level 1 segment from the DB + segments_db.delete_network_segment( + self.context, level_1_segment_1['id']) + + # use our level 1 segment and return bindings for it + level_0_segment_id = next( + x_ for x_ in port_context.network.network_segments + if x_['physical_network'] is None)['id'] + port_context._clear_binding_levels() + binding_levels = [ + port_obj.PortBindingLevel( + self.context, + port_id=port['port']['id'], + level=0, + driver='fake_agent', + segment_id=level_0_segment_id, + host='fake_host'), + port_obj.PortBindingLevel( + self.context, + port_id=port['port']['id'], + level=1, + driver='fake_agent', + segment_id=level_1_segment_1['id'], + host='fake_host')] + port_context._binding_levels = binding_levels + port_context._binding.vif_type = portbindings.VIF_TYPE_OVS + + _bind_port.call_count += 1 + elif _bind_port.call_count == 1: + # check that we do not see the old, deleted segment anymore + self.assertEqual(1, len(port_context.network.network_segments)) + self.assertIsNone(next( + (x_ for x_ in port_context.network.network_segments + if x_['id'] == level_1_segment_1['id']), + None)) + # create a new level 1 segment + segments_db.add_network_segment( + self.context, network['network']['id'], level_1_segment_2, + is_dynamic=True) + + # use our new level 1 segment in a binding + level_0_segment_id = next( + x_ for x_ in port_context.network.network_segments + if x_['physical_network'] is None)['id'] + port_context._clear_binding_levels() + binding_levels = [ + port_obj.PortBindingLevel( + self.context, + port_id=port['port']['id'], + level=0, + driver='fake_agent', + segment_id=level_0_segment_id, + host='fake_host'), + port_obj.PortBindingLevel( + self.context, + port_id=port['port']['id'], + level=1, + driver='fake_agent', + segment_id=level_1_segment_2['id'], + host='fake_host')] + port_context._binding_levels = binding_levels + port_context._binding.vif_type = portbindings.VIF_TYPE_OVS + + _bind_port.call_count += 1 + else: + raise Exception('Mocked _bind_port got called more than twice') + + _bind_port.call_count = 0 + + orig_set_binding_levels = ml2_db.set_binding_levels + + def _set_binding_levels(*args, **kwargs): + call_count = _set_binding_levels.call_count + _set_binding_levels.call_count += 1 + try: + return_value = orig_set_binding_levels(*args, **kwargs) + except db_exc.RetryRequest: + if call_count != 0: + raise AssertionError('Second call to set_binding_levels() ' + 'raised RetryRequest') + else: + raise + else: + if call_count == 0: + raise AssertionError('First call to set_binding_levels() ' + 'did not raise RetryRequest') + + return return_value + + _set_binding_levels.call_count = 0 + + with mock.patch('neutron.plugins.ml2.managers.MechanismManager.' + 'bind_port', side_effect=_bind_port), \ + mock.patch('neutron.plugins.ml2.db.set_binding_levels', + wraps=_set_binding_levels) as sbl_mock, \ + mock.patch.object(mech_test.TestMechanismDriver, + 'update_port_precommit'), \ + mock.patch.object(mech_test.TestMechanismDriver, + 'update_port_postcommit'): + + plugin._bind_port_if_needed(port_context, allow_notify=True) + self.assertEqual(2, sbl_mock.call_count) + + binding_level = next( + pbl for pbl in port_obj.PortBindingLevel.get_objects( + self.context, port_id=port['port']['id'], host='fake_host') + if pbl.level == 1) + self.assertEqual(level_1_segment_2['id'], binding_level.segment_id) + def test__commit_port_binding_populating_with_binding_levels(self): port_vif_type = portbindings.VIF_TYPE_OVS bound_vif_type = portbindings.VIF_TYPE_OVS From 94de0db05f62fe158ca91e08be73cc7fb984268d Mon Sep 17 00:00:00 2001 From: Dmitry Galkin Date: Thu, 18 Jul 2024 16:58:26 +0200 Subject: [PATCH 144/184] Update the PTR record during FIP allocation if exists. Currently Neutron-Designate integration will fail if target PTR record already exists. This can happen if the FIP was in use before or if PTR was created manually. Instead of failing with error we want to update existing PTR in such case. --- .../services/externaldns/drivers/designate/driver.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/neutron/services/externaldns/drivers/designate/driver.py b/neutron/services/externaldns/drivers/designate/driver.py index 9169bbcaaea..30a62c501b4 100644 --- a/neutron/services/externaldns/drivers/designate/driver.py +++ b/neutron/services/externaldns/drivers/designate/driver.py @@ -98,6 +98,17 @@ def create_record_set(self, context, dns_domain, dns_name, records): designate_admin.recordsets.create(in_addr_zone_name, in_addr_name, 'PTR', [recordset_name]) + except d_exc.Conflict: + # It can happen that we have left-over or manually created PTR + # from before (e.g. by a project that was using same FIP). + # If PTR exists, update it even if it is 'managed'. + c_designate, c_designate_admin = get_clients(context, + edit_managed=True) + recordset_dict = {'records': [recordset_name]} + # Use own instance of admin client as a precaution + c_designate_admin.recordsets.update(in_addr_zone_name, + in_addr_name, + recordset_dict) except d_exc.NotFound: # Note(jh): If multiple PTRs get created at the same time, # the creation of the zone may fail with a conflict because From 2efab8c919ee6dd5a45b30fdb536ce48cd0c26d8 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Tue, 30 Apr 2024 16:25:49 +0200 Subject: [PATCH 145/184] Use custom neutron-lib --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e8d0e8ff40f..15aa47305e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,8 @@ Jinja2>=2.10 # BSD License (3 clause) keystonemiddleware>=5.1.0 # Apache-2.0 netaddr>=0.7.18 # BSD netifaces>=0.10.4 # MIT -neutron-lib>=3.9.0 # Apache-2.0 +#neutron-lib>=3.9.0 # Apache-2.0 +neutron-lib @ git+https://github.com/sapcc/neutron-lib@stable/2024.1-m3 python-neutronclient>=7.8.0 # Apache-2.0 tenacity>=6.0.0 # Apache-2.0 SQLAlchemy>=1.4.23 # MIT From 324b0d4272a1b5527f08394b88faaae07bffbc65 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Mon, 30 Sep 2024 11:09:17 +0200 Subject: [PATCH 146/184] Run Unittest with python 3.10 --- concourse_unit_test_task | 6 +++--- neutron/conf/.DS_Store | Bin 0 -> 8196 bytes 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 neutron/conf/.DS_Store diff --git a/concourse_unit_test_task b/concourse_unit_test_task index 09041f9c805..8348ebf7534 100755 --- a/concourse_unit_test_task +++ b/concourse_unit_test_task @@ -1,9 +1,9 @@ export DEBIAN_FRONTEND=noninteractive && \ -export TOX_CONSTRAINTS_FILE=https://raw.githubusercontent.com/sapcc/requirements/stable/yoga-m3/upper-constraints.txt && \ +export TOX_CONSTRAINTS_FILE=https://raw.githubusercontent.com/sapcc/requirements/stable/2024.1-m3/upper-constraints.txt && \ apt-get update && \ apt-get install -y build-essential python3-pip python3-dev git libpcre++-dev gettext sudo iproute2 && \ pip install -U pip && \ pip install tox "six>=1.14.0" && \ -git clone -b stable/yoga-m3 --single-branch https://github.com/sapcc/neutron.git --depth=1 && \ +git clone -b stable/2024.1-m3 --single-branch https://github.com/sapcc/neutron.git --depth=1 && \ cd neutron && \ -tox -e pep8,py38 +tox -e pep8,py310 diff --git a/neutron/conf/.DS_Store b/neutron/conf/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..33f2f3814e1146877ad7d769175eddae6cc80c4d GIT binary patch literal 8196 zcmeHMU2GIp6h3EK=$)aZQ=p|VS-P@du(nXzLXjWY{!pxz-)-q1u*~j^bYwbHc4l{h zTGJ;?6rzbrj6aWRP{V_XiI^CTf6>GjOhW{WFZy77@X3T1A3S&NE}?%R@xh-kH<^3p z-h0lSIp6)xy>oT}06VhAW`J4%V03Y*E}?3k!pHeFr$~jKQj$pa0BNwm1TU5M#^-2< z9U%fC0wDq+0wDq+0{;aB=*;GYEpYCO(Xb8?2obn15#aknlrApg1vw>Tuys%sqyQv& z3J?^!r#v9Ai56tMAg6>3OleM$Jz&8U3n>N)a~h9wb&~OdoDx!)0}6A%!jrL(p$iIz%8uU@ihYd>D*_3pVBMcjfOX+fC8f$Ek7K^8BWz>nN3!E?Qj1%GeTi zEHUcl6Ta?ey$(Gy!drc=Yp06$dg_Q}=9B7*PS^2u%P}*7fn}Jaa&*9Q3^(85W*x%| zY!hOD$xKeFt0yKJH@7r5H$+>vZk=q1PHb!4+z@SR+cG&Rv+CHU9fy;r>@ml^Ae_O% zh5!|_oVz%mXSSE?6rPo=E_(Ju(KA_Dq74ix!-H1ZbrRM&GpSa3}bQdP5_dDtX9D`$3*HkmHtGaVTB zE!Xjmm>G{`EZ!tuU%paa9a;O}!%bVZ@9yf^cWAm|X{B1VOi_jjBgeO%w9HI@-ZK2r zA>B)xj$t_`2XdBYrEIg$(Z|dxRAhO&qGo07s@0mdkm_JIWu4Ai{#iA~*tEK|vO=lT z9+1@!*-?XDa-VDPCmF$rR;R6JY=rlBW=yI@x3WR2msOqhdE~N~qEXo-tDf9J#!nPIxXlcPfvvDr}Gk)9%<9`RR%7cCC}CU$W!;*}b~w z9~d{C;#+lVk1_QdHtdb+8S_A%I3{emwO+=8m7{)cw8yY~HxodzzfRMJ_+;&dVH)Il zZOzIGEur-@^?R~WEhlFYt7$<5xmhe;FV-c%R$eiSWBk-w1#6)dcEUjz0t51J0bYSi z@D^NwtMDm&3)kQ}+<=>K3x0v$;1Bo{7hxGPlyMm@$7-y>7&c%dZp7`_iCx%@d$AY$ z@dys$5FW>qIEqiu zf5hMMwuF)-l}P1Mg;Xi2QcPMWHA>A=i||4!M$X7gucR(&&)E|wa>hmENT=tEoJjXx zrRQIboEzmLR+g`*UK88U*t~T|{0^N20gIfwo67-xXOKRiZ?N98c_)HOiS!e{%`*$d zmRiP{%%-_^T~vvvizos_bKQDsE++Pi=0o+7h$>OgiRQ*l5k)Pbkdg!2mU?Ppq0zQY zQPffjE_r&@%Gw%bm$F;tp{_!p+ulJl@lauZpR$+X1NaC&gYSs4Kf%xNE8IrF#aK=h zU4v1g={j6bG;P9WY{6FCg}bpGJBYNsxE~MVAv}yDcnp(x0(GLSjbrFw9?ueOpC;m- z$7k_5d>$|2C43cM!`JbRfWYtJ`?Co7_)J3LC-V_9m&iD_>zt&qB9UjBg{^xaP$I@_ zdH%m=;otx7F-tfcA`l|*e?|aHyAoX; Date: Mon, 16 Sep 2024 16:56:30 +0200 Subject: [PATCH 147/184] [FWaaS] Add SAP flavored fwaas --- custom-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/custom-requirements.txt b/custom-requirements.txt index 9850e793c34..20051f0a316 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -38,3 +38,4 @@ git+https://github.com/sapcc/openstack-rate-limit-middleware.git#egg=rate-limit- -e git+https://github.com/sapcc/networking-bgpvpn@stable/yoga-m3#egg=networking-bgpvpn -e git+https://github.com/sapcc/networking-interconnection@stable/yoga-m3#egg=networking_interconnection -e git+https://github.com/sapcc/networking-ccloud@stable/yoga-m3#egg=networking_ccloud +-e git+https://github.com/sapcc/neutron-fwaas@stable/yoga-m3#egg=neutron_fwaas From a4b96b10475469afbc405ff709304b34a31be12a Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Thu, 1 Aug 2024 10:38:49 +0200 Subject: [PATCH 148/184] [neutron-dhcp-agent] creating resolv.conf in namespaces Using the 'regular' /etc/resolv.conf inside the network namespaces prevents proper DNS resolution, as these nameservers usually are unreachable from within the namespaces. This change enables creation of a resolv.conf in each namespace. The new setting netns_resolvconf enables this feature, by default it is disabled. Default nameservers are 127.0.0.1 and ::1 if IPv6 is enabled, for search domains the networks dns_domain is used if set. These defaults can be overwritten via settings file. To skip setting nameservers, search domains or options in the generated file and not use the dynamic defaults, one can add the settings key in the configuration file but leave out any value, e.g.: netns_resolvconf = yes netns_resolvconf_search = netns_resolvconf_options = netns_resolvconf_nameservers = This would create an empty resolv.conf. --- neutron/agent/linux/dhcp.py | 55 ++++ neutron/conf/agent/dhcp.py | 17 ++ neutron/tests/unit/agent/dhcp/test_agent.py | 321 ++++++++++++++++++++ neutron/tests/unit/agent/linux/test_dhcp.py | 43 +-- 4 files changed, 409 insertions(+), 27 deletions(-) diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index 5b3cb504394..bf7f53f83a1 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -1535,6 +1535,8 @@ def should_enable_metadata(cls, conf, network): class DeviceManager(object): + NNS_RESOLVCONF_PATH = "/etc/netns/" + def __init__(self, conf, plugin): self.conf = conf self.plugin = plugin @@ -1876,6 +1878,11 @@ def setup(self, network, segment=None): ip_lib.IPWrapper().ensure_namespace(network.namespace) ip_lib.set_ip_nonlocal_bind_for_namespace(network.namespace, 1, root_namespace=True) + # We should now have a network namespace, lets configure + # it to use the locally running dnsmasq for DNS + if self.conf.netns_resolvconf: + self._write_resolvconf(network.namespace) + if netutils.is_ipv6_enabled(): self.driver.configure_ipv6_ra(network.namespace, 'default', constants.ACCEPT_RA_DISABLED) @@ -1962,3 +1969,51 @@ def fill_dhcp_udp_checksums(self, namespace): iptables_mgr.ipv4['mangle'].add_rule('POSTROUTING', ipv4_rule) iptables_mgr.ipv6['mangle'].add_rule('POSTROUTING', ipv6_rule) iptables_mgr.apply() + + def _write_resolvconf(self, netns): + rconf_path = f"{self.NNS_RESOLVCONF_PATH}/{netns}" + + try: + os.makedirs(rconf_path, exist_ok=True) + except OSError as err: + LOG.error("Failed to create directory '%s' " + "for resolv.conf in namespace '%s' -- %s", + rconf_path, netns, err) + return + + try: + with open(f"{rconf_path}/resolv.conf", "w") as rcnf: + + rcnf.write("# autogenerated by neutron dhcp_agent\n" + f"# for namespace {netns}\n") + + if self.conf.netns_resolvconf_options: + rcnf.write("options " + f"{self.conf.netns_resolvconf_options}\n") + + cfg_search = self.conf.netns_resolvconf_search + + if cfg_search is None: + # only if the option is not set, + # use our (dynamic) default + cfg_search = self.conf.dns_domain + + # This allows the admin to skip search domains by + # configuring an empty string: + if cfg_search: + rcnf.write(f"search {cfg_search}\n") + + cfg_nameservers = self.conf.netns_resolvconf_nameservers + + if cfg_nameservers is None: + cfg_nameservers = [] + if netutils.is_ipv6_enabled(): + cfg_nameservers.append('::1') + cfg_nameservers.append('127.0.0.1') + + for nameserver in cfg_nameservers: + rcnf.write(f"nameserver {nameserver}\n") + + except (OSError, ValueError) as err: + LOG.error("Failed to create resolv.conf in namespace '%s' -- %s", + netns, err) diff --git a/neutron/conf/agent/dhcp.py b/neutron/conf/agent/dhcp.py index 4b545ed4e6f..9204a79115f 100644 --- a/neutron/conf/agent/dhcp.py +++ b/neutron/conf/agent/dhcp.py @@ -75,6 +75,23 @@ 'This will only be invoked if the value is not 0. ' 'If a network has N updates in X seconds then ' 'it will reload once and not N times.')), + cfg.BoolOpt('netns_resolvconf', default=False, + help=_("Create a resolv.conf in each network namespace to use " + "the local dnsmasq for DNS." + )), + cfg.ListOpt('netns_resolvconf_nameservers', + help=_("List of DNS servers to be configured inside network " + "namespaces. " + "If not set uses ::1 (if IPv6 is enabled) and " + "127.0.0.1 (always) by default.")), + cfg.StrOpt('netns_resolvconf_options', + default="timeout:2 no-tld-query edns0 attempts:5", + help=_("resolv.conf options inside network namespaces")), + cfg.StrOpt('netns_resolvconf_search', + default=None, + help=_("resolv.conf search domains inside network namespaces. " + "If not set uses the dns_domain. Set to empty string " + "to disable search parameter")), ] DHCP_OPTS = [ diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index 65e80ac26f9..07fbc456073 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -16,8 +16,10 @@ import collections import copy import datetime +import os import signal import sys +from tempfile import TemporaryDirectory from unittest import mock import uuid @@ -2496,6 +2498,325 @@ def test_set_default_route_two_subnets(self): self.assertFalse(device.route.delete_gateway.called) device.route.add_gateway.assert_has_calls(expected) + def test_resolvconf_in_netns_created(self): + # check if we write a resolv.conf + + netns = 'netns' + + with TemporaryDirectory() as tmpdir: + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + self.assertTrue(os.path.isfile(resolvconf)) + + def test_resolvconf_in_netns_idempotent(self): + # check if the call is idempotent + # and raises no exception if the file already exists + + netns = 'netns' + + with TemporaryDirectory() as tmpdir: + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + self.assertTrue(os.path.isfile(resolvconf)) + + # now (try to) write it again without exceptions + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + self.assertTrue(os.path.isfile(resolvconf)) + + def test_resolvconf_in_netns_permissions_dir(self): + # Check that missing permissions do not + # raise an unhandled exception breaking the agent setup + # but ensure an error gets logged. + + netns = 'netns' + + with self.assertLogs('neutron.agent.linux.dhcp', + level='ERROR') as log: + with TemporaryDirectory() as tmpdir: + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + + # need to mock, because when tests are running + # as root in a container, permissions will be ignored! + with mock.patch("builtins.open") as mock_open: + with mock.patch("os.makedirs", + side_effect=IOError('mocked error') + ) as mock_mkdirs: + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + + self.assertFalse(os.path.isfile(resolvconf)) + + # we fail early when the directory does not exist and + # can not be created, so open should never be called. + + mock_mkdirs.assert_called() + mock_open.assert_not_called() + + expecting = ('ERROR:neutron.agent.linux.dhcp:' + 'Failed to create directory') + + # log.output is a list of strings, so a simple "in" + # will not work for a partial match, str() helps + self.assertIn(expecting, str(log.output)) + + def test_resolvconf_in_netns_permissions_file(self): + # Check that missing permissions do not + # raise an unhandled exception breaking the agent setup + # but ensure an error gets logged. + + netns = 'netns' + + with self.assertLogs('neutron.agent.linux.dhcp', + level='ERROR') as log: + with TemporaryDirectory() as tmpdir: + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + + # need to mock, because when tests are running + # as root in a container, permissions will be ignored! + with mock.patch("builtins.open", + side_effect=PermissionError('mocked error') + ) as mock_open: + with mock.patch("os.makedirs") as mock_mkdirs: + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + + self.assertFalse(os.path.isfile(resolvconf)) + + mock_mkdirs.assert_called() + mock_open.assert_called() + + expecting = ('ERROR:neutron.agent.linux.dhcp:' + 'Failed to create resolv.conf') + + # log.output is a list of strings, so a simple "in" + # will not work for a partial match, str() helps + self.assertIn(expecting, str(log.output)) + + @staticmethod + def _read_resolvconf(resolvconf, filter_keywords=False): + settings = set() + valid_keywords = set(('options', 'search', 'nameserver')) + + with open(resolvconf, 'r') as f: + for line in f.readlines(): + keyword, *values = line.strip().split(' ', 1) + # ignore empty lines and comments + if keyword and not keyword.startswith('#'): + if filter_keywords and keyword not in valid_keywords: + # some tests only check valid settings + continue + value = values[0] if values else None + settings.add((keyword, value)) + return settings + + def test_resolvconf_in_netns_test_parser(self): + # with most resolvconf tests depending on the helper function, + # _read_resolvconf, lets test that one as well here + + with TemporaryDirectory() as tmpdir: + resolvconf = f"{tmpdir}/resolv.conf" + with open(resolvconf, 'w') as f: + f.write("# comment\n") + f.write("\n") + f.write(" \n") + f.write("#commented out\n") + f.write("invalid setting\n") + f.write("orphaned \n") + f.write("nameserver 1.2.3\n") + f.write("nameserver 4.5.6\n") + f.write("search domain a b c\n") + f.write("options go here\n") + + settings = self._read_resolvconf(resolvconf) + filtered = self._read_resolvconf(resolvconf, filter_keywords=True) + + expected_filtered = set(( + ('nameserver', '1.2.3'), + ('nameserver', '4.5.6'), + ('search', 'domain a b c'), + ('options', 'go here'), + )) + + invalid_settings = set((('invalid', 'setting'), ('orphaned', None))) + expected_settings = expected_filtered | invalid_settings + + self.assertEqual(settings, expected_settings) + self.assertEqual(filtered, expected_filtered) + + def test_resolvconf_in_netns_is_complete(self): + # check if the resolv.conf has all default settings + + netns = 'netns' + + cfg.CONF.set_override('dns_domain', 'some.dns.domain.') + + with TemporaryDirectory() as tmpdir: + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + + required_keywords = set(('nameserver', 'search', 'options')) + # we must not set filter_keywords to True here in any case, + # this test is supposed to check for invalid keywords/settings + settings = self._read_resolvconf(resolvconf) + found_keywords = set([x[0] for x in settings]) + + self.assertEqual(required_keywords, found_keywords) + + def test_resolvconf_in_netns_settings(self): + # check if the resolv.conf gets all configured settings + + netns = 'netns' + + cfg.CONF.set_override('netns_resolvconf_nameservers', + ['foo-ns-1', 'foo-ns-2']) + + cfg.CONF.set_override('netns_resolvconf_search', + 'some search domains') + + cfg.CONF.set_override('netns_resolvconf_options', + 'some dns opts') + + expected_settings = set(( + ("options", "some dns opts"), + ("search", "some search domains"), + ("nameserver", "foo-ns-1"), + ("nameserver", "foo-ns-2"), + )) + + with TemporaryDirectory() as tmpdir: + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + + # only check our settings, completeness/correctness is + # already checked in test_resolvconf_in_netns_is_complete + found_settings = self._read_resolvconf(resolvconf, + filter_keywords=True) + + self.assertEqual(expected_settings, found_settings) + + def test_resolvconf_in_netns_defaults(self): + # check if the resolv.conf gets all default settings + + netns = 'netns' + + cfg.CONF.set_override('dns_domain', 'some.dns.domain.') + + for ipv6_enabled in True, False: + + with mock.patch.object(netutils, 'is_ipv6_enabled') as mock_v6: + mock_v6.return_value = ipv6_enabled + + expected_settings = set(( + ("options", cfg.CONF.netns_resolvconf_options), + ("search", cfg.CONF.dns_domain), + ("nameserver", "127.0.0.1"), + )) + + if ipv6_enabled: + expected_settings.add(("nameserver", "::1")) + + with TemporaryDirectory() as tmpdir: + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + + # only check our settings, completeness/correctness is + # already checked in test_resolvconf_in_netns_is_complete + found_settings = self._read_resolvconf(resolvconf, + filter_keywords=True) + + self.assertEqual(expected_settings, found_settings) + + def test_resolvconf_in_netns_settings_allow_skip(self): + # check if settings in the resolv.conf can be skipped + # by setting the config options to an empty value + + netns = 'netns' + + # The defaults of the settings have to be None, so we can distinguish + # between the option not being present in the config file, so we use + # our dynamic defaults, e.g. the search domain, and the case where the + # operator intentionally sets the config setting to an empty value to + # prevent us from using any defaults and skip the keyword in the + # resolv.conf file entirely. + # + # As this is not necessarily obvious, these assertions + # should prevent a change in the defaults that would break + # this functionality: + + self.assertIsNone( + cfg.CONF._get_opt_info( + 'netns_resolvconf_nameservers' + )['opt'].default + ) + + self.assertIsNone( + cfg.CONF._get_opt_info( + 'netns_resolvconf_search' + )['opt'].default + ) + + cfg.CONF.set_override('netns_resolvconf_nameservers', + []) + + cfg.CONF.set_override('netns_resolvconf_search', + '') + + cfg.CONF.set_override('netns_resolvconf_options', + '') + + with TemporaryDirectory() as tmpdir: + resolvconf = f"{tmpdir}/{netns}/resolv.conf" + + dh = dhcp.DeviceManager(cfg.CONF, mock.Mock()) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh._write_resolvconf(netns) + + found_settings = self._read_resolvconf(resolvconf) + + # there should be no settings found in the resolv.conf, + # i.e. it should be empty except for a comment + self.assertEqual(set(), found_settings) + + def test_resolvconf_in_netns_setup(self): + # check if we create the resolv.conf (but only when enabled) + + # setting is present and default is False? + self.assertFalse(getattr(cfg, 'netns_resolvconf', None)) + + for write_file in (True, False): + cfg.CONF.set_override('netns_resolvconf', write_file) + with TemporaryDirectory() as tmpdir: + with mock.patch.object(dhcp.ip_lib, 'IPDevice') \ + as mock_IPDevice: + plugin = mock.Mock() + device = mock.Mock() + mock_IPDevice.return_value = device + device.route.get_gateway.return_value = None + net = copy.deepcopy(fake_network) + plugin.create_dhcp_port.return_value = fake_dhcp_port + dh = dhcp.DeviceManager(cfg.CONF, plugin) + dh.NNS_RESOLVCONF_PATH = tmpdir + dh.setup(net) + + resolvconf = f"{tmpdir}/{net.namespace}/resolv.conf" + self.assertEqual(write_file, os.path.isfile(resolvconf)) + class TestDHCPResourceUpdate(base.BaseTestCase): diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index 4f7f4a22049..154844ce5a8 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -24,6 +24,7 @@ from neutron_lib import exceptions from neutron_lib import fixture as lib_fixtures from oslo_config import cfg +from oslo_config import fixture as fixture_config import oslo_messaging from oslo_utils import fileutils from oslo_utils import netutils @@ -1132,13 +1133,15 @@ def spawn_process(self): class TestConfBase(base.BaseTestCase): def setUp(self): super(TestConfBase, self).setUp() - self.conf = config.setup_conf() - self.conf.register_opts(base_config.core_opts) - self.conf.register_opts(dhcp_config.DHCP_OPTS) - self.conf.register_opts(dhcp_config.DNSMASQ_OPTS) - self.conf.register_opts(config.DHCP_PROTOCOL_OPTS) - config.register_external_process_opts(self.conf) - config.register_interface_driver_opts_helper(self.conf) + conf = config.setup_conf() + conf.register_opts(base_config.core_opts) + conf.register_opts(dhcp_config.DHCP_OPTS) + conf.register_opts(dhcp_config.DHCP_AGENT_OPTS) + conf.register_opts(dhcp_config.DNSMASQ_OPTS) + conf.register_opts(config.DHCP_PROTOCOL_OPTS) + config.register_external_process_opts(conf) + config.register_interface_driver_opts_helper(conf) + self.conf = self.useFixture(fixture_config.Config(conf)).conf class TestBase(TestConfBase): @@ -1146,12 +1149,8 @@ def setUp(self): super(TestBase, self).setUp() instance = mock.patch("neutron.agent.linux.dhcp.DeviceManager") self.mock_mgr = instance.start() - self.conf.register_opt(cfg.BoolOpt('enable_isolated_metadata', - default=True)) - self.conf.register_opt(cfg.BoolOpt("force_metadata", - default=False)) - self.conf.register_opt(cfg.BoolOpt('enable_metadata_network', - default=False)) + # default is False, tests expect it to be True: + self.conf.set_override('enable_isolated_metadata', True) self.config_parse(self.conf) self.conf.set_override('state_path', '') @@ -3360,10 +3359,6 @@ def setUp(self): def _test_setup(self, load_interface_driver, ip_lib, use_gateway_ips): with mock.patch.object(dhcp.ip_lib, 'IPDevice') as mock_IPDevice: # Create DeviceManager. - self.conf.register_opt(cfg.BoolOpt('enable_isolated_metadata', - default=False)) - self.conf.register_opt(cfg.BoolOpt('force_metadata', - default=False)) plugin = mock.Mock() device = mock.Mock() mock_IPDevice.return_value = device @@ -3442,12 +3437,10 @@ def _test_setup_reserved(self, enable_isolated_metadata=False, force_metadata=False): with mock.patch.object(dhcp.ip_lib, 'IPDevice') as mock_IPDevice: # Create DeviceManager. - self.conf.register_opt( - cfg.BoolOpt('enable_isolated_metadata', - default=enable_isolated_metadata)) - self.conf.register_opt( - cfg.BoolOpt('force_metadata', - default=force_metadata)) + self.conf.set_override('enable_isolated_metadata', + enable_isolated_metadata) + self.conf.set_override('force_metadata', + force_metadata) plugin = mock.Mock() device = mock.Mock() mock_IPDevice.return_value = device @@ -3515,10 +3508,6 @@ def test_setup_reserved_2(self): """ with mock.patch.object(dhcp.ip_lib, 'IPDevice') as mock_IPDevice: # Create DeviceManager. - self.conf.register_opt( - cfg.BoolOpt('enable_isolated_metadata', default=False)) - self.conf.register_opt( - cfg.BoolOpt('force_metadata', default=False)) plugin = mock.Mock() device = mock.Mock() mock_IPDevice.return_value = device From 1e411d2140598f53abcac6a4dc942a71cc4f8180 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Thu, 8 Aug 2024 14:30:17 +0200 Subject: [PATCH 149/184] [tests] add a --version to the flake8 call to help debugging --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 8d7d182c7b6..1f40ee315a1 100644 --- a/tox.ini +++ b/tox.ini @@ -124,6 +124,7 @@ commands= bash ./tools/misc-sanity-checks.sh bash {toxinidir}/tools/check_unit_test_structure.sh # Checks for coding and style guidelines + flake8 --version flake8 bash ./tools/coding-checks.sh --pylint '{posargs}' neutron-db-manage --config-file neutron/tests/etc/neutron.conf check_migration From f21f4363a851293f7319c5be6aa4714210a07183 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Mon, 25 Nov 2024 09:27:00 +0100 Subject: [PATCH 150/184] [Caracal] Update custom requirements --- custom-requirements.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index 20051f0a316..1d71da5e340 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -29,13 +29,13 @@ git+https://github.com/sapcc/openstack-uwsgi-middleware.git@main#egg=uwsgi-middl git+https://github.com/sapcc/openstack-rate-limit-middleware.git#egg=rate-limit-middleware # Networking Drivers --e git+https://github.com/sapcc/asr1k-neutron-l3@stable/yoga-m3#egg=asr1k-neutron-l3 --e git+https://github.com/sapcc/networking-aci.git@stable/yoga-m3#egg=networking_aci[acicobra] --e git+https://github.com/sapcc/networking-manila.git@stable/yoga-m3#egg=networking_manila --e git+https://github.com/sapcc/networking-f5.git@stable/yoga-m3#egg=networking_f5 --e git+https://github.com/sapcc/networking-arista.git@stable/yoga-m3#egg=networking_arista --e git+https://github.com/sapcc/networking-nsx-t.git@stable/yoga-m3#egg=networking_nsxv3 --e git+https://github.com/sapcc/networking-bgpvpn@stable/yoga-m3#egg=networking-bgpvpn --e git+https://github.com/sapcc/networking-interconnection@stable/yoga-m3#egg=networking_interconnection --e git+https://github.com/sapcc/networking-ccloud@stable/yoga-m3#egg=networking_ccloud --e git+https://github.com/sapcc/neutron-fwaas@stable/yoga-m3#egg=neutron_fwaas +-e git+https://github.com/sapcc/asr1k-neutron-l3@stable/2024.1-m3#egg=asr1k-neutron-l3 +-e git+https://github.com/sapcc/networking-aci.git@stable/2024.1-m3#egg=networking_aci[acicobra] +-e git+https://github.com/sapcc/networking-manila.git@stable/2024.1-m3#egg=networking_manila +-e git+https://github.com/sapcc/networking-f5.git@stable/2024.1-m3#egg=networking_f5 +-e git+https://github.com/sapcc/networking-arista.git@stable/2024.1-m3#egg=networking_arista +-e git+https://github.com/sapcc/networking-nsx-t.git@stable/2024.1-m3#egg=networking_nsxv3 +-e git+https://github.com/sapcc/networking-bgpvpn@stable/2024.1-m3#egg=networking-bgpvpn +-e git+https://github.com/sapcc/networking-interconnection@stable/2024.1-m3#egg=networking_interconnection +-e git+https://github.com/sapcc/networking-ccloud@stable/2024.1-m3#egg=networking_ccloud +-e git+https://github.com/sapcc/neutron-fwaas@stable/2024.1-m3#egg=neutron_fwaas From 8dcac73fa81c07b3c427b2d12ebb0896599a1c0c Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Tue, 7 Mar 2023 17:44:37 +0100 Subject: [PATCH 151/184] Start all workers in neutron rpc servers For the neutron-rpc-server we need to make sure that we start all workers (via the start_all_workers()) as start_rpc_workers() does not seem to start workers of plugins. In our situation this means that for example networking-nsxv3 does not have its API endpoints running and just fails. This manifests in messaging timeouts, as the agent is asking for callbacks that nobody listens on. It looks a bit weird, as we'd expect start_rpc_workers() to start all necessary workers, but, well... this works. Co-Authored-By: Andrew Karpow --- neutron/server/rpc_eventlet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/server/rpc_eventlet.py b/neutron/server/rpc_eventlet.py index a6736e6d444..1db4e085350 100644 --- a/neutron/server/rpc_eventlet.py +++ b/neutron/server/rpc_eventlet.py @@ -34,7 +34,7 @@ def eventlet_rpc_server(): manager.init() ext_mgr = extensions.PluginAwareExtensionManager.get_instance() ext_mgr.extend_resources("2.0", attributes.RESOURCES) - rpc_workers_launcher = service.start_rpc_workers() + rpc_workers_launcher = service.start_all_workers() except NotImplementedError: LOG.info("RPC was already started in parent process by " "plugin.") From 6926c763c27917bb495a29e02a636bf791d86194 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 17 Apr 2025 14:40:51 +0200 Subject: [PATCH 152/184] Add SAPCC SentryEventHandler --- custom-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/custom-requirements.txt b/custom-requirements.txt index 1d71da5e340..d38e15edfa8 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -6,6 +6,7 @@ dumb-init # sentry client raven +git+https://github.com/sapcc/sentrylogger.git#egg=sapcc_sentrylogger # agent checks for neutron openstack-agent-checks From 86e827e96a4b5bd218f90510c34179c5fb9d49e7 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Wed, 30 Apr 2025 11:57:04 +0200 Subject: [PATCH 153/184] Set branch for sappcc sentrylogger installation Adding the sapcc_sentrylogger dependency without a branch name causes an issue once we update our CI pipeline. As no branch is specified master branch is assumed as default causing the pipeline to fail. Once we set the name our scripts updating the CI pipeline will pick up the branch name and do not assume a default anymore. --- custom-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index d38e15edfa8..4375e3af067 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -6,7 +6,7 @@ dumb-init # sentry client raven -git+https://github.com/sapcc/sentrylogger.git#egg=sapcc_sentrylogger +git+https://github.com/sapcc/sentrylogger.git@main#egg=sapcc_sentrylogger # agent checks for neutron openstack-agent-checks From f39dccefdddeb4aa2ff990111c9849c3ca574cc6 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Fri, 28 Mar 2025 13:45:18 +0100 Subject: [PATCH 154/184] support custom network settings per domain for edns logging and DNS This adds support for configuring custom upstream DNS servers and to disable the edns logging for neutron networks, based on the name of the OpenStack domain or, for development, the project id. By changing the upstream DNS servers used by dnsmasq, local hostnames are still resolvable, but queries for external names will be sent to the custom set of resolvers from the configuration. This is in contrast to the existing dns server setting for networks, which changes the nameservers announced via DHCP. The configuration is done in neutron server. When a dhcp-agent first configures a network, it does an rpc request to retrieve all necessary configuration data. On the server side we add the custom DNS servers and a flag for the edns logging to the returned data. The agent code then uses this data to configure the dnsmasq instances accordingly. When that data is absent the local configuration is used as default, like before. To decide if the network is to be configured with a custom set of nameservers, the neutron rpc server will query keystone for the domain of the network and match the domain name against its configuration file. To help development and debugging, support to directly match the id of a project was also added. --- neutron/agent/linux/dhcp.py | 30 ++- neutron/api/rpc/handlers/dhcp_rpc.py | 189 +++++++++++++++- neutron/conf/service.py | 17 ++ neutron/opts.py | 6 +- neutron/service.py | 1 + neutron/tests/unit/agent/linux/test_dhcp.py | 73 ++++++- .../unit/api/rpc/handlers/test_dhcp_rpc.py | 202 ++++++++++++++++++ 7 files changed, 511 insertions(+), 7 deletions(-) diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index bf7f53f83a1..167b5984356 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -17,6 +17,7 @@ import collections import copy import io +import ipaddress import itertools import os import re @@ -567,7 +568,24 @@ def _build_cmdline_callback(self, pid_file): cmd.append('--conf-file=%s' % (self.conf.dnsmasq_config_file.strip() or '/dev/null')) - for server in self.conf.dnsmasq_dns_servers: + + # if the network has custom upstreams set, we will use them instead + if hasattr(self.network, 'dns_custom_upstreams'): + # Do some input validation on the data we got via rpc call, to + # avoid dnsmasq not starting - worst case is we have no dns, but + # at least dhcp is working. if all servers are wrong. Should not + # happen as we are doing validation on the server side as well. + dns_servers = [] + for server in self.network.dns_custom_upstreams: + try: + dns_servers.append(ipaddress.ip_address(server).compressed) + except ValueError: + LOG.error('Invalid DNS server "%s" for network %s', + server, self.network.id) + else: + dns_servers = self.conf.dnsmasq_dns_servers + + for server in dns_servers: cmd.append('--server=%s' % server) if self.conf.dns_domain: @@ -591,8 +609,16 @@ def _build_cmdline_callback(self, pid_file): cmd.append('--log-dhcp') cmd.append('--log-facility=%s' % log_filename) + edns_fingerprinting_enabled = self.conf.edns_client_fingerprint + + if hasattr(self.network, 'dns_ednslogging_enabled'): + if not isinstance(self.network.dns_ednslogging_enabled, bool): + sval = str(self.network.dns_ednslogging_enabled).lower() + self.network.dns_ednslogging_enabled = sval in ('yes', 'true') + edns_fingerprinting_enabled = self.network.dns_ednslogging_enabled + # fingerprint the client (network id + client IP) - if self.conf.edns_client_fingerprint: + if edns_fingerprinting_enabled: cmd.append('--add-cpe-id=%s' % self.network.id) if self._is_dnsmasq_umbrella_supported(): cmd.append('--umbrella') diff --git a/neutron/api/rpc/handlers/dhcp_rpc.py b/neutron/api/rpc/handlers/dhcp_rpc.py index 2faadc600d7..5405edb62fd 100644 --- a/neutron/api/rpc/handlers/dhcp_rpc.py +++ b/neutron/api/rpc/handlers/dhcp_rpc.py @@ -14,9 +14,12 @@ # limitations under the License. import copy +import ipaddress import itertools import operator +from typing import Optional +from keystoneauth1 import loading as ks_loading from neutron_lib.api.definitions import portbindings from neutron_lib.api import extensions from neutron_lib.callbacks import resources @@ -26,6 +29,7 @@ from neutron_lib.exceptions import agent as agent_exc from neutron_lib.plugins import directory from neutron_lib.plugins import utils as p_utils +from openstack import connection from oslo_config import cfg from oslo_db import exception as db_exc from oslo_log import log as logging @@ -43,6 +47,179 @@ LOG = logging.getLogger(__name__) +class DomainLookupFailed(Exception): + pass + + +class CustomNetworkConfigurator: + + def __init__(self): + self._KEYSTONE = None + self._domain_id_cache = {} + self._domain_name_cache = {} + + def add_dnssettings_to_net(self, network_dict): + """Add custom dns settings to the network if the network + is found to be part of one of the custom OpenStack domains + or projects from our settings. + """ + + if not (cfg.CONF.customdns.domain_name_prefixes or + cfg.CONF.customdns.project_ids): + # The config is empty, there is no need to do any lookups + # against keystone + return + + if not self._is_customdns_project(network_dict['project_id']): + return + + # logging always has to be disabled for all custom domains + network_dict['dns_ednslogging_enabled'] = False + + if cfg.CONF.customdns.upstream_dns_servers: + # only set if the config setting isn't empty, so we do not + # break DNS resolution in that domain when the config is + # incomplete. Can also be used intentionally to only disable + # logging but keep the default upstream servers. + addrs = [] + # TODO(mutax): make e.g. custom config item type to validate only + # once on startup + for item in cfg.CONF.customdns.upstream_dns_servers: + try: + addr = ipaddress.ip_address(item) + addrs.append(addr.compressed) + except ValueError: + LOG.error("Custom DNS settings invalid for network %s " + "not a valid IP for DNS: %s", + network_dict['id'], item) + network_dict['dns_custom_upstreams'] = addrs + + LOG.debug("Network %s is in a custom OS-domain, " + "customized DNS settings: " + "dns_ednslogging_enabled=%s, " + "dns_custom_upstreams=%s", + network_dict['id'], + network_dict.get('dns_ednslogging_enabled', 'NOT-SET'), + network_dict.get('dns_custom_upstreams', 'NOT-SET')) + + @property + def _keystone_connection(self): + auth_section = 'nova' # name of the section in the config file + # TODO(mutax): trying to use the section 'keystone_authtoken' + # throws an exception because it misses a timeout setting in + # the section, but nova also misses it? makes no sense, needs + # investigation + # Would be nice to not use the nova service user for that... + + if not self._KEYSTONE: + # this needs to be a Singleton, so we do not pile up sockets + LOG.debug("domainlookup: creating new connection to keystone") + auth = ks_loading.load_auth_from_conf_options( + cfg.CONF, auth_section) + keystone_session = ks_loading.load_session_from_conf_options( + cfg.CONF, auth_section, auth=auth) + self._KEYSTONE = connection.Connection( + session=keystone_session, oslo_conf=cfg.CONF, + connect_retries=cfg.CONF.http_retries) + + return self._KEYSTONE + + def get_domain_name(self, project_id: str) -> str: + """query keystone to get the name of the domain that + the project belongs to. Will cache both the project_id to + domain_id mapping and the domain_id to domain_name mapping. + """ + # this is inspired by code taken from + # class ProjectIdMiddleware in api/extensions.py + + if not project_id: + raise ValueError(_("No project_id provided!")) + + domain_id = self._domain_id_cache.get(project_id) + + if not domain_id: + LOG.debug("domainlookup: project %s not in cache", + project_id) + project = self._keystone_connection.get_project(project_id) + + if not project: + msg = f"Unable to find project {project_id}" + raise DomainLookupFailed(msg) + + domain_id = project.domain_id + if not domain_id: + msg = (f"Project {project_id} has an" + f"invalid (empty) domain id: '{domain_id}'") + raise DomainLookupFailed(msg) + + self._domain_id_cache[project_id] = domain_id + + domain_name = self._domain_name_cache.get(domain_id) + + if not domain_name: + LOG.debug("domainlookup: domain %s for project %s not in cache", + domain_id, project_id) + domain = self._keystone_connection.get_domain(domain_id) + + if not domain: + msg = f"Domain {domain_id} for project {project_id} not found" + raise DomainLookupFailed(msg) + + domain_name = domain.name + self._domain_name_cache[domain_id] = domain_name + + return domain_name + + def _is_customdns_project(self, project_id: Optional[str]) -> bool: + """check if the network is in an OpenStack domain or project that we + want to configure in a custom way. + For domains we use prefix matches on the name, for projects we + directly match on the id. + """ + + # should not happen, but would never match anyway + if not project_id: + return False + + # check if the project-id matches the list for custom settings + if project_id in cfg.CONF.customdns.project_ids: + # this comes in handy for testing, no need for a test-domain! + LOG.debug("domainlookup: project %s matches customdns project ids", + project_id) + return True + + # now try to retrieve the OpenStack domain name via the project id, + # this uses a local cache and on a cache miss queries keystone + domain_name = None + try: + domain_name = self.get_domain_name(project_id) + except Exception as e: # noqa + # If Keystone is not reachable or something goes wrong with + # the lookup, we do not want to fail configuring all networks. + # Currently, the sane thing to do is using default settings in + # those cases. As we want to fail to the default in all error + # cases anyway, we can use a bare Exception here. + # TODO(mutax): I do want to get the stack trace logged, but I + # also want to get a nice warning to the log independent of the + # source of the error - but now we log the same error twice. + LOG.exception('Failed to retrieve domain to set custom dns for' + ' project %s - %s: %s', project_id, type(e), e + ) + + # in case of an error or empty result, we fall back to the 'safe' + # side by using default settings. + if not domain_name: + LOG.warning('Unable to retrieve domain name for project %s,' + ' falling back to default settings!', + project_id) + return False + + # check if the OpenStack domain name starts with one of the prefixes + # from our config (or is equal). + return domain_name.startswith( + tuple(cfg.CONF.customdns.domain_name_prefixes)) + + class DhcpRpcCallback(object): """DHCP agent RPC callback in plugin implementations. @@ -82,6 +259,8 @@ class DhcpRpcCallback(object): namespace=constants.RPC_NAMESPACE_DHCP_PLUGIN, version='1.10') + _domain_lookup = CustomNetworkConfigurator() + def _get_active_networks(self, context, **kwargs): """Retrieve and return a list of the active networks.""" host = kwargs.get('host') @@ -231,7 +410,7 @@ def get_network_info(self, context, **kwargs): # the order changes. # TODO(ralonsoh): in Z+, remove "tenant_id" parameter. DHCP agents # should read only "project_id". - ret = {'id': network.id, + network_dict = {'id': network.id, 'project_id': network.project_id, 'tenant_id': network.project_id, 'admin_state_up': network.admin_state_up, @@ -241,7 +420,7 @@ def get_network_info(self, context, **kwargs): 'ports': ports, 'mtu': network.mtu} if seg_plug: - ret['segments'] = [{ + network_dict['segments'] = [{ 'id': segment.id, 'network_id': segment.network_id, 'name': segment.name, @@ -251,7 +430,11 @@ def get_network_info(self, context, **kwargs): 'is_dynamic': segment.is_dynamic, 'segment_index': segment.segment_index, 'hosts': segment.hosts} for segment in network.segments] - return ret + + if cfg.CONF.customdns.enabled: + self._domain_lookup.add_dnssettings_to_net(network_dict) + + return network_dict @db_api.retry_db_errors def release_dhcp_port(self, context, **kwargs): diff --git a/neutron/conf/service.py b/neutron/conf/service.py index 5c4a3289753..889376dc9f6 100644 --- a/neutron/conf/service.py +++ b/neutron/conf/service.py @@ -55,11 +55,28 @@ 'call.')), ] +DNSSETTINGS_CONF_SECTION = 'customdns' +DNSSETTINGS_OPTS = [ + cfg.BoolOpt('enabled', + default=False, + help=_("Enable domain specific DNS settings")), + cfg.ListOpt('upstream_dns_servers', default=[], + help=_("Custom upstream DNS server IPs")), + cfg.ListOpt('domain_name_prefixes', default=[], + help=_("OS Domain Name Prefixes to match against")), + cfg.ListOpt('project_ids', default=[], + help=_("IDs of projects to match for testing only")), +] + def register_service_opts(opts, conf=cfg.CONF): conf.register_opts(opts) +def register_dns_opts(opts, conf=cfg.CONF): + conf.register_opts(opts, group=DNSSETTINGS_CONF_SECTION) + + def get_rpc_workers(conf=cfg.CONF): """Retrieve the conf knob rpc_workers, register option first if needed""" try: diff --git a/neutron/opts.py b/neutron/opts.py index 06aa2915b0d..0cf70c174d3 100644 --- a/neutron/opts.py +++ b/neutron/opts.py @@ -204,7 +204,11 @@ def list_opts(): ('designate', neutron.conf.services.extdns_designate_driver.designate_opts ), - ('quotas', neutron.conf.quota.core_quota_opts) + ('quotas', neutron.conf.quota.core_quota_opts), + (neutron.conf.service.DNSSETTINGS_CONF_SECTION, + itertools.chain( + neutron.conf.service.DNSSETTINGS_OPTS) + ), ] diff --git a/neutron/service.py b/neutron/service.py index 784366036ff..c3a174a0515 100644 --- a/neutron/service.py +++ b/neutron/service.py @@ -43,6 +43,7 @@ service.register_service_opts(service.SERVICE_OPTS) service.register_service_opts(service.RPC_EXTRA_OPTS) +service.register_dns_opts(service.DNSSETTINGS_OPTS) LOG = logging.getLogger(__name__) diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index 154844ce5a8..bc3f73fcfef 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -1685,7 +1685,78 @@ def test_spawn_cfg_edns_client_fingerprint_without_umbrella(self, '--add-cpe-id=%s' % network.id], network=network) - def test_spawn_cfg_enable_dnsmasq_log(self): + @mock.patch.object(sanity_checks, + 'dnsmasq_umbrella_supported', return_value=True) + def test_spawn_cfg_edns_umbrella_override_per_network_notset(self, _mock): + self.conf.set_override('edns_client_fingerprint', True) + network = FakeDualNetwork() + + # do not fail building a cmdline when attribute is missing + self.assertFalse(hasattr(network, 'dns_ednslogging_enabled')) + self._test_spawn(['--conf-file=', + '--domain=openstacklocal', + '--add-cpe-id=%s' % network.id, + '--umbrella'], + network=network) + + @mock.patch.object(sanity_checks, + 'dnsmasq_umbrella_supported', return_value=True) + def test_spawn_cfg_edns_umbrella_override_per_network_true(self, _mock): + self.conf.set_override('edns_client_fingerprint', True) + network = FakeDualNetwork() + + network.dns_ednslogging_enabled = True + self._test_spawn(['--conf-file=', + '--domain=openstacklocal', + '--add-cpe-id=%s' % network.id, + '--umbrella'], + network=network) + + @mock.patch.object(sanity_checks, + 'dnsmasq_umbrella_supported', return_value=True) + def test_spawn_cfg_edns_umbrella_override_per_network_false(self, _mock): + self.conf.set_override('edns_client_fingerprint', True) + network = FakeDualNetwork() + + network.dns_ednslogging_enabled = False + self._test_spawn(['--conf-file=', + '--domain=openstacklocal', + ], + network=network) + + def test_spawn_cfg_dns_upstreams_override_per_network_notset(self): + network = FakeDualNetwork() + self.assertFalse(hasattr(network, 'dns_custom_upstreams')) + # do not fail building a cmdline when attribute is missing + self._test_spawn(['--conf-file=', + '--domain=openstacklocal', + ], + network=network) + + def test_spawn_cfg_dns_upstreams_override_per_network_set(self): + network = FakeDualNetwork() + network.dns_custom_upstreams = ['1.1.1.1', '8.8.8.8'] + self._test_spawn(['--conf-file=', + '--server=1.1.1.1', '--server=8.8.8.8', + '--domain=openstacklocal', + ], + network=network) + + def test_spawn_cfg_dns_upstreams_do_override_config(self): + self.conf.set_override('dnsmasq_local_resolv', True) + self.conf.set_override('dnsmasq_dns_servers', ['9.9.9.9']) + network = FakeDualNetwork() + network.dns_custom_upstreams = ['1.1.1.1', '8.8.8.8'] + + self._test_spawn(['--conf-file=', + '--server=1.1.1.1', '--server=8.8.8.8', + '--domain=openstacklocal', + ], + network=network) + + @mock.patch.object(sanity_checks, + 'dnsmasq_umbrella_supported', return_value=True) + def test_spawn_cfg_enable_dnsmasq_log(self, _mock): self.conf.set_override('dnsmasq_base_log_dir', '/tmp') network = FakeV4Network() dhcp_dns_log = \ diff --git a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py index fbbf3b474fc..40711c4140c 100644 --- a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py +++ b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py @@ -14,6 +14,8 @@ # limitations under the License. import operator + +from collections import UserDict from unittest import mock from neutron_lib.api.definitions import portbindings @@ -22,17 +24,217 @@ from neutron_lib import exceptions from neutron_lib.plugins import constants as plugin_constants from neutron_lib.plugins import directory +from oslo_config import cfg from oslo_db import exception as db_exc from oslo_messaging.rpc import dispatcher as rpc_dispatcher from oslo_utils import uuidutils from neutron.api.rpc.handlers import dhcp_rpc +from neutron.api.rpc.handlers.dhcp_rpc import CustomNetworkConfigurator from neutron.common import utils from neutron.db import provisioning_blocks from neutron.objects import network as network_obj from neutron.tests import base +class MockedDBObj(UserDict): + def __getattr__(self, attr): + try: + return self.data[attr] + except KeyError: + raise AttributeError(f"'MockedNetwork' has no attribute '{attr}'") + + +class TestDhcpRpcCustomNetworkConfigurator(base.BaseTestCase): + + def test_network_dict_empty(self): + """ensure nothing is added to the network dict when + nothing is configured + """ + cnc = CustomNetworkConfigurator() + + empty_dict = {} + cnc.add_dnssettings_to_net(empty_dict) + + self.assertFalse(bool(empty_dict)) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_no_match_no_change(self, mock_keystone): + """ensure that we do not change a setting if the domain does not match + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 123, 'project_id': 666} + mock_project = MockedDBObj(id=666, domain_id=42) + mock_domain = MockedDBObj(id=42, name='mydomain') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('domain_name_prefixes', ['some', 'other'], + group='customdns') + + cnc = CustomNetworkConfigurator() + + cnc.add_dnssettings_to_net(mock_network) + + mock_keystone.get_project.assert_called_with(666) + mock_keystone.get_domain.assert_called_with(42) + + # assert we do not change the settings + self.assertIsNone(mock_network.get('dns_ednslogging_enabled')) + self.assertIsNone(mock_network.get('dns_custom_upstreams')) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_network_id_lookup(self, mock_keystone): + """ensure keystone lookup methods are called and the network + returned matches the expected settings + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 123, 'project_id': 666} + mock_project = MockedDBObj(id=666, domain_id=42) + mock_domain = MockedDBObj(id=42, name='mydomain') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], + group='customdns') + + cnc = CustomNetworkConfigurator() + + cnc.add_dnssettings_to_net(mock_network) + + mock_keystone.get_project.assert_called_with(666) + mock_keystone.get_domain.assert_called_with(42) + + # assert we get the correct settings when no nameservers are set + # but logging should be off + self.assertFalse(mock_network.get('dns_ednslogging_enabled')) + self.assertIsNone(mock_network.get('dns_custom_upstreams')) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_nameserver_settings(self, mock_keystone): + """ensure the configured nameserver IPs are present in the network + dict returned + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 123, 'project_id': 666} + mock_project = MockedDBObj(id=666, domain_id=42) + mock_domain = MockedDBObj(id=42, name='mydomain') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + dns1 = "2001:db8::456" + dns2 = "192.0.2.123" + + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], + group='customdns') + cfg.CONF.set_override('upstream_dns_servers', [dns1, dns2], + group='customdns') + + cnc = CustomNetworkConfigurator() + + cnc.add_dnssettings_to_net(mock_network) + self.assertFalse(mock_network.get('dns_ednslogging_enabled')) + sentinel = object() + upstreams = mock_network.get('dns_custom_upstreams', sentinel) + self.assertNotEqual(sentinel, upstreams) + self.assertIsNotNone(upstreams) + self.assertIn(dns1, upstreams) + self.assertIn(dns2, upstreams) + self.assertEqual(len(upstreams), 2) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_exceptions_prefixmatch(self, mock_keystone): + """ensure we are doing a prefix match on the domain name """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 123, 'project_id': 666} + mock_project = MockedDBObj(id=666, domain_id=42) + mock_domain = MockedDBObj(id=42, name='mydomain-123') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + dns1 = "2001:db8::456" + dns2 = "192.0.2.123" + + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], + group='customdns') + cfg.CONF.set_override('upstream_dns_servers', [dns1, dns2], + group='customdns') + + cnc = CustomNetworkConfigurator() + + cnc.add_dnssettings_to_net(mock_network) + + self.assertFalse(mock_network.get('dns_ednslogging_enabled')) + + upstreams = mock_network.get('dns_custom_upstreams') + self.assertIn(dns1, upstreams) + self.assertIn(dns2, upstreams) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_exceptions_catched_project_lookup(self, mock_keystone): + """ensure that all exceptions are catched and do not break the + rpc call + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 123, 'project_id': 666} + mock_domain = MockedDBObj(id=42, name='mydomain-123') + + mock_keystone.get_project.side_effect = Exception('Test') + mock_keystone.get_domain.return_value = mock_domain + + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], + group='customdns') + + cnc = CustomNetworkConfigurator() + + cnc.add_dnssettings_to_net(mock_network) + upstreams = mock_network.get('dns_custom_upstreams') + self.assertIsNone(upstreams) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_exceptions_catched_domain_lookup(self, mock_keystone): + """ensure that all exceptions are catched and do not break the + rpc call + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 123, 'project_id': 666} + mock_project = MockedDBObj(id=666, domain_id=42) + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.side_effect = Exception('Test') + + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], + group='customdns') + + cnc = CustomNetworkConfigurator() + + cnc.add_dnssettings_to_net(mock_network) + upstreams = mock_network.get('dns_custom_upstreams') + self.assertIsNone(upstreams) + + class TestDhcpRpcCallback(base.BaseTestCase): def setUp(self): From c4bdb2973ff4a21078842d44d23d39163059add7 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Wed, 30 Apr 2025 13:15:02 +0200 Subject: [PATCH 155/184] [customdns] adding network id to log on errors --- neutron/api/rpc/handlers/dhcp_rpc.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/neutron/api/rpc/handlers/dhcp_rpc.py b/neutron/api/rpc/handlers/dhcp_rpc.py index 5405edb62fd..e3bfe3df666 100644 --- a/neutron/api/rpc/handlers/dhcp_rpc.py +++ b/neutron/api/rpc/handlers/dhcp_rpc.py @@ -17,7 +17,6 @@ import ipaddress import itertools import operator -from typing import Optional from keystoneauth1 import loading as ks_loading from neutron_lib.api.definitions import portbindings @@ -70,7 +69,7 @@ def add_dnssettings_to_net(self, network_dict): # against keystone return - if not self._is_customdns_project(network_dict['project_id']): + if not self._is_customdns_network(network_dict): return # logging always has to be disabled for all custom domains @@ -170,12 +169,13 @@ def get_domain_name(self, project_id: str) -> str: return domain_name - def _is_customdns_project(self, project_id: Optional[str]) -> bool: + def _is_customdns_network(self, network_dict: dict) -> bool: """check if the network is in an OpenStack domain or project that we want to configure in a custom way. For domains we use prefix matches on the name, for projects we directly match on the id. """ + project_id = network_dict['project_id'] # should not happen, but would never match anyway if not project_id: @@ -203,15 +203,16 @@ def _is_customdns_project(self, project_id: Optional[str]) -> bool: # also want to get a nice warning to the log independent of the # source of the error - but now we log the same error twice. LOG.exception('Failed to retrieve domain to set custom dns for' - ' project %s - %s: %s', project_id, type(e), e + ' project %s of network %s - %s: %s', + project_id, network_dict['id'], type(e), e ) # in case of an error or empty result, we fall back to the 'safe' # side by using default settings. if not domain_name: LOG.warning('Unable to retrieve domain name for project %s,' - ' falling back to default settings!', - project_id) + ' falling back to default settings for network %s', + project_id, network_dict['id']) return False # check if the OpenStack domain name starts with one of the prefixes From e07e7739a5525aed6739505cbe303af8119dbd34 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 21 Jun 2024 12:39:24 +0000 Subject: [PATCH 156/184] [OVN] Enable the WSGI module for the OVN mechanism driver This patch enables the use of the WSGI module with the ML2/OVN mechanism driver. The ML2/OVN requires two events that are called during the Neutron eventlet server initialization: * BEFORE_SPAWN: called once before the API workers have been created and after the ML2 plugin code has been initalizated. * AFTER_INIT: called when the API worker is started; at this point the different worker processes have been spawned. The WSGI module didn't make these event calls. Now these events are called during the API server initialization, after the ML2 plugin has been initalizated but before the server is running and attending any request. This approach differs from the Neutron eventlet server event calls because the BEFORE_SPAWN event is called for all API workers; that means the method ``OVNMechanismDriver.pre_fork_initialize`` is called as many times as workers are configured. Closes-Bug: #1912359 Change-Id: I684c6cea620308a6617b665400ce608650a2adfd --- neutron/common/ovn/utils.py | 8 +++++--- neutron/server/api_eventlet.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/neutron/common/ovn/utils.py b/neutron/common/ovn/utils.py index c210685b908..0a487aa60d3 100644 --- a/neutron/common/ovn/utils.py +++ b/neutron/common/ovn/utils.py @@ -639,10 +639,12 @@ def get_port_subnet_ids(port): return [f['subnet_id'] for f in fixed_ips] -def get_method_class(method): - if not inspect.ismethod(method): +def get_method_class(method_or_class): + if not inspect.ismethod(method_or_class): + if inspect.isclass(method_or_class): + return method_or_class return - return method.__self__.__class__ + return method_or_class.__self__.__class__ def ovn_metadata_name(id_): diff --git a/neutron/server/api_eventlet.py b/neutron/server/api_eventlet.py index 79101d8ff30..17fc60f2caf 100644 --- a/neutron/server/api_eventlet.py +++ b/neutron/server/api_eventlet.py @@ -16,12 +16,16 @@ import os import signal +from neutron_lib.callbacks import events +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources from oslo_config import cfg from oslo_reports import guru_meditation_report as gmr from neutron.common import config from neutron.common import profiler from neutron import version +from neutron import wsgi def eventlet_api_server(): @@ -34,4 +38,9 @@ def eventlet_api_server(): signum=signal.SIGWINCH) profiler.setup('neutron-server', cfg.CONF.host) - return config.load_paste_app('neutron') + app = config.load_paste_app('neutron') + registry.publish(resources.PROCESS, events.BEFORE_SPAWN, + wsgi.WorkerService) + registry.publish(resources.PROCESS, events.AFTER_INIT, + wsgi.WorkerService) + return app From ebaaf47e346263cd7f73c5e5250d8ebcf3cd91a4 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Fri, 29 Nov 2024 09:30:23 +0000 Subject: [PATCH 157/184] [OVN] Improve initial hash ring setup When using WSGI module, the multiprocess event is not shared between the WSGI workers. This new implementation retrieves the WSGI start time to define the OVN hash ring register creation time. That will be used to filter out the stale registers. Depends-On: https://review.opendev.org/c/openstack/devstack/+/936669 Closes-Bug: #2083570 Change-Id: Id9f851f33c2cb3d2c2759a3c66adf2599a3122fe --- doc/source/admin/config-wsgi.rst | 7 ++ neutron/common/utils.py | 7 +- neutron/common/wsgi_utils.py | 32 +++++++++ neutron/db/ovn_hash_ring_db.py | 40 ++++++++--- .../drivers/ovn/mech_driver/mech_driver.py | 71 ++++++++++++++----- .../ovn/mech_driver/test_mech_driver.py | 37 ++++++++++ .../wsgi_start-time-101ce9c9a36b8a4f.yaml | 8 +++ 7 files changed, 173 insertions(+), 29 deletions(-) create mode 100644 neutron/common/wsgi_utils.py create mode 100644 releasenotes/notes/wsgi_start-time-101ce9c9a36b8a4f.yaml diff --git a/doc/source/admin/config-wsgi.rst b/doc/source/admin/config-wsgi.rst index 096ead1cab1..65e61d3fa21 100644 --- a/doc/source/admin/config-wsgi.rst +++ b/doc/source/admin/config-wsgi.rst @@ -46,6 +46,7 @@ Create a ``/etc/neutron/neutron-api-uwsgi.ini`` file with the content below: master = true processes = 2 wsgi-file = /neutron-api + start-time = %t .. end @@ -160,3 +161,9 @@ in processing agents heartbeats. If OVN ML2 plugin is used without any additional agents, neutron requires no worker for RPC message processing. Set both rpc_workers and rpc_state_report_workers to 0, to disable RPC workers. + +.. note:: + ML2/OVN uses the ``[uwsgi]start-time = %t`` parameter to create the OVN hash + ring registers during the initialization process. This value is populated + by the uWSGi process with the start time. For more information, check + `Configuring uWSGI _`. diff --git a/neutron/common/utils.py b/neutron/common/utils.py index 6f32788f5a4..3a94f10a08b 100644 --- a/neutron/common/utils.py +++ b/neutron/common/utils.py @@ -17,7 +17,7 @@ # when needed. """Utilities and helper functions.""" - +import datetime import functools import hashlib import hmac @@ -1116,3 +1116,8 @@ def read_file(path: str) -> str: return file.read() except FileNotFoundError: return '' + + +def ts_to_datetime(timestamp): + """Converts timestamp (in seconds) to datetime""" + return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) diff --git a/neutron/common/wsgi_utils.py b/neutron/common/wsgi_utils.py new file mode 100644 index 00000000000..6bb6704818f --- /dev/null +++ b/neutron/common/wsgi_utils.py @@ -0,0 +1,32 @@ +# Copyright (c) 2024 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. + + +def get_start_time(): + """Return the 'start-time=%t' config varible in the WSGI config + + This variable contains the start time of the WSGI server. Check + https://uwsgi-docs.readthedocs.io/en/latest/Configuration.html + #magic-variables + """ + try: + # pylint: disable=import-outside-toplevel + import uwsgi + start_time = uwsgi.opt.get('start-time') + if not start_time: + return + return int(start_time.decode(encoding='utf-8')) + except ImportError: + return diff --git a/neutron/db/ovn_hash_ring_db.py b/neutron/db/ovn_hash_ring_db.py index 55430a55a45..3e9d1864657 100644 --- a/neutron/db/ovn_hash_ring_db.py +++ b/neutron/db/ovn_hash_ring_db.py @@ -30,26 +30,48 @@ # NOTE(ralonsoh): this was migrated from networking-ovn to neutron and should # be refactored to be integrated in a OVO. @db_api.retry_if_session_inactive() -def add_node(context, group_name, node_uuid=None): +def add_node(context, group_name, node_uuid=None, created_at=None): if node_uuid is None: node_uuid = uuidutils.generate_uuid() with db_api.CONTEXT_WRITER.using(context): - context.session.add(ovn_models.OVNHashRing( - node_uuid=node_uuid, hostname=CONF.host, group_name=group_name)) + kwargs = {'node_uuid': node_uuid, + 'hostname': CONF.host, + 'group_name': group_name} + if created_at: + kwargs['created_at'] = created_at + context.session.add(ovn_models.OVNHashRing(**kwargs)) LOG.info('Node %s from host "%s" and group "%s" added to the Hash Ring', node_uuid, CONF.host, group_name) return node_uuid @db_api.retry_if_session_inactive() -def remove_nodes_from_host(context, group_name): - with db_api.CONTEXT_WRITER.using(context): - context.session.query(ovn_models.OVNHashRing).filter( +@db_api.CONTEXT_READER +def get_nodes(context, group_name, created_at=None): + query = context.session.query(ovn_models.OVNHashRing).filter( + ovn_models.OVNHashRing.group_name == group_name) + if created_at: + query = query.filter( + ovn_models.OVNHashRing.created_at == created_at) + return query.all() + + +@db_api.retry_if_session_inactive() +def remove_nodes_from_host(context, group_name, created_at=None): + with (db_api.CONTEXT_WRITER.using(context)): + query = context.session.query(ovn_models.OVNHashRing).filter( ovn_models.OVNHashRing.hostname == CONF.host, - ovn_models.OVNHashRing.group_name == group_name).delete() - LOG.info('Nodes from host "%s" and group "%s" removed from the Hash Ring', - CONF.host, group_name) + ovn_models.OVNHashRing.group_name == group_name) + if created_at: + query = query.filter( + ovn_models.OVNHashRing.created_at != created_at) + query.delete() + msg = ('Nodes from host "%s" and group "%s" removed from the Hash Ring' % + (CONF.host, group_name)) + if created_at: + msg += ' created at %s' % str(created_at) + LOG.info(msg) @db_api.retry_if_session_inactive() diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index e6f6471d785..7968eee2111 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -32,13 +32,13 @@ from neutron_lib.callbacks import resources from neutron_lib import constants as const 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 from neutron_lib.exceptions import availability_zone as az_exc from neutron_lib.placement import utils as place_utils from neutron_lib.plugins import directory from neutron_lib.plugins.ml2 import api from neutron_lib.utils import helpers -from oslo_concurrency import lockutils from oslo_config import cfg from oslo_db import exception as os_db_exc from oslo_log import log @@ -52,6 +52,8 @@ from neutron.common.ovn import exceptions as ovn_exceptions from neutron.common.ovn import extensions as ovn_extensions from neutron.common.ovn import utils as ovn_utils +from neutron.common import utils as n_utils +from neutron.common import wsgi_utils from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf from neutron.db import ovn_hash_ring_db from neutron.db import ovn_revision_numbers_db @@ -123,6 +125,10 @@ def initialize(self): self._maintenance_thread = None self._hash_ring_thread = None self._hash_ring_probe_event = multiprocessing.Event() + self._start_time = wsgi_utils.get_start_time() + if self._start_time: + LOG.info('Server start time: %s', + str(n_utils.ts_to_datetime(self._start_time))) self.node_uuid = None self.hash_ring_group = ovn_const.HASH_RING_ML2_GROUP self.sg_enabled = ovn_acl.is_sg_enabled() @@ -303,37 +309,64 @@ def should_post_fork_initialize(worker_class): worker.MaintenanceWorker, service.RpcWorker) - @lockutils.synchronized('hash_ring_probe_lock', external=True) def _setup_hash_ring(self): """Setup the hash ring. - The first worker to acquire the lock is responsible for cleaning - the hash ring from previous runs as well as start the probing - thread for this host. Subsequently workers just need to register - themselves to the hash ring. + The first worker to execute this method will remove the hash ring from + previous runs as well as start the probing thread for this host. + Subsequently workers just need to register themselves to the hash ring. """ # Attempt to remove the node from the ring when the worker stops sh = oslo_service.SignalHandler() atexit.register(self._remove_node_from_hash_ring) sh.add_handler("SIGTERM", self._remove_node_from_hash_ring) + if self._start_time: + self._setup_hash_ring_start_time() + else: + self._setup_hash_ring_event() + + def _register_hash_ring_maintenance(self): + self._hash_ring_thread = maintenance.MaintenanceThread() + self._hash_ring_thread.add_periodics( + maintenance.HashRingHealthCheckPeriodics( + self.hash_ring_group)) + self._hash_ring_thread.start() + LOG.info('Hash Ring probing thread has started') + + def _setup_hash_ring_event(self): + LOG.debug('Hash Ring setup using multiprocess event lock') admin_context = n_context.get_admin_context() if not self._hash_ring_probe_event.is_set(): - # Clear existing entries + # Clear existing entries. This code section should be executed + # only once per node (chassis); the multiprocess event should be + # set just after the ``is_set`` check. + self._hash_ring_probe_event.set() ovn_hash_ring_db.remove_nodes_from_host(admin_context, self.hash_ring_group) - self.node_uuid = ovn_hash_ring_db.add_node(admin_context, - self.hash_ring_group) - self._hash_ring_thread = maintenance.MaintenanceThread() - self._hash_ring_thread.add_periodics( - maintenance.HashRingHealthCheckPeriodics( - self.hash_ring_group)) - self._hash_ring_thread.start() - LOG.info("Hash Ring probing thread has started") - self._hash_ring_probe_event.set() - else: - self.node_uuid = ovn_hash_ring_db.add_node(admin_context, - self.hash_ring_group) + self._register_hash_ring_maintenance() + self.node_uuid = ovn_hash_ring_db.add_node(admin_context, + self.hash_ring_group) + + def _setup_hash_ring_start_time(self): + LOG.debug('Hash Ring setup using WSGI start time') + admin_context = n_context.get_admin_context() + with db_api.CONTEXT_WRITER.using(admin_context): + # Delete all node registers without created_at=self._start_time + created_at = n_utils.ts_to_datetime(self._start_time) + ovn_hash_ring_db.remove_nodes_from_host( + admin_context, self.hash_ring_group, created_at=created_at) + self.node_uuid = ovn_hash_ring_db.add_node( + admin_context, self.hash_ring_group, created_at=created_at) + newer_nodes = ovn_hash_ring_db.get_nodes( + admin_context, self.hash_ring_group, created_at=created_at) + LOG.debug('Hash Ring setup, this worker has detected %s OVN hash' + 'ring registers in the database', len(newer_nodes)) + + if len(newer_nodes) == 1: + # If only one register per host is present, that means this worker + # is the first one to register itself. + self._register_hash_ring_maintenance() def post_fork_initialize(self, resource, event, trigger, payload=None): # Initialize API/Maintenance workers with OVN IDL connections diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index ff317ce0146..80b1d2b97bd 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -23,8 +23,10 @@ from neutron_lib.api.definitions import portbindings from neutron_lib import constants +from neutron_lib.db import api as db_api from neutron_lib.exceptions import agent as agent_exc from oslo_config import cfg +from oslo_utils import timeutils from oslo_utils import uuidutils from ovsdbapp.backend.ovs_idl import event @@ -32,6 +34,7 @@ from neutron.common.ovn import utils from neutron.common import utils as n_utils from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf +from neutron.db import ovn_hash_ring_db from neutron.db import ovn_revision_numbers_db as db_rev from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import ovsdb_monitor from neutron.tests import base as tests_base @@ -57,6 +60,40 @@ } +class TestOVNMechanismDriver(base.TestOVNFunctionalBase): + + def test__setup_hash_ring_start_time(self): + # Create a differentiated OVN hash ring name. + ring_group = uuidutils.generate_uuid() + self.mech_driver.hash_ring_group = ring_group + + # Create several OVN hash registers left by a previous execution. + created_at = timeutils.utcnow() - datetime.timedelta(1) + with db_api.CONTEXT_WRITER.using(self.context): + for _ in range(3): + self.node_uuid = ovn_hash_ring_db.add_node( + self.context, ring_group, created_at=created_at) + + # Check the existing OVN hash ring registers. + ovn_hrs = ovn_hash_ring_db.get_nodes(self.context, ring_group) + self.assertEqual(3, len(ovn_hrs)) + + start_time = timeutils.utcnow() + self.mech_driver._start_time = int(start_time.timestamp()) + with mock.patch.object(self.mech_driver, + '_register_hash_ring_maintenance') as \ + mock_register_maintenance: + for _ in range(3): + self.mech_driver._setup_hash_ring_start_time() + + ovn_hrs = ovn_hash_ring_db.get_nodes(self.context, ring_group) + self.assertEqual(3, len(ovn_hrs)) + for ovn_hr in ovn_hrs: + self.assertEqual(int(start_time.timestamp()), + ovn_hr.created_at.timestamp()) + mock_register_maintenance.assert_called_once() + + class TestPortBinding(base.TestOVNFunctionalBase): def setUp(self, **kwargs): diff --git a/releasenotes/notes/wsgi_start-time-101ce9c9a36b8a4f.yaml b/releasenotes/notes/wsgi_start-time-101ce9c9a36b8a4f.yaml new file mode 100644 index 00000000000..a6f9efa3724 --- /dev/null +++ b/releasenotes/notes/wsgi_start-time-101ce9c9a36b8a4f.yaml @@ -0,0 +1,8 @@ +--- +other: + - | + The Neutron API using the WSGI module requires a new configuration + parameter: ``[uwsgi]start-time=%t``. The uWSGI process will populate this + value when executed, defining the start time of the Neutron API. This value + will be used by Neutron ML2/OVN to create the OVN hash ring registers per + worker. From 0ea1f100baab67d829fad186fdbd34c36fc90a68 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Mon, 2 Dec 2024 14:31:04 +0000 Subject: [PATCH 158/184] [OVN] Create a OVN hash ring maintenance thread per worker The class ``HashRingHealthCheckPeriodics`` now handles the OVN hash ring update status of a single register. With this change, each API worker will spawn its own maintenance worker to update its own OVN hash ring register. The ``HashRingManager`` will also store the OVN hash ring ``updated_at`` value, in order to avoid unnecessary updates. The ``OvnIdlDistributedLock`` will retrieve this ``updated_at`` value instead of having a local timer. If the register is updated, the ``OvnIdlDistributedLock`` notify method won't refresh it. The method ``touch_node`` no longer is decorated with ``retry_if_session_inactive``. If the node update fails, other calls to update the OVN hash ring register will be responsible of refreshing it. The goals of this patch are: * To make each worker acountable of its own OVN hash ring. * To void multiple registers updates. * To update the OVN hash registers when needed, only if they are outdated. Related-Bug: #2083570 Change-Id: Ia15f48c28fe6431eac4778fd0c6a88c035a4f712 --- neutron/common/ovn/hash_ring_manager.py | 6 +- neutron/db/ovn_hash_ring_db.py | 30 +++++---- .../drivers/ovn/mech_driver/mech_driver.py | 54 +++++++-------- .../ovn/mech_driver/ovsdb/maintenance.py | 7 +- .../ovn/mech_driver/ovsdb/ovsdb_monitor.py | 5 +- .../ovn/mech_driver/test_mech_driver.py | 8 +-- .../unit/common/ovn/test_hash_ring_manager.py | 65 ++++++++++++------- .../tests/unit/db/test_ovn_hash_ring_db.py | 40 ++++++------ .../mech_driver/ovsdb/test_ovsdb_monitor.py | 24 +++++-- 9 files changed, 137 insertions(+), 102 deletions(-) diff --git a/neutron/common/ovn/hash_ring_manager.py b/neutron/common/ovn/hash_ring_manager.py index 59d6107f4ad..d9c1a5b1808 100644 --- a/neutron/common/ovn/hash_ring_manager.py +++ b/neutron/common/ovn/hash_ring_manager.py @@ -32,6 +32,7 @@ class HashRingManager(object): def __init__(self, group_name): self._hash_ring = None + self._node_last_touch = {} self._last_time_loaded = None self._check_hashring_startup = True self._group = group_name @@ -92,6 +93,8 @@ def _load_hash_ring(self, refresh=False): constants.HASH_RING_NODES_TIMEOUT, self._group) self._hash_ring = hashring.HashRing({node.node_uuid for node in nodes}) + self._node_last_touch = {node.node_uuid: node.updated_at + for node in nodes} self._last_time_loaded = timeutils.utcnow() self._offline_node_count = db_hash_ring.count_offline_nodes( self.admin_ctx, constants.HASH_RING_NODES_TIMEOUT, @@ -112,7 +115,8 @@ def get_node(self, key): try: # We need to pop the value from the set. If empty, # KeyError is raised - return self._hash_ring[key].pop() + node_uuid = self._hash_ring[key].pop() + return node_uuid, self._node_last_touch[node_uuid] except KeyError: raise exceptions.HashRingIsEmpty( key=key, node_count=self._offline_node_count) diff --git a/neutron/db/ovn_hash_ring_db.py b/neutron/db/ovn_hash_ring_db.py index 3e9d1864657..2af2adc711d 100644 --- a/neutron/db/ovn_hash_ring_db.py +++ b/neutron/db/ovn_hash_ring_db.py @@ -57,6 +57,14 @@ def get_nodes(context, group_name, created_at=None): return query.all() +@db_api.retry_if_session_inactive() +@db_api.CONTEXT_READER +def get_node(context, group_name, node_uuid): + return context.session.query(ovn_models.OVNHashRing).filter( + ovn_models.OVNHashRing.group_name == group_name, + ovn_models.OVNHashRing.node_uuid == node_uuid).one() + + @db_api.retry_if_session_inactive() def remove_nodes_from_host(context, group_name, created_at=None): with (db_api.CONTEXT_WRITER.using(context)): @@ -91,21 +99,17 @@ def cleanup_old_nodes(context, days): LOG.info('Cleaned up Hash Ring nodes older than %d days', days) -@db_api.retry_if_session_inactive() -def _touch(context, updated_at=None, **filter_args): +@db_api.CONTEXT_WRITER +def touch_node(context, node_uuid, updated_at=None): + # NOTE(ralonsoh): there are several mechanisms to update the node OVN hash + # ring register. This method does not retry the DB operation in case of + # failure but relies on the success of later calls. That will prevent from + # blocking the DB needlessly. if updated_at is None: updated_at = timeutils.utcnow() - with db_api.CONTEXT_WRITER.using(context): - context.session.query(ovn_models.OVNHashRing).filter_by( - **filter_args).update({'updated_at': updated_at}) - - -def touch_nodes_from_host(context, group_name): - _touch(context, hostname=CONF.host, group_name=group_name) - - -def touch_node(context, node_uuid): - _touch(context, node_uuid=node_uuid) + context.session.query(ovn_models.OVNHashRing).filter( + ovn_models.OVNHashRing.node_uuid == node_uuid).update( + {'updated_at': updated_at}) def _get_nodes_query(context, interval, group_name, offline=False, diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index 7968eee2111..b88e3869494 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -321,52 +321,52 @@ def _setup_hash_ring(self): atexit.register(self._remove_node_from_hash_ring) sh.add_handler("SIGTERM", self._remove_node_from_hash_ring) + admin_context = n_context.get_admin_context() if self._start_time: - self._setup_hash_ring_start_time() + self._setup_hash_ring_start_time(admin_context) else: - self._setup_hash_ring_event() + self._setup_hash_ring_event(admin_context) + self._register_hash_ring_maintenance() def _register_hash_ring_maintenance(self): + """Maintenance method for the node OVN hash ring register + + The ``self.node_uuid`` value must be set before calling this method. + """ self._hash_ring_thread = maintenance.MaintenanceThread() self._hash_ring_thread.add_periodics( maintenance.HashRingHealthCheckPeriodics( - self.hash_ring_group)) + self.hash_ring_group, self.node_uuid)) self._hash_ring_thread.start() - LOG.info('Hash Ring probing thread has started') + LOG.info('Hash Ring probing thread for node %s has started', + self.node_uuid) - def _setup_hash_ring_event(self): + def _setup_hash_ring_event(self, context): LOG.debug('Hash Ring setup using multiprocess event lock') - admin_context = n_context.get_admin_context() if not self._hash_ring_probe_event.is_set(): # Clear existing entries. This code section should be executed # only once per node (chassis); the multiprocess event should be # set just after the ``is_set`` check. self._hash_ring_probe_event.set() - ovn_hash_ring_db.remove_nodes_from_host(admin_context, + ovn_hash_ring_db.remove_nodes_from_host(context, self.hash_ring_group) - self._register_hash_ring_maintenance() - self.node_uuid = ovn_hash_ring_db.add_node(admin_context, + self.node_uuid = ovn_hash_ring_db.add_node(context, self.hash_ring_group) - def _setup_hash_ring_start_time(self): + @db_api.retry_if_session_inactive() + @db_api.CONTEXT_WRITER + def _setup_hash_ring_start_time(self, context): LOG.debug('Hash Ring setup using WSGI start time') - admin_context = n_context.get_admin_context() - with db_api.CONTEXT_WRITER.using(admin_context): - # Delete all node registers without created_at=self._start_time - created_at = n_utils.ts_to_datetime(self._start_time) - ovn_hash_ring_db.remove_nodes_from_host( - admin_context, self.hash_ring_group, created_at=created_at) - self.node_uuid = ovn_hash_ring_db.add_node( - admin_context, self.hash_ring_group, created_at=created_at) - newer_nodes = ovn_hash_ring_db.get_nodes( - admin_context, self.hash_ring_group, created_at=created_at) - LOG.debug('Hash Ring setup, this worker has detected %s OVN hash' - 'ring registers in the database', len(newer_nodes)) - - if len(newer_nodes) == 1: - # If only one register per host is present, that means this worker - # is the first one to register itself. - self._register_hash_ring_maintenance() + # Delete all node registers without created_at=self._start_time + created_at = n_utils.ts_to_datetime(self._start_time) + ovn_hash_ring_db.remove_nodes_from_host( + context, self.hash_ring_group, created_at=created_at) + self.node_uuid = ovn_hash_ring_db.add_node( + context, self.hash_ring_group, created_at=created_at) + newer_nodes = ovn_hash_ring_db.get_nodes( + context, self.hash_ring_group, created_at=created_at) + LOG.debug('Hash Ring setup, this worker has detected %s OVN hash ' + 'ring registers in the database', len(newer_nodes)) def post_fork_initialize(self, resource, event, trigger, payload=None): # Initialize API/Maintenance workers with OVN IDL connections diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py index 241eff5b904..0524c3f4274 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/maintenance.py @@ -1344,16 +1344,17 @@ def set_network_type(self): class HashRingHealthCheckPeriodics(object): - def __init__(self, group): + def __init__(self, group, node_uuid): self._group = group + self._node_uuid = node_uuid self.ctx = n_context.get_admin_context() @periodics.periodic(spacing=ovn_const.HASH_RING_TOUCH_INTERVAL) - def touch_hash_ring_nodes(self): + def touch_hash_ring_node(self): # NOTE(lucasagomes): Note that we do not rely on the OVSDB lock # here because we want the maintenance tasks from each instance to # execute this task. - hash_ring_db.touch_nodes_from_host(self.ctx, self._group) + hash_ring_db.touch_node(self.ctx, self._node_uuid) # Check the number of the nodes in the ring and log a message in # case they are out of sync. See LP #2024205 for more information diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py index 1a5e0f83e75..3eb67d91167 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovsdb_monitor.py @@ -723,7 +723,8 @@ def notify(self, event, row, updates=None): try: self.notify_handler.notify(event, row, updates, global_=True) try: - target_node = self._hash_ring.get_node(str(row.uuid)) + target_node, node_last_touch = self._hash_ring.get_node( + str(row.uuid)) except exceptions.HashRingIsEmpty as e: LOG.error('HashRing is empty, error: %s', e) return @@ -732,6 +733,7 @@ def notify(self, event, row, updates=None): # If the worker hasn't been health checked by the maintenance # thread (see bug #1834498), indicate that it's alive here + self._last_touch = node_last_touch time_now = timeutils.utcnow() touch_timeout = time_now - datetime.timedelta( seconds=ovn_const.HASH_RING_TOUCH_INTERVAL) @@ -742,7 +744,6 @@ def notify(self, event, row, updates=None): try: ctx = neutron_context.get_admin_context() ovn_hash_ring_db.touch_node(ctx, self._node_uuid) - self._last_touch = time_now except Exception: LOG.exception('Hash Ring node %s failed to heartbeat', self._node_uuid) diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 80b1d2b97bd..a805f3d90f8 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -80,18 +80,14 @@ def test__setup_hash_ring_start_time(self): start_time = timeutils.utcnow() self.mech_driver._start_time = int(start_time.timestamp()) - with mock.patch.object(self.mech_driver, - '_register_hash_ring_maintenance') as \ - mock_register_maintenance: - for _ in range(3): - self.mech_driver._setup_hash_ring_start_time() + for _ in range(3): + self.mech_driver._setup_hash_ring_start_time(self.context) ovn_hrs = ovn_hash_ring_db.get_nodes(self.context, ring_group) self.assertEqual(3, len(ovn_hrs)) for ovn_hr in ovn_hrs: self.assertEqual(int(start_time.timestamp()), ovn_hr.created_at.timestamp()) - mock_register_maintenance.assert_called_once() class TestPortBinding(base.TestOVNFunctionalBase): diff --git a/neutron/tests/unit/common/ovn/test_hash_ring_manager.py b/neutron/tests/unit/common/ovn/test_hash_ring_manager.py index 482dba57d3c..b74d67b1f88 100644 --- a/neutron/tests/unit/common/ovn/test_hash_ring_manager.py +++ b/neutron/tests/unit/common/ovn/test_hash_ring_manager.py @@ -38,19 +38,23 @@ def setUp(self): self.admin_ctx = context.get_admin_context() def _verify_hashes(self, hash_dict): - for uuid_, target_node in hash_dict.items(): - self.assertEqual(target_node, - self.hash_ring_manager.get_node(uuid_)) + for node, target_node in hash_dict.items(): + self.assertEqual(target_node.node_uuid, + self.hash_ring_manager.get_node(node)[0]) + self.assertEqual(target_node.updated_at, + self.hash_ring_manager.get_node(node)[1]) def test_get_node(self): # Use pre-defined UUIDs to make the hashes predictable - node_1_uuid = db_hash_ring.add_node( - self.admin_ctx, HASH_RING_TEST_GROUP, 'node-1') - node_2_uuid = db_hash_ring.add_node( - self.admin_ctx, HASH_RING_TEST_GROUP, 'node-2') + db_hash_ring.add_node(self.admin_ctx, HASH_RING_TEST_GROUP, 'node-1') + node1 = db_hash_ring.get_node(self.admin_ctx, HASH_RING_TEST_GROUP, + 'node-1') + db_hash_ring.add_node(self.admin_ctx, HASH_RING_TEST_GROUP, 'node-2') + node2 = db_hash_ring.get_node(self.admin_ctx, HASH_RING_TEST_GROUP, + 'node-2') - hash_dict_before = {'fake-uuid': node_1_uuid, - 'fake-uuid-0': node_2_uuid} + hash_dict_before = {'fake-uuid': node1, + 'fake-uuid-0': node2} self._verify_hashes(hash_dict_before) def test_get_node_no_active_nodes(self): @@ -60,15 +64,19 @@ def test_get_node_no_active_nodes(self): def test_ring_rebalance(self): # Use pre-defined UUIDs to make the hashes predictable - node_1_uuid = db_hash_ring.add_node( - self.admin_ctx, HASH_RING_TEST_GROUP, 'node-1') - node_2_uuid = db_hash_ring.add_node( - self.admin_ctx, HASH_RING_TEST_GROUP, 'node-2') + db_hash_ring.add_node(self.admin_ctx, HASH_RING_TEST_GROUP, 'node-1') + node1 = db_hash_ring.get_node(self.admin_ctx, HASH_RING_TEST_GROUP, + 'node-1') + db_hash_ring.add_node(self.admin_ctx, HASH_RING_TEST_GROUP, 'node-2') + node2 = db_hash_ring.get_node(self.admin_ctx, HASH_RING_TEST_GROUP, + 'node-2') # Add another node from a different host with mock.patch.object(db_hash_ring, 'CONF') as mock_conf: mock_conf.host = 'another-host-52359446-c366' - another_host_node = db_hash_ring.add_node( + db_hash_ring.add_node(self.admin_ctx, HASH_RING_TEST_GROUP, + 'another-host') + node_other = db_hash_ring.get_node( self.admin_ctx, HASH_RING_TEST_GROUP, 'another-host') # Assert all nodes are alive in the ring @@ -76,9 +84,9 @@ def test_ring_rebalance(self): self.assertEqual(3, len(self.hash_ring_manager._hash_ring.nodes)) # Hash certain values against the nodes - hash_dict_before = {'fake-uuid': node_1_uuid, - 'fake-uuid-0': node_2_uuid, - 'fake-uuid-ABCDE': another_host_node} + hash_dict_before = {'fake-uuid': node1, + 'fake-uuid-0': node2, + 'fake-uuid-ABCDE': node_other} self._verify_hashes(hash_dict_before) # Mock utcnow() as the HASH_RING_NODES_TIMEOUT have expired @@ -87,27 +95,34 @@ def test_ring_rebalance(self): seconds=constants.HASH_RING_NODES_TIMEOUT) with mock.patch.object(timeutils, 'utcnow') as mock_utcnow: mock_utcnow.return_value = fake_utcnow - db_hash_ring.touch_nodes_from_host( - self.admin_ctx, HASH_RING_TEST_GROUP) + for _node in [node1, node2]: + db_hash_ring.touch_node(self.admin_ctx, _node.node_uuid) # Now assert that the ring was re-balanced and only the node from # another host is marked as alive self.hash_ring_manager.refresh() - self.assertEqual([another_host_node], + self.assertEqual([node_other.node_uuid], list(self.hash_ring_manager._hash_ring.nodes.keys())) # Now only "another_host_node" is alive, all values should hash to it - hash_dict_after_rebalance = {'fake-uuid': another_host_node, - 'fake-uuid-0': another_host_node, - 'fake-uuid-ABCDE': another_host_node} + hash_dict_after_rebalance = {'fake-uuid': node_other, + 'fake-uuid-0': node_other, + 'fake-uuid-ABCDE': node_other} self._verify_hashes(hash_dict_after_rebalance) # Now touch the nodes so they appear active again - db_hash_ring.touch_nodes_from_host( - self.admin_ctx, HASH_RING_TEST_GROUP) + for _node in [node1, node2]: + db_hash_ring.touch_node(self.admin_ctx, _node.node_uuid) self.hash_ring_manager.refresh() # The ring should re-balance and as it was before + node1 = db_hash_ring.get_node(self.admin_ctx, HASH_RING_TEST_GROUP, + 'node-1') + node2 = db_hash_ring.get_node(self.admin_ctx, HASH_RING_TEST_GROUP, + 'node-2') + hash_dict_before = {'fake-uuid': node1, + 'fake-uuid-0': node2, + 'fake-uuid-ABCDE': node_other} self._verify_hashes(hash_dict_before) @mock.patch.object(hash_ring_manager.LOG, 'debug') diff --git a/neutron/tests/unit/db/test_ovn_hash_ring_db.py b/neutron/tests/unit/db/test_ovn_hash_ring_db.py index 9aac99acc9c..d4e246320e9 100644 --- a/neutron/tests/unit/db/test_ovn_hash_ring_db.py +++ b/neutron/tests/unit/db/test_ovn_hash_ring_db.py @@ -89,7 +89,7 @@ def test_remove_nodes_from_host(self): self.assertIsNotNone(self._get_node_row(another_host_node)) def test_touch_nodes_from_host(self): - nodes = self._add_nodes_and_assert_exists(count=3) + node_uuids = self._add_nodes_and_assert_exists(count=3) # Add another node from a different host with mock.patch.object(ovn_hash_ring_db, 'CONF') as mock_conf: @@ -97,8 +97,8 @@ def test_touch_nodes_from_host(self): another_host_node = self._add_nodes_and_assert_exists()[0] # Assert that updated_at isn't updated yet - for node in nodes: - node_db = self._get_node_row(node) + for node_uuid in node_uuids: + node_db = self._get_node_row(node_uuid) self.assertEqual(node_db.created_at, node_db.updated_at) # Assert the same for the node from another host @@ -107,12 +107,12 @@ def test_touch_nodes_from_host(self): # Touch the nodes from our host time.sleep(1) - ovn_hash_ring_db.touch_nodes_from_host(self.admin_ctx, - HASH_RING_TEST_GROUP) + for node_uuid in node_uuids: + ovn_hash_ring_db.touch_node(self.admin_ctx, node_uuid) # Assert that updated_at is now updated - for node in nodes: - node_db = self._get_node_row(node) + for node_uuid in node_uuids: + node_db = self._get_node_row(node_uuid) self.assertGreater(node_db.updated_at, node_db.created_at) # Assert that the node from another host hasn't been touched @@ -121,7 +121,7 @@ def test_touch_nodes_from_host(self): self.assertEqual(node_db.created_at, node_db.updated_at) def test_active_nodes(self): - self._add_nodes_and_assert_exists(count=3) + node_uuids = self._add_nodes_and_assert_exists(count=3) # Add another node from a different host with mock.patch.object(ovn_hash_ring_db, 'CONF') as mock_conf: @@ -137,8 +137,8 @@ def test_active_nodes(self): fake_utcnow = timeutils.utcnow() - datetime.timedelta(seconds=60) with mock.patch.object(timeutils, 'utcnow') as mock_utcnow: mock_utcnow.return_value = fake_utcnow - ovn_hash_ring_db.touch_nodes_from_host(self.admin_ctx, - HASH_RING_TEST_GROUP) + for node_uuid in node_uuids: + ovn_hash_ring_db.touch_node(self.admin_ctx, node_uuid) # Now assert that all nodes from our host are seeing as offline. # Only the node from another host should be active @@ -230,8 +230,8 @@ def test_touch_nodes_from_host_different_groups(self): # Touch the nodes from group1 time.sleep(1) - ovn_hash_ring_db.touch_nodes_from_host(self.admin_ctx, - HASH_RING_TEST_GROUP) + for node_uuid in group1: + ovn_hash_ring_db.touch_node(self.admin_ctx, node_uuid) # Assert that updated_at was updated for group1 for node in group1: @@ -244,7 +244,7 @@ def test_touch_nodes_from_host_different_groups(self): self.assertEqual(node_db.created_at, node_db.updated_at) def test_count_offline_nodes(self): - self._add_nodes_and_assert_exists(count=3) + node_uuids = self._add_nodes_and_assert_exists(count=3) # Assert no nodes are considered offline self.assertEqual(0, ovn_hash_ring_db.count_offline_nodes( @@ -255,16 +255,16 @@ def test_count_offline_nodes(self): fake_utcnow = timeutils.utcnow() - datetime.timedelta(seconds=60) with mock.patch.object(timeutils, 'utcnow') as mock_utcnow: mock_utcnow.return_value = fake_utcnow - ovn_hash_ring_db.touch_nodes_from_host(self.admin_ctx, - HASH_RING_TEST_GROUP) + for node_uuid in node_uuids: + ovn_hash_ring_db.touch_node(self.admin_ctx, node_uuid) # Now assert that all nodes from our host are seeing as offline self.assertEqual(3, ovn_hash_ring_db.count_offline_nodes( self.admin_ctx, interval=60, group_name=HASH_RING_TEST_GROUP)) # Touch the nodes again without faking utcnow() - ovn_hash_ring_db.touch_nodes_from_host(self.admin_ctx, - HASH_RING_TEST_GROUP) + for node_uuid in node_uuids: + ovn_hash_ring_db.touch_node(self.admin_ctx, node_uuid) # Assert no nodes are considered offline self.assertEqual(0, ovn_hash_ring_db.count_offline_nodes( @@ -288,15 +288,15 @@ def test_remove_node_by_uuid(self): def test_cleanup_old_nodes(self): # Add 2 new nodes - self._add_nodes_and_assert_exists(count=2) + node_uuids = self._add_nodes_and_assert_exists(count=2) # Subtract 5 days from utcnow() and touch the nodes to make # them to appear stale fake_utcnow = timeutils.utcnow() - datetime.timedelta(days=5) with mock.patch.object(timeutils, 'utcnow') as mock_utcnow: mock_utcnow.return_value = fake_utcnow - ovn_hash_ring_db.touch_nodes_from_host(self.admin_ctx, - HASH_RING_TEST_GROUP) + for node_uuid in node_uuids: + ovn_hash_ring_db.touch_node(self.admin_ctx, node_uuid) # Add 3 new nodes self._add_nodes_and_assert_exists(count=3) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py index f0c0e82e418..3227bb67d8d 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovsdb_monitor.py @@ -220,7 +220,8 @@ def setUp(self): self.mock_get_node = mock.patch.object( hash_ring_manager.HashRingManager, - 'get_node', return_value=self.node_uuid).start() + 'get_node', + return_value=(self.node_uuid, timeutils.utcnow())).start() self.mock_update_tables = mock.patch.object( self.idl, 'update_tables').start() @@ -231,9 +232,17 @@ def _assert_has_notify_calls(self): self.assertEqual(2, len(self.idl.notify_handler.mock_calls)) @mock.patch.object(ovn_hash_ring_db, 'touch_node') - def test_notify(self, mock_touch_node): + def test_notify_updated_node(self, mock_touch_node): self.idl.notify(self.fake_event, self.fake_row) + mock_touch_node.assert_not_called() + self._assert_has_notify_calls() + @mock.patch.object(ovn_hash_ring_db, 'touch_node') + def test_notify_not_updated_node(self, mock_touch_node): + updated_at = timeutils.utcnow() - datetime.timedelta( + seconds=ovn_const.HASH_RING_CACHE_TIMEOUT + 10) + self.mock_get_node.return_value = (self.node_uuid, updated_at) + self.idl.notify(self.fake_event, self.fake_row) mock_touch_node.assert_called_once_with(mock.ANY, self.node_uuid) self._assert_has_notify_calls() @@ -266,6 +275,9 @@ def test_notify_last_touch_expired(self, mock_touch_node): @mock.patch.object(ovsdb_monitor.LOG, 'exception') @mock.patch.object(ovn_hash_ring_db, 'touch_node') def test_notify_touch_node_exception(self, mock_touch_node, mock_log): + updated_at = timeutils.utcnow() - datetime.timedelta( + seconds=ovn_const.HASH_RING_CACHE_TIMEOUT + 10) + self.mock_get_node.return_value = (self.node_uuid, updated_at) mock_touch_node.side_effect = Exception('BoOooOmmMmmMm') self.idl.notify(self.fake_event, self.fake_row) @@ -277,7 +289,8 @@ def test_notify_touch_node_exception(self, mock_touch_node, mock_log): self._assert_has_notify_calls() def test_notify_different_node(self): - self.mock_get_node.return_value = 'different-node-uuid' + self.mock_get_node.return_value = ('different-node-uuid', + timeutils.utcnow()) self.idl.notify('fake-event', self.fake_row) # Assert that notify() wasn't called for a different node uuid self.idl.notify_handler.notify.assert_called_once_with( @@ -399,7 +412,8 @@ def setUp(self): self.mech_driver.set_port_status_up = mock.Mock() self.mech_driver.set_port_status_down = mock.Mock() self._mock_hash_ring = mock.patch.object( - self.idl._hash_ring, 'get_node', return_value=self.idl._node_uuid) + self.idl._hash_ring, 'get_node', + return_value=(self.idl._node_uuid, timeutils.utcnow())) self._mock_hash_ring.start() def _test_lsp_helper(self, event, new_row_json, old_row_json=None, @@ -559,7 +573,7 @@ def setUp(self): } self._mock_hash_ring = mock.patch.object( self.sb_idl._hash_ring, 'get_node', - return_value=self.sb_idl._node_uuid) + return_value=(self.sb_idl._node_uuid, timeutils.utcnow())) self._mock_hash_ring.start() def _test_chassis_helper(self, event, new_row_json, old_row_json=None): From 754134659fc88585f862c8f94f42ef6c743b6503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 22 May 2025 11:30:39 +0200 Subject: [PATCH 159/184] Add CODEOWNERS Based on our engineering policy and PCI DSS 4.0 requirements, all repositories that modify production infrastructure must enforce a two-person approval process. This patch adds a CODEOWNERS file to to define the approvers for the repository. --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..55cbbf63f89 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @sapcc/network-api-contributors @sapcc/cc_github_managers_approval +/.github/CODEOWNERS @sapcc/cc_github_managers_approval From f186f86a680ff78a044657f4791260e8189b77a9 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Mon, 2 Jun 2025 18:50:49 +0200 Subject: [PATCH 160/184] customdns - fix keystone session, needed for caracal/sdk update After updating to Caracal and using openstacksdk 3.0.0 the sdk refused initializing the identity client correctly to talk to keystone. This apparently happens when a full config is given to the Connection, and should not have worked before. Also now calling identity directly without indirection, which also should be more efficient because it uses a direct get for the id. --- neutron/api/rpc/handlers/dhcp_rpc.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/neutron/api/rpc/handlers/dhcp_rpc.py b/neutron/api/rpc/handlers/dhcp_rpc.py index e3bfe3df666..23357975fed 100644 --- a/neutron/api/rpc/handlers/dhcp_rpc.py +++ b/neutron/api/rpc/handlers/dhcp_rpc.py @@ -118,10 +118,13 @@ def _keystone_connection(self): keystone_session = ks_loading.load_session_from_conf_options( cfg.CONF, auth_section, auth=auth) self._KEYSTONE = connection.Connection( - session=keystone_session, oslo_conf=cfg.CONF, - connect_retries=cfg.CONF.http_retries) + session=keystone_session, + connect_retries=cfg.CONF.http_retries, + service_types={'identity'}, + strict_proxies=True, + identity_interface='internal') - return self._KEYSTONE + return self._KEYSTONE.identity def get_domain_name(self, project_id: str) -> str: """query keystone to get the name of the domain that From 97b62692cae952fc4e64138b38187909ef799f46 Mon Sep 17 00:00:00 2001 From: Mikhail Samoylov Date: Sat, 28 Jun 2025 06:07:15 +0400 Subject: [PATCH 161/184] Use designate /v2/reverse/floatingips/ endpoint to create DNS zones and DNS entries during FIP creation. Current driver uses zones and recordset endpoints instead of specific designate endpoint which optimazied for FIP's. Using zones and recordsets endpoint instead of optimized designate endoint makes driver debug more complicated, with this changes neutron part simplified. Change-Id: I5e53a8dc941fed0bb8caf53823f94a75bce908ab --- .../conf/services/extdns_designate_driver.py | 2 + neutron/db/dns_db.py | 25 +- .../drivers/designate/driver_ccloud.py | 276 ++++++++++++++++++ .../drivers/designate/test_driver_ccloud.py | 258 ++++++++++++++++ setup.cfg | 1 + 5 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 neutron/services/externaldns/drivers/designate/driver_ccloud.py create mode 100644 neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py diff --git a/neutron/conf/services/extdns_designate_driver.py b/neutron/conf/services/extdns_designate_driver.py index 56ccf5fc89d..c621fdf1729 100644 --- a/neutron/conf/services/extdns_designate_driver.py +++ b/neutron/conf/services/extdns_designate_driver.py @@ -67,6 +67,8 @@ def __call__(self, value): help=_('The email address to be used when creating PTR zones. ' 'If not specified, the email address will be ' 'admin@')), + cfg.StrOpt('region_name', + help=_('Name of designate region to use.')), ] diff --git a/neutron/db/dns_db.py b/neutron/db/dns_db.py index 5d49ec42c5d..62d16d262c6 100644 --- a/neutron/db/dns_db.py +++ b/neutron/db/dns_db.py @@ -28,6 +28,8 @@ from neutron.objects import network from neutron.objects import ports as port_obj from neutron.services.externaldns import driver +from neutron.services.externaldns.drivers.designate.driver_ccloud import\ + DesignateCcloud LOG = logging.getLogger(__name__) @@ -47,6 +49,7 @@ class DNSDbMixin(object): """Mixin class to add DNS methods to db_base_plugin_v2.""" _dns_driver = None + _ccloud_dns_driver_enabled = None @property def dns_driver(self): @@ -56,6 +59,9 @@ def dns_driver(self): return try: self._dns_driver = driver.ExternalDNSService.get_instance() + self._ccloud_dns_driver_enabled = isinstance( + self._dns_driver, DesignateCcloud + ) LOG.debug("External DNS driver loaded: %s", cfg.CONF.external_dns_driver) return self._dns_driver @@ -114,7 +120,7 @@ def _process_dns_floatingip_create_postcommit(self, context, self._add_ips_to_external_dns_service( context, dns_actions_data.current_dns_domain, dns_actions_data.current_dns_name, - [floatingip_data['floating_ip_address']]) + floatingip_data) def _process_dns_floatingip_update_precommit(self, context, floatingip_data): @@ -174,7 +180,7 @@ def _process_dns_floatingip_update_postcommit(self, context, self._add_ips_to_external_dns_service( context, dns_actions_data.current_dns_domain, dns_actions_data.current_dns_name, - [floatingip_data['floating_ip_address']]) + floatingip_data) def _process_dns_floatingip_delete(self, context, floatingip_data): if not extensions.is_extension_supported( @@ -240,11 +246,18 @@ def _get_requested_state_for_external_dns_service_update(self, context, return None, None def _add_ips_to_external_dns_service(self, context, dns_domain, dns_name, - records): - ips = [str(r) for r in records] + floatingip_data): + ips = [str(floatingip_data['floating_ip_address'])] try: - self.dns_driver.create_record_set(context, dns_domain, dns_name, - ips) + if self._ccloud_dns_driver_enabled: + fip_id = floatingip_data.get("id") + self.dns_driver.create_record_set( + context, dns_domain, dns_name, + ips, fip_id=fip_id + ) + else: + self.dns_driver.create_record_set(context, dns_domain, + dns_name, ips) except (dns_exc.DNSDomainNotFound, dns_exc.DuplicateRecordSet) as e: LOG.exception("Error publishing floating IP data in external " "DNS service. Name: '%(name)s'. Domain: " diff --git a/neutron/services/externaldns/drivers/designate/driver_ccloud.py b/neutron/services/externaldns/drivers/designate/driver_ccloud.py new file mode 100644 index 00000000000..11af3e89c5c --- /dev/null +++ b/neutron/services/externaldns/drivers/designate/driver_ccloud.py @@ -0,0 +1,276 @@ +# Copyright (c) 2016 IBM +# Copyright 2025 SAP SE +# 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 designateclient import exceptions as d_exc +from designateclient.v2 import client as d_client +from keystoneauth1 import loading +from keystoneauth1 import token_endpoint +import netaddr +from neutron_lib import constants +from neutron_lib.exceptions import dns as dns_exc +from oslo_config import cfg +from oslo_log import log + +from neutron.conf.services import extdns_designate_driver +from neutron.services.externaldns import driver + +IPV4_PTR_ZONE_PREFIX_MIN_SIZE = 8 +IPV4_PTR_ZONE_PREFIX_MAX_SIZE = 24 +IPV6_PTR_ZONE_PREFIX_MIN_SIZE = 4 +IPV6_PTR_ZONE_PREFIX_MAX_SIZE = 124 + +_SESSION = None + +CONF = cfg.CONF +extdns_designate_driver.register_designate_opts() + +LOG = log.getLogger(__name__) + + +def get_clients(context, all_projects=False, edit_managed=False): + global _SESSION + + if not _SESSION: + _SESSION = loading.load_session_from_conf_options( + CONF, 'designate') + + auth = token_endpoint.Token(CONF.designate.url, context.auth_token) + client = d_client.Client(session=_SESSION, auth=auth) + admin_auth = loading.load_auth_from_conf_options(CONF, 'designate') + admin_client = d_client.Client(session=_SESSION, auth=admin_auth, + endpoint_override=CONF.designate.url, + all_projects=all_projects, + edit_managed=edit_managed) + return client, admin_client + + +def get_all_projects_client(context): + auth = token_endpoint.Token(CONF.designate.url, context.auth_token) + return d_client.Client(session=_SESSION, auth=auth, all_projects=True) + + +def get_all_projects_edit_managed_client(context): + return get_clients(context, all_projects=True, edit_managed=True) + + +class DesignateCcloud(driver.ExternalDNSService): + """Driver for Designate.""" + + def __init__(self): + super().__init__() + ipv4_ptr_zone_size = CONF.designate.ipv4_ptr_zone_prefix_size + ipv6_ptr_zone_size = CONF.designate.ipv6_ptr_zone_prefix_size + + if (ipv4_ptr_zone_size < IPV4_PTR_ZONE_PREFIX_MIN_SIZE or + ipv4_ptr_zone_size > IPV4_PTR_ZONE_PREFIX_MAX_SIZE or + (ipv4_ptr_zone_size % 8) != 0): + raise dns_exc.InvalidPTRZoneConfiguration( + parameter='ipv4_ptr_zone_size', number='8', + maximum=str(IPV4_PTR_ZONE_PREFIX_MAX_SIZE), + minimum=str(IPV4_PTR_ZONE_PREFIX_MIN_SIZE)) + + if (ipv6_ptr_zone_size < IPV6_PTR_ZONE_PREFIX_MIN_SIZE or + ipv6_ptr_zone_size > IPV6_PTR_ZONE_PREFIX_MAX_SIZE or + (ipv6_ptr_zone_size % 4) != 0): + raise dns_exc.InvalidPTRZoneConfiguration( + parameter='ipv6_ptr_zone_size', number='4', + maximum=str(IPV6_PTR_ZONE_PREFIX_MAX_SIZE), + minimum=str(IPV6_PTR_ZONE_PREFIX_MIN_SIZE)) + + def create_record_set(self, context, dns_domain, dns_name, records, + fip_id=None): + """Create a record set in the specified zone. + + :param context: neutron api request context + :type context: neutron_lib.context.Context + :param dns_domain: the dns_domain where the record set will be created + :type dns_domain: String + :param dns_name: the name associated with the record set + :type dns_name: String + :param records: the records in the set + :type records: List of Strings + :param fip_id: Floating IP id + :type fip_id: String + :raises: neutron.extensions.dns.DNSDomainNotFound + neutron.extensions.dns.DuplicateRecordSet + """ + designate, designate_admin = get_clients(context) + v4, v6 = self._classify_records(records) + try: + if v4: + designate.recordsets.create(dns_domain, dns_name, 'A', v4) + if v6: + designate.recordsets.create(dns_domain, dns_name, 'AAAA', v6) + except d_exc.NotFound: + raise dns_exc.DNSDomainNotFound(dns_domain=dns_domain) + except d_exc.Conflict: + fqdn = ".".join([dns_name, dns_domain]) + if v4: + designate.recordsets.update(dns_domain, fqdn, {"records": v4}) + if v6: + designate.recordsets.update(dns_domain, fqdn, {"records": v6}) + except d_exc.OverQuota: + raise dns_exc.ExternalDNSOverQuota(resource="recordset") + + if not CONF.designate.allow_reverse_dns_lookup: + return + # Set up the PTR records + if fip_id: + designate.floatingips.set( + f"{CONF.designate.region_name}:{fip_id}", + f"{dns_name}.{dns_domain}" + ) + else: + # Set up the PTR records + recordset_name = '%s.%s' % (dns_name, dns_domain) + ptr_zone_email = 'admin@%s' % dns_domain[:-1] + if CONF.designate.ptr_zone_email: + ptr_zone_email = CONF.designate.ptr_zone_email + for record in records: + in_addr_name = netaddr.IPAddress(record).reverse_dns + in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) + in_addr_zone_description = ( + 'An %s zone for reverse lookups set up by Neutron.' % + '.'.join(in_addr_name.split('.')[-3:])) + try: + # Since we don't delete in-addr zones, assume it already + # exists. If it doesn't, create it + designate_admin.recordsets.create(in_addr_zone_name, + in_addr_name, 'PTR', + [recordset_name]) + except d_exc.Conflict: + # It can happen that we have left + # -over or manually created PTR + # from before (e.g. by a project that was using same FIP). + # If PTR exists, update it even if it is 'managed'. + c_designate, c_designate_admin = get_clients( + context, + edit_managed=True + ) + recordset_dict = {'records': [recordset_name]} + # Use own instance of admin client as a precaution + c_designate_admin.recordsets.update(in_addr_zone_name, + in_addr_name, + recordset_dict) + except d_exc.NotFound: + # Note(jh): If multiple PTRs get created at the same time, + # the creation of the zone may fail with a conflict because + # it has already been created by a parallel job. So we + # ignore that error and try to create the recordset + # anyway. That call will still fail in the end if something + # is really broken. See bug 1891309. + try: + designate_admin.zones.create( + in_addr_zone_name, email=ptr_zone_email, + description=in_addr_zone_description) + except d_exc.Conflict: + LOG.debug('Conflict when trying to create PTR zone %s,' + ' assuming it exists.', + in_addr_zone_name) + pass + except d_exc.OverQuota: + raise dns_exc.ExternalDNSOverQuota(resource='zone') + designate_admin.recordsets.create(in_addr_zone_name, + in_addr_name, 'PTR', + [recordset_name]) + + def _classify_records(self, records): + v4 = [] + v6 = [] + for record in records: + if netaddr.IPAddress(record).version == 4: + v4.append(record) + else: + v6.append(record) + return v4, v6 + + def _get_in_addr_zone_name(self, in_addr_name): + units = self._get_bytes_or_nybles_to_skip(in_addr_name) + return '.'.join(in_addr_name.split('.')[units:]) + + def _get_bytes_or_nybles_to_skip(self, in_addr_name): + if 'in-addr.arpa' in in_addr_name: + return int((constants.IPv4_BITS - + CONF.designate.ipv4_ptr_zone_prefix_size) / 8) + return int((constants.IPv6_BITS - + CONF.designate.ipv6_ptr_zone_prefix_size) / 4) + + def delete_record_set(self, context, dns_domain, dns_name, records): + client, admin_client = get_clients(context) + ids_to_delete = [] + try: + # first try regular client: + ids_to_delete = self._get_ids_ips_to_delete( + dns_domain, '%s.%s' % (dns_name, dns_domain), records, client) + except dns_exc.DNSDomainNotFound: + # Try whether we have admin powers and can see all projects + # and also handle managed records (to prevent leftover PTRs): + client, admin_client = get_all_projects_edit_managed_client( + context) + try: + ids_to_delete = self._get_ids_ips_to_delete( + dns_domain, + '%s.%s' % (dns_name, dns_domain), + records, + client) + except dns_exc.DNSDomainNotFound: + LOG.debug("The domain '%s' not found in Designate", + dns_domain) + except d_exc.Forbidden: + LOG.error("Cannot determine Designate record ids for " + "deletion of: '%(name)s.%(dom)s'", + {'name': dns_name, 'dom': dns_domain}) + + for _id in ids_to_delete: + try: + client.recordsets.delete(dns_domain, _id) + except (d_exc.Forbidden, d_exc.NotFound) as exc: + LOG.error("Cannot delete Designate record with id %(recid)s in" + " domain: %(dom)s. Error: %(err)s", + {'recid': _id, 'dom': dns_domain, 'err': exc}) + + if not CONF.designate.allow_reverse_dns_lookup: + return + + # PTR records part + client, admin_client = get_all_projects_edit_managed_client( + context) + for record in records: + in_addr_name = netaddr.IPAddress(record).reverse_dns + in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) + try: + admin_client.recordsets.delete(in_addr_zone_name, + in_addr_name) + except (dns_exc.DNSDomainNotFound, d_exc.NotFound): + LOG.debug("No '%s' PTR record was found in Designate.", + in_addr_name) + except d_exc.Forbidden: + LOG.error("Cannot delete '%s' PTR record.", + in_addr_name) + + def _get_ids_ips_to_delete(self, dns_domain, name, records, + designate_client): + try: + recordsets = designate_client.recordsets.list( + 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 diff --git a/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py b/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py new file mode 100644 index 00000000000..738597641a0 --- /dev/null +++ b/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py @@ -0,0 +1,258 @@ +# Copyright 2025 SAP SE +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +from unittest import mock + +from oslo_config import cfg + +from neutron.tests.unit.extensions.test_l3\ + import L3NatDBFloatingIpTestCaseWithDNS +from neutron.tests.unit.extensions.test_l3\ + import L3TestExtensionManagerWithDNS + +from neutron.services.externaldns.drivers.designate import driver_ccloud + +from .test_driver import TestDesignateDriver + + +class L3NatDBFloatingIpTestCaseWithDNSCcloud(L3NatDBFloatingIpTestCaseWithDNS): + """Unit tests for floating ip with external DNS integration""" + + fmt = 'json' + DNS_NAME = 'test' + DNS_DOMAIN = 'test-domain.org.' + PUBLIC_CIDR = '11.0.0.0/24' + PRIVATE_CIDR = '10.0.0.0/24' + mock_client = mock.MagicMock() + mock_admin_client = mock.MagicMock() + MOCK_PATH = ('neutron.services.externaldns.drivers.' + 'designate.driver_ccloud.get_clients') + mock_config = {'return_value': (mock_client, mock_admin_client)} + _extension_drivers = ['dns'] + + def setUp(self): + ext_mgr = L3TestExtensionManagerWithDNS() + plugin = 'neutron.plugins.ml2.plugin.Ml2Plugin' + cfg.CONF.set_override('extension_drivers', + self._extension_drivers, + group='ml2') + super(L3NatDBFloatingIpTestCaseWithDNS, self).setUp( + plugin=plugin, ext_mgr=ext_mgr) + cfg.CONF.set_override('external_dns_driver', 'designate_ccloud') + self.mock_client.reset_mock() + self.mock_admin_client.reset_mock() + + def _assert_recordset_created(self, floating_ip_address, floating_ip_id): + # The recordsets.create function should be called with: + # dns_domain, dns_name, 'A', ip_address ('A' for IPv4, 'AAAA' for IPv6) + self.mock_client.recordsets.create.assert_called_with( + self.DNS_DOMAIN, + self.DNS_NAME, + 'A', + [floating_ip_address] + ) + self.mock_client.floatingips.set.assert_called_with( + f"{None}:{floating_ip_id}", + f"{self.DNS_NAME}.{self.DNS_DOMAIN}") + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create(self, mock_args): + with self._create_floatingip_with_dns(): + pass + self.mock_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + self.mock_admin_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create_with_flip_dns(self, mock_args): + with self._create_floatingip_with_dns( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create_with_net_port_dns(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns(net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME, + assoc_port=True) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create_with_flip_and_net_port_dns(self, mock_args): + # If both network+port and the floating ip have dns domain and + # dns name, floating ip's information should take priority + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns(net_dns_domain='junkdomain.org.', + port_dns_name='junk', + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME, + assoc_port=True) as flip: + floatingip = flip + # External DNS service should have been called with floating ip's + # dns information, not the network+port's dns information + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port(self, mock_args): + with self._create_floatingip_with_dns_on_update(): + pass + self.mock_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + self.mock_admin_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port_with_flip_dns(self, mock_args): + with self._create_floatingip_with_dns_on_update( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port_with_net_port_dns(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns_on_update( + net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port_with_flip_and_net_port_dns(self, + mock_args): + # If both network+port and the floating ip have dns domain and + # dns name, floating ip's information should take priority + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns_on_update( + net_dns_domain='junkdomain.org.', + port_dns_name='junk', + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_disassociate_port(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns(net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME, assoc_port=True) as flip: + fake_recordset = {'id': '', + 'records': [flip['floating_ip_address']]} + # This method is called during recordset deletion, which + # will fail unless the list function call returns something like + # this fake value + self.mock_client.recordsets.list.return_value = ([fake_recordset]) + # Port gets disassociated if port_id is not in the request body + data = {'floatingip': {}} + req = self.new_update_request('floatingips', data, flip['id']) + res = req.get_response(self._api_for_resource('floatingip')) + floatingip = self.deserialize(self.fmt, res)['floatingip'] + flip_port_id = floatingip['port_id'] + self.assertEqual(200, res.status_code) + self.assertIsNone(flip_port_id) + in_addr_name, in_addr_zone_name = self._get_in_addr( + floatingip['floating_ip_address']) + self.mock_client.recordsets.delete.assert_called_with( + self.DNS_DOMAIN, '') + self.mock_admin_client.recordsets.delete.assert_called_with( + in_addr_zone_name, in_addr_name) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_delete(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + # This method is called during recordset deletion, which will + # fail unless the list function call returns something like + # this fake value + fake_recordset = {'id': '', + 'records': [floatingip['floating_ip_address']]} + self.mock_client.recordsets.list.return_value = [fake_recordset] + in_addr_name, in_addr_zone_name = self._get_in_addr( + floatingip['floating_ip_address']) + self.mock_client.recordsets.delete.assert_called_with( + self.DNS_DOMAIN, '') + self.mock_admin_client.recordsets.delete.assert_called_with( + in_addr_zone_name, in_addr_name) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_no_PTR_record(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + + # Disabling this option should stop the admin client from creating + # PTR records. So set this option and make sure the admin client + # wasn't called to create any records + cfg.CONF.set_override('allow_reverse_dns_lookup', False, + group='designate') + + with self._create_floatingip_with_dns( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME + ) as flip: + floatingip = flip + + self.mock_client.recordsets.create.assert_called_with( + self.DNS_DOMAIN, self.DNS_NAME, 'A', + [floatingip['floating_ip_address']] + ) + self.mock_admin_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + +class TestCCloudDesignateDriver(TestDesignateDriver): + def setUp(self): + # skip our parents setup and call it's parent instead: + super(TestDesignateDriver, self).setUp() + self.context = mock.Mock() + self.client = mock.Mock() + self.admin_client = mock.Mock() + self.all_projects_client = mock.Mock() + mock.patch.object(driver_ccloud, 'get_clients', return_value=( + self.client, self.admin_client)).start() + mock.patch.object(driver_ccloud, 'get_all_projects_client', + return_value=self.all_projects_client).start() + self.driver = driver_ccloud.DesignateCcloud() + + def test_create_record_set_duplicate_recordset(self): + + # The Ccloud driver should not raise an exception here, + # in contrast to the default driver. Let's ensure correct behavior + self.driver.create_record_set(self.context, 'example.test.', + 'test', ['192.168.0.10']) diff --git a/setup.cfg b/setup.cfg index aca634fc417..7b27c5927d3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -160,6 +160,7 @@ neutron.agent.linux.pd_drivers = dibbler = neutron.agent.linux.dibbler:PDDibbler neutron.services.external_dns_drivers = designate = neutron.services.externaldns.drivers.designate.driver:Designate + designate_ccloud = neutron.services.externaldns.drivers.designate.driver_ccloud:DesignateCcloud oslo.config.opts = designate.auth = neutron.opts:list_designate_auth_opts ironic.auth = neutron.opts:list_ironic_auth_opts From 4674a5d0002c954ba42751753da51ea8d1a007fa Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Wed, 2 Jul 2025 16:06:38 +0200 Subject: [PATCH 162/184] Revert "Use designate /v2/reverse/floatingips/ endpoint to create DNS zones and" --- .../conf/services/extdns_designate_driver.py | 2 - neutron/db/dns_db.py | 25 +- .../drivers/designate/driver_ccloud.py | 276 ------------------ .../drivers/designate/test_driver_ccloud.py | 258 ---------------- setup.cfg | 1 - 5 files changed, 6 insertions(+), 556 deletions(-) delete mode 100644 neutron/services/externaldns/drivers/designate/driver_ccloud.py delete mode 100644 neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py diff --git a/neutron/conf/services/extdns_designate_driver.py b/neutron/conf/services/extdns_designate_driver.py index c621fdf1729..56ccf5fc89d 100644 --- a/neutron/conf/services/extdns_designate_driver.py +++ b/neutron/conf/services/extdns_designate_driver.py @@ -67,8 +67,6 @@ def __call__(self, value): help=_('The email address to be used when creating PTR zones. ' 'If not specified, the email address will be ' 'admin@')), - cfg.StrOpt('region_name', - help=_('Name of designate region to use.')), ] diff --git a/neutron/db/dns_db.py b/neutron/db/dns_db.py index 62d16d262c6..5d49ec42c5d 100644 --- a/neutron/db/dns_db.py +++ b/neutron/db/dns_db.py @@ -28,8 +28,6 @@ from neutron.objects import network from neutron.objects import ports as port_obj from neutron.services.externaldns import driver -from neutron.services.externaldns.drivers.designate.driver_ccloud import\ - DesignateCcloud LOG = logging.getLogger(__name__) @@ -49,7 +47,6 @@ class DNSDbMixin(object): """Mixin class to add DNS methods to db_base_plugin_v2.""" _dns_driver = None - _ccloud_dns_driver_enabled = None @property def dns_driver(self): @@ -59,9 +56,6 @@ def dns_driver(self): return try: self._dns_driver = driver.ExternalDNSService.get_instance() - self._ccloud_dns_driver_enabled = isinstance( - self._dns_driver, DesignateCcloud - ) LOG.debug("External DNS driver loaded: %s", cfg.CONF.external_dns_driver) return self._dns_driver @@ -120,7 +114,7 @@ def _process_dns_floatingip_create_postcommit(self, context, self._add_ips_to_external_dns_service( context, dns_actions_data.current_dns_domain, dns_actions_data.current_dns_name, - floatingip_data) + [floatingip_data['floating_ip_address']]) def _process_dns_floatingip_update_precommit(self, context, floatingip_data): @@ -180,7 +174,7 @@ def _process_dns_floatingip_update_postcommit(self, context, self._add_ips_to_external_dns_service( context, dns_actions_data.current_dns_domain, dns_actions_data.current_dns_name, - floatingip_data) + [floatingip_data['floating_ip_address']]) def _process_dns_floatingip_delete(self, context, floatingip_data): if not extensions.is_extension_supported( @@ -246,18 +240,11 @@ def _get_requested_state_for_external_dns_service_update(self, context, return None, None def _add_ips_to_external_dns_service(self, context, dns_domain, dns_name, - floatingip_data): - ips = [str(floatingip_data['floating_ip_address'])] + records): + ips = [str(r) for r in records] try: - if self._ccloud_dns_driver_enabled: - fip_id = floatingip_data.get("id") - self.dns_driver.create_record_set( - context, dns_domain, dns_name, - ips, fip_id=fip_id - ) - else: - self.dns_driver.create_record_set(context, dns_domain, - dns_name, ips) + self.dns_driver.create_record_set(context, dns_domain, dns_name, + ips) except (dns_exc.DNSDomainNotFound, dns_exc.DuplicateRecordSet) as e: LOG.exception("Error publishing floating IP data in external " "DNS service. Name: '%(name)s'. Domain: " diff --git a/neutron/services/externaldns/drivers/designate/driver_ccloud.py b/neutron/services/externaldns/drivers/designate/driver_ccloud.py deleted file mode 100644 index 11af3e89c5c..00000000000 --- a/neutron/services/externaldns/drivers/designate/driver_ccloud.py +++ /dev/null @@ -1,276 +0,0 @@ -# Copyright (c) 2016 IBM -# Copyright 2025 SAP SE -# 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 designateclient import exceptions as d_exc -from designateclient.v2 import client as d_client -from keystoneauth1 import loading -from keystoneauth1 import token_endpoint -import netaddr -from neutron_lib import constants -from neutron_lib.exceptions import dns as dns_exc -from oslo_config import cfg -from oslo_log import log - -from neutron.conf.services import extdns_designate_driver -from neutron.services.externaldns import driver - -IPV4_PTR_ZONE_PREFIX_MIN_SIZE = 8 -IPV4_PTR_ZONE_PREFIX_MAX_SIZE = 24 -IPV6_PTR_ZONE_PREFIX_MIN_SIZE = 4 -IPV6_PTR_ZONE_PREFIX_MAX_SIZE = 124 - -_SESSION = None - -CONF = cfg.CONF -extdns_designate_driver.register_designate_opts() - -LOG = log.getLogger(__name__) - - -def get_clients(context, all_projects=False, edit_managed=False): - global _SESSION - - if not _SESSION: - _SESSION = loading.load_session_from_conf_options( - CONF, 'designate') - - auth = token_endpoint.Token(CONF.designate.url, context.auth_token) - client = d_client.Client(session=_SESSION, auth=auth) - admin_auth = loading.load_auth_from_conf_options(CONF, 'designate') - admin_client = d_client.Client(session=_SESSION, auth=admin_auth, - endpoint_override=CONF.designate.url, - all_projects=all_projects, - edit_managed=edit_managed) - return client, admin_client - - -def get_all_projects_client(context): - auth = token_endpoint.Token(CONF.designate.url, context.auth_token) - return d_client.Client(session=_SESSION, auth=auth, all_projects=True) - - -def get_all_projects_edit_managed_client(context): - return get_clients(context, all_projects=True, edit_managed=True) - - -class DesignateCcloud(driver.ExternalDNSService): - """Driver for Designate.""" - - def __init__(self): - super().__init__() - ipv4_ptr_zone_size = CONF.designate.ipv4_ptr_zone_prefix_size - ipv6_ptr_zone_size = CONF.designate.ipv6_ptr_zone_prefix_size - - if (ipv4_ptr_zone_size < IPV4_PTR_ZONE_PREFIX_MIN_SIZE or - ipv4_ptr_zone_size > IPV4_PTR_ZONE_PREFIX_MAX_SIZE or - (ipv4_ptr_zone_size % 8) != 0): - raise dns_exc.InvalidPTRZoneConfiguration( - parameter='ipv4_ptr_zone_size', number='8', - maximum=str(IPV4_PTR_ZONE_PREFIX_MAX_SIZE), - minimum=str(IPV4_PTR_ZONE_PREFIX_MIN_SIZE)) - - if (ipv6_ptr_zone_size < IPV6_PTR_ZONE_PREFIX_MIN_SIZE or - ipv6_ptr_zone_size > IPV6_PTR_ZONE_PREFIX_MAX_SIZE or - (ipv6_ptr_zone_size % 4) != 0): - raise dns_exc.InvalidPTRZoneConfiguration( - parameter='ipv6_ptr_zone_size', number='4', - maximum=str(IPV6_PTR_ZONE_PREFIX_MAX_SIZE), - minimum=str(IPV6_PTR_ZONE_PREFIX_MIN_SIZE)) - - def create_record_set(self, context, dns_domain, dns_name, records, - fip_id=None): - """Create a record set in the specified zone. - - :param context: neutron api request context - :type context: neutron_lib.context.Context - :param dns_domain: the dns_domain where the record set will be created - :type dns_domain: String - :param dns_name: the name associated with the record set - :type dns_name: String - :param records: the records in the set - :type records: List of Strings - :param fip_id: Floating IP id - :type fip_id: String - :raises: neutron.extensions.dns.DNSDomainNotFound - neutron.extensions.dns.DuplicateRecordSet - """ - designate, designate_admin = get_clients(context) - v4, v6 = self._classify_records(records) - try: - if v4: - designate.recordsets.create(dns_domain, dns_name, 'A', v4) - if v6: - designate.recordsets.create(dns_domain, dns_name, 'AAAA', v6) - except d_exc.NotFound: - raise dns_exc.DNSDomainNotFound(dns_domain=dns_domain) - except d_exc.Conflict: - fqdn = ".".join([dns_name, dns_domain]) - if v4: - designate.recordsets.update(dns_domain, fqdn, {"records": v4}) - if v6: - designate.recordsets.update(dns_domain, fqdn, {"records": v6}) - except d_exc.OverQuota: - raise dns_exc.ExternalDNSOverQuota(resource="recordset") - - if not CONF.designate.allow_reverse_dns_lookup: - return - # Set up the PTR records - if fip_id: - designate.floatingips.set( - f"{CONF.designate.region_name}:{fip_id}", - f"{dns_name}.{dns_domain}" - ) - else: - # Set up the PTR records - recordset_name = '%s.%s' % (dns_name, dns_domain) - ptr_zone_email = 'admin@%s' % dns_domain[:-1] - if CONF.designate.ptr_zone_email: - ptr_zone_email = CONF.designate.ptr_zone_email - for record in records: - in_addr_name = netaddr.IPAddress(record).reverse_dns - in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) - in_addr_zone_description = ( - 'An %s zone for reverse lookups set up by Neutron.' % - '.'.join(in_addr_name.split('.')[-3:])) - try: - # Since we don't delete in-addr zones, assume it already - # exists. If it doesn't, create it - designate_admin.recordsets.create(in_addr_zone_name, - in_addr_name, 'PTR', - [recordset_name]) - except d_exc.Conflict: - # It can happen that we have left - # -over or manually created PTR - # from before (e.g. by a project that was using same FIP). - # If PTR exists, update it even if it is 'managed'. - c_designate, c_designate_admin = get_clients( - context, - edit_managed=True - ) - recordset_dict = {'records': [recordset_name]} - # Use own instance of admin client as a precaution - c_designate_admin.recordsets.update(in_addr_zone_name, - in_addr_name, - recordset_dict) - except d_exc.NotFound: - # Note(jh): If multiple PTRs get created at the same time, - # the creation of the zone may fail with a conflict because - # it has already been created by a parallel job. So we - # ignore that error and try to create the recordset - # anyway. That call will still fail in the end if something - # is really broken. See bug 1891309. - try: - designate_admin.zones.create( - in_addr_zone_name, email=ptr_zone_email, - description=in_addr_zone_description) - except d_exc.Conflict: - LOG.debug('Conflict when trying to create PTR zone %s,' - ' assuming it exists.', - in_addr_zone_name) - pass - except d_exc.OverQuota: - raise dns_exc.ExternalDNSOverQuota(resource='zone') - designate_admin.recordsets.create(in_addr_zone_name, - in_addr_name, 'PTR', - [recordset_name]) - - def _classify_records(self, records): - v4 = [] - v6 = [] - for record in records: - if netaddr.IPAddress(record).version == 4: - v4.append(record) - else: - v6.append(record) - return v4, v6 - - def _get_in_addr_zone_name(self, in_addr_name): - units = self._get_bytes_or_nybles_to_skip(in_addr_name) - return '.'.join(in_addr_name.split('.')[units:]) - - def _get_bytes_or_nybles_to_skip(self, in_addr_name): - if 'in-addr.arpa' in in_addr_name: - return int((constants.IPv4_BITS - - CONF.designate.ipv4_ptr_zone_prefix_size) / 8) - return int((constants.IPv6_BITS - - CONF.designate.ipv6_ptr_zone_prefix_size) / 4) - - def delete_record_set(self, context, dns_domain, dns_name, records): - client, admin_client = get_clients(context) - ids_to_delete = [] - try: - # first try regular client: - ids_to_delete = self._get_ids_ips_to_delete( - dns_domain, '%s.%s' % (dns_name, dns_domain), records, client) - except dns_exc.DNSDomainNotFound: - # Try whether we have admin powers and can see all projects - # and also handle managed records (to prevent leftover PTRs): - client, admin_client = get_all_projects_edit_managed_client( - context) - try: - ids_to_delete = self._get_ids_ips_to_delete( - dns_domain, - '%s.%s' % (dns_name, dns_domain), - records, - client) - except dns_exc.DNSDomainNotFound: - LOG.debug("The domain '%s' not found in Designate", - dns_domain) - except d_exc.Forbidden: - LOG.error("Cannot determine Designate record ids for " - "deletion of: '%(name)s.%(dom)s'", - {'name': dns_name, 'dom': dns_domain}) - - for _id in ids_to_delete: - try: - client.recordsets.delete(dns_domain, _id) - except (d_exc.Forbidden, d_exc.NotFound) as exc: - LOG.error("Cannot delete Designate record with id %(recid)s in" - " domain: %(dom)s. Error: %(err)s", - {'recid': _id, 'dom': dns_domain, 'err': exc}) - - if not CONF.designate.allow_reverse_dns_lookup: - return - - # PTR records part - client, admin_client = get_all_projects_edit_managed_client( - context) - for record in records: - in_addr_name = netaddr.IPAddress(record).reverse_dns - in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) - try: - admin_client.recordsets.delete(in_addr_zone_name, - in_addr_name) - except (dns_exc.DNSDomainNotFound, d_exc.NotFound): - LOG.debug("No '%s' PTR record was found in Designate.", - in_addr_name) - except d_exc.Forbidden: - LOG.error("Cannot delete '%s' PTR record.", - in_addr_name) - - def _get_ids_ips_to_delete(self, dns_domain, name, records, - designate_client): - try: - recordsets = designate_client.recordsets.list( - 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 diff --git a/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py b/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py deleted file mode 100644 index 738597641a0..00000000000 --- a/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py +++ /dev/null @@ -1,258 +0,0 @@ -# Copyright 2025 SAP SE -# All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# - -from unittest import mock - -from oslo_config import cfg - -from neutron.tests.unit.extensions.test_l3\ - import L3NatDBFloatingIpTestCaseWithDNS -from neutron.tests.unit.extensions.test_l3\ - import L3TestExtensionManagerWithDNS - -from neutron.services.externaldns.drivers.designate import driver_ccloud - -from .test_driver import TestDesignateDriver - - -class L3NatDBFloatingIpTestCaseWithDNSCcloud(L3NatDBFloatingIpTestCaseWithDNS): - """Unit tests for floating ip with external DNS integration""" - - fmt = 'json' - DNS_NAME = 'test' - DNS_DOMAIN = 'test-domain.org.' - PUBLIC_CIDR = '11.0.0.0/24' - PRIVATE_CIDR = '10.0.0.0/24' - mock_client = mock.MagicMock() - mock_admin_client = mock.MagicMock() - MOCK_PATH = ('neutron.services.externaldns.drivers.' - 'designate.driver_ccloud.get_clients') - mock_config = {'return_value': (mock_client, mock_admin_client)} - _extension_drivers = ['dns'] - - def setUp(self): - ext_mgr = L3TestExtensionManagerWithDNS() - plugin = 'neutron.plugins.ml2.plugin.Ml2Plugin' - cfg.CONF.set_override('extension_drivers', - self._extension_drivers, - group='ml2') - super(L3NatDBFloatingIpTestCaseWithDNS, self).setUp( - plugin=plugin, ext_mgr=ext_mgr) - cfg.CONF.set_override('external_dns_driver', 'designate_ccloud') - self.mock_client.reset_mock() - self.mock_admin_client.reset_mock() - - def _assert_recordset_created(self, floating_ip_address, floating_ip_id): - # The recordsets.create function should be called with: - # dns_domain, dns_name, 'A', ip_address ('A' for IPv4, 'AAAA' for IPv6) - self.mock_client.recordsets.create.assert_called_with( - self.DNS_DOMAIN, - self.DNS_NAME, - 'A', - [floating_ip_address] - ) - self.mock_client.floatingips.set.assert_called_with( - f"{None}:{floating_ip_id}", - f"{self.DNS_NAME}.{self.DNS_DOMAIN}") - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_create(self, mock_args): - with self._create_floatingip_with_dns(): - pass - self.mock_client.recordsets.create.assert_not_called() - self.mock_client.floatingips.set.assert_not_called() - self.mock_admin_client.recordsets.create.assert_not_called() - self.mock_client.floatingips.set.assert_not_called() - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_create_with_flip_dns(self, mock_args): - with self._create_floatingip_with_dns( - flip_dns_domain=self.DNS_DOMAIN, - flip_dns_name=self.DNS_NAME) as flip: - floatingip = flip - self._assert_recordset_created(floatingip['floating_ip_address'], - floatingip["id"]) - self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) - self.assertEqual(self.DNS_NAME, floatingip['dns_name']) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_create_with_net_port_dns(self, mock_args): - cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) - with self._create_floatingip_with_dns(net_dns_domain=self.DNS_DOMAIN, - port_dns_name=self.DNS_NAME, - assoc_port=True) as flip: - floatingip = flip - self._assert_recordset_created(floatingip['floating_ip_address'], - floatingip["id"]) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_create_with_flip_and_net_port_dns(self, mock_args): - # If both network+port and the floating ip have dns domain and - # dns name, floating ip's information should take priority - cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) - with self._create_floatingip_with_dns(net_dns_domain='junkdomain.org.', - port_dns_name='junk', - flip_dns_domain=self.DNS_DOMAIN, - flip_dns_name=self.DNS_NAME, - assoc_port=True) as flip: - floatingip = flip - # External DNS service should have been called with floating ip's - # dns information, not the network+port's dns information - self._assert_recordset_created(floatingip['floating_ip_address'], - floatingip["id"]) - - self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) - self.assertEqual(self.DNS_NAME, floatingip['dns_name']) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_associate_port(self, mock_args): - with self._create_floatingip_with_dns_on_update(): - pass - self.mock_client.recordsets.create.assert_not_called() - self.mock_client.floatingips.set.assert_not_called() - self.mock_admin_client.recordsets.create.assert_not_called() - self.mock_client.floatingips.set.assert_not_called() - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_associate_port_with_flip_dns(self, mock_args): - with self._create_floatingip_with_dns_on_update( - flip_dns_domain=self.DNS_DOMAIN, - flip_dns_name=self.DNS_NAME) as flip: - floatingip = flip - self._assert_recordset_created(floatingip['floating_ip_address'], - floatingip["id"]) - self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) - self.assertEqual(self.DNS_NAME, floatingip['dns_name']) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_associate_port_with_net_port_dns(self, mock_args): - cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) - with self._create_floatingip_with_dns_on_update( - net_dns_domain=self.DNS_DOMAIN, - port_dns_name=self.DNS_NAME) as flip: - floatingip = flip - self._assert_recordset_created(floatingip['floating_ip_address'], - floatingip["id"]) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_associate_port_with_flip_and_net_port_dns(self, - mock_args): - # If both network+port and the floating ip have dns domain and - # dns name, floating ip's information should take priority - cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) - with self._create_floatingip_with_dns_on_update( - net_dns_domain='junkdomain.org.', - port_dns_name='junk', - flip_dns_domain=self.DNS_DOMAIN, - flip_dns_name=self.DNS_NAME) as flip: - floatingip = flip - self._assert_recordset_created(floatingip['floating_ip_address'], - floatingip["id"]) - self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) - self.assertEqual(self.DNS_NAME, floatingip['dns_name']) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_disassociate_port(self, mock_args): - cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) - with self._create_floatingip_with_dns(net_dns_domain=self.DNS_DOMAIN, - port_dns_name=self.DNS_NAME, assoc_port=True) as flip: - fake_recordset = {'id': '', - 'records': [flip['floating_ip_address']]} - # This method is called during recordset deletion, which - # will fail unless the list function call returns something like - # this fake value - self.mock_client.recordsets.list.return_value = ([fake_recordset]) - # Port gets disassociated if port_id is not in the request body - data = {'floatingip': {}} - req = self.new_update_request('floatingips', data, flip['id']) - res = req.get_response(self._api_for_resource('floatingip')) - floatingip = self.deserialize(self.fmt, res)['floatingip'] - flip_port_id = floatingip['port_id'] - self.assertEqual(200, res.status_code) - self.assertIsNone(flip_port_id) - in_addr_name, in_addr_zone_name = self._get_in_addr( - floatingip['floating_ip_address']) - self.mock_client.recordsets.delete.assert_called_with( - self.DNS_DOMAIN, '') - self.mock_admin_client.recordsets.delete.assert_called_with( - in_addr_zone_name, in_addr_name) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_delete(self, mock_args): - cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) - with self._create_floatingip_with_dns( - flip_dns_domain=self.DNS_DOMAIN, - flip_dns_name=self.DNS_NAME) as flip: - floatingip = flip - # This method is called during recordset deletion, which will - # fail unless the list function call returns something like - # this fake value - fake_recordset = {'id': '', - 'records': [floatingip['floating_ip_address']]} - self.mock_client.recordsets.list.return_value = [fake_recordset] - in_addr_name, in_addr_zone_name = self._get_in_addr( - floatingip['floating_ip_address']) - self.mock_client.recordsets.delete.assert_called_with( - self.DNS_DOMAIN, '') - self.mock_admin_client.recordsets.delete.assert_called_with( - in_addr_zone_name, in_addr_name) - - @mock.patch(MOCK_PATH, **mock_config) - def test_floatingip_no_PTR_record(self, mock_args): - cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) - - # Disabling this option should stop the admin client from creating - # PTR records. So set this option and make sure the admin client - # wasn't called to create any records - cfg.CONF.set_override('allow_reverse_dns_lookup', False, - group='designate') - - with self._create_floatingip_with_dns( - flip_dns_domain=self.DNS_DOMAIN, - flip_dns_name=self.DNS_NAME - ) as flip: - floatingip = flip - - self.mock_client.recordsets.create.assert_called_with( - self.DNS_DOMAIN, self.DNS_NAME, 'A', - [floatingip['floating_ip_address']] - ) - self.mock_admin_client.recordsets.create.assert_not_called() - self.mock_client.floatingips.set.assert_not_called() - self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) - self.assertEqual(self.DNS_NAME, floatingip['dns_name']) - - -class TestCCloudDesignateDriver(TestDesignateDriver): - def setUp(self): - # skip our parents setup and call it's parent instead: - super(TestDesignateDriver, self).setUp() - self.context = mock.Mock() - self.client = mock.Mock() - self.admin_client = mock.Mock() - self.all_projects_client = mock.Mock() - mock.patch.object(driver_ccloud, 'get_clients', return_value=( - self.client, self.admin_client)).start() - mock.patch.object(driver_ccloud, 'get_all_projects_client', - return_value=self.all_projects_client).start() - self.driver = driver_ccloud.DesignateCcloud() - - def test_create_record_set_duplicate_recordset(self): - - # The Ccloud driver should not raise an exception here, - # in contrast to the default driver. Let's ensure correct behavior - self.driver.create_record_set(self.context, 'example.test.', - 'test', ['192.168.0.10']) diff --git a/setup.cfg b/setup.cfg index 7b27c5927d3..aca634fc417 100644 --- a/setup.cfg +++ b/setup.cfg @@ -160,7 +160,6 @@ neutron.agent.linux.pd_drivers = dibbler = neutron.agent.linux.dibbler:PDDibbler neutron.services.external_dns_drivers = designate = neutron.services.externaldns.drivers.designate.driver:Designate - designate_ccloud = neutron.services.externaldns.drivers.designate.driver_ccloud:DesignateCcloud oslo.config.opts = designate.auth = neutron.opts:list_designate_auth_opts ironic.auth = neutron.opts:list_ironic_auth_opts From e9946720d88fab39ee95e76d0276bfde1c44496c Mon Sep 17 00:00:00 2001 From: Mikhail Samoylov Date: Sat, 28 Jun 2025 06:07:15 +0400 Subject: [PATCH 163/184] Use designate /v2/reverse/floatingips/ endpoint to create DNS zones and DNS entries during FIP creation. Current driver uses zones and recordset endpoints instead of specific designate endpoint which optimazied for FIP's. Using zones and recordsets endpoint instead of optimized designate endoint makes driver debug more complicated, with this changes neutron part simplified. Change-Id: I5e53a8dc941fed0bb8caf53823f94a75bce908ab --- .../conf/services/extdns_designate_driver.py | 2 + neutron/db/dns_db.py | 25 +- .../drivers/designate/driver_ccloud.py | 276 ++++++++++++++++++ .../drivers/designate/test_driver_ccloud.py | 258 ++++++++++++++++ setup.cfg | 1 + 5 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 neutron/services/externaldns/drivers/designate/driver_ccloud.py create mode 100644 neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py diff --git a/neutron/conf/services/extdns_designate_driver.py b/neutron/conf/services/extdns_designate_driver.py index 56ccf5fc89d..c621fdf1729 100644 --- a/neutron/conf/services/extdns_designate_driver.py +++ b/neutron/conf/services/extdns_designate_driver.py @@ -67,6 +67,8 @@ def __call__(self, value): help=_('The email address to be used when creating PTR zones. ' 'If not specified, the email address will be ' 'admin@')), + cfg.StrOpt('region_name', + help=_('Name of designate region to use.')), ] diff --git a/neutron/db/dns_db.py b/neutron/db/dns_db.py index 5d49ec42c5d..62d16d262c6 100644 --- a/neutron/db/dns_db.py +++ b/neutron/db/dns_db.py @@ -28,6 +28,8 @@ from neutron.objects import network from neutron.objects import ports as port_obj from neutron.services.externaldns import driver +from neutron.services.externaldns.drivers.designate.driver_ccloud import\ + DesignateCcloud LOG = logging.getLogger(__name__) @@ -47,6 +49,7 @@ class DNSDbMixin(object): """Mixin class to add DNS methods to db_base_plugin_v2.""" _dns_driver = None + _ccloud_dns_driver_enabled = None @property def dns_driver(self): @@ -56,6 +59,9 @@ def dns_driver(self): return try: self._dns_driver = driver.ExternalDNSService.get_instance() + self._ccloud_dns_driver_enabled = isinstance( + self._dns_driver, DesignateCcloud + ) LOG.debug("External DNS driver loaded: %s", cfg.CONF.external_dns_driver) return self._dns_driver @@ -114,7 +120,7 @@ def _process_dns_floatingip_create_postcommit(self, context, self._add_ips_to_external_dns_service( context, dns_actions_data.current_dns_domain, dns_actions_data.current_dns_name, - [floatingip_data['floating_ip_address']]) + floatingip_data) def _process_dns_floatingip_update_precommit(self, context, floatingip_data): @@ -174,7 +180,7 @@ def _process_dns_floatingip_update_postcommit(self, context, self._add_ips_to_external_dns_service( context, dns_actions_data.current_dns_domain, dns_actions_data.current_dns_name, - [floatingip_data['floating_ip_address']]) + floatingip_data) def _process_dns_floatingip_delete(self, context, floatingip_data): if not extensions.is_extension_supported( @@ -240,11 +246,18 @@ def _get_requested_state_for_external_dns_service_update(self, context, return None, None def _add_ips_to_external_dns_service(self, context, dns_domain, dns_name, - records): - ips = [str(r) for r in records] + floatingip_data): + ips = [str(floatingip_data['floating_ip_address'])] try: - self.dns_driver.create_record_set(context, dns_domain, dns_name, - ips) + if self._ccloud_dns_driver_enabled: + fip_id = floatingip_data.get("id") + self.dns_driver.create_record_set( + context, dns_domain, dns_name, + ips, fip_id=fip_id + ) + else: + self.dns_driver.create_record_set(context, dns_domain, + dns_name, ips) except (dns_exc.DNSDomainNotFound, dns_exc.DuplicateRecordSet) as e: LOG.exception("Error publishing floating IP data in external " "DNS service. Name: '%(name)s'. Domain: " diff --git a/neutron/services/externaldns/drivers/designate/driver_ccloud.py b/neutron/services/externaldns/drivers/designate/driver_ccloud.py new file mode 100644 index 00000000000..11af3e89c5c --- /dev/null +++ b/neutron/services/externaldns/drivers/designate/driver_ccloud.py @@ -0,0 +1,276 @@ +# Copyright (c) 2016 IBM +# Copyright 2025 SAP SE +# 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 designateclient import exceptions as d_exc +from designateclient.v2 import client as d_client +from keystoneauth1 import loading +from keystoneauth1 import token_endpoint +import netaddr +from neutron_lib import constants +from neutron_lib.exceptions import dns as dns_exc +from oslo_config import cfg +from oslo_log import log + +from neutron.conf.services import extdns_designate_driver +from neutron.services.externaldns import driver + +IPV4_PTR_ZONE_PREFIX_MIN_SIZE = 8 +IPV4_PTR_ZONE_PREFIX_MAX_SIZE = 24 +IPV6_PTR_ZONE_PREFIX_MIN_SIZE = 4 +IPV6_PTR_ZONE_PREFIX_MAX_SIZE = 124 + +_SESSION = None + +CONF = cfg.CONF +extdns_designate_driver.register_designate_opts() + +LOG = log.getLogger(__name__) + + +def get_clients(context, all_projects=False, edit_managed=False): + global _SESSION + + if not _SESSION: + _SESSION = loading.load_session_from_conf_options( + CONF, 'designate') + + auth = token_endpoint.Token(CONF.designate.url, context.auth_token) + client = d_client.Client(session=_SESSION, auth=auth) + admin_auth = loading.load_auth_from_conf_options(CONF, 'designate') + admin_client = d_client.Client(session=_SESSION, auth=admin_auth, + endpoint_override=CONF.designate.url, + all_projects=all_projects, + edit_managed=edit_managed) + return client, admin_client + + +def get_all_projects_client(context): + auth = token_endpoint.Token(CONF.designate.url, context.auth_token) + return d_client.Client(session=_SESSION, auth=auth, all_projects=True) + + +def get_all_projects_edit_managed_client(context): + return get_clients(context, all_projects=True, edit_managed=True) + + +class DesignateCcloud(driver.ExternalDNSService): + """Driver for Designate.""" + + def __init__(self): + super().__init__() + ipv4_ptr_zone_size = CONF.designate.ipv4_ptr_zone_prefix_size + ipv6_ptr_zone_size = CONF.designate.ipv6_ptr_zone_prefix_size + + if (ipv4_ptr_zone_size < IPV4_PTR_ZONE_PREFIX_MIN_SIZE or + ipv4_ptr_zone_size > IPV4_PTR_ZONE_PREFIX_MAX_SIZE or + (ipv4_ptr_zone_size % 8) != 0): + raise dns_exc.InvalidPTRZoneConfiguration( + parameter='ipv4_ptr_zone_size', number='8', + maximum=str(IPV4_PTR_ZONE_PREFIX_MAX_SIZE), + minimum=str(IPV4_PTR_ZONE_PREFIX_MIN_SIZE)) + + if (ipv6_ptr_zone_size < IPV6_PTR_ZONE_PREFIX_MIN_SIZE or + ipv6_ptr_zone_size > IPV6_PTR_ZONE_PREFIX_MAX_SIZE or + (ipv6_ptr_zone_size % 4) != 0): + raise dns_exc.InvalidPTRZoneConfiguration( + parameter='ipv6_ptr_zone_size', number='4', + maximum=str(IPV6_PTR_ZONE_PREFIX_MAX_SIZE), + minimum=str(IPV6_PTR_ZONE_PREFIX_MIN_SIZE)) + + def create_record_set(self, context, dns_domain, dns_name, records, + fip_id=None): + """Create a record set in the specified zone. + + :param context: neutron api request context + :type context: neutron_lib.context.Context + :param dns_domain: the dns_domain where the record set will be created + :type dns_domain: String + :param dns_name: the name associated with the record set + :type dns_name: String + :param records: the records in the set + :type records: List of Strings + :param fip_id: Floating IP id + :type fip_id: String + :raises: neutron.extensions.dns.DNSDomainNotFound + neutron.extensions.dns.DuplicateRecordSet + """ + designate, designate_admin = get_clients(context) + v4, v6 = self._classify_records(records) + try: + if v4: + designate.recordsets.create(dns_domain, dns_name, 'A', v4) + if v6: + designate.recordsets.create(dns_domain, dns_name, 'AAAA', v6) + except d_exc.NotFound: + raise dns_exc.DNSDomainNotFound(dns_domain=dns_domain) + except d_exc.Conflict: + fqdn = ".".join([dns_name, dns_domain]) + if v4: + designate.recordsets.update(dns_domain, fqdn, {"records": v4}) + if v6: + designate.recordsets.update(dns_domain, fqdn, {"records": v6}) + except d_exc.OverQuota: + raise dns_exc.ExternalDNSOverQuota(resource="recordset") + + if not CONF.designate.allow_reverse_dns_lookup: + return + # Set up the PTR records + if fip_id: + designate.floatingips.set( + f"{CONF.designate.region_name}:{fip_id}", + f"{dns_name}.{dns_domain}" + ) + else: + # Set up the PTR records + recordset_name = '%s.%s' % (dns_name, dns_domain) + ptr_zone_email = 'admin@%s' % dns_domain[:-1] + if CONF.designate.ptr_zone_email: + ptr_zone_email = CONF.designate.ptr_zone_email + for record in records: + in_addr_name = netaddr.IPAddress(record).reverse_dns + in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) + in_addr_zone_description = ( + 'An %s zone for reverse lookups set up by Neutron.' % + '.'.join(in_addr_name.split('.')[-3:])) + try: + # Since we don't delete in-addr zones, assume it already + # exists. If it doesn't, create it + designate_admin.recordsets.create(in_addr_zone_name, + in_addr_name, 'PTR', + [recordset_name]) + except d_exc.Conflict: + # It can happen that we have left + # -over or manually created PTR + # from before (e.g. by a project that was using same FIP). + # If PTR exists, update it even if it is 'managed'. + c_designate, c_designate_admin = get_clients( + context, + edit_managed=True + ) + recordset_dict = {'records': [recordset_name]} + # Use own instance of admin client as a precaution + c_designate_admin.recordsets.update(in_addr_zone_name, + in_addr_name, + recordset_dict) + except d_exc.NotFound: + # Note(jh): If multiple PTRs get created at the same time, + # the creation of the zone may fail with a conflict because + # it has already been created by a parallel job. So we + # ignore that error and try to create the recordset + # anyway. That call will still fail in the end if something + # is really broken. See bug 1891309. + try: + designate_admin.zones.create( + in_addr_zone_name, email=ptr_zone_email, + description=in_addr_zone_description) + except d_exc.Conflict: + LOG.debug('Conflict when trying to create PTR zone %s,' + ' assuming it exists.', + in_addr_zone_name) + pass + except d_exc.OverQuota: + raise dns_exc.ExternalDNSOverQuota(resource='zone') + designate_admin.recordsets.create(in_addr_zone_name, + in_addr_name, 'PTR', + [recordset_name]) + + def _classify_records(self, records): + v4 = [] + v6 = [] + for record in records: + if netaddr.IPAddress(record).version == 4: + v4.append(record) + else: + v6.append(record) + return v4, v6 + + def _get_in_addr_zone_name(self, in_addr_name): + units = self._get_bytes_or_nybles_to_skip(in_addr_name) + return '.'.join(in_addr_name.split('.')[units:]) + + def _get_bytes_or_nybles_to_skip(self, in_addr_name): + if 'in-addr.arpa' in in_addr_name: + return int((constants.IPv4_BITS - + CONF.designate.ipv4_ptr_zone_prefix_size) / 8) + return int((constants.IPv6_BITS - + CONF.designate.ipv6_ptr_zone_prefix_size) / 4) + + def delete_record_set(self, context, dns_domain, dns_name, records): + client, admin_client = get_clients(context) + ids_to_delete = [] + try: + # first try regular client: + ids_to_delete = self._get_ids_ips_to_delete( + dns_domain, '%s.%s' % (dns_name, dns_domain), records, client) + except dns_exc.DNSDomainNotFound: + # Try whether we have admin powers and can see all projects + # and also handle managed records (to prevent leftover PTRs): + client, admin_client = get_all_projects_edit_managed_client( + context) + try: + ids_to_delete = self._get_ids_ips_to_delete( + dns_domain, + '%s.%s' % (dns_name, dns_domain), + records, + client) + except dns_exc.DNSDomainNotFound: + LOG.debug("The domain '%s' not found in Designate", + dns_domain) + except d_exc.Forbidden: + LOG.error("Cannot determine Designate record ids for " + "deletion of: '%(name)s.%(dom)s'", + {'name': dns_name, 'dom': dns_domain}) + + for _id in ids_to_delete: + try: + client.recordsets.delete(dns_domain, _id) + except (d_exc.Forbidden, d_exc.NotFound) as exc: + LOG.error("Cannot delete Designate record with id %(recid)s in" + " domain: %(dom)s. Error: %(err)s", + {'recid': _id, 'dom': dns_domain, 'err': exc}) + + if not CONF.designate.allow_reverse_dns_lookup: + return + + # PTR records part + client, admin_client = get_all_projects_edit_managed_client( + context) + for record in records: + in_addr_name = netaddr.IPAddress(record).reverse_dns + in_addr_zone_name = self._get_in_addr_zone_name(in_addr_name) + try: + admin_client.recordsets.delete(in_addr_zone_name, + in_addr_name) + except (dns_exc.DNSDomainNotFound, d_exc.NotFound): + LOG.debug("No '%s' PTR record was found in Designate.", + in_addr_name) + except d_exc.Forbidden: + LOG.error("Cannot delete '%s' PTR record.", + in_addr_name) + + def _get_ids_ips_to_delete(self, dns_domain, name, records, + designate_client): + try: + recordsets = designate_client.recordsets.list( + 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 diff --git a/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py b/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py new file mode 100644 index 00000000000..738597641a0 --- /dev/null +++ b/neutron/tests/unit/services/externaldns/drivers/designate/test_driver_ccloud.py @@ -0,0 +1,258 @@ +# Copyright 2025 SAP SE +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +from unittest import mock + +from oslo_config import cfg + +from neutron.tests.unit.extensions.test_l3\ + import L3NatDBFloatingIpTestCaseWithDNS +from neutron.tests.unit.extensions.test_l3\ + import L3TestExtensionManagerWithDNS + +from neutron.services.externaldns.drivers.designate import driver_ccloud + +from .test_driver import TestDesignateDriver + + +class L3NatDBFloatingIpTestCaseWithDNSCcloud(L3NatDBFloatingIpTestCaseWithDNS): + """Unit tests for floating ip with external DNS integration""" + + fmt = 'json' + DNS_NAME = 'test' + DNS_DOMAIN = 'test-domain.org.' + PUBLIC_CIDR = '11.0.0.0/24' + PRIVATE_CIDR = '10.0.0.0/24' + mock_client = mock.MagicMock() + mock_admin_client = mock.MagicMock() + MOCK_PATH = ('neutron.services.externaldns.drivers.' + 'designate.driver_ccloud.get_clients') + mock_config = {'return_value': (mock_client, mock_admin_client)} + _extension_drivers = ['dns'] + + def setUp(self): + ext_mgr = L3TestExtensionManagerWithDNS() + plugin = 'neutron.plugins.ml2.plugin.Ml2Plugin' + cfg.CONF.set_override('extension_drivers', + self._extension_drivers, + group='ml2') + super(L3NatDBFloatingIpTestCaseWithDNS, self).setUp( + plugin=plugin, ext_mgr=ext_mgr) + cfg.CONF.set_override('external_dns_driver', 'designate_ccloud') + self.mock_client.reset_mock() + self.mock_admin_client.reset_mock() + + def _assert_recordset_created(self, floating_ip_address, floating_ip_id): + # The recordsets.create function should be called with: + # dns_domain, dns_name, 'A', ip_address ('A' for IPv4, 'AAAA' for IPv6) + self.mock_client.recordsets.create.assert_called_with( + self.DNS_DOMAIN, + self.DNS_NAME, + 'A', + [floating_ip_address] + ) + self.mock_client.floatingips.set.assert_called_with( + f"{None}:{floating_ip_id}", + f"{self.DNS_NAME}.{self.DNS_DOMAIN}") + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create(self, mock_args): + with self._create_floatingip_with_dns(): + pass + self.mock_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + self.mock_admin_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create_with_flip_dns(self, mock_args): + with self._create_floatingip_with_dns( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create_with_net_port_dns(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns(net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME, + assoc_port=True) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_create_with_flip_and_net_port_dns(self, mock_args): + # If both network+port and the floating ip have dns domain and + # dns name, floating ip's information should take priority + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns(net_dns_domain='junkdomain.org.', + port_dns_name='junk', + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME, + assoc_port=True) as flip: + floatingip = flip + # External DNS service should have been called with floating ip's + # dns information, not the network+port's dns information + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port(self, mock_args): + with self._create_floatingip_with_dns_on_update(): + pass + self.mock_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + self.mock_admin_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port_with_flip_dns(self, mock_args): + with self._create_floatingip_with_dns_on_update( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port_with_net_port_dns(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns_on_update( + net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_associate_port_with_flip_and_net_port_dns(self, + mock_args): + # If both network+port and the floating ip have dns domain and + # dns name, floating ip's information should take priority + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns_on_update( + net_dns_domain='junkdomain.org.', + port_dns_name='junk', + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + self._assert_recordset_created(floatingip['floating_ip_address'], + floatingip["id"]) + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_disassociate_port(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns(net_dns_domain=self.DNS_DOMAIN, + port_dns_name=self.DNS_NAME, assoc_port=True) as flip: + fake_recordset = {'id': '', + 'records': [flip['floating_ip_address']]} + # This method is called during recordset deletion, which + # will fail unless the list function call returns something like + # this fake value + self.mock_client.recordsets.list.return_value = ([fake_recordset]) + # Port gets disassociated if port_id is not in the request body + data = {'floatingip': {}} + req = self.new_update_request('floatingips', data, flip['id']) + res = req.get_response(self._api_for_resource('floatingip')) + floatingip = self.deserialize(self.fmt, res)['floatingip'] + flip_port_id = floatingip['port_id'] + self.assertEqual(200, res.status_code) + self.assertIsNone(flip_port_id) + in_addr_name, in_addr_zone_name = self._get_in_addr( + floatingip['floating_ip_address']) + self.mock_client.recordsets.delete.assert_called_with( + self.DNS_DOMAIN, '') + self.mock_admin_client.recordsets.delete.assert_called_with( + in_addr_zone_name, in_addr_name) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_delete(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + with self._create_floatingip_with_dns( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME) as flip: + floatingip = flip + # This method is called during recordset deletion, which will + # fail unless the list function call returns something like + # this fake value + fake_recordset = {'id': '', + 'records': [floatingip['floating_ip_address']]} + self.mock_client.recordsets.list.return_value = [fake_recordset] + in_addr_name, in_addr_zone_name = self._get_in_addr( + floatingip['floating_ip_address']) + self.mock_client.recordsets.delete.assert_called_with( + self.DNS_DOMAIN, '') + self.mock_admin_client.recordsets.delete.assert_called_with( + in_addr_zone_name, in_addr_name) + + @mock.patch(MOCK_PATH, **mock_config) + def test_floatingip_no_PTR_record(self, mock_args): + cfg.CONF.set_override('dns_domain', self.DNS_DOMAIN) + + # Disabling this option should stop the admin client from creating + # PTR records. So set this option and make sure the admin client + # wasn't called to create any records + cfg.CONF.set_override('allow_reverse_dns_lookup', False, + group='designate') + + with self._create_floatingip_with_dns( + flip_dns_domain=self.DNS_DOMAIN, + flip_dns_name=self.DNS_NAME + ) as flip: + floatingip = flip + + self.mock_client.recordsets.create.assert_called_with( + self.DNS_DOMAIN, self.DNS_NAME, 'A', + [floatingip['floating_ip_address']] + ) + self.mock_admin_client.recordsets.create.assert_not_called() + self.mock_client.floatingips.set.assert_not_called() + self.assertEqual(self.DNS_DOMAIN, floatingip['dns_domain']) + self.assertEqual(self.DNS_NAME, floatingip['dns_name']) + + +class TestCCloudDesignateDriver(TestDesignateDriver): + def setUp(self): + # skip our parents setup and call it's parent instead: + super(TestDesignateDriver, self).setUp() + self.context = mock.Mock() + self.client = mock.Mock() + self.admin_client = mock.Mock() + self.all_projects_client = mock.Mock() + mock.patch.object(driver_ccloud, 'get_clients', return_value=( + self.client, self.admin_client)).start() + mock.patch.object(driver_ccloud, 'get_all_projects_client', + return_value=self.all_projects_client).start() + self.driver = driver_ccloud.DesignateCcloud() + + def test_create_record_set_duplicate_recordset(self): + + # The Ccloud driver should not raise an exception here, + # in contrast to the default driver. Let's ensure correct behavior + self.driver.create_record_set(self.context, 'example.test.', + 'test', ['192.168.0.10']) diff --git a/setup.cfg b/setup.cfg index aca634fc417..7b27c5927d3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -160,6 +160,7 @@ neutron.agent.linux.pd_drivers = dibbler = neutron.agent.linux.dibbler:PDDibbler neutron.services.external_dns_drivers = designate = neutron.services.externaldns.drivers.designate.driver:Designate + designate_ccloud = neutron.services.externaldns.drivers.designate.driver_ccloud:DesignateCcloud oslo.config.opts = designate.auth = neutron.opts:list_designate_auth_opts ironic.auth = neutron.opts:list_ironic_auth_opts From 5f8a922d2e7625afb268f834cf7e46f82ff4bf85 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Mon, 14 Jul 2025 11:09:57 +0200 Subject: [PATCH 164/184] Send Custom Notification to Nova MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To customize behavior in Nova’s server creation process, an effective approach is to send custom events that Nova can wait on. In our case, we want to pause the server creation until the ML2 port binding (handled by the NSX-T agent) is completed. This helps avoid a race condition. --- neutron/notifiers/nova.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/neutron/notifiers/nova.py b/neutron/notifiers/nova.py index 9cb6c3880e2..287f1e3bfac 100644 --- a/neutron/notifiers/nova.py +++ b/neutron/notifiers/nova.py @@ -251,6 +251,12 @@ def send_port_status(self, mapper, connection, port): self.batch_notifier.queue_event(event) port._notify_event = None + def send_custom_port_status(self, event): + if not isinstance(event, dict): + raise exc.InvalidInput( + error_message="Custom port status event must be a dict") + self.batch_notifier.queue_event(event) + def notify_port_active_direct(self, port): """Notify nova about active port From 34819de31526ba8213d53afc8a56ddefebd6829d Mon Sep 17 00:00:00 2001 From: Tobias Jungel <1773291+toanju@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:12:02 +0200 Subject: [PATCH 165/184] linuxbridge: change default for port security enablement The so far default enabled port security even without enabled port-security extension. This hides the port security configuration since the API is incorrectly showing port security as disabled. To prevent this confusion, port security is now disabled by default. --- .../plugins/ml2/drivers/linuxbridge/agent/arp_protect.py | 2 +- .../ml2/drivers/linuxbridge/agent/test_arp_protect.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py b/neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py index 0c1b13be195..b3c7a239c3b 100644 --- a/neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py +++ b/neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py @@ -27,7 +27,7 @@ def setup_arp_spoofing_protection(vif, port_details): - if not port_details.get('port_security_enabled', True): + if not port_details.get('port_security_enabled', False): # clear any previous entries related to this port delete_arp_spoofing_protection([vif]) LOG.info("Skipping ARP spoofing rules for port '%s' because " diff --git a/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_arp_protect.py b/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_arp_protect.py index bcd8d44b9d9..8f99c5c5f33 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_arp_protect.py +++ b/neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_arp_protect.py @@ -25,10 +25,12 @@ VIF = 'vif_tap0' PORT_NO_SEC = {'port_security_enabled': False} PORT_TRUSTED = {'device_owner': constants.DEVICE_OWNER_ROUTER_GW} -PORT = {'fixed_ips': [{'ip_address': '10.1.1.1'}], +PORT = {'port_security_enabled': True, + 'fixed_ips': [{'ip_address': '10.1.1.1'}], 'device_owner': 'nobody', 'mac_address': '00:11:22:33:44:55'} -PORT_ADDR_PAIR = {'fixed_ips': [{'ip_address': '10.1.1.1'}], +PORT_ADDR_PAIR = {'port_security_enabled': True, + 'fixed_ips': [{'ip_address': '10.1.1.1'}], 'device_owner': 'nobody', 'mac_address': '00:11:22:33:44:55', 'allowed_address_pairs': [ From 81aab6f32768f97d931819804185f79889b6034b Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Tue, 5 Aug 2025 12:04:23 +0200 Subject: [PATCH 166/184] Install neutron-vpnaas into neutron image --- custom-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/custom-requirements.txt b/custom-requirements.txt index 4375e3af067..20479661d6c 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -40,3 +40,4 @@ git+https://github.com/sapcc/openstack-rate-limit-middleware.git#egg=rate-limit- -e git+https://github.com/sapcc/networking-interconnection@stable/2024.1-m3#egg=networking_interconnection -e git+https://github.com/sapcc/networking-ccloud@stable/2024.1-m3#egg=networking_ccloud -e git+https://github.com/sapcc/neutron-fwaas@stable/2024.1-m3#egg=neutron_fwaas +-e git+https://github.com/sapcc/neutron-vpnaas@stable/2024.1-m3#egg=neutron_vpnaas From b834780bc7b4ff12010a08306970ac758e8609c6 Mon Sep 17 00:00:00 2001 From: Tobias Jungel <1773291+toanju@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:36:40 +0200 Subject: [PATCH 167/184] rpc: change default for port security Modify the general default for port security in the rpc server. The previous commit changed only the defaults for the linuxbridge agent. However, the rpc server also needs to be updated to ensure that the default for port security is consistent. fixes: 34819de --- neutron/plugins/ml2/rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/rpc.py b/neutron/plugins/ml2/rpc.py index be298e73634..c6fc6d2fd0e 100644 --- a/neutron/plugins/ml2/rpc.py +++ b/neutron/plugins/ml2/rpc.py @@ -175,7 +175,7 @@ def _get_device_details(self, rpc_context, agent_id, host, device, 'fixed_ips': port['fixed_ips'], 'device_owner': port['device_owner'], 'allowed_address_pairs': port['allowed_address_pairs'], - 'port_security_enabled': port.get(psec.PORTSECURITY, True), + 'port_security_enabled': port.get(psec.PORTSECURITY, False), qos_consts.QOS_POLICY_ID: port.get(qos_consts.QOS_POLICY_ID), qos_consts.QOS_NETWORK_POLICY_ID: qos_network_policy_id, 'profile': port[portbindings.PROFILE], From 4af1213bab42239920a1d50e773bf304885d5ded Mon Sep 17 00:00:00 2001 From: Tobias Jungel <1773291+toanju@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:53:49 +0200 Subject: [PATCH 168/184] iptables: add feature flag to toggle anti spoofing rules This allows to disable the configuration of the anti-spoofing rules using a feature flag. This is currently required since we do not yet have port security enabled and using the Linux bridge driver will still configure the anti-spoofing rules by default. For the spoofing case this configures DHCP to be allowed by default like in the anti-spoofing case. In addition, this adds default allow rules towards the metadata server for the anti spoofing case as well. --- neutron/agent/linux/iptables_comments.py | 2 + neutron/agent/linux/iptables_firewall.py | 34 ++++++- neutron/conf/agent/securitygroups_rpc.py | 6 +- .../agent/linux/test_iptables_firewall.py | 89 +++++++++++++++++++ 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/neutron/agent/linux/iptables_comments.py b/neutron/agent/linux/iptables_comments.py index 569a8d7641f..0149639047d 100644 --- a/neutron/agent/linux/iptables_comments.py +++ b/neutron/agent/linux/iptables_comments.py @@ -36,3 +36,5 @@ TRUSTED_ACCEPT = 'Accept all packets when port is trusted.' IPV6_RA_DROP = 'Drop IPv6 Router Advts from VM Instance.' IPV6_ICMP_ALLOW = 'Allow IPv6 ICMP traffic.' +IPV4_METADATA_ALLOW = 'Allow IPv4 traffic to metadata service.' +IPV6_METADATA_ALLOW = 'Allow IPv6 traffic to metadata service.' diff --git a/neutron/agent/linux/iptables_firewall.py b/neutron/agent/linux/iptables_firewall.py index c81fae9f3db..18324ca5765 100644 --- a/neutron/agent/linux/iptables_firewall.py +++ b/neutron/agent/linux/iptables_firewall.py @@ -90,6 +90,8 @@ def __init__(self, namespace=None): lambda: collections.defaultdict(list)) self.pre_sg_members = None self.enable_ipset = cfg.CONF.SECURITYGROUP.enable_ipset + self.enable_anti_spoofing_rules = \ + cfg.CONF.SECURITYGROUP.enable_anti_spoofing_rules self.updated_rule_sg_ids = set() self.updated_sg_members = set() self.devices_with_updated_sg_members = collections.defaultdict(list) @@ -574,6 +576,7 @@ def _spoofing_rule(self, port, ipv4_rules, ipv6_rules): mac_ipv4_pairs, ipv4_rules) self._setup_spoof_filter_chain(port, self.iptables.ipv6['filter'], mac_ipv6_pairs, ipv6_rules) + # TODO(toanju): Add allow rules to reach the metadata server # Fixed rules for traffic after source address is verified # Allow dhcp client renewal and rebinding ipv4_rules += [comment_rule('-p udp -m udp --sport 68 --dport 67 ' @@ -588,6 +591,27 @@ def _spoofing_rule(self, port, ipv4_rules, ipv6_rules): '--dport 547 ' '-j RETURN', comment=ic.DHCP_CLIENT)] + def _non_spoofing_rule(self, ipv4_rules, ipv6_rules): + # Allow dhcp client discovery and request + ipv4_rules += [comment_rule('-p udp -m udp --sport 68 --dport 67 ' + '-j RETURN', comment=ic.DHCP_CLIENT)] + # Allow http to the metadata server for v4 + ipv4_rules += [comment_rule('-d %s -p tcp -m tcp --dport 80 ' + '-j RETURN' % constants.METADATA_V4_CIDR, + comment=ic.IPV4_METADATA_ALLOW)] + # Allow http to the metadata server for v6 + ipv6_rules += [comment_rule('-d %s -p tcp -m tcp --dport 80 ' + '-j RETURN' % constants.METADATA_V6_CIDR, + comment=ic.IPV6_METADATA_ALLOW)] + # Allow all icmp v6 traffic including the RAs that are blocked in the + # anti-spoofing case above + ipv6_rules += [comment_rule('-p ipv6-icmp -j RETURN', + comment=ic.IPV6_ICMP_ALLOW)] + # Allow DHCPv6 client messages + ipv6_rules += [comment_rule('-p udp -m udp --sport 546 ' + '--dport 547 ' + '-j RETURN', comment=ic.DHCP_CLIENT)] + def _drop_dhcp_rule(self, ipv4_rules, ipv6_rules): # Note(nati) Drop dhcp packet from VM ipv4_rules += [comment_rule('-p udp -m udp --sport 67 ' @@ -688,9 +712,13 @@ def _add_rules_by_security_group(self, port, direction): def _add_fixed_egress_rules(self, port, ipv4_iptables_rules, ipv6_iptables_rules): - self._spoofing_rule(port, - ipv4_iptables_rules, - ipv6_iptables_rules) + if self.enable_anti_spoofing_rules: + self._spoofing_rule(port, + ipv4_iptables_rules, + ipv6_iptables_rules) + else: + self._non_spoofing_rule(ipv4_iptables_rules, + ipv6_iptables_rules) self._drop_dhcp_rule(ipv4_iptables_rules, ipv6_iptables_rules) def _generate_ipset_rule_args(self, sg_rule, remote_gid): diff --git a/neutron/conf/agent/securitygroups_rpc.py b/neutron/conf/agent/securitygroups_rpc.py index c20ef0d2fe9..ea54fd98793 100644 --- a/neutron/conf/agent/securitygroups_rpc.py +++ b/neutron/conf/agent/securitygroups_rpc.py @@ -42,7 +42,11 @@ default=[], help=_('Comma-separated list of ethertypes to be permitted, in ' 'hexadecimal (starting with "0x"). For example, "0x4008" ' - 'to permit InfiniBand.')) + 'to permit InfiniBand.')), + cfg.BoolOpt( + 'enable_anti_spoofing_rules', + default=True, + help=_('Enable anti spoofing rules.')), ] diff --git a/neutron/tests/unit/agent/linux/test_iptables_firewall.py b/neutron/tests/unit/agent/linux/test_iptables_firewall.py index 16a21115952..fb1be0592ee 100644 --- a/neutron/tests/unit/agent/linux/test_iptables_firewall.py +++ b/neutron/tests/unit/agent/linux/test_iptables_firewall.py @@ -2118,6 +2118,95 @@ def test_ip_spoofing_filter_with_multiple_ips(self): mock.call.add_rule('sg-chain', '-j ACCEPT')] self.v4filter_inst.assert_has_calls(calls) + def test_no_ip_spoofing_filter_with_multiple_ips(self): + # this is the modified test_ip_spoofing_filter_with_multiple_ips which + # allows spoofing and thus has less rules than the previous test + port = {'device': 'tapfake_dev', + 'mac_address': 'ff:ff:ff:ff:ff:ff', + 'network_id': 'fake_net', + 'fixed_ips': ['10.0.0.1', 'fe80::1', '10.0.0.2']} + self.firewall.enable_anti_spoofing_rules = False + self.firewall.prepare_port_filter(port) + calls = [mock.call.add_chain('sg-fallback'), + mock.call.add_rule( + 'sg-fallback', '-j DROP', + comment=ic.UNMATCH_DROP), + mock.call.add_chain('sg-chain'), + mock.call.add_rule('PREROUTING', mock.ANY, # zone set + comment=None), + mock.call.add_rule('PREROUTING', mock.ANY, # zone set + comment=None), + mock.call.add_rule('PREROUTING', mock.ANY, # zone set + comment=None), + mock.call.add_rule('PREROUTING', + '-m physdev --physdev-out tapfake_dev ' + '-j ACCEPT', + top=False, comment=ic.TRUSTED_ACCEPT), + mock.call.add_rule('PREROUTING', + '-m physdev --physdev-in tapfake_dev ' + '-j ACCEPT', + top=False, comment=ic.TRUSTED_ACCEPT), + mock.call.add_chain('ifake_dev'), + mock.call.add_rule('FORWARD', + '-m physdev --physdev-out tapfake_dev ' + '--physdev-is-bridged -j $sg-chain', + top=True, comment=ic.VM_INT_SG), + mock.call.add_rule('sg-chain', + '-m physdev --physdev-out tapfake_dev ' + '--physdev-is-bridged -j $ifake_dev', + top=False, comment=ic.SG_TO_VM_SG), + mock.call.add_rule( + 'ifake_dev', + '-m state --state RELATED,ESTABLISHED -j RETURN', + top=False, comment=None), + mock.call.add_rule( + 'ifake_dev', + '-m state --state INVALID -j DROP', + top=False, comment=None), + mock.call.add_rule('ifake_dev', + '-j $sg-fallback', + top=False, comment=None), + mock.call.add_chain('ofake_dev'), + mock.call.add_rule('FORWARD', + '-m physdev --physdev-in tapfake_dev ' + '--physdev-is-bridged -j $sg-chain', + top=True, comment=ic.VM_INT_SG), + mock.call.add_rule('sg-chain', + '-m physdev --physdev-in tapfake_dev ' + '--physdev-is-bridged -j $ofake_dev', + top=False, comment=ic.SG_TO_VM_SG), + mock.call.add_rule('INPUT', + '-m physdev --physdev-in tapfake_dev ' + '--physdev-is-bridged -j $ofake_dev', + top=False, comment=ic.INPUT_TO_SG), + mock.call.add_rule( + 'ofake_dev', + '-p udp -m udp --sport 68 --dport 67 -j RETURN', + top=False, comment=None), + mock.call.add_rule( + 'ofake_dev', + '-d %s -p tcp -m tcp --dport 80 -j RETURN' % + constants.METADATA_V4_CIDR, + top=False, comment=None), + mock.call.add_rule( + 'ofake_dev', + '-p udp -m udp --sport 67 --dport 68 -j DROP', + top=False, comment=None), + mock.call.add_rule( + 'ofake_dev', + '-m state --state RELATED,ESTABLISHED -j RETURN', + top=False, comment=None), + mock.call.add_rule( + 'ofake_dev', + '-m state --state INVALID -j DROP', + top=False, comment=None), + mock.call.add_rule('ofake_dev', + '-j $sg-fallback', + top=False, comment=None), + mock.call.add_rule('sg-chain', '-j ACCEPT')] + self.firewall.enable_anti_spoofing_rules = True + self.v4filter_inst.assert_has_calls(calls) + def test_ip_spoofing_no_fixed_ips(self): port = {'device': 'tapfake_dev', 'mac_address': 'ff:ff:ff:ff:ff:ff', From e1c12f1dc3357e46c12e3273c46c338038bc3dd6 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Mon, 8 Dec 2025 15:48:27 +0100 Subject: [PATCH 169/184] Remove raven from custom requirements We no longer use raven, but use sentrylogger instead. --- custom-requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index 20479661d6c..a0a845b4046 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -5,7 +5,6 @@ jaeger-client dumb-init # sentry client -raven git+https://github.com/sapcc/sentrylogger.git@main#egg=sapcc_sentrylogger # agent checks for neutron From 625bae88982858d23d4c015cef260b346cb44e63 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Mon, 8 Dec 2025 17:04:07 +0100 Subject: [PATCH 170/184] Move away from editable custom requirements We don't to install our custom requirements as editable and it is causing some problems in the ci with other packages depending on these packages without specifying them as editable. --- custom-requirements.txt | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index a0a845b4046..f79890648eb 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -29,14 +29,14 @@ git+https://github.com/sapcc/openstack-uwsgi-middleware.git@main#egg=uwsgi-middl git+https://github.com/sapcc/openstack-rate-limit-middleware.git#egg=rate-limit-middleware # Networking Drivers --e git+https://github.com/sapcc/asr1k-neutron-l3@stable/2024.1-m3#egg=asr1k-neutron-l3 --e git+https://github.com/sapcc/networking-aci.git@stable/2024.1-m3#egg=networking_aci[acicobra] --e git+https://github.com/sapcc/networking-manila.git@stable/2024.1-m3#egg=networking_manila --e git+https://github.com/sapcc/networking-f5.git@stable/2024.1-m3#egg=networking_f5 --e git+https://github.com/sapcc/networking-arista.git@stable/2024.1-m3#egg=networking_arista --e git+https://github.com/sapcc/networking-nsx-t.git@stable/2024.1-m3#egg=networking_nsxv3 --e git+https://github.com/sapcc/networking-bgpvpn@stable/2024.1-m3#egg=networking-bgpvpn --e git+https://github.com/sapcc/networking-interconnection@stable/2024.1-m3#egg=networking_interconnection --e git+https://github.com/sapcc/networking-ccloud@stable/2024.1-m3#egg=networking_ccloud --e git+https://github.com/sapcc/neutron-fwaas@stable/2024.1-m3#egg=neutron_fwaas --e git+https://github.com/sapcc/neutron-vpnaas@stable/2024.1-m3#egg=neutron_vpnaas +asr1k-neutron-l3 @ git+https://github.com/sapcc/asr1k-neutron-l3@stable/2024.1-m3 +networking_aci[acicobra] @ git+https://github.com/sapcc/networking-aci.git@stable/2024.1-m3 +networking_manila @ git+https://github.com/sapcc/networking-manila.git@stable/2024.1-m3 +networking_f5 @ git+https://github.com/sapcc/networking-f5.git@stable/2024.1-m3 +networking_arista @ git+https://github.com/sapcc/networking-arista.git@stable/2024.1-m3 +networking_nsxv3 @ git+https://github.com/sapcc/networking-nsx-t.git@stable/2024.1-m3 +networking-bgpvpn @ git+https://github.com/sapcc/networking-bgpvpn@stable/2024.1-m3 +networking_interconnection @ git+https://github.com/sapcc/networking-interconnection@stable/2024.1-m3 +networking_ccloud @ git+https://github.com/sapcc/networking-ccloud@stable/2024.1-m3 +neutron-fwaas @ git+https://github.com/sapcc/neutron-fwaas@stable/2024.1-m3 +neutron-vpnaas @ git+https://github.com/sapcc/neutron-vpnaas@stable/2024.1-m3 From ac63cc549ee366bb7f4613c1c462f54784908083 Mon Sep 17 00:00:00 2001 From: Brian Haley Date: Wed, 17 Jul 2024 17:39:50 -0400 Subject: [PATCH 171/184] Use convert_version_to_tuple() instead of pkg_resources In [0] when we changed code to consistently use convert_version_to_tuple() instead of the packaging library, one place was missed since it used the pkg_resources library. Change to use the same code throughout the tree for version checks. Also fixes a pylint warning as the pkg_resources API usage generates a DeprecationWarning. [0] https://review.opendev.org/c/openstack/neutron/+/890162 TrivialFix Change-Id: I54e4e310b660acf3dd4cf07a50636512904b578c --- neutron/agent/linux/iptables_manager.py | 3 ++- neutron/common/utils.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/neutron/agent/linux/iptables_manager.py b/neutron/agent/linux/iptables_manager.py index f933f827387..e7537575f75 100644 --- a/neutron/agent/linux/iptables_manager.py +++ b/neutron/agent/linux/iptables_manager.py @@ -487,7 +487,8 @@ def get_rules_for_table(self, table): privsep_exec=True).split('\n') def _get_version(self): - # Output example is "iptables v1.6.2" + # Output example is "iptables v1.8.7 (nf_tables)", + # this will return "1.8.7" args = ['iptables', '--version'] version = str(linux_utils.execute( args, run_as_root=True, privsep_exec=True).split()[1][1:]) diff --git a/neutron/common/utils.py b/neutron/common/utils.py index 3a94f10a08b..e9faff91ea4 100644 --- a/neutron/common/utils.py +++ b/neutron/common/utils.py @@ -50,8 +50,8 @@ from oslo_utils import excutils from oslo_utils import timeutils from oslo_utils import uuidutils +from oslo_utils import versionutils from osprofiler import profiler -import pkg_resources from sqlalchemy.dialects.mysql import dialect as mysql_dialect from sqlalchemy.dialects.postgresql import dialect as postgresql_dialect from sqlalchemy.dialects.sqlite import dialect as sqlite_dialect @@ -356,8 +356,8 @@ def get_socket_address_family(ip_version): def is_version_greater_equal(version1, version2): """Returns True if version1 is greater or equal than version2 else False""" - return (pkg_resources.parse_version(version1) >= - pkg_resources.parse_version(version2)) + return (versionutils.convert_version_to_tuple(version1) >= + versionutils.convert_version_to_tuple(version2)) class DelayedStringRenderer(object): From 531eefc0ba769c29f932c6f47e9c5ab756831898 Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Thu, 3 Oct 2024 21:26:30 +0900 Subject: [PATCH 172/184] Get rid of pkg_resources ... because it was removed in Python 3.12 [1]. [1] https://docs.python.org/3/whatsnew/3.12.html#ensurepip Note: Selectable entry points were introduced in importlib_metadata 3.6 and Python 3.10 . Change-Id: I1b478a63ad1d73f9f3528939362797ea1fc68534 --- neutron/conf/db/migration_cli.py | 9 +++++++-- neutron/db/migration/cli.py | 4 ++-- neutron/tests/unit/db/test_migration.py | 12 ++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/neutron/conf/db/migration_cli.py b/neutron/conf/db/migration_cli.py index 987ff1ca4ec..e28ad9a8b7e 100644 --- a/neutron/conf/db/migration_cli.py +++ b/neutron/conf/db/migration_cli.py @@ -10,16 +10,21 @@ # License for the specific language governing permissions and limitations # under the License. +import sys + from oslo_config import cfg -import pkg_resources from neutron._i18n import _ +if sys.version_info < (3, 10, 0): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points MIGRATION_ENTRYPOINTS = 'neutron.db.alembic_migrations' migration_entrypoints = { entrypoint.name: entrypoint - for entrypoint in pkg_resources.iter_entry_points(MIGRATION_ENTRYPOINTS) + for entrypoint in entry_points(group=MIGRATION_ENTRYPOINTS) } INSTALLED_SUBPROJECTS = list(migration_entrypoints) diff --git a/neutron/db/migration/cli.py b/neutron/db/migration/cli.py index 4f52a551953..5fe3ba3a7d1 100644 --- a/neutron/db/migration/cli.py +++ b/neutron/db/migration/cli.py @@ -564,13 +564,13 @@ def _get_installed_entrypoint(subproject): def _get_subproject_script_location(subproject): '''Get the script location for the installed subproject.''' entrypoint = _get_installed_entrypoint(subproject) - return ':'.join([entrypoint.module_name, entrypoint.attrs[0]]) + return ':'.join([entrypoint.module, entrypoint.attr]) def _get_subproject_base(subproject): '''Get the import base name for the installed subproject.''' entrypoint = _get_installed_entrypoint(subproject) - return entrypoint.module_name.split('.')[0] + return entrypoint.module.split('.')[0] def get_alembic_version_table(config): diff --git a/neutron/tests/unit/db/test_migration.py b/neutron/tests/unit/db/test_migration.py index 6520bb9ecdd..0e9688240b1 100644 --- a/neutron/tests/unit/db/test_migration.py +++ b/neutron/tests/unit/db/test_migration.py @@ -14,6 +14,7 @@ # under the License. import copy +import importlib.metadata import os import re import sys @@ -28,7 +29,6 @@ from neutron_lib import fixture as lib_fixtures from neutron_lib.utils import helpers from oslo_utils import fileutils -import pkg_resources import sqlalchemy as sa from testtools import matchers @@ -143,13 +143,13 @@ def mocked_root_dir(cfg): config = alembic_config.Config(ini) config.set_main_option('neutron_project', project) module_name = project.replace('-', '_') + '.db.migration' - attrs = ('alembic_migrations',) - script_location = ':'.join([module_name, attrs[0]]) + script_location = ':'.join([module_name, 'alembic_migrations']) config.set_main_option('script_location', script_location) self.configs.append(config) - entrypoint = pkg_resources.EntryPoint(project, - module_name, - attrs=attrs) + entrypoint = importlib.metadata.EntryPoint( + name=project, + group='neutron.db.alembic_migrations', + value=script_location) migration_cli.migration_entrypoints[project] = entrypoint def _main_test_helper(self, argv, func_name, exp_kwargs=[{}]): From d4304244c4e00c51e01955d603adaeaaa861fa21 Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Mon, 30 Mar 2026 11:26:40 +0200 Subject: [PATCH 173/184] tox: Pin virtualenv `virtualenv` 2.38 bumped setuptools, which does not contain `pkg_resources` anymore. Since we're on an older release, our dependencies still require `pkg_resources` to be present. Hence, installing and running unit-tests fails. We're pinning the virtualenv even though we already removed all pkg_resources imports from Neutron as some of the test requirements (at the moment flake8 for the pep8 check) still require pkg_resources to be present. Adopted from sapcc/nova[0]. [0] https://github.com/sapcc/nova/commit/d58abc917709245aebdf6e38dfe05bf1e62d458c --- tox.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tox.ini b/tox.ini index 1f40ee315a1..8aa7b4dbf6d 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,10 @@ envlist = docs,py3,pep8 minversion = 3.18.0 ignore_basepython_conflict = True +# specify virtualenv here to keep local runs consistent with the +# gate (it sets the versions of pip, setuptools, and wheel) +requires = virtualenv<20.38 + [testenv] basepython = {env:TOX_PYTHON:python3} setenv = VIRTUAL_ENV={envdir} From 7c74c667ca31f685d5bc0f6d5e466a788be79a8e Mon Sep 17 00:00:00 2001 From: Sebastian Lohff Date: Fri, 27 Mar 2026 17:59:15 +0100 Subject: [PATCH 174/184] Improve convergence when migrating w/ linuxbridge When we migrate a vm between two kvm hypervisors that are using the linuxbridge driver, we need to make sure that the network is present when the vm comes up. This is important so the VMs/HVs rARP packets are send out and the network fabric behind the HV knows where to find the VM after a migration. With linuxbridge and Nova it is not clear who actually creates the bridge and adds the tap interface to it. Generally it seems to be that Nova does this first and Neutron only discovers this in one of its agent's loops. But here we have a problem: The bond is only created and put into the bridge once the port binding is active. It seems like OVS had a similar problem, which was patched here[0]. The code looks very similar to our problematic piece of code, so we adapt the idea: The port is not only bound if the host of the port matches, but also if the migrating_to field from the binding profile matches the host. This helps us getting the bond faster into the bridge. Additionally, we can - via config - increase the polling timeout of the linuxbridge agent to make sure we do our deed as fast as possible. Our patch looks a bit different from [0]: We don't fetch the active bindings, as all the information we need is already present in the port dict that we have. In some cases (at least in the tests) the profile might be a string and not already a json-decoded data structure, so we need to do the decoding ourselves in some cases (same as [0]). As a midterm solution it would probably make sense to get this done in a quicker, more reactive fashion (i.e. not wait for a loop, but directly react on the event). Another option would be to migrate away from linuxbridge, but one step after the other. Note that this is only happening on "cold" hosts, which have never seen the OpenStack network so far. Linuxbridge does not seem to clean up the bridge or bond interface (which might also be something which we need to look into in the future). [0] https://github.com/openstack/neutron/commit/f8a22c7d4aa654eaad3b683073849c873ea3beff --- neutron/plugins/ml2/rpc.py | 18 +++++++++++++++++- neutron/tests/unit/plugins/ml2/test_rpc.py | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/neutron/plugins/ml2/rpc.py b/neutron/plugins/ml2/rpc.py index c6fc6d2fd0e..5b6358dd4b7 100644 --- a/neutron/plugins/ml2/rpc.py +++ b/neutron/plugins/ml2/rpc.py @@ -26,6 +26,7 @@ from oslo_config import cfg from oslo_log import log import oslo_messaging +from oslo_serialization import jsonutils from osprofiler import profiler from sqlalchemy.orm import exc @@ -152,9 +153,10 @@ def _get_device_details(self, rpc_context, agent_id, host, device, 'vif_type': port_context.vif_type}) return {'device': device} + migrating_to = _get_migrating_to_from_port(port) if (port['device_owner'].startswith( n_const.DEVICE_OWNER_COMPUTE_PREFIX) and - port[portbindings.HOST_ID] != host): + port[portbindings.HOST_ID] != host and migrating_to != host): LOG.debug("Device %(device)s has no active binding in host " "%(host)s", {'device': device, 'host': host}) @@ -514,3 +516,17 @@ def binding_activate(self, context, port_id, host): cctxt = self.client.prepare(topic=self.topic_port_binding_activate, fanout=True, version='1.5') cctxt.cast(context, 'binding_activate', port_id=port_id, host=host) + + +def _get_migrating_to_from_port(port): + profile = port.get(portbindings.PROFILE) + if not profile: + return None + if isinstance(profile, str): + try: + profile = jsonutils.loads(profile) + except ValueError: + return None + migrating_to = profile.get("migrating_to") + + return migrating_to diff --git a/neutron/tests/unit/plugins/ml2/test_rpc.py b/neutron/tests/unit/plugins/ml2/test_rpc.py index 74073c951be..349aeb8e08e 100644 --- a/neutron/tests/unit/plugins/ml2/test_rpc.py +++ b/neutron/tests/unit/plugins/ml2/test_rpc.py @@ -163,6 +163,16 @@ def test_get_device_details_port_no_active_in_host(self): res = self.callbacks.get_device_details(mock.Mock(), host='host') self.assertIn(constants.NO_ACTIVE_BINDING, res) + def test_get_device_details_port_honor_migrating_to(self): + port = collections.defaultdict(lambda: 'fake_port') + self.plugin.get_bound_port_context().current = port + port['device_owner'] = constants.DEVICE_OWNER_COMPUTE_PREFIX + port[portbindings.HOST_ID] = 'other-host' + port[portbindings.PROFILE] = {'migrating_to': 'host'} + res = self.callbacks.get_device_details(mock.Mock(), host='host') + self.assertNotIn(constants.NO_ACTIVE_BINDING, res) + self.assertIn('port_id', res) + def test_get_device_details_qos_policy_id_from_port(self): port = collections.defaultdict( lambda: 'fake_port', From 196209d1c6bc7de8172d76b1713adf9a5898a284 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Fri, 29 May 2026 16:41:12 +0200 Subject: [PATCH 175/184] [dhcp-agent] support DhcpAgentWithStateReport to be marked unschedulable Admin-shutting an agent leads to the agent being completely vacated. However we have cases in which we want an agent to stay online, but prevent any new workloads to be scheduled on it. This can be a capacity consideration or because it is meant to be decomissioned. In addition this allows us in the dev environment to replace agents with experimental versions, without causing disruption. It is still possible to manually schedule a network on this dhcp-agent for testing. A similar feature was implemented for asr1k-neutron-l3 here: https://github.com/sapcc/asr1k-neutron-l3/commit/425cc812a7b9d0ccb43c367c0c3adce0e85873be Note: We are not filtering in `AutoScheduler.auto_schedule_networks`. This method gets called with the host as parameter, so the scheduling decision is already made. --- neutron/agent/dhcp/agent.py | 4 +- neutron/conf/agent/dhcp.py | 9 +++ neutron/scheduler/dhcp_agent_scheduler.py | 26 +++++++++ neutron/tests/common/helpers.py | 10 +++- .../scheduler/test_dhcp_agent_scheduler.py | 55 +++++++++++++++++++ 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/neutron/agent/dhcp/agent.py b/neutron/agent/dhcp/agent.py index 31b7c1f73da..25a186c0fc0 100644 --- a/neutron/agent/dhcp/agent.py +++ b/neutron/agent/dhcp/agent.py @@ -1091,7 +1091,9 @@ def __init__(self, host=None, conf=None): 'configurations': { 'dhcp_driver': self.conf.dhcp_driver, 'dhcp_lease_duration': self.conf.dhcp_lease_duration, - 'log_agent_heartbeats': self.conf.AGENT.log_agent_heartbeats}, + 'log_agent_heartbeats': self.conf.AGENT.log_agent_heartbeats, + 'scheduling_disabled': self.conf.AGENT.scheduling_disabled, + }, 'start_flag': True, 'agent_type': constants.AGENT_TYPE_DHCP} report_interval = self.conf.AGENT.report_interval diff --git a/neutron/conf/agent/dhcp.py b/neutron/conf/agent/dhcp.py index 9204a79115f..e34303d67b0 100644 --- a/neutron/conf/agent/dhcp.py +++ b/neutron/conf/agent/dhcp.py @@ -141,8 +141,17 @@ "a default gateway.")), ] +DHCP_AGENT_STATE_OPTS = [ + # we want this only for the dhcp-agent, so we are not adding that to + # AGENT_STATE_OPTS in register_agent_state_opts_helper() + cfg.BoolOpt('scheduling_disabled', default=False, + help="No (new) networks will be scheduled automatically " + "on this dhcp-agent."), +] + def register_agent_dhcp_opts(cfg=cfg.CONF): + cfg.register_opts(DHCP_AGENT_STATE_OPTS, 'AGENT') cfg.register_opts(DHCP_AGENT_OPTS) cfg.register_opts(DHCP_OPTS) cfg.register_opts(DNSMASQ_OPTS) diff --git a/neutron/scheduler/dhcp_agent_scheduler.py b/neutron/scheduler/dhcp_agent_scheduler.py index 87115a2105e..6b99965c7fa 100644 --- a/neutron/scheduler/dhcp_agent_scheduler.py +++ b/neutron/scheduler/dhcp_agent_scheduler.py @@ -266,6 +266,28 @@ def _filter_agents_with_network_access(self, plugin, context, if agent['host'] in hostable_dhcp_hosts] return reachable_agents + def _filter_agents_where_scheduling_disabled(self, dhcp_agent_candidates): + """Remove agents from list where 'scheduling_disabled' is True.""" + + schedulable_agents = [] + disabled_hosts = [] + + # We do not want any networks to get scheduled on agents + # where we have scheduling disabled. Filter those out. + for candidate in dhcp_agent_candidates: + if not candidate.configurations.get( + 'scheduling_disabled', False): + schedulable_agents.append(candidate) + else: + disabled_hosts.append(candidate.host) + + if disabled_hosts: + LOG.debug('Ignoring agent hosts %s in DhcpFilter, ' + 'scheduling of those dhcp-agents is disabled', + ', '.join(disabled_hosts)) + + return schedulable_agents + def _get_dhcp_agents_hosting_network(self, plugin, context, network): """Return dhcp agents hosting the given network or None if a given network is already hosted by enough number of agents. @@ -319,6 +341,10 @@ def _get_network_hostable_dhcp_agents(self, plugin, context, network): agent for agent in active_dhcp_agents if agent.id not in hosted_agent_ids and plugin.is_eligible_agent( context, True, agent)] + + hostable_dhcp_agents = self._filter_agents_where_scheduling_disabled( + hostable_dhcp_agents) + hostable_dhcp_agents = self._filter_agents_with_network_access( plugin, context, network, hostable_dhcp_agents) diff --git a/neutron/tests/common/helpers.py b/neutron/tests/common/helpers.py index e6ff9e6f199..4c42976b975 100644 --- a/neutron/tests/common/helpers.py +++ b/neutron/tests/common/helpers.py @@ -82,7 +82,8 @@ def register_l3_agent(host=HOST, agent_mode=constants.L3_AGENT_MODE_LEGACY, return _register_agent(agent) -def _get_dhcp_agent_dict(host, networks=0, az=DEFAULT_AZ): +def _get_dhcp_agent_dict(host, networks=0, az=DEFAULT_AZ, + scheduling_disabled=None): agent = { 'binary': constants.AGENT_PROCESS_DHCP, 'host': host, @@ -91,13 +92,16 @@ def _get_dhcp_agent_dict(host, networks=0, az=DEFAULT_AZ): 'availability_zone': az, 'configurations': {'dhcp_driver': 'dhcp_driver', 'networks': networks}} + if scheduling_disabled is not None: + agent['configurations']['scheduling_disabled'] = scheduling_disabled return agent def register_dhcp_agent(host=HOST, networks=0, admin_state_up=True, - alive=True, az=DEFAULT_AZ): + alive=True, az=DEFAULT_AZ, scheduling_disabled=None): agent = _register_agent( - _get_dhcp_agent_dict(host, networks, az=az)) + _get_dhcp_agent_dict(host, networks, az=az, + scheduling_disabled=scheduling_disabled)) if not admin_state_up: set_agent_admin_state(agent['id']) diff --git a/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py b/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py index e81cad8a0a0..3e2bc4e7558 100644 --- a/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py +++ b/neutron/tests/unit/scheduler/test_dhcp_agent_scheduler.py @@ -129,6 +129,61 @@ def _test_reschedule_vs_network_on_dead_agent(self, scheduler.schedule( plugin, self.ctx, network)) + def test_no_schedule_on_unschedulable_agent(self): + """When a dhcp-agent is marked with 'scheduling disabled', we + do not want it to be selected for automatic scheduling. + This tests that those agents are filtered out but regular + agents are still being chosen. + """ + + az = helpers.DEFAULT_AZ + network = {'id': self.network_id} + + plugin = mock.Mock() + plugin.get_network.return_value = self.network + plugin.filter_hosts_with_network_access.side_effect = ( + lambda context, network_id, hosts: hosts) + plugin.get_dhcp_agents_hosting_networks.return_value = [] + + agent_nosched = helpers.register_dhcp_agent( + 'host-a', + admin_state_up=True, alive=True, + az=az, scheduling_disabled=True) + + agent_okay = helpers.register_dhcp_agent( + 'host-b', + admin_state_up=True, alive=True, + az=az) + + # Check if the configuration is applied correctly, but + # only to the one agent we want. This is testing if the + # modified helper returns correct agents for the tests. + no_sched_kwd = 'scheduling_disabled' + self.assertNotIn(no_sched_kwd, agent_okay.configurations) + self.assertIn(no_sched_kwd, agent_nosched.configurations) + self.assertTrue(agent_nosched.configurations.get(no_sched_kwd)) + + # The real tests start here: + + # 1. Assert that we are still able to schedule to a regular agent + # when there is a non-schedulable one present. + plugin.get_agent_objects.return_value = [agent_nosched, agent_okay] + scheduler = dhcp_agent_scheduler.ChanceScheduler() + + # The one regular agent should still be available to host the network. + self.assertEqual([agent_okay], + scheduler.schedule(plugin, self.ctx, network) + ) + + # 2. Assert that we do not schedule on a 'scheduling_disabled' agent: + plugin.get_agent_objects.return_value = [agent_nosched] + scheduler = dhcp_agent_scheduler.ChanceScheduler() + + # No agent should be available to host the network. + self.assertEqual([], + scheduler.schedule(plugin, self.ctx, network) + ) + def test_network_rescheduled_when_db_returns_active_hosts(self): self._test_reschedule_vs_network_on_dead_agent(True) From 2a161828dfe2581fbc3f75a7abcd0b4a4cf885c4 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Thu, 18 Jun 2026 14:10:52 +0200 Subject: [PATCH 176/184] support individual custom network settings per domain for edns logging and DNS We already allow configuration of custom upstream DNS servers for dnsmasq, based on the OpenStack domain or -- for testing -- based on the project-id. We currently can configure only two sets of DNS upstream servers: One for SAP OpenStack domains and one for external OpenStack domains. For external domains we also always disable the edns and umbrella logging options in dnsmasq. Now the requirement arose to configure DNS servers individually on a per-domain basis and also to be able to enable logging individually. The requirements now are: - use default DNS servers and enable logging when no config is set - enable logging and custom upstreams for specific domains individually - allow fallback for domains matching a certain prefix to use a different default set of resolvers and setting for logging. This is achieved via a new configuration setting 'config_file' in the customdns section of neutron.conf that points to a yaml file. This file allows setting logging and upstream dns settings as follows: ```yaml matches: - domain_name_prefixes: - external-abc project_ids: - 5dc81c6355ff478188f8fda11a971c41 upstream_dns_servers: - 192.0.2.10 - 192.0.2.20 ednslogging: True - domain_name_prefixes: - external-abcd - external- upstream_dns_servers: - 192.0.2.30 - 192.0.2.40 ednslogging: False ``` related: https://github.com/sapcc/neutron/commit/f39dccefdddeb4aa2ff990111c9849c3ca574cc6 https://github.com/sapcc/neutron/commit/f186f86a680ff78a044657f4791260e8189b77a9 https://github.com/sapcc/neutron/commit/c4bdb2973ff4a21078842d44d23d39163059add7 --- neutron/api/rpc/handlers/dhcp_rpc.py | 264 ++++++++++++ neutron/conf/service.py | 16 +- .../unit/api/rpc/handlers/test_dhcp_rpc.py | 395 +++++++++++++++++- requirements.txt | 2 + 4 files changed, 670 insertions(+), 7 deletions(-) diff --git a/neutron/api/rpc/handlers/dhcp_rpc.py b/neutron/api/rpc/handlers/dhcp_rpc.py index 23357975fed..6be43ec82af 100644 --- a/neutron/api/rpc/handlers/dhcp_rpc.py +++ b/neutron/api/rpc/handlers/dhcp_rpc.py @@ -14,9 +14,11 @@ # limitations under the License. import copy +from dataclasses import dataclass import ipaddress import itertools import operator +import pathlib from keystoneauth1 import loading as ks_loading from neutron_lib.api.definitions import portbindings @@ -34,6 +36,7 @@ from oslo_log import log as logging import oslo_messaging from oslo_utils import excutils +import yaml from neutron._i18n import _ from neutron.common import utils @@ -50,12 +53,144 @@ class DomainLookupFailed(Exception): pass +class CustomNetworkConfigError(Exception): + pass + + +@dataclass +class CustomNetworkSettings: + dns_ednslogging_enabled: bool + dns_custom_upstreams: set[str] | None = None + + def __post_init__(self): + if not isinstance(self.dns_ednslogging_enabled, bool): + raise TypeError(_("dns_ednslogging_enabled must be a bool: %s") + % self.dns_ednslogging_enabled) + if self.dns_custom_upstreams: + self._validate_upstreams() + + def _validate_upstreams(self): + """ensure that all elements are valid IP addresses and make it a + set of strings containing the normalized IP addresses. + """ + if not self.dns_custom_upstreams: + self.dns_custom_upstreams = set() + return + + addrs: set[str] = set() + for item in self.dns_custom_upstreams: + try: + addr = ipaddress.ip_address(item) + addrs.add(addr.compressed) + except ValueError: + LOG.error("not a valid IP for DNS entry: %s", item) + raise + + self.dns_custom_upstreams = addrs + + class CustomNetworkConfigurator: def __init__(self): self._KEYSTONE = None self._domain_id_cache = {} self._domain_name_cache = {} + self._dns_config: dict[str, dict[str, CustomNetworkSettings]] = {} + self._config_file = cfg.CONF.customdns.config_file + self._load_config() + + def _load_config(self): + """load or reload custom dns config from file.""" + + if not self._config_file: + return + + LOG.debug("loading customdns config from '%s'", self._config_file) + + try: + cfgfile = pathlib.Path(self._config_file) + config = yaml.load(cfgfile.read_bytes(), Loader=yaml.SafeLoader) + except IOError as e: + raise CustomNetworkConfigError(_("failed to load " + "config file '%s': %s") + % (self._config_file, e)) + if not config: + msg = (_("failed to load config file '%s': empty file?") + % self._config_file) + raise CustomNetworkConfigError(msg) + + # make an empty config file fail hard + try: + matches = config['matches'] + except KeyError: + msg = (_("Missing 'matches:' in custom DNS config file '%s'") % + self._config_file) + raise CustomNetworkConfigError(msg) + + # but accept an intentionally empty list + if not matches: + return + + dns_config = {'projects': {}, 'domains': {}} + + mandatory_keys = {'ednslogging', } + valid_keys = mandatory_keys | { + 'project_ids', + 'domain_name_prefixes', + 'upstream_dns_servers' + } + + for item in matches: + # Each match config consists of three (optional) items + # project_ids, domain_name_prefixes and upstream_dns_servers and + # one mandatory setting ednslogging. + # If project id _or_ domain prefix match, the upstream dns_servers + # and ednslogging setting will be applied to the network. + + invalid_keys = item.keys() - valid_keys + if invalid_keys: + msg = (_("Invalid key(s) in config file '%s' at '%s': %s") + % (self._config_file, item, + ", ".join(list(invalid_keys)))) + raise CustomNetworkConfigError(msg) + + missing_keys = mandatory_keys - item.keys() + if missing_keys: + msg = (_("Missing key(s) in config file '%s' at '%s': %s") + % (self._config_file, item, + ", ".join(list(missing_keys)))) + raise CustomNetworkConfigError(msg) + + project_ids = item.get('project_ids', []) + domain_prefixes = item.get('domain_name_prefixes', []) + upstreams = item.get('upstream_dns_servers', []) + ednslogging = item['ednslogging'] + + try: + netconfig = CustomNetworkSettings( + dns_ednslogging_enabled=ednslogging, + dns_custom_upstreams=set(upstreams), + ) + except (TypeError, ValueError) as e: + msg = _("Error parsing custom DNS config: %s") % e + raise CustomNetworkConfigError(msg) + + for project_id in project_ids: + if project_id in dns_config['projects']: + msg = _("project %s already configured!") % project_id + raise CustomNetworkConfigError(msg) + + dns_config['projects'][project_id] = netconfig + + for domain_prefix in domain_prefixes: + if domain_prefix in dns_config['domains']: + msg = (_("domain-prefix '%s' already configured!") + % domain_prefix) + raise CustomNetworkConfigError(msg) + + dns_config['domains'][domain_prefix] = netconfig + + self._dns_config = dns_config def add_dnssettings_to_net(self, network_dict): """Add custom dns settings to the network if the network @@ -63,6 +198,133 @@ def add_dnssettings_to_net(self, network_dict): or projects from our settings. """ + # TODO(mutax): after migration replace this whole method with + # the method '_add_external_dnssettings_to_net' + + # check if we got an external custom dns config + if self._dns_config: + self._add_external_dnssettings_to_net(network_dict) + else: + self._add_legacy_dnssettings_to_net(network_dict) + + def _add_external_dnssettings_to_net(self, network_dict): + """Add custom dns settings specified via external config file + to the network, if the network matches the criteria set in the + config file. + """ + + if not self._dns_config: + return + + # first check if we have a match in the project ids, + # this is the cheapest lookup + if self._apply_project_settings(network_dict): + return + + # next try to match openstack domain name prefixes. + self._apply_domain_settings(network_dict) + + def _apply_project_settings(self, network_dict: dict) -> bool: + """apply project-specific DNS settings if they exist. + Returns True if settings were found to allow skipping of + further processing. Not to be called directly. + """ + + if not self._dns_config: + return False + + project_id = network_dict['project_id'] + + # check if the project-id matches the list for custom settings + custom_config = self._dns_config['projects'].get(project_id) + if custom_config: + LOG.debug("setting custom settings for net %s, " + "project %s matches: %s", + network_dict['id'], project_id, custom_config + ) + + network_dict['dns_ednslogging_enabled'] = ( + custom_config.dns_ednslogging_enabled) + + if custom_config.dns_custom_upstreams: + network_dict['dns_custom_upstreams'] = ( + custom_config.dns_custom_upstreams) + return True + + return False + + def _apply_domain_settings(self, network_dict: dict) -> bool: + """apply domain-specific DNS settings if they exist. + Returns True if settings were found to allow skipping of + further processing for consistency. Not to be called directly. + """ + + if not self._dns_config: + return False + + # try to retrieve the OpenStack domain name via the project id, + # this uses a local cache and on a cache miss queries keystone + project_id = network_dict['project_id'] + domain_name = None + try: + domain_name = self.get_domain_name(project_id) + except Exception as e: # noqa + # If Keystone is not reachable or something goes wrong with + # the lookup, we do not want to fail configuring all networks. + # Currently, the sane thing to do is using default settings in + # those cases. As we want to fail to the default in all error + # cases anyway, we can use a bare Exception here. + # TODO(mutax): I do want to get the stack trace logged, but I + # also want to get a nice warning to the log independent of the + # source of the error - but now we log the same error twice. + LOG.exception('Failed to retrieve domain to set custom dns for' + ' project %s of network %s - %s: %s', + project_id, network_dict['id'], type(e), e + ) + + # in case of an error or empty result, we fall back to the 'safe' + # side by using default settings. + if not domain_name: + LOG.warning('Unable to retrieve domain name for project %s,' + ' falling back to default settings for network %s', + project_id, network_dict['id']) + return False + + # check if the OpenStack domain name starts with one of the prefixes + # from our config (or is equal). + # we are now doing a longest prefix match, allowing defaults to be set + # i.e. abc- can provide settings for all domains starting with abc, + # while at the same time abc-123 can be used to match a specific one + + for domain_prefix in sorted(self._dns_config['domains'].keys(), + key=len, + reverse=True): + + if domain_name.startswith(domain_prefix): + custom_config = self._dns_config['domains'][domain_prefix] + + LOG.debug("setting custom settings for net %s, " + "domain %s matches prefix %s: %s", + network_dict['id'], domain_name, + domain_prefix, custom_config + ) + + network_dict['dns_ednslogging_enabled'] = ( + custom_config.dns_ednslogging_enabled) + + if custom_config.dns_custom_upstreams: + network_dict['dns_custom_upstreams'] = ( + custom_config.dns_custom_upstreams) + return True + return False + + def _add_legacy_dnssettings_to_net(self, network_dict): + """If the network domain or project match the configured list, + apply custom dns servers to the configuration. + Legacy method to be removed after deployment of the new config files. + """ + # TODO(mutax): remove this method after migration to new config file + if not (cfg.CONF.customdns.domain_name_prefixes or cfg.CONF.customdns.project_ids): # The config is empty, there is no need to do any lookups @@ -180,6 +442,8 @@ def _is_customdns_network(self, network_dict: dict) -> bool: """ project_id = network_dict['project_id'] + # TODO(mutax): remove this method after migration to new config file + # should not happen, but would never match anyway if not project_id: return False diff --git a/neutron/conf/service.py b/neutron/conf/service.py index 889376dc9f6..89b02bbae11 100644 --- a/neutron/conf/service.py +++ b/neutron/conf/service.py @@ -59,13 +59,21 @@ DNSSETTINGS_OPTS = [ cfg.BoolOpt('enabled', default=False, - help=_("Enable domain specific DNS settings")), + help=_("Enable domain specific DNS settings.")), + # TODO(mutax): remove deprecated options after migration to config file cfg.ListOpt('upstream_dns_servers', default=[], - help=_("Custom upstream DNS server IPs")), + help=_("Custom upstream DNS server IPs" + " (deprecated, use config_file)")), cfg.ListOpt('domain_name_prefixes', default=[], - help=_("OS Domain Name Prefixes to match against")), + help=_("OS Domain Name Prefixes to match against" + " (deprecated, use config_file)")), cfg.ListOpt('project_ids', default=[], - help=_("IDs of projects to match for testing only")), + help=_("IDs of projects to match for testing only" + " (deprecated, use config_file)")), + cfg.StrOpt('config_file', default=None, + help=_("Path to yaml config file for OpenStack domain " + "or project specific DNS settings.") + ), ] diff --git a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py index 40711c4140c..451738dfb65 100644 --- a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py +++ b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py @@ -14,6 +14,7 @@ # limitations under the License. import operator +import pathlib from collections import UserDict from unittest import mock @@ -30,7 +31,9 @@ from oslo_utils import uuidutils from neutron.api.rpc.handlers import dhcp_rpc +from neutron.api.rpc.handlers.dhcp_rpc import CustomNetworkConfigError from neutron.api.rpc.handlers.dhcp_rpc import CustomNetworkConfigurator +from neutron.api.rpc.handlers.dhcp_rpc import CustomNetworkSettings from neutron.common import utils from neutron.db import provisioning_blocks from neutron.objects import network as network_obj @@ -58,10 +61,148 @@ def test_network_dict_empty(self): self.assertFalse(bool(empty_dict)) + @mock.patch.object(pathlib.Path, 'read_bytes') + def test_external_yaml_config_parser(self, mock_pathlib): + """Basic tests for an empty, invalid or on-purpose empty + config file. + """ + + cfg.CONF.set_override('enabled', True, + group='customdns') + # just needs to be set and a valid filename, + # return data is mocked above. + cfg.CONF.set_override('config_file', 'irrelevant.yaml', + group='customdns') + + empty_config = b"" + + mock_pathlib.return_value = empty_config + self.assertRaises(CustomNetworkConfigError, CustomNetworkConfigurator) + + invalid_config = b""" + foobar: + """ + + mock_pathlib.return_value = invalid_config + self.assertRaises(CustomNetworkConfigError, CustomNetworkConfigurator) + + no_config = b""" + matches: + """ + + mock_pathlib.return_value = no_config + cnc = CustomNetworkConfigurator() + self.assertEqual({}, cnc._dns_config) + + def _get_cnc_from_yaml_config(self, configdata: bytes)\ + -> CustomNetworkConfigurator: + """returns a CustomNetworkConfigurator instance + using the configuration in configdata provided as raw binary yaml + """ + + cfg.CONF.set_override('enabled', True, + group='customdns') + # just needs to be set and a valid filename, + # return data is mocked to return 'configdata'. + cfg.CONF.set_override('config_file', 'irrelevant.yaml', + group='customdns') + + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + mock_pathlib.return_value = configdata + cnc = CustomNetworkConfigurator() + + return cnc + + def test_external_yaml_config(self): + """Test if the yaml config is converted to the expected internal + data structure of our class. Ensures all options are picked up. + """ + + example_config = b""" + matches: + - domain_name_prefixes: + - ext-abc + - ext-def + project_ids: + - 5dc81c6355ff478188f8fda11a971c41 + - 0631d17744fe4a04b16494ae9056ae17 + ednslogging: False + upstream_dns_servers: + - 192.0.2.10 + - 192.0.2.20 + - domain_name_prefixes: + - ext-abcd + - ext- + ednslogging: True + upstream_dns_servers: + - 192.0.2.30 + - 192.0.2.40 + """ + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + # CustomNetworkSettings will convert the list of IPs to a set + # so we can compare them with ease below. + config_1 = CustomNetworkSettings( + False, {'192.0.2.10', '192.0.2.20'}) + config_2 = CustomNetworkSettings( + True, {'192.0.2.30', '192.0.2.40'}) + + example_config_expected = { + 'domains': {'ext-': config_2, + 'ext-abc': config_1, + 'ext-abcd': config_2, + 'ext-def': config_1 + }, + 'projects': {'0631d17744fe4a04b16494ae9056ae17': config_1, + '5dc81c6355ff478188f8fda11a971c41': config_1 + } + } + + self.assertEqual(example_config_expected, cnc._dns_config) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_no_match_no_change_yaml(self, mock_keystone): + """ensure that we do not change a setting if the domain does not match + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 'net-123', 'project_id': 'p-666'} + mock_project = MockedDBObj(id='p-666', domain_id='d-42') + mock_domain = MockedDBObj(id='d-42', name='mydomain') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + example_config = b""" + matches: + - domain_name_prefixes: + - ext-abc + project_ids: + - 5dc81c6355ff478188f8fda11a971c41 + ednslogging: True + upstream_dns_servers: + - 192.0.2.10 + - 192.0.2.20 + """ + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_dnssettings_to_net(mock_network) + + mock_keystone.get_project.assert_called_with('p-666') + mock_keystone.get_domain.assert_called_with('d-42') + + # assert we do not change the settings + self.assertIsNone(mock_network.get('dns_ednslogging_enabled')) + self.assertIsNone(mock_network.get('dns_custom_upstreams')) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") def test_no_match_no_change(self, mock_keystone): """ensure that we do not change a setting if the domain does not match """ + # TODO(mutax): remove this test after migration to new config file # we manipulate the network, so we need fresh mock objects mock_network = {'id': 123, 'project_id': 666} @@ -87,11 +228,45 @@ def test_no_match_no_change(self, mock_keystone): self.assertIsNone(mock_network.get('dns_ednslogging_enabled')) self.assertIsNone(mock_network.get('dns_custom_upstreams')) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_network_id_lookup_yaml(self, mock_keystone): + """ensure keystone lookup methods are called and the network + returned matches the expected settings + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 'net-123', 'project_id': 'p-666'} + mock_project = MockedDBObj(id='p-666', domain_id='d-42') + mock_domain = MockedDBObj(id='d-42', name='mydomain') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + example_config = b""" + matches: + - domain_name_prefixes: + - mydomain + ednslogging: False + """ + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_dnssettings_to_net(mock_network) + + mock_keystone.get_project.assert_called_with('p-666') + mock_keystone.get_domain.assert_called_with('d-42') + + # assert we get the correct settings when no nameservers are set + # but logging should be off + self.assertFalse(mock_network.get('dns_ednslogging_enabled')) + self.assertIsNone(mock_network.get('dns_custom_upstreams')) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") def test_network_id_lookup(self, mock_keystone): """ensure keystone lookup methods are called and the network returned matches the expected settings """ + # TODO(mutax): remove this test after migration to new config file # we manipulate the network, so we need fresh mock objects mock_network = {'id': 123, 'project_id': 666} @@ -118,11 +293,51 @@ def test_network_id_lookup(self, mock_keystone): self.assertFalse(mock_network.get('dns_ednslogging_enabled')) self.assertIsNone(mock_network.get('dns_custom_upstreams')) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_nameserver_settings_yaml(self, mock_keystone): + """ensure the configured nameserver IPs are present in the network + dict returned + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 'net-123', 'project_id': 'p-666'} + mock_project = MockedDBObj(id='p-666', domain_id='d-42') + mock_domain = MockedDBObj(id='d-42', name='mydomain') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + dns1 = "2001:db8::456" + dns2 = "192.0.2.123" + + example_config = b""" + matches: + - domain_name_prefixes: + - mydomain + upstream_dns_servers: + - %s + - %s + ednslogging: False + """ % (dns1.encode(), dns2.encode()) + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_dnssettings_to_net(mock_network) + self.assertFalse(mock_network.get('dns_ednslogging_enabled')) + sentinel = object() + upstreams = mock_network.get('dns_custom_upstreams', sentinel) + self.assertNotEqual(sentinel, upstreams) + self.assertIsNotNone(upstreams) + self.assertIn(dns1, upstreams) + self.assertIn(dns2, upstreams) + self.assertEqual(len(upstreams), 2) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") def test_nameserver_settings(self, mock_keystone): """ensure the configured nameserver IPs are present in the network dict returned """ + # TODO(mutax): remove this test after migration to new config file # we manipulate the network, so we need fresh mock objects mock_network = {'id': 123, 'project_id': 666} @@ -155,8 +370,53 @@ def test_nameserver_settings(self, mock_keystone): self.assertEqual(len(upstreams), 2) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_exceptions_prefixmatch(self, mock_keystone): + def test_longest_domain_prefix_wins_yaml(self, mock_keystone): + """ensure we are doing a longest prefix match on the domain name, + that is if a match 'ext-123' and 'ext-' is present, a domain named + 'ext-1234' will match the settings for 'ext-123' and not 'ext-'. + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 'net-123', 'project_id': 'p-666'} + mock_project = MockedDBObj(id='p-666', domain_id='d-42') + mock_domain = MockedDBObj(id='d-42', name='mydomain-123') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + dns1 = "2001:db8::456" + dns2 = "192.0.2.123" + + example_config = b""" + matches: + - domain_name_prefixes: + - mydo + upstream_dns_servers: + - 192.0.2.222 + - 192.0.2.111 + ednslogging: True + - domain_name_prefixes: + - mydomain- + upstream_dns_servers: + - %s + - %s + ednslogging: False + """ % (dns1.encode(), dns2.encode()) + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_dnssettings_to_net(mock_network) + + self.assertFalse(mock_network.get('dns_ednslogging_enabled')) + + upstreams = mock_network.get('dns_custom_upstreams') + self.assertIn(dns1, upstreams) + self.assertIn(dns2, upstreams) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_longest_domain_prefix_wins(self, mock_keystone): """ensure we are doing a prefix match on the domain name """ + # TODO(mutax): remove this test after migration to new config file # we manipulate the network, so we need fresh mock objects mock_network = {'id': 123, 'project_id': 666} @@ -187,10 +447,44 @@ def test_exceptions_prefixmatch(self, mock_keystone): self.assertIn(dns2, upstreams) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_exceptions_catched_project_lookup(self, mock_keystone): + def test_project_lookup_exceptions_dont_prevent_netconf_yaml( + self, + mock_keystone): + """ensure that all exceptions are catched and do not break the + rpc call when doing the project lookup + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 'net-123', 'project_id': 'p-666'} + mock_domain = MockedDBObj(id='d-42', name='mydomain-123') + + mock_keystone.get_project.side_effect = Exception('Test') + mock_keystone.get_domain.return_value = mock_domain + + example_config = b""" + matches: + - domain_name_prefixes: + - mydomain- + upstream_dns_servers: + - 2001:db8::456 + - 192.0.2.123 + ednslogging: False + """ + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_dnssettings_to_net(mock_network) + upstreams = mock_network.get('dns_custom_upstreams') + self.assertIsNone(upstreams) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_project_lookup_exceptions_do_not_prevent_netconfig( + self, + mock_keystone): """ensure that all exceptions are catched and do not break the rpc call """ + # TODO(mutax): remove this test after migration to new config file # we manipulate the network, so we need fresh mock objects mock_network = {'id': 123, 'project_id': 666} @@ -211,10 +505,44 @@ def test_exceptions_catched_project_lookup(self, mock_keystone): self.assertIsNone(upstreams) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_exceptions_catched_domain_lookup(self, mock_keystone): + def test_domain_lookup_exceptions_do_not_prevent_netconfig_yaml( + self, + mock_keystone): + """ensure that all exceptions are catched and do not break the + rpc call when doing the domain lookup + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 'net-123', 'project_id': 'p-666'} + mock_project = MockedDBObj(id='p-666', domain_id='d-42') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.side_effect = Exception('Test') + + example_config = b""" + matches: + - domain_name_prefixes: + - mydomain + upstream_dns_servers: + - 2001:db8::456 + - 192.0.2.123 + ednslogging: False + """ + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_dnssettings_to_net(mock_network) + upstreams = mock_network.get('dns_custom_upstreams') + self.assertIsNone(upstreams) + + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_domain_lookup_exceptions_do_not_prevent_netconfig( + self, + mock_keystone): """ensure that all exceptions are catched and do not break the rpc call """ + # TODO(mutax): remove this test after migration to new config file # we manipulate the network, so we need fresh mock objects mock_network = {'id': 123, 'project_id': 666} @@ -234,6 +562,67 @@ def test_exceptions_catched_domain_lookup(self, mock_keystone): upstreams = mock_network.get('dns_custom_upstreams') self.assertIsNone(upstreams) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_network_ednslogging_setting_yaml(self, mock_keystone): + """ensure the networks can be configured with or without edns logging + """ + + # we manipulate the network, so we need fresh mock objects + mock_network_nologging = {'id': 'net-nolog-123', 'project_id': 'p-666'} + mock_network_logging = {'id': 'net-log-456', 'project_id': 'p-667'} + + example_config = b""" + matches: + - project_ids: + - p-666 + ednslogging: False + - project_ids: + - p-667 + ednslogging: True + """ + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_dnssettings_to_net(mock_network_nologging) + cnc.add_dnssettings_to_net(mock_network_logging) + + # assert we get the correct settings when no nameservers are set + # but logging is configured accordingly + self.assertFalse(mock_network_nologging.get('dns_ednslogging_enabled')) + self.assertTrue(mock_network_logging.get('dns_ednslogging_enabled')) + + self.assertIsNone(mock_network_nologging.get('dns_custom_upstreams')) + self.assertIsNone(mock_network_logging.get('dns_custom_upstreams')) + + def test_exceptions_configerror_types_yaml(self): + """ensure we are catching non-ip entries + """ + + example_config = b""" + matches: + - project_ids: + - p-666 + upstream_dns_servers: + - not-an-ip-address + ednslogging: False + """ + + try: + self._get_cnc_from_yaml_config(configdata=example_config) + except CustomNetworkConfigError as e: + self.assertIn('not-an-ip-address', str(e)) + + example_config = b""" + matches: + - project_ids: + - p-666 + ednslogging: NotABoolean + """ + try: + self._get_cnc_from_yaml_config(configdata=example_config) + except CustomNetworkConfigError as e: + self.assertIn('NotABoolean', str(e)) + class TestDhcpRpcCallback(base.BaseTestCase): diff --git a/requirements.txt b/requirements.txt index 15aa47305e8..2631276de74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,3 +59,5 @@ os-vif>=3.1.0 # Apache-2.0 futurist>=1.2.0 # Apache-2.0 tooz>=1.58.0 # Apache-2.0 wmi>=1.4.9;sys_platform=='win32' # MIT + +PyYAML>=6.0.1 # MIT From 0b17372c61e5af6161516c3afe9ffb6c4a42d8c1 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Thu, 25 Jun 2026 11:17:11 +0200 Subject: [PATCH 177/184] [customdns] Fix missing opt exception on dhcp_rcp import When importing dhcp_rpc without first registering the DNSSETTINGS_OPTS config options, the import will fail with a oslo_config.cfg.NoSuchOptError exception, as we were instantiating CustomNetworkConfigurator() as a class variable. This results in the code doing the import (in our case the nsx-t agent from a custom driver) crashing on startup. To fix this, we now create this object only once when creating the first instance of DhcpRpcCallback. --- neutron/api/rpc/handlers/dhcp_rpc.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/neutron/api/rpc/handlers/dhcp_rpc.py b/neutron/api/rpc/handlers/dhcp_rpc.py index 6be43ec82af..d5f0eb47178 100644 --- a/neutron/api/rpc/handlers/dhcp_rpc.py +++ b/neutron/api/rpc/handlers/dhcp_rpc.py @@ -31,6 +31,7 @@ from neutron_lib.plugins import directory from neutron_lib.plugins import utils as p_utils from openstack import connection +from oslo_concurrency import lockutils from oslo_config import cfg from oslo_db import exception as db_exc from oslo_log import log as logging @@ -48,6 +49,17 @@ LOG = logging.getLogger(__name__) +_CUSTOM_NETWORK_CONFIGURATOR = None + + +def get_custom_network_configurator(): + global _CUSTOM_NETWORK_CONFIGURATOR + if _CUSTOM_NETWORK_CONFIGURATOR is None: + with lockutils.lock("CustomNetworkConfiguratorSingleton"): + if _CUSTOM_NETWORK_CONFIGURATOR is None: + _CUSTOM_NETWORK_CONFIGURATOR = CustomNetworkConfigurator() + return _CUSTOM_NETWORK_CONFIGURATOR + class DomainLookupFailed(Exception): pass @@ -96,13 +108,14 @@ def __init__(self): self._domain_id_cache = {} self._domain_name_cache = {} self._dns_config: dict[str, dict[str, CustomNetworkSettings]] = {} - self._config_file = cfg.CONF.customdns.config_file + self._config_file: str | None = cfg.CONF.customdns.config_file self._load_config() def _load_config(self): """load or reload custom dns config from file.""" if not self._config_file: + # option is empty or not set return LOG.debug("loading customdns config from '%s'", self._config_file) @@ -527,7 +540,13 @@ class DhcpRpcCallback(object): namespace=constants.RPC_NAMESPACE_DHCP_PLUGIN, version='1.10') - _domain_lookup = CustomNetworkConfigurator() + def __init__(self): + super().__init__() + if cfg.CONF.customdns.enabled: + # load config as early as possible to notice issues + self._config_lookup = get_custom_network_configurator() + else: + self._config_lookup = None def _get_active_networks(self, context, **kwargs): """Retrieve and return a list of the active networks.""" @@ -699,8 +718,8 @@ def get_network_info(self, context, **kwargs): 'segment_index': segment.segment_index, 'hosts': segment.hosts} for segment in network.segments] - if cfg.CONF.customdns.enabled: - self._domain_lookup.add_dnssettings_to_net(network_dict) + if self._config_lookup: + self._config_lookup.add_dnssettings_to_net(network_dict) return network_dict From 5451e7f5c6a4fff4c41cc09d9298c5848f7a7780 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Wed, 17 Jun 2026 12:06:18 +0200 Subject: [PATCH 178/184] Write DHCP Agent status to file for readiness checks Enable container readiness checks by writing the DHCP agent's status to a local JSON file. The status includes sync state, timestamp, and network namespace information, allowing containers to determine readiness without querying the Neutron database. Previously, we relied on the agent's status in the Neutron DB for readiness checks. This caused database overload during mass restarts, especially under high customer request volumes, as readiness checks added unnecessary pressure to the DB. The status file tracks whether all active networks have corresponding network namespaces, reporting "synced", "unsynced", or "error" states. This eliminates external dependencies and reduces database load during container lifecycle events. --- neutron/agent/dhcp/agent.py | 79 ++++++- neutron/tests/unit/agent/dhcp/test_agent.py | 242 +++++++++++++++++++- 2 files changed, 319 insertions(+), 2 deletions(-) diff --git a/neutron/agent/dhcp/agent.py b/neutron/agent/dhcp/agent.py index 25a186c0fc0..5c18356de6f 100644 --- a/neutron/agent/dhcp/agent.py +++ b/neutron/agent/dhcp/agent.py @@ -17,7 +17,9 @@ import copy import functools import os +from pathlib import Path import threading +import time import eventlet from neutron_lib.agent import constants as agent_consts @@ -30,11 +32,13 @@ from oslo_log import helpers as log_helpers from oslo_log import log as logging import oslo_messaging +from oslo_serialization import jsonutils from oslo_service import loopingcall from oslo_utils import fileutils from oslo_utils import importutils from oslo_utils import netutils from oslo_utils import timeutils +from pyroute2 import netns from neutron._i18n import _ from neutron.agent.common import base_agent_rpc @@ -57,6 +61,8 @@ DHCP_READY_PORTS_SYNC_MAX = 64 +AGENT_STATUS_FILE = "/var/run/dhcp-agent-status.json" + def _sync_lock(f): """Decorator to block all operations for a global sync call.""" @@ -76,6 +82,68 @@ def wrapped(*args, **kwargs): return wrapped +def _remove_status_file(): + path = Path(AGENT_STATUS_FILE) + path.unlink() + LOG.info("Agent status file %s removed", AGENT_STATUS_FILE) + + +def _find_missing_netns(active_networks): + ns = Path(netns.NETNS_RUN_DIR) + active_ns = set() + synced_nets = set() + + for net in active_networks: + # admin_state_up is a boolean + if any(s for s in net.subnets if s.enable_dhcp) and net.admin_state_up: + active_ns.add(net.namespace) + + for net in ns.iterdir(): + if net.name.startswith('qdhcp-'): + synced_nets.add(net.name) + + return active_ns - synced_nets + + +def _create_status_file(ready, message): + path = Path(AGENT_STATUS_FILE) + try: + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + except OSError: + LOG.error('Failed to create directory %s', path.parent) + return + + status = { + "time": time.time(), + "ready": ready, + "message": message, + } + + try: + with open(path, "w") as status_file: + jsonutils.dump(status, status_file) + except OSError: + LOG.error('Failed to write status file %s', path) + + +def _write_status_failure(error): + _create_status_file(False, str(error)) + + +def _write_sync_status(active_networks): + missing_netns = _find_missing_netns(active_networks) + + if missing_netns: + ready = False + message = (f"Missing {len(missing_netns)} of {len(active_networks)} " + f"networks - {', '.join(sorted(missing_netns)[:5])}") + else: + ready = True + message = "All networks synced" + + _create_status_file(ready, message) + + class DHCPResourceUpdate(queue.ResourceUpdate): def __init__(self, _id, priority, action=None, resource=None, @@ -155,6 +223,7 @@ def __init__(self, host=None, conf=None): self.restarted_metadata_proxy_set = set() def init_host(self): + _create_status_file(False, "DHCP agent starting") self.sync_state() def _populate_networks_cache(self): @@ -344,7 +413,7 @@ def sync_state(self, networks=None): # was down self.dhcp_ready_ports |= set(self.cache.get_port_ids(only_nets)) LOG.info('Synchronizing state complete') - + _write_sync_status(active_networks) except Exception as e: if only_nets: for network_id in only_nets: @@ -352,6 +421,7 @@ def sync_state(self, networks=None): else: self.schedule_resync(e) LOG.exception('Unable to sync network state.') + _write_status_failure(e) def _dhcp_ready_ports_loop(self): """Notifies the server of any ports that had reservations setup.""" @@ -843,6 +913,10 @@ def disable_isolated_metadata_proxy(self, network): if is_router_id: del self._metadata_routers[network.id] + def stop(self): + super().stop() + _remove_status_file() + class DhcpPluginApi(base_agent_rpc.BasePluginApi): """Agent side of the dhcp rpc API. @@ -1138,3 +1212,6 @@ def agent_updated(self, context, payload): def after_start(self): LOG.info("DHCP agent started") + + def stop(self): + super().stop() diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index 07fbc456073..f50a27c4c2d 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -17,8 +17,10 @@ import copy import datetime import os +from pathlib import Path import signal import sys +from tempfile import NamedTemporaryFile from tempfile import TemporaryDirectory from unittest import mock import uuid @@ -29,8 +31,10 @@ from neutron_lib import exceptions from oslo_config import cfg import oslo_messaging +from oslo_serialization import jsonutils from oslo_utils import netutils from oslo_utils import timeutils +from pyroute2 import netns import testtools from neutron.agent.dhcp import agent as dhcp_agent @@ -291,6 +295,9 @@ def setUp(self): self.mock_ip_wrapper_p = mock.patch("neutron.agent.linux.ip_lib." "IPWrapper") self.mock_ip_wrapper = self.mock_ip_wrapper_p.start() + self.mock_create_status_file_p = mock.patch( + 'neutron.agent.dhcp.agent._create_status_file') + self.mock_create_status_file_p.start() def test_init_resync_throttle_conf(self): try: @@ -446,7 +453,10 @@ def test_call_driver_get_metadata_bind_interface_returns_segments(self): agent.call_driver('get_metadata_bind_interface', network)) def _test_sync_state_helper(self, known_net_ids, active_net_ids): - active_networks = set(mock.Mock(id=netid) for netid in active_net_ids) + active_networks = set( + mock.Mock(id=netid, namespace=netid) + for netid in active_net_ids + ) with mock.patch(DHCP_PLUGIN) as plug: mock_plugin = mock.Mock() @@ -2869,3 +2879,233 @@ def test__lt__port_fixed_ips_matching(self): # In this case, both "port" events have matching IPs. "__lt__" method # uses the timestamp: date2 < date1 self.assertLess(update2, update1) + + +class TestAgentStatus(base.BaseTestCase): + + def test_find_missing_netns_with_missing_and_present(self): + with TemporaryDirectory() as tmpdir: + active_net_ids = ["present-network-id", + "present-in-neutron-db-but-not-on-agent"] + active_networks = set( + mock.Mock(id=netid, namespace=f"qdhcp-{netid}", + admin_state_up=True, + subnets=[mock.Mock(enable_dhcp=True)]) + for netid in active_net_ids + ) + + # Create a netns file for the present network only + netns_dir = Path(tmpdir) + present_netns_file = netns_dir / 'qdhcp-present-network-id' + present_netns_file.touch() + + # Mock the NETNS_RUN_DIR to use our temp directory + with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): + missing_netns = dhcp_agent._find_missing_netns( + active_networks + ) + + self.assertEqual(1, len(missing_netns)) + self.assertEqual( + "qdhcp-present-in-neutron-db-but-not-on-agent", + missing_netns.pop(), + ) + + # Test successfully synced network (all namespaces present) + active_net_ids = ["synced-network-id"] + all_synced_networks = set( + mock.Mock(id=netid, namespace=f"qdhcp-{netid}", + admin_state_up=True, + subnets=[mock.Mock(enable_dhcp=True)]) + for netid in active_net_ids + ) + + synced_netns_file = netns_dir / 'qdhcp-synced-network-id' + synced_netns_file.touch() + + with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): + missing_netns = dhcp_agent._find_missing_netns( + all_synced_networks) + + self.assertEqual(0, len(missing_netns)) + self.assertEqual(set(), missing_netns) + + def test_write_status_failure(self): + with TemporaryDirectory() as tmpdir: + status_file_path = Path(tmpdir) / 'dhcp-agent-status.txt' + error_message = "Test error: network sync failed" + test_error = Exception(error_message) + + with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', + status_file_path): + dhcp_agent._write_status_failure(test_error) + + # Verify the file was created + self.assertTrue(status_file_path.exists()) + + # Read and verify the status file content + with open(status_file_path, 'rb') as f: + status = jsonutils.load(f) + + self.assertFalse(status["ready"]) + self.assertEqual(error_message, status["message"]) + self.assertIn("time", status) + self.assertIsInstance(status["time"], (int, float)) + + def test_write_status_synced(self): + with TemporaryDirectory() as tmpdir: + status_file_path = Path(tmpdir) / 'dhcp-agent-status.json' + netns_dir = Path(tmpdir) + + # Create networks with corresponding namespace files (all synced) + active_net_ids = ["network-1", "network-2"] + active_networks = set( + mock.Mock(id=netid, namespace=f"qdhcp-{netid}", + admin_state_up=True, + subnets=[mock.Mock(enable_dhcp=True)]) + for netid in active_net_ids + ) + + # Create netns files for all networks + (netns_dir / 'qdhcp-network-1').touch() + (netns_dir / 'qdhcp-network-2').touch() + + with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', + status_file_path): + with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): + dhcp_agent._write_sync_status(active_networks) + + # Verify the file was created + self.assertTrue(status_file_path.exists()) + + # Read and verify the status file content + with open(status_file_path, 'rb') as f: + status = jsonutils.load(f) + + self.assertTrue(status["ready"]) + self.assertEqual("All networks synced", status["message"]) + self.assertIn("time", status) + self.assertIsInstance(status["time"], (int, float)) + + def test_write_status_unsynced(self): + with TemporaryDirectory() as tmpdir: + status_file_path = Path(tmpdir) / 'dhcp-agent-status.txt' + netns_dir = Path(tmpdir) + + # Create networks but only create netns file for one + active_net_ids = [ + "synced-network", + "missing-network-1", + "missing-network-2" + ] + active_networks = set( + mock.Mock(id=netid, namespace=f"qdhcp-{netid}", + admin_state_up=True, + subnets=[mock.Mock(enable_dhcp=True)]) + for netid in active_net_ids + ) + + # Create netns file only for the synced network + (netns_dir / 'qdhcp-synced-network').touch() + + with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', + status_file_path): + with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): + dhcp_agent._write_sync_status(active_networks) + + # Verify the file was created + self.assertTrue(status_file_path.exists()) + + # Read and verify the status file content + with open(status_file_path, 'rb') as f: + status = jsonutils.load(f) + + self.assertFalse(status["ready"]) + message = status["message"] + self.assertIn("Missing 2 of 3 networks", message) + self.assertIn("qdhcp-missing-network-1", message) + self.assertIn("qdhcp-missing-network-2", message) + self.assertIn("time", status) + self.assertIsInstance(status["time"], (int, float)) + + +class TestAgentStatusIntegration(base.BaseTestCase): + + def setUp(self): + super(TestAgentStatusIntegration, self).setUp() + + entry.register_options(cfg.CONF) + cfg.CONF.set_override('interface_driver', + 'neutron.agent.linux.interface.NullDriver') + cfg.CONF.set_override('report_interval', 0, 'AGENT') + self.driver_cls_p = mock.patch( + 'neutron.agent.dhcp.agent.importutils.import_class') + self.driver = mock.Mock(name='driver') + self.driver.existing_dhcp_networks.return_value = [] + self.driver_cls = self.driver_cls_p.start() + self.driver_cls.return_value = self.driver + mock.patch("os.makedirs").start() + mock.patch( + "neutron.agent.metadata.driver.HaproxyConfigurator").start() + mock.patch("neutron.agent.linux.ip_lib.IPWrapper").start() + + def test_sync_status(self): + active_net_ids = ["a"] + active_networks = set( + mock.Mock(id=netid, namespace=f"qdhcp-{netid}", + admin_state_up=True, + subnets=[mock.Mock(enable_dhcp=True)]) + for netid in active_net_ids + ) + + with TemporaryDirectory() as net_ns: + with NamedTemporaryFile(mode='w') as status_file: + netns_dir = Path(net_ns) + for net_id in active_net_ids: + (netns_dir / f"qdhcp-{net_id}").touch() + + dhcp_agent.AGENT_STATUS_FILE = status_file.name + + with mock.patch(DHCP_PLUGIN) as plug: + mock_plugin = mock.Mock() + mock_plugin.get_active_networks_info.return_value = ( + active_networks + ) + plug.return_value = mock_plugin + dhcp = dhcp_agent.DhcpAgent(HOSTNAME) + attrs_to_mock = dict( + (a, mock.DEFAULT) + for a in ['disable_dhcp_helper', 'cache', + 'safe_configure_dhcp_for_network'] + ) + with mock.patch.multiple(dhcp, **attrs_to_mock) as mocks: + mocks['cache'].get_network_ids.return_value = [] + mocks['cache'].get_port_ids.return_value = range(4) + with mock.patch.object(netns, 'NETNS_RUN_DIR', net_ns): + dhcp.sync_state() + + with open(status_file.name, 'rb') as f: + status = jsonutils.load(f) + self.assertTrue(status["ready"]) + self.assertEqual(status["message"], + "All networks synced") + + def test_sync_status_failure(self): + with (NamedTemporaryFile(mode='w') as status_file): + with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', + status_file.name): + + with mock.patch(DHCP_PLUGIN) as plug: + mock_plugin = mock.Mock() + error_msg = "test error" + mock_plugin.get_active_networks_info.side_effect = \ + Exception(error_msg) + plug.return_value = mock_plugin + + dhcp = dhcp_agent.DhcpAgent(HOSTNAME) + dhcp.sync_state() + + with open(status_file.name, 'rb') as f: + status = jsonutils.load(f) + self.assertFalse(status["ready"]) + self.assertEqual(status["message"], "test error") From 9e230ce20a1514d9e0417705c94f4cbdfe976c73 Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Fri, 26 Jun 2026 12:23:46 +0200 Subject: [PATCH 179/184] [customdns] removing customdns configuration via neutron config file The customdns configuration is now done using a yaml config file pointed to in the neutron config file. This commit removes the now unused options from the configuration, removes the obsolete code and the tests and cleans up the code. With this change the yaml configuration file is now mandatory when the customdns feature is enabled. We also renamed some of the tests to better describe their purpose and renamed the "doubled" tests for the functionality back to the original names by removing the _yaml postfix. Additional tests ensure the configuration file is required when the customdns feature is enabled and exceptions are handled accordingly. --- neutron/api/rpc/handlers/dhcp_rpc.py | 201 ++------- neutron/conf/service.py | 10 - .../unit/api/rpc/handlers/test_dhcp_rpc.py | 381 +++++++----------- 3 files changed, 185 insertions(+), 407 deletions(-) diff --git a/neutron/api/rpc/handlers/dhcp_rpc.py b/neutron/api/rpc/handlers/dhcp_rpc.py index d5f0eb47178..701af706a92 100644 --- a/neutron/api/rpc/handlers/dhcp_rpc.py +++ b/neutron/api/rpc/handlers/dhcp_rpc.py @@ -75,30 +75,38 @@ class CustomNetworkSettings: dns_custom_upstreams: set[str] | None = None def __post_init__(self): + if not isinstance(self.dns_ednslogging_enabled, bool): raise TypeError(_("dns_ednslogging_enabled must be a bool: %s") % self.dns_ednslogging_enabled) + if self.dns_custom_upstreams: - self._validate_upstreams() + try: + self.dns_custom_upstreams = self._validate_ip_addresses( + self.dns_custom_upstreams) + except ValueError as e: + LOG.error("Invalid DNS server list: %s", e) + raise - def _validate_upstreams(self): + @staticmethod + def _validate_ip_addresses(addresses: set[str] | None) -> set[str]: """ensure that all elements are valid IP addresses and make it a set of strings containing the normalized IP addresses. """ - if not self.dns_custom_upstreams: - self.dns_custom_upstreams = set() - return - addrs: set[str] = set() - for item in self.dns_custom_upstreams: + if not addresses: + return set() + + validated: set[str] = set() + for item in addresses: try: addr = ipaddress.ip_address(item) - addrs.add(addr.compressed) + validated.add(addr.compressed) except ValueError: - LOG.error("not a valid IP for DNS entry: %s", item) + LOG.error("not a valid IP address: %s", item) raise - self.dns_custom_upstreams = addrs + return validated class CustomNetworkConfigurator: @@ -115,8 +123,8 @@ def _load_config(self): """load or reload custom dns config from file.""" if not self._config_file: - # option is empty or not set - return + raise CustomNetworkConfigError(_("no config_file set but custom " + "network config requested")) LOG.debug("loading customdns config from '%s'", self._config_file) @@ -150,7 +158,7 @@ def _load_config(self): valid_keys = mandatory_keys | { 'project_ids', 'domain_name_prefixes', - 'upstream_dns_servers' + 'upstream_dns_servers', } for item in matches: @@ -206,23 +214,8 @@ def _load_config(self): self._dns_config = dns_config def add_dnssettings_to_net(self, network_dict): - """Add custom dns settings to the network if the network - is found to be part of one of the custom OpenStack domains - or projects from our settings. - """ - - # TODO(mutax): after migration replace this whole method with - # the method '_add_external_dnssettings_to_net' - - # check if we got an external custom dns config - if self._dns_config: - self._add_external_dnssettings_to_net(network_dict) - else: - self._add_legacy_dnssettings_to_net(network_dict) - - def _add_external_dnssettings_to_net(self, network_dict): """Add custom dns settings specified via external config file - to the network, if the network matches the criteria set in the + to the network, if the network matches any of the criteria set in the config file. """ @@ -231,49 +224,36 @@ def _add_external_dnssettings_to_net(self, network_dict): # first check if we have a match in the project ids, # this is the cheapest lookup - if self._apply_project_settings(network_dict): - return - - # next try to match openstack domain name prefixes. - self._apply_domain_settings(network_dict) - - def _apply_project_settings(self, network_dict: dict) -> bool: - """apply project-specific DNS settings if they exist. - Returns True if settings were found to allow skipping of - further processing. Not to be called directly. - """ - - if not self._dns_config: - return False project_id = network_dict['project_id'] - # check if the project-id matches the list for custom settings custom_config = self._dns_config['projects'].get(project_id) if custom_config: LOG.debug("setting custom settings for net %s, " "project %s matches: %s", network_dict['id'], project_id, custom_config ) + else: + # try to match openstack domain name prefixes. + custom_config = self._find_domain_settings(network_dict) - network_dict['dns_ednslogging_enabled'] = ( - custom_config.dns_ednslogging_enabled) + if not custom_config: + return - if custom_config.dns_custom_upstreams: - network_dict['dns_custom_upstreams'] = ( - custom_config.dns_custom_upstreams) - return True + network_dict['dns_ednslogging_enabled'] = ( + custom_config.dns_ednslogging_enabled) - return False + if custom_config.dns_custom_upstreams: + network_dict['dns_custom_upstreams'] = ( + custom_config.dns_custom_upstreams) - def _apply_domain_settings(self, network_dict: dict) -> bool: - """apply domain-specific DNS settings if they exist. - Returns True if settings were found to allow skipping of - further processing for consistency. Not to be called directly. + def _find_domain_settings(self, network_dict: dict) -> ( + CustomNetworkSettings | None): + """lookup domain-specific DNS settings if they exist. """ if not self._dns_config: - return False + return None # try to retrieve the OpenStack domain name via the project id, # this uses a local cache and on a cache miss queries keystone @@ -301,7 +281,7 @@ def _apply_domain_settings(self, network_dict: dict) -> bool: LOG.warning('Unable to retrieve domain name for project %s,' ' falling back to default settings for network %s', project_id, network_dict['id']) - return False + return None # check if the OpenStack domain name starts with one of the prefixes # from our config (or is equal). @@ -322,59 +302,9 @@ def _apply_domain_settings(self, network_dict: dict) -> bool: domain_prefix, custom_config ) - network_dict['dns_ednslogging_enabled'] = ( - custom_config.dns_ednslogging_enabled) - - if custom_config.dns_custom_upstreams: - network_dict['dns_custom_upstreams'] = ( - custom_config.dns_custom_upstreams) - return True - return False - - def _add_legacy_dnssettings_to_net(self, network_dict): - """If the network domain or project match the configured list, - apply custom dns servers to the configuration. - Legacy method to be removed after deployment of the new config files. - """ - # TODO(mutax): remove this method after migration to new config file - - if not (cfg.CONF.customdns.domain_name_prefixes or - cfg.CONF.customdns.project_ids): - # The config is empty, there is no need to do any lookups - # against keystone - return - - if not self._is_customdns_network(network_dict): - return + return custom_config - # logging always has to be disabled for all custom domains - network_dict['dns_ednslogging_enabled'] = False - - if cfg.CONF.customdns.upstream_dns_servers: - # only set if the config setting isn't empty, so we do not - # break DNS resolution in that domain when the config is - # incomplete. Can also be used intentionally to only disable - # logging but keep the default upstream servers. - addrs = [] - # TODO(mutax): make e.g. custom config item type to validate only - # once on startup - for item in cfg.CONF.customdns.upstream_dns_servers: - try: - addr = ipaddress.ip_address(item) - addrs.append(addr.compressed) - except ValueError: - LOG.error("Custom DNS settings invalid for network %s " - "not a valid IP for DNS: %s", - network_dict['id'], item) - network_dict['dns_custom_upstreams'] = addrs - - LOG.debug("Network %s is in a custom OS-domain, " - "customized DNS settings: " - "dns_ednslogging_enabled=%s, " - "dns_custom_upstreams=%s", - network_dict['id'], - network_dict.get('dns_ednslogging_enabled', 'NOT-SET'), - network_dict.get('dns_custom_upstreams', 'NOT-SET')) + return None @property def _keystone_connection(self): @@ -447,59 +377,6 @@ def get_domain_name(self, project_id: str) -> str: return domain_name - def _is_customdns_network(self, network_dict: dict) -> bool: - """check if the network is in an OpenStack domain or project that we - want to configure in a custom way. - For domains we use prefix matches on the name, for projects we - directly match on the id. - """ - project_id = network_dict['project_id'] - - # TODO(mutax): remove this method after migration to new config file - - # should not happen, but would never match anyway - if not project_id: - return False - - # check if the project-id matches the list for custom settings - if project_id in cfg.CONF.customdns.project_ids: - # this comes in handy for testing, no need for a test-domain! - LOG.debug("domainlookup: project %s matches customdns project ids", - project_id) - return True - - # now try to retrieve the OpenStack domain name via the project id, - # this uses a local cache and on a cache miss queries keystone - domain_name = None - try: - domain_name = self.get_domain_name(project_id) - except Exception as e: # noqa - # If Keystone is not reachable or something goes wrong with - # the lookup, we do not want to fail configuring all networks. - # Currently, the sane thing to do is using default settings in - # those cases. As we want to fail to the default in all error - # cases anyway, we can use a bare Exception here. - # TODO(mutax): I do want to get the stack trace logged, but I - # also want to get a nice warning to the log independent of the - # source of the error - but now we log the same error twice. - LOG.exception('Failed to retrieve domain to set custom dns for' - ' project %s of network %s - %s: %s', - project_id, network_dict['id'], type(e), e - ) - - # in case of an error or empty result, we fall back to the 'safe' - # side by using default settings. - if not domain_name: - LOG.warning('Unable to retrieve domain name for project %s,' - ' falling back to default settings for network %s', - project_id, network_dict['id']) - return False - - # check if the OpenStack domain name starts with one of the prefixes - # from our config (or is equal). - return domain_name.startswith( - tuple(cfg.CONF.customdns.domain_name_prefixes)) - class DhcpRpcCallback(object): """DHCP agent RPC callback in plugin implementations. diff --git a/neutron/conf/service.py b/neutron/conf/service.py index 89b02bbae11..6112f476fd4 100644 --- a/neutron/conf/service.py +++ b/neutron/conf/service.py @@ -60,16 +60,6 @@ cfg.BoolOpt('enabled', default=False, help=_("Enable domain specific DNS settings.")), - # TODO(mutax): remove deprecated options after migration to config file - cfg.ListOpt('upstream_dns_servers', default=[], - help=_("Custom upstream DNS server IPs" - " (deprecated, use config_file)")), - cfg.ListOpt('domain_name_prefixes', default=[], - help=_("OS Domain Name Prefixes to match against" - " (deprecated, use config_file)")), - cfg.ListOpt('project_ids', default=[], - help=_("IDs of projects to match for testing only" - " (deprecated, use config_file)")), cfg.StrOpt('config_file', default=None, help=_("Path to yaml config file for OpenStack domain " "or project specific DNS settings.") diff --git a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py index 451738dfb65..00ee92f6473 100644 --- a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py +++ b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py @@ -34,6 +34,7 @@ from neutron.api.rpc.handlers.dhcp_rpc import CustomNetworkConfigError from neutron.api.rpc.handlers.dhcp_rpc import CustomNetworkConfigurator from neutron.api.rpc.handlers.dhcp_rpc import CustomNetworkSettings +from neutron.api.rpc.handlers.dhcp_rpc import DhcpRpcCallback from neutron.common import utils from neutron.db import provisioning_blocks from neutron.objects import network as network_obj @@ -50,71 +51,164 @@ def __getattr__(self, attr): class TestDhcpRpcCustomNetworkConfigurator(base.BaseTestCase): - def test_network_dict_empty(self): - """ensure nothing is added to the network dict when - nothing is configured + def _get_cnc_from_yaml_config(self, configdata: bytes)\ + -> CustomNetworkConfigurator: + """returns a CustomNetworkConfigurator instance + using the configuration in configdata provided as raw binary yaml """ - cnc = CustomNetworkConfigurator() - empty_dict = {} - cnc.add_dnssettings_to_net(empty_dict) + cfg.CONF.set_override('enabled', True, + group='customdns') + # just needs to be set and a valid filename, + # return data is mocked to return 'configdata'. + cfg.CONF.set_override('config_file', 'irrelevant.yaml', + group='customdns') - self.assertFalse(bool(empty_dict)) + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + mock_pathlib.return_value = configdata + cnc = CustomNetworkConfigurator() - @mock.patch.object(pathlib.Path, 'read_bytes') - def test_external_yaml_config_parser(self, mock_pathlib): - """Basic tests for an empty, invalid or on-purpose empty - config file. - """ + return cnc + def test_network_dict_empty(self): + """Ensure nothing is added to the network dict when + nothing is configured. + """ cfg.CONF.set_override('enabled', True, group='customdns') - # just needs to be set and a valid filename, - # return data is mocked above. cfg.CONF.set_override('config_file', 'irrelevant.yaml', group='customdns') - empty_config = b"" + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: - mock_pathlib.return_value = empty_config - self.assertRaises(CustomNetworkConfigError, CustomNetworkConfigurator) + mock_pathlib.return_value = b"matches:\n" + cnc = CustomNetworkConfigurator() + mock_pathlib.assert_called_once() - invalid_config = b""" - foobar: - """ + empty_dict = {} + cnc.add_dnssettings_to_net(empty_dict) + self.assertFalse(bool(empty_dict)) - mock_pathlib.return_value = invalid_config - self.assertRaises(CustomNetworkConfigError, CustomNetworkConfigurator) + def test_ensure_config_not_read_if_not_enabled(self): + """Ensure that we do not try to load the config file and there is no + CustomNetworkConfigurator instance created, when the feature is not + enabled. + """ + cfg.CONF.set_override('enabled', False, + group='customdns') + cfg.CONF.set_override('config_file', 'mock_did_not_work.yaml', + group='customdns') - no_config = b""" - matches: - """ + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + msg = "[Errno 2] No such file or directory: 'mock_was_called.yaml'" + mock_pathlib.side_effect = FileNotFoundError(msg) + rpc_callback = DhcpRpcCallback() - mock_pathlib.return_value = no_config - cnc = CustomNetworkConfigurator() - self.assertEqual({}, cnc._dns_config) + mock_pathlib.assert_not_called() + self.assertIsNone(rpc_callback._config_lookup) - def _get_cnc_from_yaml_config(self, configdata: bytes)\ - -> CustomNetworkConfigurator: - """returns a CustomNetworkConfigurator instance - using the configuration in configdata provided as raw binary yaml + def test_ensure_config_file_is_required_if_enabled(self): + """Ensure that if the feature is enabled but the file cannot be found + we raise an exception when trying to start and trying to create + an instance of DhcpRpcCallback. + """ + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('config_file', 'mock_did_not_work.yaml', + group='customdns') + + # DhcpRpcCallback will try to create a CustomNetworkConfigurator + # which should trigger the exception: + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + msg = "[Errno 2] No such file or directory: 'mock_was_called.yaml'" + mock_pathlib.side_effect = FileNotFoundError(msg) + self.assertRaises(CustomNetworkConfigError, + DhcpRpcCallback) + mock_pathlib.assert_called_once() + + def test_ensure_config_enabled_flag_ignored_by_configurator(self): + """Ensure that if the feature is disabled, we still can instanciate + a CustomNetworkConfigurator. + """ + cfg.CONF.set_override('enabled', False, + group='customdns') + cfg.CONF.set_override('config_file', 'mock_did_not_work.yaml', + group='customdns') + + # CustomNetworkConfigurator will try to load the config file + # with enabled=False because the flag triggers the overall feature + # only and the config parser/config helper should not be too closely + # coupled here. + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + + mock_pathlib.return_value = b"matches:\n" + cnc = CustomNetworkConfigurator() + mock_pathlib.assert_called_once() + self.assertEqual({}, cnc._dns_config) + + def test_ensure_config_enabled_requires_valid_configfile(self): + """Ensure that if the feature is enabled, we require a valid + configuration file. + """ + cfg.CONF.set_override('enabled', True, + group='customdns') + cfg.CONF.set_override('config_file', None, + group='customdns') + + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + msg = "[Errno 2] No such file or directory: 'mock_was_called.yaml'" + mock_pathlib.side_effect = FileNotFoundError(msg) + self.assertRaises(CustomNetworkConfigError, + DhcpRpcCallback) + mock_pathlib.assert_not_called() + + def test_yaml_config_parser(self): + """Basic tests for an empty, invalid or on-purpose empty + config file. """ cfg.CONF.set_override('enabled', True, group='customdns') # just needs to be set and a valid filename, - # return data is mocked to return 'configdata'. + # returned data is mocked. cfg.CONF.set_override('config_file', 'irrelevant.yaml', group='customdns') + # we do not allow an empty file as valid config + empty_config = b"" with mock.patch.object(pathlib.Path, 'read_bytes') as \ mock_pathlib: - mock_pathlib.return_value = configdata - cnc = CustomNetworkConfigurator() + mock_pathlib.return_value = empty_config + self.assertRaises(CustomNetworkConfigError, + CustomNetworkConfigurator) - return cnc + # we do not allow a file that has no metrics: key as valid config + invalid_config = b""" + foobar: + """ + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + mock_pathlib.return_value = invalid_config + self.assertRaises(CustomNetworkConfigError, + CustomNetworkConfigurator) + + # we _do_ allow a config with an on-purpose empty ruleset + no_config = b""" + matches: + """ + with mock.patch.object(pathlib.Path, 'read_bytes') as \ + mock_pathlib: + mock_pathlib.return_value = no_config + cnc = CustomNetworkConfigurator() + self.assertEqual({}, cnc._dns_config) - def test_external_yaml_config(self): + def test_yaml_config_loader(self): """Test if the yaml config is converted to the expected internal data structure of our class. Ensures all options are picked up. """ @@ -163,8 +257,9 @@ def test_external_yaml_config(self): self.assertEqual(example_config_expected, cnc._dns_config) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_no_match_no_change_yaml(self, mock_keystone): - """ensure that we do not change a setting if the domain does not match + def test_no_match_no_change(self, mock_keystone): + """ensure that we do not change a network setting if the domain + and project do not match the ones in the config """ # we manipulate the network, so we need fresh mock objects @@ -199,37 +294,7 @@ def test_no_match_no_change_yaml(self, mock_keystone): self.assertIsNone(mock_network.get('dns_custom_upstreams')) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_no_match_no_change(self, mock_keystone): - """ensure that we do not change a setting if the domain does not match - """ - # TODO(mutax): remove this test after migration to new config file - - # we manipulate the network, so we need fresh mock objects - mock_network = {'id': 123, 'project_id': 666} - mock_project = MockedDBObj(id=666, domain_id=42) - mock_domain = MockedDBObj(id=42, name='mydomain') - - mock_keystone.get_project.return_value = mock_project - mock_keystone.get_domain.return_value = mock_domain - - cfg.CONF.set_override('enabled', True, - group='customdns') - cfg.CONF.set_override('domain_name_prefixes', ['some', 'other'], - group='customdns') - - cnc = CustomNetworkConfigurator() - - cnc.add_dnssettings_to_net(mock_network) - - mock_keystone.get_project.assert_called_with(666) - mock_keystone.get_domain.assert_called_with(42) - - # assert we do not change the settings - self.assertIsNone(mock_network.get('dns_ednslogging_enabled')) - self.assertIsNone(mock_network.get('dns_custom_upstreams')) - - @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_network_id_lookup_yaml(self, mock_keystone): + def test_network_id_lookup(self, mock_keystone): """ensure keystone lookup methods are called and the network returned matches the expected settings """ @@ -262,41 +327,9 @@ def test_network_id_lookup_yaml(self, mock_keystone): self.assertIsNone(mock_network.get('dns_custom_upstreams')) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_network_id_lookup(self, mock_keystone): - """ensure keystone lookup methods are called and the network - returned matches the expected settings - """ - # TODO(mutax): remove this test after migration to new config file - - # we manipulate the network, so we need fresh mock objects - mock_network = {'id': 123, 'project_id': 666} - mock_project = MockedDBObj(id=666, domain_id=42) - mock_domain = MockedDBObj(id=42, name='mydomain') - - mock_keystone.get_project.return_value = mock_project - mock_keystone.get_domain.return_value = mock_domain - - cfg.CONF.set_override('enabled', True, - group='customdns') - cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], - group='customdns') - - cnc = CustomNetworkConfigurator() - - cnc.add_dnssettings_to_net(mock_network) - - mock_keystone.get_project.assert_called_with(666) - mock_keystone.get_domain.assert_called_with(42) - - # assert we get the correct settings when no nameservers are set - # but logging should be off - self.assertFalse(mock_network.get('dns_ednslogging_enabled')) - self.assertIsNone(mock_network.get('dns_custom_upstreams')) - - @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_nameserver_settings_yaml(self, mock_keystone): - """ensure the configured nameserver IPs are present in the network - dict returned + def test_nameserver_settings_applied(self, mock_keystone): + """ensure that if the domain of a network matched, the configured + nameserver IPs are present in the network dict returned """ # we manipulate the network, so we need fresh mock objects @@ -333,47 +366,12 @@ def test_nameserver_settings_yaml(self, mock_keystone): self.assertEqual(len(upstreams), 2) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_nameserver_settings(self, mock_keystone): - """ensure the configured nameserver IPs are present in the network - dict returned - """ - # TODO(mutax): remove this test after migration to new config file - - # we manipulate the network, so we need fresh mock objects - mock_network = {'id': 123, 'project_id': 666} - mock_project = MockedDBObj(id=666, domain_id=42) - mock_domain = MockedDBObj(id=42, name='mydomain') - - mock_keystone.get_project.return_value = mock_project - mock_keystone.get_domain.return_value = mock_domain - - dns1 = "2001:db8::456" - dns2 = "192.0.2.123" - - cfg.CONF.set_override('enabled', True, - group='customdns') - cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], - group='customdns') - cfg.CONF.set_override('upstream_dns_servers', [dns1, dns2], - group='customdns') - - cnc = CustomNetworkConfigurator() - - cnc.add_dnssettings_to_net(mock_network) - self.assertFalse(mock_network.get('dns_ednslogging_enabled')) - sentinel = object() - upstreams = mock_network.get('dns_custom_upstreams', sentinel) - self.assertNotEqual(sentinel, upstreams) - self.assertIsNotNone(upstreams) - self.assertIn(dns1, upstreams) - self.assertIn(dns2, upstreams) - self.assertEqual(len(upstreams), 2) - - @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_longest_domain_prefix_wins_yaml(self, mock_keystone): + def test_longest_domain_prefix_wins(self, mock_keystone): """ensure we are doing a longest prefix match on the domain name, that is if a match 'ext-123' and 'ext-' is present, a domain named 'ext-1234' will match the settings for 'ext-123' and not 'ext-'. + + This allows configuration of a fallback for all "ext-*" domains. """ # we manipulate the network, so we need fresh mock objects @@ -414,40 +412,7 @@ def test_longest_domain_prefix_wins_yaml(self, mock_keystone): self.assertIn(dns2, upstreams) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_longest_domain_prefix_wins(self, mock_keystone): - """ensure we are doing a prefix match on the domain name """ - # TODO(mutax): remove this test after migration to new config file - - # we manipulate the network, so we need fresh mock objects - mock_network = {'id': 123, 'project_id': 666} - mock_project = MockedDBObj(id=666, domain_id=42) - mock_domain = MockedDBObj(id=42, name='mydomain-123') - - mock_keystone.get_project.return_value = mock_project - mock_keystone.get_domain.return_value = mock_domain - - dns1 = "2001:db8::456" - dns2 = "192.0.2.123" - - cfg.CONF.set_override('enabled', True, - group='customdns') - cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], - group='customdns') - cfg.CONF.set_override('upstream_dns_servers', [dns1, dns2], - group='customdns') - - cnc = CustomNetworkConfigurator() - - cnc.add_dnssettings_to_net(mock_network) - - self.assertFalse(mock_network.get('dns_ednslogging_enabled')) - - upstreams = mock_network.get('dns_custom_upstreams') - self.assertIn(dns1, upstreams) - self.assertIn(dns2, upstreams) - - @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_project_lookup_exceptions_dont_prevent_netconf_yaml( + def test_project_lookup_exceptions_dont_prevent_netconf( self, mock_keystone): """ensure that all exceptions are catched and do not break the @@ -478,34 +443,7 @@ def test_project_lookup_exceptions_dont_prevent_netconf_yaml( self.assertIsNone(upstreams) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_project_lookup_exceptions_do_not_prevent_netconfig( - self, - mock_keystone): - """ensure that all exceptions are catched and do not break the - rpc call - """ - # TODO(mutax): remove this test after migration to new config file - - # we manipulate the network, so we need fresh mock objects - mock_network = {'id': 123, 'project_id': 666} - mock_domain = MockedDBObj(id=42, name='mydomain-123') - - mock_keystone.get_project.side_effect = Exception('Test') - mock_keystone.get_domain.return_value = mock_domain - - cfg.CONF.set_override('enabled', True, - group='customdns') - cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], - group='customdns') - - cnc = CustomNetworkConfigurator() - - cnc.add_dnssettings_to_net(mock_network) - upstreams = mock_network.get('dns_custom_upstreams') - self.assertIsNone(upstreams) - - @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_domain_lookup_exceptions_do_not_prevent_netconfig_yaml( + def test_domain_lookup_exceptions_do_not_prevent_netconfig( self, mock_keystone): """ensure that all exceptions are catched and do not break the @@ -536,34 +474,7 @@ def test_domain_lookup_exceptions_do_not_prevent_netconfig_yaml( self.assertIsNone(upstreams) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_domain_lookup_exceptions_do_not_prevent_netconfig( - self, - mock_keystone): - """ensure that all exceptions are catched and do not break the - rpc call - """ - # TODO(mutax): remove this test after migration to new config file - - # we manipulate the network, so we need fresh mock objects - mock_network = {'id': 123, 'project_id': 666} - mock_project = MockedDBObj(id=666, domain_id=42) - - mock_keystone.get_project.return_value = mock_project - mock_keystone.get_domain.side_effect = Exception('Test') - - cfg.CONF.set_override('enabled', True, - group='customdns') - cfg.CONF.set_override('domain_name_prefixes', ['mydomain'], - group='customdns') - - cnc = CustomNetworkConfigurator() - - cnc.add_dnssettings_to_net(mock_network) - upstreams = mock_network.get('dns_custom_upstreams') - self.assertIsNone(upstreams) - - @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") - def test_network_ednslogging_setting_yaml(self, mock_keystone): + def test_network_ednslogging_setting(self, mock_keystone): """ensure the networks can be configured with or without edns logging """ @@ -594,8 +505,8 @@ def test_network_ednslogging_setting_yaml(self, mock_keystone): self.assertIsNone(mock_network_nologging.get('dns_custom_upstreams')) self.assertIsNone(mock_network_logging.get('dns_custom_upstreams')) - def test_exceptions_configerror_types_yaml(self): - """ensure we are catching non-ip entries + def test_exceptions_configerror_types(self): + """ensure we are catching non-ip entries in the dns server settings """ example_config = b""" From 24a81496f6c9ce42545f4af14b58640d77070241 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 16 Jul 2026 11:46:03 +0200 Subject: [PATCH 180/184] Regularly run the agent check Problem: The sync_loop runs only when triggered by specific events (e.g., network create/delete operations). This means the agent status check does not run regularly as previously assumed. In rapid create/delete scenarios, the status can become unsynced: the network exists in the database but the network namespace is never created. From Neutron's perspective everything appears fine, but the agent status shows unsynced and never updates. Solution: Use the cache as the source of truth for agent readiness checks. The cache is kept up-to-date by the agent on every operation (create/ delete network/port/subnet). Run regular checks against the cache to determine if all networks are synced and mark the agent ready accordingly. For detecting when the cache is out of sync, we rely on other measures such as the custom exporter for network namespaces and MariaDB exports. --- neutron/agent/dhcp/agent.py | 30 ++++++----- neutron/conf/agent/dhcp.py | 3 ++ neutron/tests/unit/agent/dhcp/test_agent.py | 58 ++++++--------------- 3 files changed, 36 insertions(+), 55 deletions(-) diff --git a/neutron/agent/dhcp/agent.py b/neutron/agent/dhcp/agent.py index 5c18356de6f..6b7c5d8da52 100644 --- a/neutron/agent/dhcp/agent.py +++ b/neutron/agent/dhcp/agent.py @@ -88,21 +88,16 @@ def _remove_status_file(): LOG.info("Agent status file %s removed", AGENT_STATUS_FILE) -def _find_missing_netns(active_networks): +def _find_missing_netns(active_network_ids): ns = Path(netns.NETNS_RUN_DIR) - active_ns = set() + active_nets = set(active_network_ids) synced_nets = set() - for net in active_networks: - # admin_state_up is a boolean - if any(s for s in net.subnets if s.enable_dhcp) and net.admin_state_up: - active_ns.add(net.namespace) - for net in ns.iterdir(): if net.name.startswith('qdhcp-'): - synced_nets.add(net.name) + synced_nets.add(net.name.removeprefix('qdhcp-')) - return active_ns - synced_nets + return active_nets - synced_nets def _create_status_file(ready, message): @@ -130,13 +125,13 @@ def _write_status_failure(error): _create_status_file(False, str(error)) -def _write_sync_status(active_networks): - missing_netns = _find_missing_netns(active_networks) +def _write_sync_status(active_network_ids): + missing_netns = _find_missing_netns(active_network_ids) if missing_netns: ready = False - message = (f"Missing {len(missing_netns)} of {len(active_networks)} " - f"networks - {', '.join(sorted(missing_netns)[:5])}") + message = (f"Missing {len(missing_netns)} of {len(active_network_ids)}" + f" networks - {', '.join(sorted(missing_netns)[:5])}") else: ready = True message = "All networks synced" @@ -413,7 +408,7 @@ def sync_state(self, networks=None): # was down self.dhcp_ready_ports |= set(self.cache.get_port_ids(only_nets)) LOG.info('Synchronizing state complete') - _write_sync_status(active_networks) + _write_sync_status(self.cache.get_network_ids()) except Exception as e: if only_nets: for network_id in only_nets: @@ -461,6 +456,7 @@ def start_ready_ports_loop(self): @utils.exception_logger() def _periodic_resync_helper(self): """Resync the dhcp state at the configured interval and throttle.""" + last_check = time.monotonic() while True: # threading.Event.wait blocks until the internal flag is true. It # returns the internal flag on exit, so it will always return True @@ -485,6 +481,12 @@ def _periodic_resync_helper(self): LOG.debug("resync (%(network)s): %(reason)s", {"reason": r, "network": net}) self.sync_state(list(reasons.keys())) + # sync state also performs a _write_sync_status + last_check = time.monotonic() + elif (last_check + cfg.CONF.dhcp_agent_check_interval < + time.monotonic()): + last_check = time.monotonic() + _write_sync_status(self.cache.get_network_ids()) def periodic_resync(self): """Spawn a thread to periodically resync the dhcp state.""" diff --git a/neutron/conf/agent/dhcp.py b/neutron/conf/agent/dhcp.py index e34303d67b0..785eac90f5c 100644 --- a/neutron/conf/agent/dhcp.py +++ b/neutron/conf/agent/dhcp.py @@ -92,6 +92,9 @@ help=_("resolv.conf search domains inside network namespaces. " "If not set uses the dns_domain. Set to empty string " "to disable search parameter")), + cfg.IntOpt('dhcp_agent_check_interval', default=30, + help=_('Number of seconds between running ' + 'the dhcp-agent-check for detecting missing networks')), ] DHCP_OPTS = [ diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index f50a27c4c2d..a7cda2697e5 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -2885,14 +2885,8 @@ class TestAgentStatus(base.BaseTestCase): def test_find_missing_netns_with_missing_and_present(self): with TemporaryDirectory() as tmpdir: - active_net_ids = ["present-network-id", - "present-in-neutron-db-but-not-on-agent"] - active_networks = set( - mock.Mock(id=netid, namespace=f"qdhcp-{netid}", - admin_state_up=True, - subnets=[mock.Mock(enable_dhcp=True)]) - for netid in active_net_ids - ) + active_networks = {"present-network-id", + "present-in-neutron-db-but-not-on-agent"} # Create a netns file for the present network only netns_dir = Path(tmpdir) @@ -2907,25 +2901,19 @@ def test_find_missing_netns_with_missing_and_present(self): self.assertEqual(1, len(missing_netns)) self.assertEqual( - "qdhcp-present-in-neutron-db-but-not-on-agent", + "present-in-neutron-db-but-not-on-agent", missing_netns.pop(), ) # Test successfully synced network (all namespaces present) - active_net_ids = ["synced-network-id"] - all_synced_networks = set( - mock.Mock(id=netid, namespace=f"qdhcp-{netid}", - admin_state_up=True, - subnets=[mock.Mock(enable_dhcp=True)]) - for netid in active_net_ids - ) + active_net_ids = {"synced-network-id"} synced_netns_file = netns_dir / 'qdhcp-synced-network-id' synced_netns_file.touch() with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): missing_netns = dhcp_agent._find_missing_netns( - all_synced_networks) + active_net_ids) self.assertEqual(0, len(missing_netns)) self.assertEqual(set(), missing_netns) @@ -2958,13 +2946,7 @@ def test_write_status_synced(self): netns_dir = Path(tmpdir) # Create networks with corresponding namespace files (all synced) - active_net_ids = ["network-1", "network-2"] - active_networks = set( - mock.Mock(id=netid, namespace=f"qdhcp-{netid}", - admin_state_up=True, - subnets=[mock.Mock(enable_dhcp=True)]) - for netid in active_net_ids - ) + active_net_ids = {"network-1", "network-2"} # Create netns files for all networks (netns_dir / 'qdhcp-network-1').touch() @@ -2973,7 +2955,7 @@ def test_write_status_synced(self): with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', status_file_path): with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): - dhcp_agent._write_sync_status(active_networks) + dhcp_agent._write_sync_status(active_net_ids) # Verify the file was created self.assertTrue(status_file_path.exists()) @@ -2993,17 +2975,11 @@ def test_write_status_unsynced(self): netns_dir = Path(tmpdir) # Create networks but only create netns file for one - active_net_ids = [ + active_net_ids = { "synced-network", "missing-network-1", "missing-network-2" - ] - active_networks = set( - mock.Mock(id=netid, namespace=f"qdhcp-{netid}", - admin_state_up=True, - subnets=[mock.Mock(enable_dhcp=True)]) - for netid in active_net_ids - ) + } # Create netns file only for the synced network (netns_dir / 'qdhcp-synced-network').touch() @@ -3011,7 +2987,7 @@ def test_write_status_unsynced(self): with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', status_file_path): with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): - dhcp_agent._write_sync_status(active_networks) + dhcp_agent._write_sync_status(active_net_ids) # Verify the file was created self.assertTrue(status_file_path.exists()) @@ -3023,8 +2999,8 @@ def test_write_status_unsynced(self): self.assertFalse(status["ready"]) message = status["message"] self.assertIn("Missing 2 of 3 networks", message) - self.assertIn("qdhcp-missing-network-1", message) - self.assertIn("qdhcp-missing-network-2", message) + self.assertIn("missing-network-1", message) + self.assertIn("missing-network-2", message) self.assertIn("time", status) self.assertIsInstance(status["time"], (int, float)) @@ -3054,6 +3030,8 @@ def test_sync_status(self): active_networks = set( mock.Mock(id=netid, namespace=f"qdhcp-{netid}", admin_state_up=True, + non_local_subnets=[], + ports=[], subnets=[mock.Mock(enable_dhcp=True)]) for netid in active_net_ids ) @@ -3075,12 +3053,10 @@ def test_sync_status(self): dhcp = dhcp_agent.DhcpAgent(HOSTNAME) attrs_to_mock = dict( (a, mock.DEFAULT) - for a in ['disable_dhcp_helper', 'cache', - 'safe_configure_dhcp_for_network'] + for a in ['disable_dhcp_helper', 'call_driver', + 'update_isolated_metadata_proxy'] ) - with mock.patch.multiple(dhcp, **attrs_to_mock) as mocks: - mocks['cache'].get_network_ids.return_value = [] - mocks['cache'].get_port_ids.return_value = range(4) + with mock.patch.multiple(dhcp, **attrs_to_mock): with mock.patch.object(netns, 'NETNS_RUN_DIR', net_ns): dhcp.sync_state() From e1382a6e31d7dcd55db0cc31ed563ed51ab4189a Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Tue, 28 Jul 2026 10:15:02 +0200 Subject: [PATCH 181/184] Add synced networks to status file and moved file This writes the list of synced networks into a file, that can be shared to a k8s sidecar container. Moved the file to /run/dhcp-agent to enable shared mount between containers. This enables a prometheus exporter, running in a sidecar, to export the synced networks in an agent. Because a restart of the dhcp-agent container will remove all network namespaces from the kernel but not the files in /run/netns, sharing this as mount is not an option. This will prevent the dhcp-agent from starting up again, because the namespace files are in place, but no namespaces. --- neutron/agent/dhcp/agent.py | 40 ++++--- neutron/tests/unit/agent/dhcp/test_agent.py | 119 +++++++------------- 2 files changed, 63 insertions(+), 96 deletions(-) diff --git a/neutron/agent/dhcp/agent.py b/neutron/agent/dhcp/agent.py index 6b7c5d8da52..0400b0806b3 100644 --- a/neutron/agent/dhcp/agent.py +++ b/neutron/agent/dhcp/agent.py @@ -61,7 +61,7 @@ DHCP_READY_PORTS_SYNC_MAX = 64 -AGENT_STATUS_FILE = "/var/run/dhcp-agent-status.json" +AGENT_STATUS_FILE = "/run/dhcp-agent/status.json" def _sync_lock(f): @@ -88,19 +88,15 @@ def _remove_status_file(): LOG.info("Agent status file %s removed", AGENT_STATUS_FILE) -def _find_missing_netns(active_network_ids): - ns = Path(netns.NETNS_RUN_DIR) - active_nets = set(active_network_ids) +def _find_synced_net_ns(): synced_nets = set() + for net in netns.listnetns(): + if net.startswith('qdhcp-'): + synced_nets.add(net.removeprefix('qdhcp-')) + return synced_nets - for net in ns.iterdir(): - if net.name.startswith('qdhcp-'): - synced_nets.add(net.name.removeprefix('qdhcp-')) - return active_nets - synced_nets - - -def _create_status_file(ready, message): +def _create_status_file(ready, message, synced_networks=None): path = Path(AGENT_STATUS_FILE) try: path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) @@ -108,25 +104,32 @@ def _create_status_file(ready, message): LOG.error('Failed to create directory %s', path.parent) return - status = { + if synced_networks is None: + synced_networks = _find_synced_net_ns() + + status_message = { "time": time.time(), "ready": ready, "message": message, + "synced_networks": sorted(synced_networks), } try: - with open(path, "w") as status_file: - jsonutils.dump(status, status_file) + tmp = path.with_suffix(".tmp") + with open(tmp, "w") as status_file: + jsonutils.dump(status_message, status_file) + tmp.rename(path) except OSError: LOG.error('Failed to write status file %s', path) def _write_status_failure(error): - _create_status_file(False, str(error)) + _create_status_file(ready=False, message=str(error)) def _write_sync_status(active_network_ids): - missing_netns = _find_missing_netns(active_network_ids) + synced_nets = _find_synced_net_ns() + missing_netns = set(active_network_ids) - synced_nets if missing_netns: ready = False @@ -136,7 +139,8 @@ def _write_sync_status(active_network_ids): ready = True message = "All networks synced" - _create_status_file(ready, message) + _create_status_file(ready=ready, message=message, + synced_networks=synced_nets) class DHCPResourceUpdate(queue.ResourceUpdate): @@ -218,7 +222,7 @@ def __init__(self, host=None, conf=None): self.restarted_metadata_proxy_set = set() def init_host(self): - _create_status_file(False, "DHCP agent starting") + _create_status_file(ready=False, message="DHCP agent starting") self.sync_state() def _populate_networks_cache(self): diff --git a/neutron/tests/unit/agent/dhcp/test_agent.py b/neutron/tests/unit/agent/dhcp/test_agent.py index a7cda2697e5..ce32b8e51bf 100644 --- a/neutron/tests/unit/agent/dhcp/test_agent.py +++ b/neutron/tests/unit/agent/dhcp/test_agent.py @@ -2883,41 +2883,6 @@ def test__lt__port_fixed_ips_matching(self): class TestAgentStatus(base.BaseTestCase): - def test_find_missing_netns_with_missing_and_present(self): - with TemporaryDirectory() as tmpdir: - active_networks = {"present-network-id", - "present-in-neutron-db-but-not-on-agent"} - - # Create a netns file for the present network only - netns_dir = Path(tmpdir) - present_netns_file = netns_dir / 'qdhcp-present-network-id' - present_netns_file.touch() - - # Mock the NETNS_RUN_DIR to use our temp directory - with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): - missing_netns = dhcp_agent._find_missing_netns( - active_networks - ) - - self.assertEqual(1, len(missing_netns)) - self.assertEqual( - "present-in-neutron-db-but-not-on-agent", - missing_netns.pop(), - ) - - # Test successfully synced network (all namespaces present) - active_net_ids = {"synced-network-id"} - - synced_netns_file = netns_dir / 'qdhcp-synced-network-id' - synced_netns_file.touch() - - with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): - missing_netns = dhcp_agent._find_missing_netns( - active_net_ids) - - self.assertEqual(0, len(missing_netns)) - self.assertEqual(set(), missing_netns) - def test_write_status_failure(self): with TemporaryDirectory() as tmpdir: status_file_path = Path(tmpdir) / 'dhcp-agent-status.txt' @@ -2943,24 +2908,20 @@ def test_write_status_failure(self): def test_write_status_synced(self): with TemporaryDirectory() as tmpdir: status_file_path = Path(tmpdir) / 'dhcp-agent-status.json' - netns_dir = Path(tmpdir) # Create networks with corresponding namespace files (all synced) active_net_ids = {"network-1", "network-2"} - # Create netns files for all networks - (netns_dir / 'qdhcp-network-1').touch() - (netns_dir / 'qdhcp-network-2').touch() - with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', status_file_path): - with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): + with mock.patch.object(netns, 'listnetns' + ) as netns_list: + netns_list.return_value = ['qdhcp-network-1', + 'qdhcp-network-2'] dhcp_agent._write_sync_status(active_net_ids) - # Verify the file was created self.assertTrue(status_file_path.exists()) - # Read and verify the status file content with open(status_file_path, 'rb') as f: status = jsonutils.load(f) @@ -2969,10 +2930,14 @@ def test_write_status_synced(self): self.assertIn("time", status) self.assertIsInstance(status["time"], (int, float)) + self.assertEqual( + {"network-1", "network-2"}, + set(status["synced_networks"]) + ) + def test_write_status_unsynced(self): with TemporaryDirectory() as tmpdir: - status_file_path = Path(tmpdir) / 'dhcp-agent-status.txt' - netns_dir = Path(tmpdir) + status_file_path = Path(tmpdir) / 'dhcp-agent-status.json' # Create networks but only create netns file for one active_net_ids = { @@ -2981,18 +2946,15 @@ def test_write_status_unsynced(self): "missing-network-2" } - # Create netns file only for the synced network - (netns_dir / 'qdhcp-synced-network').touch() - with mock.patch.object(dhcp_agent, 'AGENT_STATUS_FILE', status_file_path): - with mock.patch.object(netns, 'NETNS_RUN_DIR', tmpdir): + with mock.patch.object(netns, 'listnetns' + ) as netns_list: + netns_list.return_value = ["qdhcp-synced-network"] dhcp_agent._write_sync_status(active_net_ids) - # Verify the file was created self.assertTrue(status_file_path.exists()) - # Read and verify the status file content with open(status_file_path, 'rb') as f: status = jsonutils.load(f) @@ -3003,6 +2965,10 @@ def test_write_status_unsynced(self): self.assertIn("missing-network-2", message) self.assertIn("time", status) self.assertIsInstance(status["time"], (int, float)) + self.assertEqual( + {"synced-network"}, + set(status["synced_networks"]) + ) class TestAgentStatusIntegration(base.BaseTestCase): @@ -3036,35 +3002,32 @@ def test_sync_status(self): for netid in active_net_ids ) - with TemporaryDirectory() as net_ns: - with NamedTemporaryFile(mode='w') as status_file: - netns_dir = Path(net_ns) - for net_id in active_net_ids: - (netns_dir / f"qdhcp-{net_id}").touch() - - dhcp_agent.AGENT_STATUS_FILE = status_file.name + with (NamedTemporaryFile(mode='w') as status_file): + dhcp_agent.AGENT_STATUS_FILE = status_file.name - with mock.patch(DHCP_PLUGIN) as plug: - mock_plugin = mock.Mock() - mock_plugin.get_active_networks_info.return_value = ( - active_networks - ) - plug.return_value = mock_plugin - dhcp = dhcp_agent.DhcpAgent(HOSTNAME) - attrs_to_mock = dict( - (a, mock.DEFAULT) - for a in ['disable_dhcp_helper', 'call_driver', - 'update_isolated_metadata_proxy'] - ) - with mock.patch.multiple(dhcp, **attrs_to_mock): - with mock.patch.object(netns, 'NETNS_RUN_DIR', net_ns): - dhcp.sync_state() + with mock.patch(DHCP_PLUGIN) as plug: + mock_plugin = mock.Mock() + mock_plugin.get_active_networks_info.return_value = ( + active_networks + ) + plug.return_value = mock_plugin + dhcp = dhcp_agent.DhcpAgent(HOSTNAME) + attrs_to_mock = dict( + (a, mock.DEFAULT) + for a in ['disable_dhcp_helper', 'call_driver', + 'update_isolated_metadata_proxy'] + ) + with mock.patch.multiple(dhcp, **attrs_to_mock): + with mock.patch.object(netns, 'listnetns' + ) as netns_list: + netns_list.return_value = ["qdhcp-a"] + dhcp.sync_state() - with open(status_file.name, 'rb') as f: - status = jsonutils.load(f) - self.assertTrue(status["ready"]) - self.assertEqual(status["message"], - "All networks synced") + with open(status_file.name, 'rb') as f: + status = jsonutils.load(f) + self.assertTrue(status["ready"]) + self.assertEqual(status["message"], + "All networks synced") def test_sync_status_failure(self): with (NamedTemporaryFile(mode='w') as status_file): From 59edfdf8bf1dd059f6f994a5da03a41db4fddf46 Mon Sep 17 00:00:00 2001 From: Rodolfo Alonso Hernandez Date: Tue, 21 Jul 2026 12:30:04 +0200 Subject: [PATCH 182/184] Prevent cross-project subnet onboard on shared networks Non-admin callers with visibility to a shared or RBAC network could onboard subnets owned by another project into their own subnetpool via ``onboard_network_subnets()``. This allowed the caller to alter the address-scope and L3 routing state of the network owner's routers. Add a project ownership check that rejects non-admin requests when the caller's ``project_id`` does not match the network's ``project_id``. Closes-Bug: #2152113 Assisted-By: Claude Opus 4.6 Signed-off-by: Rodolfo Alonso Hernandez Change-Id: Ie484bff86f082c38a69d238e414d543200403402 (cherry picked from commit fdf0943dbecf577b4feabdbad331072116f15d43) --- neutron/db/db_base_plugin_v2.py | 11 +++++++ .../unit/extensions/test_subnet_onboard.py | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index ae751650e8c..bfa67b898a6 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -1453,6 +1453,17 @@ def onboard_network_subnets(self, context, subnetpool_id, network_info): if not self._network_exists(context, network_id): raise exc.NetworkNotFound(net_id=network_id) + # Prevent cross-project subnet mutation: non-admin callers must own + # the network to onboard its subnets. Without this check a caller + # with visibility to a shared/RBAC network can reassign subnets + # owned by another project to their own subnetpool, potentially + # altering address-scope and L3 routing state for victim routers. + if not context.is_admin: + network = network_obj.Network.get_object( + context.elevated(), id=network_id) + if network.project_id != context.project_id: + raise exc.NotAuthorized() + subnetpool = subnetpool_obj.SubnetPool.get_object(context, id=subnetpool_id) if not subnetpool: diff --git a/neutron/tests/unit/extensions/test_subnet_onboard.py b/neutron/tests/unit/extensions/test_subnet_onboard.py index d0471ee57e9..6ec61a9b424 100644 --- a/neutron/tests/unit/extensions/test_subnet_onboard.py +++ b/neutron/tests/unit/extensions/test_subnet_onboard.py @@ -17,6 +17,7 @@ import contextlib import netaddr +from neutron_lib import context as n_context from neutron_lib.db import api as db_api from neutron_lib import exceptions as exc from oslo_utils import uuidutils @@ -190,6 +191,37 @@ def test_onboard_subnet_network_not_found(self): self._test_onboard_subnet_non_existing_network, subnetpool['id'], self.cidr_to_onboard) + def test_onboard_subnet_cross_project_not_authorized(self): + """Non-admin caller cannot onboard subnets from another project's net. + A project member must not be able to mutate subnets owned by a + different project via a shared network, even when the network is + RBAC-visible to the caller. + """ + _project_id = 'project1-' + _uuid() + _ctx = n_context.Context('user1', _project_id, is_admin=False) + + with self.subnetpool(self.ip_version, + prefixes=self.subnetpool_prefixes, + project_id=_project_id) as pool: + # Create a shared network owned by the default test project. + # Sharing is required so the user1 context can see (but not + # own) the network, reproducing the real-world condition. + with self.network(shared=True, as_admin=True) as shared_net: + network_id = shared_net['network']['id'] + with self.subnet(network=shared_net, + cidr=self.cidr_to_onboard, + ip_version=self.ip_version): + # The user1 can see the shared network but does + # not own it: the call must be rejected. + self.assertRaises( + exc.NotAuthorized, + self.driver.onboard_network_subnets, + _ctx, pool['id'], {'network_id': network_id}) + + # Admin call on the same pool/network must succeed. + self._test_onboard_network_subnets( + network_id, pool['id']) + def _test_onboard_subnet_no_network_id(self, subnetpool_id, cidr_to_onboard): with self.subnet(cidr=cidr_to_onboard, From 2842b141aa85ade2c33a53e52dfd89019f7b307f Mon Sep 17 00:00:00 2001 From: Florian Streibelt Date: Thu, 2 Jul 2026 15:25:44 +0200 Subject: [PATCH 183/184] [dnsmasq] allow setting of ntp servers based on OpenStack Domain Similar to the recently introduced feature of setting custom DNS upstream servers for dnsmasq based on the OpenStack Domain or project of a network, this adds support for configuring the NTP servers distributed via DHCP option 42 to the clients. Note: To take effect, the option must not be set in the dnsmasq config file, as commandline options are overwritten when they are only allowed to be given once. --- neutron/agent/linux/dhcp.py | 33 +++++++ neutron/api/rpc/handlers/dhcp_rpc.py | 85 +++++++++++------- neutron/conf/agent/dhcp.py | 4 + neutron/tests/unit/agent/linux/test_dhcp.py | 68 ++++++++++++++ .../unit/api/rpc/handlers/test_dhcp_rpc.py | 89 +++++++++++++++---- 5 files changed, 230 insertions(+), 49 deletions(-) diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index 167b5984356..201dc10dc40 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -569,6 +569,39 @@ def _build_cmdline_callback(self, pid_file): cmd.append('--conf-file=%s' % (self.conf.dnsmasq_config_file.strip() or '/dev/null')) + # check if the network has custom NTP servers set, + # or fallback to the configuration defaults (if they are set) + ntp_servers = getattr(self.network, 'ntp_servers', + self.conf.dnsmasq_ntp_servers) + + if ntp_servers: + # only if we have ntp servers, append the option. + # note that if the option is present in the config file, the config + # file will take precedence! + servers = [] + for server in ntp_servers: + try: + address = ipaddress.ip_address(server) + if address.version == 4: + servers.append(address.compressed) + else: + LOG.error('Invalid NTP server "%s" for network %s' + ' DHCP option 42 only supports IPv4', + server, self.network.id) + except ValueError: + LOG.error('Invalid NTP server "%s" for network %s', + server, self.network.id) + + if servers: + servers = ",".join(servers) + LOG.debug("Adding NTP servers %s for network %s", + servers, + self.network.id) + cmd.append(f'--dhcp-option=42,{servers}') + else: + LOG.warning('No valid NTP servers in config for network %s', + self.network.id) + # if the network has custom upstreams set, we will use them instead if hasattr(self.network, 'dns_custom_upstreams'): # Do some input validation on the data we got via rpc call, to diff --git a/neutron/api/rpc/handlers/dhcp_rpc.py b/neutron/api/rpc/handlers/dhcp_rpc.py index 701af706a92..6b0af170458 100644 --- a/neutron/api/rpc/handlers/dhcp_rpc.py +++ b/neutron/api/rpc/handlers/dhcp_rpc.py @@ -73,6 +73,7 @@ class CustomNetworkConfigError(Exception): class CustomNetworkSettings: dns_ednslogging_enabled: bool dns_custom_upstreams: set[str] | None = None + ntp_servers: set[str] | None = None def __post_init__(self): @@ -80,22 +81,28 @@ def __post_init__(self): raise TypeError(_("dns_ednslogging_enabled must be a bool: %s") % self.dns_ednslogging_enabled) - if self.dns_custom_upstreams: - try: - self.dns_custom_upstreams = self._validate_ip_addresses( - self.dns_custom_upstreams) - except ValueError as e: - LOG.error("Invalid DNS server list: %s", e) - raise + try: + self.dns_custom_upstreams = self._validate_ip_addresses( + self.dns_custom_upstreams) + except ValueError as e: + LOG.error("Invalid DNS server list: %s", e) + raise + + try: + self.ntp_servers = self._validate_ip_addresses( + self.ntp_servers) + except ValueError as e: + LOG.error("Invalid NTP server list: %s", e) + raise @staticmethod - def _validate_ip_addresses(addresses: set[str] | None) -> set[str]: + def _validate_ip_addresses(addresses: set[str] | None) -> set[str] | None: """ensure that all elements are valid IP addresses and make it a set of strings containing the normalized IP addresses. """ if not addresses: - return set() + return None validated: set[str] = set() for item in addresses: @@ -115,18 +122,19 @@ def __init__(self): self._KEYSTONE = None self._domain_id_cache = {} self._domain_name_cache = {} - self._dns_config: dict[str, dict[str, CustomNetworkSettings]] = {} + self._net_config: dict[str, dict[str, CustomNetworkSettings]] = {} self._config_file: str | None = cfg.CONF.customdns.config_file self._load_config() def _load_config(self): - """load or reload custom dns config from file.""" + """load or reload custom network configurations from file.""" if not self._config_file: raise CustomNetworkConfigError(_("no config_file set but custom " "network config requested")) - LOG.debug("loading customdns config from '%s'", self._config_file) + LOG.debug("loading custom network settings from '%s'", + self._config_file) try: cfgfile = pathlib.Path(self._config_file) @@ -140,25 +148,28 @@ def _load_config(self): % self._config_file) raise CustomNetworkConfigError(msg) - # make an empty config file fail hard + # Fail when the file is completely empty, this is most likely + # a misconfiguration... try: matches = config['matches'] except KeyError: - msg = (_("Missing 'matches:' in custom DNS config file '%s'") % + msg = (_("Missing 'matches:' in custom network settings " + "configuration file '%s'") % self._config_file) raise CustomNetworkConfigError(msg) - # but accept an intentionally empty list + # ... but accept an intentionally empty list if not matches: return - dns_config = {'projects': {}, 'domains': {}} + net_config = {'projects': {}, 'domains': {}} mandatory_keys = {'ednslogging', } valid_keys = mandatory_keys | { 'project_ids', 'domain_name_prefixes', 'upstream_dns_servers', + 'ntp_servers', } for item in matches: @@ -185,56 +196,57 @@ def _load_config(self): project_ids = item.get('project_ids', []) domain_prefixes = item.get('domain_name_prefixes', []) upstreams = item.get('upstream_dns_servers', []) + ntp_servers = item.get('ntp_servers', []) ednslogging = item['ednslogging'] try: netconfig = CustomNetworkSettings( dns_ednslogging_enabled=ednslogging, dns_custom_upstreams=set(upstreams), + ntp_servers=set(ntp_servers), ) except (TypeError, ValueError) as e: msg = _("Error parsing custom DNS config: %s") % e raise CustomNetworkConfigError(msg) for project_id in project_ids: - if project_id in dns_config['projects']: + if project_id in net_config['projects']: msg = _("project %s already configured!") % project_id raise CustomNetworkConfigError(msg) - dns_config['projects'][project_id] = netconfig + net_config['projects'][project_id] = netconfig for domain_prefix in domain_prefixes: - if domain_prefix in dns_config['domains']: + if domain_prefix in net_config['domains']: msg = (_("domain-prefix '%s' already configured!") % domain_prefix) raise CustomNetworkConfigError(msg) - dns_config['domains'][domain_prefix] = netconfig + net_config['domains'][domain_prefix] = netconfig - self._dns_config = dns_config + self._net_config = net_config - def add_dnssettings_to_net(self, network_dict): - """Add custom dns settings specified via external config file + def add_custom_settings_to_net(self, network_dict): + """Add the custom settings specified in the external config file to the network, if the network matches any of the criteria set in the config file. """ - if not self._dns_config: + if not self._net_config: return # first check if we have a match in the project ids, # this is the cheapest lookup - project_id = network_dict['project_id'] - custom_config = self._dns_config['projects'].get(project_id) + custom_config = self._net_config['projects'].get(project_id) if custom_config: LOG.debug("setting custom settings for net %s, " "project %s matches: %s", network_dict['id'], project_id, custom_config ) else: - # try to match openstack domain name prefixes. + # now try to match openstack domain name prefixes. custom_config = self._find_domain_settings(network_dict) if not custom_config: @@ -247,12 +259,16 @@ def add_dnssettings_to_net(self, network_dict): network_dict['dns_custom_upstreams'] = ( custom_config.dns_custom_upstreams) + if custom_config.ntp_servers: + network_dict['ntp_servers'] = ( + custom_config.ntp_servers) + def _find_domain_settings(self, network_dict: dict) -> ( CustomNetworkSettings | None): - """lookup domain-specific DNS settings if they exist. + """lookup domain-specific Network settings (e.g., DNS) if they exist. """ - if not self._dns_config: + if not self._net_config: return None # try to retrieve the OpenStack domain name via the project id, @@ -270,8 +286,9 @@ def _find_domain_settings(self, network_dict: dict) -> ( # TODO(mutax): I do want to get the stack trace logged, but I # also want to get a nice warning to the log independent of the # source of the error - but now we log the same error twice. - LOG.exception('Failed to retrieve domain to set custom dns for' - ' project %s of network %s - %s: %s', + LOG.exception('Failed to get OpenStack domain of project %s ' + 'while checking for custom settings for network %s -' + '%s: %s', project_id, network_dict['id'], type(e), e ) @@ -289,12 +306,12 @@ def _find_domain_settings(self, network_dict: dict) -> ( # i.e. abc- can provide settings for all domains starting with abc, # while at the same time abc-123 can be used to match a specific one - for domain_prefix in sorted(self._dns_config['domains'].keys(), + for domain_prefix in sorted(self._net_config['domains'].keys(), key=len, reverse=True): if domain_name.startswith(domain_prefix): - custom_config = self._dns_config['domains'][domain_prefix] + custom_config = self._net_config['domains'][domain_prefix] LOG.debug("setting custom settings for net %s, " "domain %s matches prefix %s: %s", @@ -596,7 +613,7 @@ def get_network_info(self, context, **kwargs): 'hosts': segment.hosts} for segment in network.segments] if self._config_lookup: - self._config_lookup.add_dnssettings_to_net(network_dict) + self._config_lookup.add_custom_settings_to_net(network_dict) return network_dict diff --git a/neutron/conf/agent/dhcp.py b/neutron/conf/agent/dhcp.py index 785eac90f5c..6e60454219d 100644 --- a/neutron/conf/agent/dhcp.py +++ b/neutron/conf/agent/dhcp.py @@ -112,6 +112,10 @@ default=[], help=_('Comma-separated list of the DNS servers which will be ' 'used as forwarders.')), + cfg.ListOpt('dnsmasq_ntp_servers', + default=[], + help=_('Comma-separated list of NTP server IPs which will be ' + 'distributed via DHCP option 42.')), cfg.StrOpt('dnsmasq_base_log_dir', help=_("Base log dir for dnsmasq logging. " "The log contains DHCP and DNS log information and " diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index bc3f73fcfef..381c202efce 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -1754,6 +1754,74 @@ def test_spawn_cfg_dns_upstreams_do_override_config(self): ], network=network) + def test_spawn_cfg_ntp_servers_not_configured(self): + """ensure we still start up when no ntp servers are set in our + configuration and that no dhcp option is added. + """ + + network = FakeDualNetwork() + + self._test_spawn(['--conf-file=', + '--domain=openstacklocal', + ], + network=network) + + def test_spawn_cfg_ntp_servers_default_from_config(self): + self.conf.set_override('dnsmasq_ntp_servers', + ['192.0.2.3', '192.0.2.4']) + network = FakeDualNetwork() + + self._test_spawn(['--conf-file=', + '--dhcp-option=42,192.0.2.3,192.0.2.4', + '--domain=openstacklocal', + ], + network=network) + + def test_spawn_cfg_ntp_servers_override_config(self): + self.conf.set_override('dnsmasq_ntp_servers', + ['192.0.2.3', '192.0.2.4']) + network = FakeDualNetwork() + network.ntp_servers = ['192.0.2.1', '192.0.2.2'] + + self._test_spawn(['--conf-file=', + '--dhcp-option=42,192.0.2.1,192.0.2.2', + '--domain=openstacklocal', + ], + network=network) + + def test_spawn_cfg_ntp_servers_ignore_ipv6_config(self): + """ensure we skip IPv6 addresses for dhcp option 42 + from our default settings, + it only supports IPv4 addresses: + https://datatracker.ietf.org/doc/html/rfc2132#section-8.3 + """ + self.conf.set_override('dnsmasq_ntp_servers', + ['192.0.2.3', '::1']) + network = FakeDualNetwork() + + self._test_spawn(['--conf-file=', + '--dhcp-option=42,192.0.2.3', + '--domain=openstacklocal', + ], + network=network) + + def test_spawn_cfg_ntp_servers_ignore_ipv6_rpc(self): + """ensure we skip IPv6 addresses for dhcp option 42 + received via network config from the rpc server, + it only supports IPv4 addresses: + https://datatracker.ietf.org/doc/html/rfc2132#section-8.3 + """ + self.conf.set_override('dnsmasq_ntp_servers', + ['192.0.2.3', '::1']) + network = FakeDualNetwork() + network.ntp_servers = ['192.0.2.1', '::1'] + + self._test_spawn(['--conf-file=', + '--dhcp-option=42,192.0.2.1', + '--domain=openstacklocal', + ], + network=network) + @mock.patch.object(sanity_checks, 'dnsmasq_umbrella_supported', return_value=True) def test_spawn_cfg_enable_dnsmasq_log(self, _mock): diff --git a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py index 00ee92f6473..b01772de7bd 100644 --- a/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py +++ b/neutron/tests/unit/api/rpc/handlers/test_dhcp_rpc.py @@ -88,7 +88,7 @@ def test_network_dict_empty(self): mock_pathlib.assert_called_once() empty_dict = {} - cnc.add_dnssettings_to_net(empty_dict) + cnc.add_custom_settings_to_net(empty_dict) self.assertFalse(bool(empty_dict)) def test_ensure_config_not_read_if_not_enabled(self): @@ -149,7 +149,7 @@ def test_ensure_config_enabled_flag_ignored_by_configurator(self): mock_pathlib.return_value = b"matches:\n" cnc = CustomNetworkConfigurator() mock_pathlib.assert_called_once() - self.assertEqual({}, cnc._dns_config) + self.assertEqual({}, cnc._net_config) def test_ensure_config_enabled_requires_valid_configfile(self): """Ensure that if the feature is enabled, we require a valid @@ -206,7 +206,7 @@ def test_yaml_config_parser(self): mock_pathlib: mock_pathlib.return_value = no_config cnc = CustomNetworkConfigurator() - self.assertEqual({}, cnc._dns_config) + self.assertEqual({}, cnc._net_config) def test_yaml_config_loader(self): """Test if the yaml config is converted to the expected internal @@ -225,6 +225,9 @@ def test_yaml_config_loader(self): upstream_dns_servers: - 192.0.2.10 - 192.0.2.20 + ntp_servers: + - 192.0.2.100 + - 192.0.2.101 - domain_name_prefixes: - ext-abcd - ext- @@ -232,6 +235,12 @@ def test_yaml_config_loader(self): upstream_dns_servers: - 192.0.2.30 - 192.0.2.40 + ntp_servers: + - 192.0.2.102 + - 192.0.2.103 + - domain_name_prefixes: + - no-opts + ednslogging: True """ cnc = self._get_cnc_from_yaml_config(configdata=example_config) @@ -239,22 +248,34 @@ def test_yaml_config_loader(self): # CustomNetworkSettings will convert the list of IPs to a set # so we can compare them with ease below. config_1 = CustomNetworkSettings( - False, {'192.0.2.10', '192.0.2.20'}) + dns_ednslogging_enabled=False, + dns_custom_upstreams={'192.0.2.10', '192.0.2.20'}, + ntp_servers={'192.0.2.100', '192.0.2.101'}, + ) config_2 = CustomNetworkSettings( - True, {'192.0.2.30', '192.0.2.40'}) + dns_ednslogging_enabled=True, + dns_custom_upstreams={'192.0.2.30', '192.0.2.40'}, + ntp_servers={'192.0.2.102', '192.0.2.103'} + ) + config_3 = CustomNetworkSettings( + dns_ednslogging_enabled=True, + dns_custom_upstreams=None, + ntp_servers=None + ) example_config_expected = { 'domains': {'ext-': config_2, 'ext-abc': config_1, 'ext-abcd': config_2, - 'ext-def': config_1 + 'ext-def': config_1, + 'no-opts': config_3 }, 'projects': {'0631d17744fe4a04b16494ae9056ae17': config_1, '5dc81c6355ff478188f8fda11a971c41': config_1 } } - self.assertEqual(example_config_expected, cnc._dns_config) + self.assertEqual(example_config_expected, cnc._net_config) @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") def test_no_match_no_change(self, mock_keystone): @@ -284,7 +305,7 @@ def test_no_match_no_change(self, mock_keystone): cnc = self._get_cnc_from_yaml_config(configdata=example_config) - cnc.add_dnssettings_to_net(mock_network) + cnc.add_custom_settings_to_net(mock_network) mock_keystone.get_project.assert_called_with('p-666') mock_keystone.get_domain.assert_called_with('d-42') @@ -316,7 +337,7 @@ def test_network_id_lookup(self, mock_keystone): cnc = self._get_cnc_from_yaml_config(configdata=example_config) - cnc.add_dnssettings_to_net(mock_network) + cnc.add_custom_settings_to_net(mock_network) mock_keystone.get_project.assert_called_with('p-666') mock_keystone.get_domain.assert_called_with('d-42') @@ -355,7 +376,7 @@ def test_nameserver_settings_applied(self, mock_keystone): cnc = self._get_cnc_from_yaml_config(configdata=example_config) - cnc.add_dnssettings_to_net(mock_network) + cnc.add_custom_settings_to_net(mock_network) self.assertFalse(mock_network.get('dns_ednslogging_enabled')) sentinel = object() upstreams = mock_network.get('dns_custom_upstreams', sentinel) @@ -365,6 +386,44 @@ def test_nameserver_settings_applied(self, mock_keystone): self.assertIn(dns2, upstreams) self.assertEqual(len(upstreams), 2) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") + def test_ntpserver_settings(self, mock_keystone): + """ensure the configured NTP server IPs are present in the network + dict returned + """ + + # we manipulate the network, so we need fresh mock objects + mock_network = {'id': 'net-123', 'project_id': 'p-666'} + mock_project = MockedDBObj(id='p-666', domain_id='d-42') + mock_domain = MockedDBObj(id='d-42', name='mydomain') + + mock_keystone.get_project.return_value = mock_project + mock_keystone.get_domain.return_value = mock_domain + + ntp1 = "2001:db8::456" + ntp2 = "192.0.2.123" + + example_config = b""" + matches: + - domain_name_prefixes: + - mydomain + ntp_servers: + - %s + - %s + ednslogging: False + """ % (ntp1.encode(), ntp2.encode()) + + cnc = self._get_cnc_from_yaml_config(configdata=example_config) + + cnc.add_custom_settings_to_net(mock_network) + sentinel = object() + upstreams = mock_network.get('ntp_servers', sentinel) + self.assertNotEqual(sentinel, upstreams) + self.assertIsNotNone(upstreams) + self.assertIn(ntp1, upstreams) + self.assertIn(ntp2, upstreams) + self.assertEqual(len(upstreams), 2) + @mock.patch.object(CustomNetworkConfigurator, "_keystone_connection") def test_longest_domain_prefix_wins(self, mock_keystone): """ensure we are doing a longest prefix match on the domain name, @@ -403,7 +462,7 @@ def test_longest_domain_prefix_wins(self, mock_keystone): cnc = self._get_cnc_from_yaml_config(configdata=example_config) - cnc.add_dnssettings_to_net(mock_network) + cnc.add_custom_settings_to_net(mock_network) self.assertFalse(mock_network.get('dns_ednslogging_enabled')) @@ -438,7 +497,7 @@ def test_project_lookup_exceptions_dont_prevent_netconf( cnc = self._get_cnc_from_yaml_config(configdata=example_config) - cnc.add_dnssettings_to_net(mock_network) + cnc.add_custom_settings_to_net(mock_network) upstreams = mock_network.get('dns_custom_upstreams') self.assertIsNone(upstreams) @@ -469,7 +528,7 @@ def test_domain_lookup_exceptions_do_not_prevent_netconfig( cnc = self._get_cnc_from_yaml_config(configdata=example_config) - cnc.add_dnssettings_to_net(mock_network) + cnc.add_custom_settings_to_net(mock_network) upstreams = mock_network.get('dns_custom_upstreams') self.assertIsNone(upstreams) @@ -494,8 +553,8 @@ def test_network_ednslogging_setting(self, mock_keystone): cnc = self._get_cnc_from_yaml_config(configdata=example_config) - cnc.add_dnssettings_to_net(mock_network_nologging) - cnc.add_dnssettings_to_net(mock_network_logging) + cnc.add_custom_settings_to_net(mock_network_nologging) + cnc.add_custom_settings_to_net(mock_network_logging) # assert we get the correct settings when no nameservers are set # but logging is configured accordingly From 2c619b555347959d7d20d24d766bf7a1bf5708f7 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 6 Aug 2026 16:59:59 +0200 Subject: [PATCH 184/184] Remove openstack-agent-checks from custom-requirements We no longer rely on openstack-agent-checks. Those checks were responsible for marking a network agent ready by asking the Neutron DB if all networks have been synced to the agent. We replaced this with a local check by not depending on any third-party tool, where the agent writes its status to a file on which we base the probe. --- custom-requirements.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom-requirements.txt b/custom-requirements.txt index f79890648eb..cf3b89fa7b8 100644 --- a/custom-requirements.txt +++ b/custom-requirements.txt @@ -7,9 +7,6 @@ dumb-init # sentry client git+https://github.com/sapcc/sentrylogger.git@main#egg=sapcc_sentrylogger -# agent checks for neutron -openstack-agent-checks - # uwsgi plugins uwsgi-dogstatsd uwsgi-shortmsecs