From a221764751de05e42069f1c097b1025bd9c4fc52 Mon Sep 17 00:00:00 2001 From: Dmitrii Shcherbakov Date: Mon, 13 Feb 2023 18:30:15 +0300 Subject: [PATCH] Allow Multiple External Gateways * Add a new API for adding/updating/removing multiple gateway ports on routers; * Implement the necessary backend changes. Partial-Bug: #2002687 Depends-On: I2618475636b2bb9bfd743a62f5d4859d4f68a547 Change-Id: Id885565e88f6f1898ca5cfac709a24dd62605d1a --- neutron/db/l3_db.py | 25 +- neutron/db/l3_extra_gws_db.py | 565 +++++++++++++++ neutron/extensions/l3_extra_gws.py | 22 + neutron/services/ovn_l3/plugin.py | 2 + .../tests/contrib/hooks/api_all_extensions | 1 + neutron/tests/unit/db/test_l3_extra_gws_db.py | 671 ++++++++++++++++++ ...3-ext-gw-multihoming-99be481ddeaa3a6d.yaml | 6 + 7 files changed, 1281 insertions(+), 11 deletions(-) create mode 100644 neutron/db/l3_extra_gws_db.py create mode 100644 neutron/extensions/l3_extra_gws.py create mode 100644 neutron/tests/unit/db/test_l3_extra_gws_db.py create mode 100644 releasenotes/notes/l3-ext-gw-multihoming-99be481ddeaa3a6d.yaml diff --git a/neutron/db/l3_db.py b/neutron/db/l3_db.py index dd1e8ee28b9..606e0aa24cc 100644 --- a/neutron/db/l3_db.py +++ b/neutron/db/l3_db.py @@ -341,7 +341,8 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase, states=(original, updated))) return updated - def _create_router_gw_port(self, context, router, network_id, ext_ips): + def _create_router_gw_port(self, context, router, network_id, ext_ips, + update_gw_port=True): # Port has no 'tenant-id', as it is hidden from user port_data = {'tenant_id': '', # intentionally not set 'network_id': network_id, @@ -367,8 +368,9 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase, self._core_plugin, context.elevated(), gw_port['id']): with db_api.CONTEXT_WRITER.using(context): router = self._get_router(context, router['id']) - router.gw_port = self._core_plugin._get_port( - context.elevated(), gw_port['id']) + if update_gw_port: + router.gw_port = self._core_plugin._get_port( + context.elevated(), gw_port['id']) router_port = l3_obj.RouterPort( context, router_id=router.id, @@ -505,9 +507,11 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase, # raise the underlying exception raise e.errors[0].error - self._check_for_dup_router_subnets(context, router, - new_network_id, - subnets) + self._check_for_dup_router_subnets( + context, router, + subnets, + constants.DEVICE_OWNER_ROUTER_GW + ) self._create_router_gw_port(context, router, new_network_id, ext_ips) @@ -669,7 +673,7 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase, query_field=l3_models.Router.id.key) def _check_for_dup_router_subnets(self, context, router, - network_id, new_subnets): + new_subnets, new_device_owner): # It's possible these ports are on the same network, but # different subnets. new_subnet_ids = {s['id'] for s in new_subnets} @@ -789,8 +793,7 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase, if subnets: self._check_for_dup_router_subnets(context, router, - port['network_id'], - subnets) + subnets, port['device_owner']) # Keep the restriction against multiple IPv4 subnets if len([s for s in subnets if s['ip_version'] == 4]) > 1: @@ -896,8 +899,8 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase, % subnet_id) raise n_exc.BadRequest(resource='router', msg=msg) self._validate_subnet_address_mode(subnet) - self._check_for_dup_router_subnets(context, router, - subnet['network_id'], [subnet]) + self._check_for_dup_router_subnets(context, router, [subnet], + constants.DEVICE_OWNER_ROUTER_INTF) fixed_ip = {'ip_address': subnet['gateway_ip'], 'subnet_id': subnet['id']} diff --git a/neutron/db/l3_extra_gws_db.py b/neutron/db/l3_extra_gws_db.py new file mode 100644 index 00000000000..de24324b562 --- /dev/null +++ b/neutron/db/l3_extra_gws_db.py @@ -0,0 +1,565 @@ +# Copyright (c) 2023 Canonical Ltd. +# +# 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 copy + +import netaddr + +from neutron._i18n import _ +from neutron.db import l3_db +from neutron.db import l3_gwmode_db +from neutron.objects import ports as port_obj +from neutron.objects import router as l3_obj +from neutron_lib.api.definitions import l3 as l3_apidef +from neutron_lib.api.definitions import l3_ext_gw_multihoming +from neutron_lib.api import extensions +from neutron_lib.callbacks import events +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources +from neutron_lib import constants +from neutron_lib.db import api as db_api +from neutron_lib.db import resource_extend +from neutron_lib.exceptions import l3 as l3_exc +from neutron_lib.exceptions import l3_ext_gw_multihoming as mh_exc +from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory + + +def format_gateway_info(gw_port): + return { + 'network_id': gw_port.network_id, + 'external_fixed_ips': [{ + 'ip_address': str(alloc.ip_address), + 'subnet_id': alloc.subnet_id, + } for alloc in gw_port.fixed_ips] + } + + +@resource_extend.has_resource_extenders +class ExtraGatewaysDbOnlyMixin(l3_gwmode_db.L3_NAT_dbonly_mixin): + """A mixin class to expose a router's extra external gateways.""" + + @staticmethod + @resource_extend.extends([l3_apidef.ROUTERS]) + def _extend_router_dict_extra_gateways(router_res, router_db): + l3_plugin = directory.get_plugin(plugin_constants.L3) + if not extensions.is_extension_supported( + l3_plugin, l3_ext_gw_multihoming.ALIAS): + return + + external_gateways = [] + for gw_port in [ + rp.port + for rp in router_db.attached_ports + if rp.port.device_owner == constants.DEVICE_OWNER_ROUTER_GW]: + if gw_port.id == router_db.gw_port_id: + external_gateways.insert(0, format_gateway_info(gw_port)) + else: + external_gateways.append(format_gateway_info(gw_port)) + + router_res[l3_ext_gw_multihoming.EXTERNAL_GATEWAYS] = external_gateways + + @registry.receives(resources.ROUTER, [events.BEFORE_DELETE]) + def _delete_router_remove_external_gateways(self, resource, event, + trigger, payload): + self._remove_all_gateways(payload.context, payload.resource_id) + + def _add_external_gateways( + self, context, router_id, gw_info_list, payload): + """Add external gateways to a router.""" + added_gateways = [] + if not gw_info_list: + return added_gateways + + # If a router already has extra gateways specified then they need to + # be changed via the update API. + router_db = self._get_router(context, router_id) + + if any(rp.port.device_owner == constants.DEVICE_OWNER_ROUTER_GW + for rp in router_db.attached_ports): + # Matching for gateway ports with the same network_id and set of + # fixed_ips is not needed since an IP allocation would fail in this + # case. And if fixed IPs don't overlap or are not specified a new + # port will simply be created. + extra_gw_info = gw_info_list + else: + compat_gw_info = gw_info_list[0] + compat_payload = copy.deepcopy(payload) + compat_payload['router'].pop('external_gateways') + compat_payload['external_gateway_info'] = compat_gw_info + + # Update the first router gateway since we treat it in a special + # way for compatibility. + self._update_router_gw_info(context, router_id, compat_gw_info, + compat_payload) + added_gateways.append(compat_gw_info) + + extra_gw_info = gw_info_list[1:] + + # Go over extra gateway ports and add them to the router. + for gw_info in extra_gw_info: + # The ``_validate_gw_info`` and ``_create_extra_gw_port`` methods + # need an updated version of the router_db object, both as a + # result of the ``_update_router_gw_info`` call above, and as + # ports are added. + router_db = self._get_router(context, router_id) + + # Here we do not need to check for external gateway port IP changes + # as there are no ports yet. + ext_ips = gw_info.get('external_fixed_ips', []) + + network_id = self._validate_gw_info(context, gw_info, + ext_ips, router_db) + self._create_extra_gw_port(context, router_db, + network_id, ext_ips) + added_gateways.append(gw_info) + + return added_gateways + + def _create_extra_gw_port(self, context, router_db, new_network_id, + ext_ips): + with db_api.CONTEXT_READER.using(context): + # This function should only be used when we have a compat port id + # added using the compat API that expects one gateway only. + if not router_db.gw_port: + raise mh_exc.UnableToAddExtraGateways( + router_id=router_db.id, + reason=_('router does not have a compatibility gateway ' + 'port')) + + if not new_network_id: + return + + subnets = self._core_plugin.get_subnets_by_network(context, + new_network_id) + # TODO(dmitriis): publish an events.BEFORE_CREATE event for a new + # resource type e.g. resources.ROUTER_EXTRA_GATEWAY. Semantically + # this is a different resource from resources.ROUTER_GATEWAY. + self._check_for_dup_router_subnets( + context, router_db, + subnets, + constants.DEVICE_OWNER_ROUTER_GW + ) + self._create_router_gw_port(context, router_db, + new_network_id, ext_ips, + update_gw_port=False) + + # TODO(dmitriis): publish an events.AFTER_CREATE event for a new + # resource type e.g. resources.ROUTER_EXTRA_GATEWAY. Semantically + # this is a different resource from resources.ROUTER_GATEWAY. + + def _check_for_dup_router_subnets(self, context, router_db, + new_subnets, new_device_owner): + """Check for overlapping subnets on different networks. + + This method overrides the one in the base class so the logic will be + triggered for both the compatibility code that might alter the state + of a single gateway port in the presence of multiple gateway ports + (without an override it could result in overlap errors that are not + relevant with the code base supporting multiple gateway ports attached + to the same network). + + It is possible to have multiple gateway ports attached to the same + external network which will cause subnets of ports to overlap but will + not cause issues with routing. However, attaching multiple gateway + ports to different networks with overlapping subnet ranges will cause + routing issues. This function checks for that kind of overlap in + addition to the compatibility cases such as an overlap between + internal and external network subnets. This is done using the + device owner field of a port that is planned to be created by the + caller: specifically, based on that this argument the method can + tell if new subnets are meant to be associated with a gateway port + or an internal port. + + :param context: neutron API request context + :type context: neutron_lib.context.Context + :param router_db: The router db object to do a check for. + :type router: neutron.db.models.l3.Router + :param new_subnets: A list of new subnets to be added to the router + :type new_subnets: list[neutron.db.models_v2.Subnet] + :param new_device_owner: A device owner field for the port that is + going to be created with new subnets. + """ + router_subnets = [] + ext_subnets = set() + for p in (rp.port for rp in router_db.attached_ports): + for ip in p['fixed_ips']: + existing_port_owner = p.get('device_owner') + if existing_port_owner == constants.DEVICE_OWNER_ROUTER_GW: + ext_subts = self._core_plugin.get_subnets( + context.elevated(), + filters={'network_id': [p['network_id']]}) + for sub in ext_subts: + router_subnets.append(sub['id']) + ext_subnets.add(sub['id']) + else: + router_subnets.append(ip['subnet_id']) + if not router_subnets: + return + + # Ignore temporary Prefix Delegation CIDRs + new_subnets = [s for s in new_subnets + if s['cidr'] != constants.PROVISIONAL_IPV6_PD_PREFIX] + id_filter = {'id': router_subnets} + subnets = self._core_plugin.get_subnets(context.elevated(), + filters=id_filter) + for sub in subnets: + for new_s in new_subnets: + # Overlapping subnet ranges are a problem if there is an + # overlap between subnets on different external networks, + # between internal and external networks or internal networks + # (including the case where an attempt to add multiple internal + # ports on the same subnet is made for the same router). + if not (new_s['id'] in ext_subnets and + new_device_owner == constants.DEVICE_OWNER_ROUTER_GW): + self._raise_on_subnets_overlap(sub, new_s) + + def _match_requested_gateway_ports(self, context, router_id, + gw_info_list): + """Match indirect references to gateway ports to the actual ports. + + Returns 3 parameters: + + 1. A dictionary which maps matched gateway port ids to + external_gateway_info dictionaries as they were passed in + 2. A dict with partial matches on fixed ips + 3. A list of gateway info dictionaries for which there aren't any + existing gateway ports. + """ + matched_port_ids = {} + part_matched_port_ids = {} + nonexistent_port_info = [] + for gw_info in gw_info_list: + net_id = gw_info['network_id'] + # Find any gateways that might be attached to the same network. + gw_ports = port_obj.Port.get_ports_by_router_and_network( + context, router_id, constants.DEVICE_OWNER_ROUTER_GW, net_id) + + if not gw_ports: + nonexistent_port_info.append(gw_info) + continue + + if not gw_info.get('external_fixed_ips'): + # Allow for one case where external_fixed_ips are not specified + # in the request but there is only one gateway port attached to + # particular network on a router - there is no ambiguity about + # which port do we want to find in this case. + if len(gw_ports) == 1: + gw_port = gw_ports[0] + part_matched_port_ids[gw_port['id']] = gw_info + continue + # Matching to specific fixed IPs of gateway ports is done + # based on the parameters of a request, otherwise it would + # be unclear which one of the gateway ports to match to. + raise mh_exc.UnableToMatchGateways( + router_id=router_id, + reason=_( + 'multiple gateway ports are attached to the same ' + 'network %s but external_fixed_ips parameter ' + 'is not specified in the request') % net_id) + + for gw_port in gw_ports: + current_set = set([a.ip_address for a in gw_port['fixed_ips']]) + target_set = set([netaddr.IPAddress(d['ip_address']) + for d in gw_info['external_fixed_ips']]) + # If there is an intersection - it's a partial match. + if current_set & target_set: + part_matched_port_ids[gw_port['id']] = gw_info + # It can also be a full match. + if current_set == target_set: + matched_port_ids[gw_port['id']] = gw_info + break + else: + raise mh_exc.UnableToMatchGateways( + router_id=router_id, + reason=_('could not match a gateway port attached to ' + 'network %s based on the specified fixed IPs ' + '%s') % (net_id, + gw_info['external_fixed_ips'])) + return matched_port_ids, part_matched_port_ids, nonexistent_port_info + + def _replace_compat_gw_port(self, context, router_db, new_gw_port_id): + with db_api.CONTEXT_WRITER.using(context): + router_db['gw_port_id'] = new_gw_port_id + + def _remove_external_gateways(self, context, router_id, gw_info_list, + payload): + """Remove external gateways from a router.""" + removed_gateways = [] + if not gw_info_list: + return removed_gateways + + gw_ports = l3_obj.RouterPort.get_gw_port_ids_by_router_id(context, + router_id) + if not gw_ports: + raise mh_exc.UnableToRemoveGateways( + router_id=router_id, + reason=_('the router does not have any external gateways')) + + # The `_validate_gw_info` method takes a DB object. + router_db = self._get_router(context, router_id) + + # Go over extra gateways and validate the specified information. + for gw_info in gw_info_list: + ext_ips = gw_info.get( + 'external_fixed_ips', []) + self._validate_gw_info(context, gw_info, ext_ips, router_db) + + found_gw_port_ids, part_matches, nonexistent_port_info = ( + self._match_requested_gateway_ports(context, router_id, + gw_info_list)) + if nonexistent_port_info: + raise mh_exc.UnableToMatchGateways( + router_id=router_id, + reason=_('could not match gateway port IDs for gateway info ' + 'with networks %s') % ( + ', '.join(i['network_id'] + for i in nonexistent_port_info))) + + # If the compatibility gw_port_id is to be removed, do it after + # the removal of extra gateway ports but stash up some information. + compat_gw_port_info = part_matches.pop(router_db['gw_port_id']) + + # Actually remove extra gateways first. + for extra_gw_port_id in part_matches.keys(): + self._delete_extra_gw_port(context, router_id, extra_gw_port_id) + removed_gateways.append(part_matches[extra_gw_port_id]) + + # If the matched gateway port ID includes the compatibility one, handle + # its removal in a compatible way. + if compat_gw_port_info: + # Removal is done by making an empty update using the + # compatibility interface. This allows reusing pre-removal checks + # like the FIP presence check. + self._update_router_gw_info(context, router_id, {}, {}) + removed_gateways.append(compat_gw_port_info) + + # If there are any ports remaining besides the compatibility one + # and its removal was done, make sure the remaining port becomes + # the compatibility port. This is not atomic but the extra GW port + # should not be removed in the process. + gw_ports = l3_obj.RouterPort.get_gw_port_ids_by_router_id(context, + router_id) + if not router_db['gw_port_id'] and len(gw_ports) > 0: + new_gw_port_id = gw_ports[0] + new_network_id = port_obj.Port.get_object( + context, id=new_gw_port_id).network_id + # Replace the gw_port_id on the router object with an existing one. + self._replace_compat_gw_port(context, router_db, new_gw_port_id) + # Generate a compatibility payload. + synthetic_payload = copy.deepcopy(payload) + synthetic_payload['router'].pop('external_gateways') + # Here we only need a network_id because the fixed IPs are already + # assigned and do not need to be changed. + info = { + 'network_id': new_network_id + } + synthetic_payload['router']['external_gateway_info'] = info + # Finally update the compatibility gateway port. + self._update_router_gw_info( + context, router_id, info, synthetic_payload) + + return removed_gateways + + def _router_extra_gw_port_has_floating_ips(self, context, router_id, + gw_port): + return l3_obj.FloatingIP.count(context, **{ + 'router_id': [router_id], + 'floating_network_id': gw_port.network_id, + }) + + def _delete_extra_gw_port(self, context, router_id, gw_port_id): + admin_ctx = context.elevated() + gw_port = port_obj.Port.get_object(context, id=gw_port_id) + fip_count = self._router_extra_gw_port_has_floating_ips(context, + router_id, + gw_port) + if fip_count: + # Check that there are still other gateway ports attached to the + # same network, otherwise this gateway port cannot be deleted. + gw_ports = port_obj.Port.get_ports_by_router_and_network( + context, router_id, constants.DEVICE_OWNER_ROUTER_GW, + gw_port.network_id) + if len(gw_ports) < 2: + raise l3_exc.RouterExternalGatewayInUseByFloatingIp( + router_id=router_id, net_id=gw_port.network_id) + + # TODO(dmitriis): publish an events.BEFORE_DELETE event for a new + # resource type e.g. resources.ROUTER_EXTRA_GATEWAY. Semantically this + # is a different resource from resources.ROUTER_GATEWAY. + + if db_api.is_session_active(admin_ctx.session): + admin_ctx.GUARD_TRANSACTION = False + self._core_plugin.delete_port( + admin_ctx, gw_port_id, l3_port_check=False) + + # TODO(dmitriis): publish an events.AFTER_DELETE event for a new + # resource type e.g. resources.ROUTER_EXTRA_GATEWAY. Semantically this + # is a different resource from resources.ROUTER_GATEWAY. + + @db_api.retry_if_session_inactive() + def add_external_gateways(self, context, router_id, body): + gateways = body['router'].get('external_gateways', + constants.ATTR_NOT_SPECIFIED) + if gateways == constants.ATTR_NOT_SPECIFIED: + return self._get_router(context, router_id) + + external_gateways = self._add_external_gateways( + context, router_id, gateways, body) + + with db_api.CONTEXT_WRITER.using(context): + router = self.update_router( + context, router_id, { + 'router': { + 'external_gateways': external_gateways}}) + return {'router': router} + + @db_api.retry_if_session_inactive() + def remove_external_gateways(self, context, router_id, body): + gateways = body['router'].get('external_gateways', + constants.ATTR_NOT_SPECIFIED) + if gateways == constants.ATTR_NOT_SPECIFIED: + return self._get_router(context, router_id) + + external_gateways = self._remove_external_gateways( + context, router_id, gateways, body) + with db_api.CONTEXT_WRITER.using(context): + router = self.update_router( + context, + router_id, + {'router': + {'external_gateways': external_gateways}}) + return {'router': router} + + def _remove_all_gateways(self, context, router_id): + router_db = self._get_router(context, router_id) + compat_gw_port_id = router_db['gw_port_id'] + gw_ports = l3_obj.RouterPort.get_gw_port_ids_by_router_id(context, + router_id) + for gw_port_id in gw_ports: + if gw_port_id != compat_gw_port_id: + self._delete_extra_gw_port(context, router_id, gw_port_id) + if compat_gw_port_id: + # Remove the compatibility gw port using the compatibility API + self._update_router_gw_info(context, router_id, {}, {}, router_db) + + def _update_external_gateways(self, context, router_id, gw_info_list, + payload): + # An empty list means "remove all gateways". + if not gw_info_list: + self._remove_all_gateways(context, router_id) + return {} + + # The `_validate_gw_info` method takes a DB object. + router_db = self._get_router(context, router_id) + + # Go over extra gateways and validate the specified information. + for gw_info in gw_info_list: + ext_ips = gw_info.get( + 'external_fixed_ips', []) + self._validate_gw_info(context, gw_info, ext_ips, router_db) + + # Find a match for the first gateway in the list. + found_gw_port_ids, part_matches, nonexistent_port_info = ( + self._match_requested_gateway_ports(context, router_id, + gw_info_list[:1])) + # If there is already an existing extra gateway port matching what was + # requested in the update for the compatibility gw port, simply update + # the compatibility gw_port_id. + if part_matches: + # Replace the gw_port_id on the router object with an existing one. + self._replace_compat_gw_port(context, router_db, + list(part_matches.keys())[0]) + + # The first gw info dict is special as it designates a compat gw. So + # we simply try to make an update using the compatibility API. + self._update_router_gw_info(context, router_id, gw_info_list[0], {}) + + # Find a match for the rest of the gateway list. + found_gw_port_ids, part_matches, nonexistent_port_info = ( + self._match_requested_gateway_ports(context, router_id, + gw_info_list[1:])) + router = l3_obj.Router.get_object(context, id=router_id) + + # For partial matches, we need to update the set of fixed IPs for + # existing ports. + for gw_port_id, gw_info in part_matches.items(): + # There can be partial matches without any fixed IPs specified, + # So we check and skip those. + fixed_ips = gw_info.get('external_fixed_ips') + if not fixed_ips: + continue + self._core_plugin.update_port( + context.elevated(), + gw_port_id, + {'port': {'fixed_ips': fixed_ips}}) + + gw_ports = l3_obj.RouterPort.get_gw_port_ids_by_router_id(context, + router_id) + # Identify the set of ports to remove based on the ones that could not + # be matched based on the supplied external gateways in the request. + ports_to_remove = set(gw_ports).difference( + set(found_gw_port_ids.keys())).difference(set([router.gw_port_id])) + + for gw_port_id in ports_to_remove: + self._remove_external_gateways( + context, router_id, [v for k, v in found_gw_port_ids.items() + if k == gw_port_id], {}) + + if nonexistent_port_info: + synthetic_payload = { + 'router': { + 'external_gateways': nonexistent_port_info}} + + self._add_external_gateways(context, router_id, + nonexistent_port_info, + synthetic_payload) + return gw_info_list + + @db_api.retry_if_session_inactive() + def update_external_gateways(self, context, router_id, body): + gateways = body['router'].get('external_gateways', + constants.ATTR_NOT_SPECIFIED) + if gateways == constants.ATTR_NOT_SPECIFIED: + return self._get_router(context, router_id) + + external_gateways = self._update_external_gateways( + context, router_id, gateways, body) + + with db_api.CONTEXT_WRITER.using(context): + router = self.update_router( + context, + router_id, + {'router': + {'external_gateways': external_gateways}}) + return {'router': router} + + def _update_router_gw_info(self, context, router_id, + info, request_body, router=None): + router_db = super()._update_router_gw_info(context, router_id, info, + request_body, router) + # If a compatibility port got removed as a result of a router update + # (by passing empty info for external_gateway_info) replace it with + # one of the existing ones. + gw_ports = l3_obj.RouterPort.get_gw_port_ids_by_router_id(context, + router_id) + if gw_ports and not router_db['gw_port_id']: + new_gw_port_id = gw_ports[0] + self._replace_compat_gw_port(context, router_db, new_gw_port_id) + return router_db + + +class ExtraGatewaysMixinDbMixin(ExtraGatewaysDbOnlyMixin, + l3_db.L3_NAT_db_mixin): + pass diff --git a/neutron/extensions/l3_extra_gws.py b/neutron/extensions/l3_extra_gws.py new file mode 100644 index 00000000000..37cc0ebf735 --- /dev/null +++ b/neutron/extensions/l3_extra_gws.py @@ -0,0 +1,22 @@ +# Copyright 2023 Canonical Ltd. +# 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.api.definitions import l3_ext_gw_multihoming as apidef +from neutron_lib.api import extensions + + +class L3_extra_gws(extensions.APIExtensionDescriptor): + + api_definition = apidef diff --git a/neutron/services/ovn_l3/plugin.py b/neutron/services/ovn_l3/plugin.py index 7d370089a2e..0bc3aeeadb6 100644 --- a/neutron/services/ovn_l3/plugin.py +++ b/neutron/services/ovn_l3/plugin.py @@ -39,6 +39,7 @@ from neutron.common import utils as common_utils from neutron.db.availability_zone import router as router_az_db from neutron.db import dns_db from neutron.db import extraroute_db +from neutron.db import l3_extra_gws_db from neutron.db import l3_fip_pools_db from neutron.db import l3_fip_port_details from neutron.db import l3_fip_qos @@ -67,6 +68,7 @@ class OVNL3RouterPlugin(service_base.ServicePluginBase, l3_fip_qos.FloatingQoSDbMixin, l3_gateway_ip_qos.L3_gw_ip_qos_db_mixin, l3_fip_pools_db.FloatingIPPoolsMixin, + l3_extra_gws_db.ExtraGatewaysDbOnlyMixin, ): """Implementation of the OVN L3 Router Service Plugin. diff --git a/neutron/tests/contrib/hooks/api_all_extensions b/neutron/tests/contrib/hooks/api_all_extensions index cdf76e0a3c6..80e2cd38f30 100644 --- a/neutron/tests/contrib/hooks/api_all_extensions +++ b/neutron/tests/contrib/hooks/api_all_extensions @@ -15,6 +15,7 @@ NETWORK_API_EXTENSIONS+=",dns-integration" NETWORK_API_EXTENSIONS+=",dvr" NETWORK_API_EXTENSIONS+=",empty-string-filtering" NETWORK_API_EXTENSIONS+=",ext-gw-mode" +NETWORK_API_EXTENSIONS+=",external-gateway-multihoming" NETWORK_API_EXTENSIONS+=",external-net" NETWORK_API_EXTENSIONS+=",extra_dhcp_opt" NETWORK_API_EXTENSIONS+=",extraroute" diff --git a/neutron/tests/unit/db/test_l3_extra_gws_db.py b/neutron/tests/unit/db/test_l3_extra_gws_db.py new file mode 100644 index 00000000000..4133653c5ff --- /dev/null +++ b/neutron/tests/unit/db/test_l3_extra_gws_db.py @@ -0,0 +1,671 @@ +# Copyright (c) 2023 Canonical Ltd. +# 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 + +import copy + +import netaddr + +from neutron_lib.api.definitions import external_net as enet_apidef +from neutron_lib.api.definitions import l3 as l3_apidef +from neutron_lib.api.definitions import l3_ext_gw_mode +from neutron_lib.api.definitions import l3_ext_gw_multihoming +from neutron_lib import constants +from neutron_lib.db import api as db_api +from neutron_lib import exceptions +from neutron_lib.utils import net as net_utils +from oslo_utils import uuidutils + +from neutron.db import l3_extra_gws_db +from neutron.db.models import l3 as l3_models +from neutron.ipam import exceptions as ipam_exceptions +from neutron.objects import ipam as ipam_obj +from neutron.objects import network as net_obj +from neutron.objects import ports as port_obj +from neutron.objects import router as l3_obj +from neutron.objects import subnet as subnet_obj +from neutron.tests.unit.extensions import test_l3 +from neutron.tests.unit import testlib_api + +_uuid = uuidutils.generate_uuid + + +class TestDbIntPlugin(test_l3.TestL3NatIntPlugin, + l3_extra_gws_db.ExtraGatewaysMixinDbMixin): + + supported_extension_aliases = [enet_apidef.ALIAS, l3_apidef.ALIAS, + l3_ext_gw_mode.ALIAS, + l3_ext_gw_multihoming.ALIAS] + + +class TestExtraGatewaysDb(testlib_api.SqlTestCase): + + def setUp(self): + super().setUp() + plugin = __name__ + '.' + TestDbIntPlugin.__name__ + self.setup_coreplugin(plugin) + self.target_object = TestDbIntPlugin() + # Patch the context + ctx_patcher = mock.patch('neutron_lib.context', autospec=True) + mock_context = ctx_patcher.start() + self.context = mock_context.get_admin_context() + self.context.elevated.return_value = self.context + self.context.session = db_api.get_writer_session() + + # Create a simple setup with one external network and a subnet on it. + self.ext_net_a_id = _uuid() + self.ext_sub_a_id = _uuid() + self.tenant_id = _uuid() + + self.network_a = net_obj.Network( + self.context, + id=self.ext_net_a_id, + project_id=self.tenant_id, + admin_state_up=True, + status=constants.NET_STATUS_ACTIVE) + self.network_a.create() + self.net_ext_a = net_obj.ExternalNetwork( + self.context, network_id=self.ext_net_a_id) + self.net_ext_a.create() + self.ext_sub_a = subnet_obj.Subnet(self.context, + id=self.ext_sub_a_id, + project_id=self.tenant_id, + ip_version=constants.IP_VERSION_4, + cidr=net_utils.AuthenticIPNetwork('192.0.2.0/25'), + gateway_ip=netaddr.IPAddress('192.0.2.1'), + network_id=self.ext_net_a_id) + self.ext_sub_a.create() + + self.ext_net_b_id = _uuid() + self.ext_sub_b_id = _uuid() + self.network_b = net_obj.Network( + self.context, + id=self.ext_net_b_id, + project_id=self.tenant_id, + admin_state_up=True, + status=constants.NET_STATUS_ACTIVE) + self.network_b.create() + self.net_ext_b = net_obj.ExternalNetwork( + self.context, network_id=self.ext_net_b_id) + self.net_ext_b.create() + + self.ext_sub_b = subnet_obj.Subnet( + self.context, + id=self.ext_sub_b_id, + project_id=self.tenant_id, + ip_version=constants.IP_VERSION_4, + cidr=net_utils.AuthenticIPNetwork('192.0.2.128/25'), + gateway_ip=netaddr.IPAddress('192.0.2.129'), + network_id=self.ext_net_b_id) + self.ext_sub_b.create() + + self.ext_net_c_id = _uuid() + self.ext_sub_c_id = _uuid() + self.network_c = net_obj.Network( + self.context, + id=self.ext_net_c_id, + project_id=self.tenant_id, + admin_state_up=True, + status=constants.NET_STATUS_ACTIVE) + self.network_c.create() + self.net_ext_c = net_obj.ExternalNetwork( + self.context, network_id=self.ext_net_c_id) + self.net_ext_c.create() + + self.ext_sub_c = subnet_obj.Subnet( + self.context, + id=self.ext_sub_c_id, + project_id=self.tenant_id, + ip_version=constants.IP_VERSION_4, + # Overlaps with subnet A above on purpose for overlap testing. + cidr=net_utils.AuthenticIPNetwork('192.0.2.0/25'), + gateway_ip=netaddr.IPAddress('192.0.2.1'), + network_id=self.ext_net_c_id) + self.ext_sub_c.create() + + # Create an IPAM subnet for fixed ip allocations. + self.ipam_ext_subnet_a = ipam_obj.IpamSubnet( + self.context, + neutron_subnet_id=self.ext_sub_a_id) + self.ipam_ext_subnet_a.create() + + self.ipam_ext_subnet_b = ipam_obj.IpamSubnet( + self.context, + neutron_subnet_id=self.ext_sub_b_id) + self.ipam_ext_subnet_b.create() + + self.ipam_ext_subnet_c = ipam_obj.IpamSubnet( + self.context, + neutron_subnet_id=self.ext_sub_c_id) + self.ipam_ext_subnet_c.create() + + # Create an allocation pool that will use the IPAM subnet. + self.ipam_ext_pool_a = ipam_obj.IpamAllocationPool( + self.context, + id=_uuid(), + ipam_subnet_id=self.ipam_ext_subnet_a.id, + first_ip='192.0.2.3', + last_ip='192.0.2.126', + ) + self.ipam_ext_pool_a.create() + + self.ipam_ext_pool_b = ipam_obj.IpamAllocationPool( + self.context, + id=_uuid(), + ipam_subnet_id=self.ipam_ext_subnet_b.id, + first_ip='192.0.2.131', + last_ip='192.0.2.254', + ) + self.ipam_ext_pool_b.create() + + self.ipam_ext_pool_c = ipam_obj.IpamAllocationPool( + self.context, + id=_uuid(), + ipam_subnet_id=self.ipam_ext_subnet_c.id, + first_ip='192.0.2.3', + last_ip='192.0.2.126', + ) + self.ipam_ext_pool_c.create() + + # Create a router that will be modified during the tests. + self.router = l3_models.Router( + id=_uuid(), + name=None, + tenant_id=self.tenant_id, + admin_state_up=True, + status=constants.NET_STATUS_ACTIVE, + enable_snat=True, + gw_port_id=None) + + self.context.session.add(self.router) + self.context.session.expire_all() + self.context.session.commit() + + def test_add_external_gateways_trivial(self): + ext_gws = [] + body = { + "router": { + "external_gateways": ext_gws + } + } + # A trivial case with an empty list passed in. + result = self.target_object._add_external_gateways( + self.context, self.router.id, ext_gws, body) + self.assertEqual([], result) + + def test_add_external_gateways_single(self): + ext_gws = [{"network_id": self.ext_net_a_id}] + body = { + "router": { + "external_gateways": ext_gws + } + } + + result = self.target_object.add_external_gateways( + self.context, self.router.id, body) + + res_gw_a = result['router']['external_gateways'][0] + + self.assertEqual(res_gw_a['network_id'], self.ext_net_a_id) + self.assertIsNotNone(res_gw_a['external_fixed_ips']) + + new_router = self.target_object.get_router(self.context, + self.router.id) + new_gw_info = new_router['external_gateway_info'] + self.assertEqual(new_gw_info['network_id'], self.ext_net_a_id) + self.assertIsNotNone(new_gw_info['external_fixed_ips']) + + gw_ports = port_obj.Port.get_ports_by_router_and_network( + self.context, self.router.id, constants.DEVICE_OWNER_ROUTER_GW, + self.ext_net_a_id) + self.assertEqual(len(gw_ports), 1) + + gw_port = gw_ports[0] + self.assertEqual(new_router['gw_port_id'], gw_port['id']) + + def test_add_external_gateways_multiple(self): + ext_gws = [ + {"network_id": self.ext_net_a_id}, + {"network_id": self.ext_net_b_id}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + + result = self.target_object.add_external_gateways( + self.context, self.router.id, body) + + res_gw_a = result['router']['external_gateways'][0] + res_gw_b = result['router']['external_gateways'][1] + + self.assertEqual(res_gw_a['network_id'], self.ext_net_a_id) + self.assertEqual(res_gw_b['network_id'], self.ext_net_b_id) + self.assertIsNotNone(res_gw_a['external_fixed_ips']) + self.assertIsNotNone(res_gw_b['external_fixed_ips']) + + new_router = self.target_object.get_router(self.context, + self.router.id) + + new_gw_info = new_router['external_gateway_info'] + self.assertEqual(new_gw_info['network_id'], self.ext_net_a_id) + self.assertIsNotNone(new_gw_info['external_fixed_ips']) + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + + self.assertEqual(len(gw_ports), 2) + + # Now check that calling the ADD API multiple times succeeds. + ext_gws = [ + {"network_id": self.ext_net_b_id}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + result = self.target_object.add_external_gateways( + self.context, self.router.id, body) + + res_gw_a = result['router']['external_gateways'][0] + res_gw_b = result['router']['external_gateways'][1] + res_gw_c = result['router']['external_gateways'][2] + + self.assertEqual(res_gw_a['network_id'], self.ext_net_a_id) + self.assertEqual(res_gw_b['network_id'], self.ext_net_b_id) + self.assertEqual(res_gw_c['network_id'], self.ext_net_b_id) + self.assertIsNotNone(res_gw_a['external_fixed_ips']) + self.assertIsNotNone(res_gw_b['external_fixed_ips']) + self.assertIsNotNone(res_gw_c['external_fixed_ips']) + + new_router = self.target_object.get_router(self.context, + self.router.id) + + new_gw_info = new_router['external_gateway_info'] + self.assertEqual(new_gw_info['network_id'], self.ext_net_a_id) + self.assertIsNotNone(new_gw_info['external_fixed_ips']) + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + + self.assertEqual(len(gw_ports), 3) + + # Check that adding a gateway with already allocated fixed IPs fails. + ext_gws = [ + {"network_id": self.ext_net_b_id, + "external_fixed_ips": res_gw_c['external_fixed_ips']}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + self.assertRaises( + ipam_exceptions.IpAddressAlreadyAllocated, + self.target_object.add_external_gateways, self.context, + self.router.id, body + ) + + def test_remove_external_gateways_trivial(self): + ext_gws = [] + body = { + "router": { + "external_gateways": ext_gws + } + } + # A trivial case with an empty list passed in. + result = self.target_object.remove_external_gateways( + self.context, self.router.id, body) + self.assertIsNone(result['router']['external_gateway_info']) + + def test_remove_external_gateways_single(self): + ext_gws = [{"network_id": self.ext_net_a_id}] + body = { + "router": { + "external_gateways": ext_gws + } + } + self.target_object.add_external_gateways( + self.context, self.router.id, body) + self.assertIsNotNone(self.router.gw_port_id) + + result = self.target_object.remove_external_gateways( + self.context, self.router.id, body) + self.assertIsNone(self.router.gw_port_id) + self.assertIsNone(result['router']['external_gateway_info']) + + def test_remove_external_gateways_multiple(self): + ext_gws = [ + {"network_id": self.ext_net_a_id}, + {"network_id": self.ext_net_b_id}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + + self.target_object.add_external_gateways( + self.context, self.router.id, body) + self.assertIsNotNone(self.router.gw_port_id) + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + self.assertEqual(len(gw_ports), 2) + + result = self.target_object.remove_external_gateways( + self.context, self.router.id, body) + self.assertIsNone(self.router.gw_port_id) + self.assertIsNone(result['router']['external_gateway_info']) + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + self.assertEqual(len(gw_ports), 0) + + def test_remove_external_gateways_remove_compat(self): + '''Test removal of a compatibility gateway port using the new API. + + When removing a compatibility gateway port using the new API we need + to make sure that an existing extra gateway port takes it place instead + as a compatibility gateway port. + ''' + ext_gws = [ + {"network_id": self.ext_net_a_id}, + {"network_id": self.ext_net_b_id}, + ] + add_body = { + "router": { + "external_gateways": ext_gws + } + } + remove_body = { + "router": { + "external_gateways": ext_gws[:1] + } + } + + self.target_object.add_external_gateways( + self.context, self.router.id, add_body) + self.assertIsNotNone(self.router.gw_port_id) + + old_gw_port_id = self.router.gw_port_id + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + self.assertEqual(len(gw_ports), 2) + + self.target_object.remove_external_gateways( + self.context, self.router.id, remove_body) + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + self.assertEqual(len(gw_ports), 1) + + new_router = self.target_object.get_router(self.context, + self.router.id) + self.assertNotEqual(old_gw_port_id, new_router['gw_port_id']) + self.assertEqual(new_router['external_gateway_info']['network_id'], + self.ext_net_b_id) + + def test_update_external_gateways_add_pristine_and_remove(self): + '''Test the addition of external gateway ports using the update API.''' + ext_gws = [ + {"network_id": self.ext_net_a_id}, + {"network_id": self.ext_net_b_id}, + ] + add_body = { + "router": { + "external_gateways": ext_gws + } + } + + result = self.target_object.update_external_gateways( + self.context, self.router.id, add_body) + self.assertIsNotNone(self.router.gw_port_id) + + res_gw_a = result['router']['external_gateways'][0] + res_gw_b = result['router']['external_gateways'][1] + self.assertEqual(res_gw_a['network_id'], self.ext_net_a_id) + self.assertEqual(res_gw_b['network_id'], self.ext_net_b_id) + self.assertIsNotNone(res_gw_a['external_fixed_ips']) + self.assertIsNotNone(res_gw_b['external_fixed_ips']) + + new_router = self.target_object.get_router(self.context, + self.router.id) + + new_gw_info = new_router['external_gateway_info'] + self.assertEqual(new_gw_info['network_id'], self.ext_net_a_id) + self.assertIsNotNone(new_gw_info['external_fixed_ips']) + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + + self.assertEqual(len(gw_ports), 2) + + # Reorder gateways. + ext_gws = [ + {"network_id": self.ext_net_b_id}, + {"network_id": self.ext_net_a_id}, + ] + update_body = { + "router": { + "external_gateways": ext_gws + } + } + result = self.target_object.update_external_gateways( + self.context, self.router.id, update_body) + self.assertIsNotNone(self.router.gw_port_id) + + new_router = self.target_object.get_router(self.context, + self.router.id) + new_gw_info = new_router['external_gateway_info'] + self.assertEqual(new_gw_info['network_id'], self.ext_net_b_id) + self.assertIsNotNone(new_gw_info['external_fixed_ips']) + + # The compat gateway should now have a different network_id. + res_gw_b = result['router']['external_gateways'][0] + res_gw_a = result['router']['external_gateways'][1] + self.assertEqual(res_gw_b['network_id'], self.ext_net_b_id) + self.assertEqual(res_gw_a['network_id'], self.ext_net_a_id) + self.assertIsNotNone(res_gw_a['external_fixed_ips']) + self.assertIsNotNone(res_gw_b['external_fixed_ips']) + + # Remove one gateway. + update_body = { + "router": { + "external_gateways": ext_gws[1:] + } + } + + result = self.target_object.update_external_gateways( + self.context, self.router.id, update_body) + self.assertIsNotNone(self.router.gw_port_id) + + new_router = self.target_object.get_router(self.context, + self.router.id) + new_gw_info = new_router['external_gateway_info'] + self.assertEqual(new_gw_info['network_id'], self.ext_net_a_id) + self.assertIsNotNone(new_gw_info['external_fixed_ips']) + + res_gw_a = result['router']['external_gateways'][0] + self.assertEqual(res_gw_a['network_id'], self.ext_net_a_id) + self.assertIsNotNone(res_gw_a['external_fixed_ips']) + + # Clear all gateways. + update_body = { + "router": { + "external_gateways": {} + } + } + + result = self.target_object.update_external_gateways( + self.context, self.router.id, update_body) + self.assertIsNone(self.router.gw_port_id) + + def test_compat_remove_via_update(self): + '''Test the removal of a gateway port using the compat API. + + Removal of a compat gateway in the presence of an extra + gateway port should make that extra gateway port a compat + gateway port. + ''' + ext_gws = [ + {"network_id": self.ext_net_a_id}, + {"network_id": self.ext_net_b_id}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + + result = self.target_object.add_external_gateways( + self.context, self.router.id, body) + + update_body = { + "router": { + "external_gateway_info": {} + } + } + # Now perform an update with an empty gw info to remove the current + # compat gw port. + result = self.target_object.update_router(self.context, self.router.id, + update_body) + + # The existing extra gateway port should now take its place. + res_gw = result['external_gateways'][0] + self.assertEqual(res_gw['network_id'], self.ext_net_b_id) + self.assertIsNotNone(res_gw['external_fixed_ips']) + self.assertEqual(len(result['external_gateways']), 1) + + new_router = self.target_object.get_router(self.context, + self.router.id) + + new_gw_info = new_router['external_gateway_info'] + self.assertEqual(new_gw_info['network_id'], self.ext_net_b_id) + self.assertIsNotNone(new_gw_info['external_fixed_ips']) + + gw_ports = l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW}) + + self.assertEqual(len(gw_ports), 1) + + def test_update_fixed_ip(self): + '''Test updating a fixed IP of an existing port.''' + ext_gws = [ + {"network_id": self.ext_net_a_id}, + {"network_id": self.ext_net_b_id}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + + result = self.target_object.add_external_gateways( + self.context, self.router.id, body) + + gw_ports_initial = [o.port_id for o in l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW})] + + fips = copy.deepcopy( + result['router']['external_gateways'][1]['external_fixed_ips']) + # Append a fixed ip not used in the allocation pool. The existing + # one should be used to find an existing port. + fips.append({'ip_address': '192.0.2.130', + 'subnet_id': fips[0]['subnet_id']}) + expected_fips = copy.deepcopy(fips) + + update_body = { + "router": { + "external_gateways": [ + {"network_id": self.ext_net_a_id}, + # Use the new set of fixed IPs in the request. + {"network_id": self.ext_net_b_id, + "external_fixed_ips": fips}, + ]} + } + result = self.target_object.update_external_gateways( + self.context, self.router.id, + update_body) + + self.assertCountEqual( + result['router']['external_gateways'][1]['external_fixed_ips'], + expected_fips, + ) + + gw_ports_final = [o.port_id for o in l3_obj.RouterPort.get_objects( + self.context, + **{'router_id': self.router.id, + 'port_type': constants.DEVICE_OWNER_ROUTER_GW})] + + # Make sure the ports are not recreated in the process, i.e. port IDs + # stay the same. + self.assertCountEqual( + gw_ports_initial, + gw_ports_final, + ) + + def test_add_external_gateways_overlapping_subnets(self): + ext_gws = [ + {"network_id": self.ext_net_a_id}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + + self.target_object.add_external_gateways(self.context, self.router.id, + body) + + ext_gws = [ + {"network_id": self.ext_net_c_id}, + ] + body = { + "router": { + "external_gateways": ext_gws + } + } + + self.assertRaisesRegex( + exceptions.BadRequest, + 'Bad router request: Cidr 192.0.2.0/25 of subnet' + f' {self.ext_sub_c_id} overlaps with cidr 192.0.2.0/25 of ' + f'subnet {self.ext_sub_a_id}.', + self.target_object.add_external_gateways, self.context, + self.router.id, body + ) diff --git a/releasenotes/notes/l3-ext-gw-multihoming-99be481ddeaa3a6d.yaml b/releasenotes/notes/l3-ext-gw-multihoming-99be481ddeaa3a6d.yaml new file mode 100644 index 00000000000..d8674836064 --- /dev/null +++ b/releasenotes/notes/l3-ext-gw-multihoming-99be481ddeaa3a6d.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added support for the ``external-gateway-multihoming`` API extension. The + L3 service plugins supporting it can now create multiple gateway ports per + router. At the time of writing this is limited to the OVN L3 plugin.