Merge "Allow Multiple External Gateways"

This commit is contained in:
Zuul 2023-06-10 02:13:02 +00:00 committed by Gerrit Code Review
commit 6626fd9c9c
7 changed files with 1281 additions and 11 deletions

View File

@ -348,7 +348,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,
@ -374,8 +375,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,
@ -512,9 +514,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)
@ -676,7 +680,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}
@ -796,8 +800,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:
@ -903,8 +906,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']}

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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"

View File

@ -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
)

View File

@ -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.