neutron/neutron/db/ipam_pluggable_backend.py
Maciej Józefczyk ab32d7ae64 [OVN] Allow IP allocation with different segments for OVN service ports
OVN creates localport [1] for each network that has metadata
and allocate IP address from subnet within this network that has
DHCP enabled. The traffic from this port will never go outside
the chassis.

While using multiple segments with subnet linked to each segment
OVN needs to create an allocation of IP address for each of those
subnets [2] in order to generate data for OVN NBDB IPv4 DHCP Options.

The change [3] started to validate that condition, while multiple
IP addresses from different segments are tried to be allocated on
one port. We can skip this for OVN Metadata port, because there
is no reason to prevent those kind of allocation for OVN.

For stable branches we decide if a port is distributed or not
by looking for DEVICE_OWNER_DHCP device owner  and `ovn` device_id,
instead DEVICE_OWNER_DISTRIBUTED device owner.

Conflicts:
   neutron/db/ipam_backend_mixin.py
   neutron/tests/unit/db/test_ipam_pluggable_backend.py

[1] http://www.openvswitch.org/support/dist-docs/ovn-architecture.7.html
[2] 5f42488a9a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py (L2279)
[3] https://review.opendev.org/#/c/709444/

Change-Id: Ib51cde89ed873f48db4daebc27a0980da9cc0f19
Closes-Bug: 1871608
(cherry picked from commit 8d1512afb0)
2020-07-27 11:49:18 +00:00

638 lines
29 KiB
Python

# Copyright (c) 2015 Infoblox Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import copy
import netaddr
from neutron_lib.api.definitions import portbindings
from neutron_lib import constants
from neutron_lib.db import api as db_api
from neutron_lib import exceptions as n_exc
from neutron_lib.objects import utils as obj_utils
from neutron_lib.plugins import constants as plugin_consts
from neutron_lib.plugins import directory
from oslo_db import exception as db_exc
from oslo_log import log as logging
from oslo_utils import excutils
from neutron.common import ipv6_utils
from neutron.db import ipam_backend_mixin
from neutron.ipam import driver
from neutron.ipam import exceptions as ipam_exc
from neutron.objects import ports as port_obj
from neutron.objects import subnet as obj_subnet
LOG = logging.getLogger(__name__)
def get_ip_update_not_allowed_device_owner_list():
l3plugin = directory.get_plugin(plugin_consts.L3)
# The following list is for IPAM to prevent direct update of port
# IP address. Currently it only has some L3 related types.
# L2 plugin can add the same list here, but for now it is not required.
return getattr(l3plugin, 'IP_UPDATE_NOT_ALLOWED_LIST', [])
def is_neutron_built_in_router(context, router_id):
l3plugin = directory.get_plugin(plugin_consts.L3)
return bool(l3plugin and
l3plugin.router_supports_scheduling(context, router_id))
class IpamPluggableBackend(ipam_backend_mixin.IpamBackendMixin):
def _get_failed_ips(self, all_ips, success_ips):
ips_list = (ip_dict['ip_address'] for ip_dict in success_ips)
return (ip_dict['ip_address'] for ip_dict in all_ips
if ip_dict['ip_address'] not in ips_list)
def _safe_rollback(self, func, *args, **kwargs):
"""Calls rollback actions and catch all exceptions.
All exceptions are catched and logged here to prevent rewriting
original exception that triggered rollback action.
"""
try:
func(*args, **kwargs)
except Exception as e:
LOG.warning("Revert failed with: %s", e)
def _ipam_deallocate_ips(self, context, ipam_driver, port, ips,
revert_on_fail=True):
"""Deallocate set of ips over IPAM.
If any single ip deallocation fails, tries to allocate deallocated
ip addresses with fixed ip request
"""
deallocated = []
try:
for ip in ips:
try:
ipam_subnet = ipam_driver.get_subnet(ip['subnet_id'])
ipam_subnet.deallocate(ip['ip_address'])
deallocated.append(ip)
except n_exc.SubnetNotFound:
LOG.debug("Subnet was not found on ip deallocation: %s",
ip)
except Exception:
with excutils.save_and_reraise_exception():
if not ipam_driver.needs_rollback():
return
LOG.debug("An exception occurred during IP deallocation.")
if revert_on_fail and deallocated:
LOG.debug("Reverting deallocation")
# In case of deadlock allocate fails with db error
# and rewrites original exception preventing db_retry
# wrappers from restarting entire api request.
self._safe_rollback(self._ipam_allocate_ips, context,
ipam_driver, port, deallocated,
revert_on_fail=False)
elif not revert_on_fail and ips:
addresses = ', '.join(self._get_failed_ips(ips,
deallocated))
LOG.error("IP deallocation failed on "
"external system for %s", addresses)
return deallocated
def _ipam_allocate_ips(self, context, ipam_driver, port, ips,
revert_on_fail=True):
"""Allocate set of ips over IPAM.
If any single ip allocation fails, tries to deallocate all
allocated ip addresses.
"""
allocated = []
factory = ipam_driver.get_address_request_factory()
# we need to start with entries that asked for a specific IP in case
# those IPs happen to be next in the line for allocation for ones that
# didn't ask for a specific IP
ips.sort(key=lambda x: 'ip_address' not in x)
try:
for ip in ips:
# By default IP info is dict, used to allocate single ip
# from single subnet.
# IP info can be list, used to allocate single ip from
# multiple subnets
ip_list = [ip] if isinstance(ip, dict) else ip
subnets = [ip_dict['subnet_id'] for ip_dict in ip_list]
try:
ip_request = factory.get_request(context, port, ip_list[0])
ipam_allocator = ipam_driver.get_allocator(subnets)
ip_address, subnet_id = ipam_allocator.allocate(ip_request)
except ipam_exc.IpAddressGenerationFailureAllSubnets:
raise n_exc.IpAddressGenerationFailure(
net_id=port['network_id'])
allocated.append({'ip_address': ip_address,
'subnet_id': subnet_id})
except Exception:
with excutils.save_and_reraise_exception():
if not ipam_driver.needs_rollback():
return
LOG.debug("An exception occurred during IP allocation.")
if revert_on_fail and allocated:
LOG.debug("Reverting allocation")
# In case of deadlock deallocation fails with db error
# and rewrites original exception preventing db_retry
# wrappers from restarting entire api request.
self._safe_rollback(self._ipam_deallocate_ips, context,
ipam_driver, port, allocated,
revert_on_fail=False)
elif not revert_on_fail and ips:
addresses = ', '.join(self._get_failed_ips(ips,
allocated))
LOG.error("IP allocation failed on "
"external system for %s", addresses)
return allocated
def _ipam_update_allocation_pools(self, context, ipam_driver, subnet):
factory = ipam_driver.get_subnet_request_factory()
subnet_request = factory.get_request(context, subnet, None)
ipam_driver.update_subnet(subnet_request)
def delete_subnet(self, context, subnet_id):
ipam_driver = driver.Pool.get_instance(None, context)
ipam_driver.remove_subnet(subnet_id)
def get_subnet(self, context, subnet_id):
ipam_driver = driver.Pool.get_instance(None, context)
return ipam_driver.get_subnet(subnet_id)
def allocate_ips_for_port_and_store(self, context, port, port_id):
# Make a copy of port dict to prevent changing
# incoming dict by adding 'id' to it.
# Deepcopy doesn't work correctly in this case, because copy of
# ATTR_NOT_SPECIFIED object happens. Address of copied object doesn't
# match original object, so 'is' check fails
# TODO(njohnston): Different behavior is required depending on whether
# a Port object is used or not; once conversion to OVO is complete only
# the first 'if' will be needed
if isinstance(port, port_obj.Port):
port_copy = {"port": self._make_port_dict(
port, process_extensions=False)}
elif 'port' in port:
port_copy = {'port': port['port'].copy()}
else:
port_copy = {'port': port.copy()}
port_copy['port']['id'] = port_id
network_id = port_copy['port']['network_id']
ips = []
try:
ips = self._allocate_ips_for_port(context, port_copy)
for ip in ips:
ip_address = ip['ip_address']
subnet_id = ip['subnet_id']
IpamPluggableBackend._store_ip_allocation(
context, ip_address, network_id,
subnet_id, port_id)
return ips
except Exception:
with excutils.save_and_reraise_exception():
if ips:
ipam_driver = driver.Pool.get_instance(None, context)
if not ipam_driver.needs_rollback():
return
LOG.debug("An exception occurred during port creation. "
"Reverting IP allocation")
self._safe_rollback(self._ipam_deallocate_ips, context,
ipam_driver, port_copy['port'], ips,
revert_on_fail=False)
def _allocate_ips_for_port(self, context, port):
"""Allocate IP addresses for the port. IPAM version.
If port['fixed_ips'] is set to 'ATTR_NOT_SPECIFIED', allocate IP
addresses for the port. If port['fixed_ips'] contains an IP address or
a subnet_id then allocate an IP address accordingly.
"""
p = port['port']
fixed_configured = p['fixed_ips'] is not constants.ATTR_NOT_SPECIFIED
fixed_ips = p['fixed_ips'] if fixed_configured else []
subnets = self._ipam_get_subnets(
context,
network_id=p['network_id'],
host=p.get(portbindings.HOST_ID),
service_type=p.get('device_owner'),
fixed_configured=fixed_configured,
fixed_ips=fixed_ips,
distributed_service=self._is_distributed_service(p))
v4, v6_stateful, v6_stateless = self._classify_subnets(
context, subnets)
if fixed_configured:
ips = self._test_fixed_ips_for_port(context,
p["network_id"],
p['fixed_ips'],
p['device_owner'],
subnets)
else:
ips = []
version_subnets = [v4, v6_stateful]
for subnets in version_subnets:
if subnets:
ips.append([{'subnet_id': s['id']}
for s in subnets])
ips.extend(self._get_auto_address_ips(v6_stateless, p))
ipam_driver = driver.Pool.get_instance(None, context)
return self._ipam_allocate_ips(context, ipam_driver, p, ips)
def _get_auto_address_ips(self, v6_stateless_subnets, port,
exclude_subnet_ids=None):
exclude_subnet_ids = exclude_subnet_ids or []
ips = []
is_router_port = (
port['device_owner'] in constants.ROUTER_INTERFACE_OWNERS_SNAT)
if not is_router_port:
for subnet in v6_stateless_subnets:
if subnet['id'] not in exclude_subnet_ids:
# IP addresses for IPv6 SLAAC and DHCPv6-stateless subnets
# are implicitly included.
ips.append({'subnet_id': subnet['id'],
'subnet_cidr': subnet['cidr'],
'eui64_address': True,
'mac': port['mac_address']})
return ips
def _test_fixed_ips_for_port(self, context, network_id, fixed_ips,
device_owner, subnets):
"""Test fixed IPs for port.
Check that configured subnets are valid prior to allocating any
IPs. Include the subnet_id in the result if only an IP address is
configured.
:raises: InvalidInput, IpAddressInUse, InvalidIpForNetwork,
InvalidIpForSubnet
"""
fixed_ip_list = []
for fixed in fixed_ips:
fixed['device_owner'] = device_owner
subnet = self._get_subnet_for_fixed_ip(context, fixed, subnets)
is_auto_addr_subnet = ipv6_utils.is_auto_address_subnet(subnet)
if ('ip_address' in fixed and
subnet['cidr'] != constants.PROVISIONAL_IPV6_PD_PREFIX):
if (is_auto_addr_subnet and device_owner not in
constants.ROUTER_INTERFACE_OWNERS):
raise ipam_exc.AllocationOnAutoAddressSubnet(
ip=fixed['ip_address'], subnet_id=subnet['id'])
fixed_ip_list.append({'subnet_id': subnet['id'],
'ip_address': fixed['ip_address']})
else:
# A scan for auto-address subnets on the network is done
# separately so that all such subnets (not just those
# listed explicitly here by subnet ID) are associated
# with the port.
if (device_owner in constants.ROUTER_INTERFACE_OWNERS_SNAT or
not is_auto_addr_subnet):
fixed_ip_list.append({'subnet_id': subnet['id']})
return fixed_ip_list
def _check_ip_changed_by_version(self, context, ip_list, version):
for ip in ip_list:
ip_address = ip.get('ip_address')
subnet_id = ip.get('subnet_id')
if ip_address:
ip_addr = netaddr.IPAddress(ip_address)
if ip_addr.version == version:
return True
elif subnet_id:
subnet = obj_subnet.Subnet.get_object(context, id=subnet_id)
if subnet and subnet.ip_version == version:
return True
return False
def _update_ips_for_port(self, context, port, host,
original_ips, new_ips, mac):
"""Add or remove IPs from the port. IPAM version"""
added = []
removed = []
changes = self._get_changed_ips_for_port(
context, original_ips, new_ips, port['device_owner'])
not_allowed_list = get_ip_update_not_allowed_device_owner_list()
if (port['device_owner'] in not_allowed_list and
is_neutron_built_in_router(context, port['device_id'])):
ip_v4_changed = self._check_ip_changed_by_version(
context, changes.remove + changes.add,
constants.IP_VERSION_4)
if ip_v4_changed:
raise ipam_exc.IPAddressChangeNotAllowed(port_id=port['id'])
try:
subnets = self._ipam_get_subnets(
context, network_id=port['network_id'], host=host,
service_type=port.get('device_owner'), fixed_configured=True,
fixed_ips=changes.add + changes.original,
distributed_service=self._is_distributed_service(port))
except ipam_exc.DeferIpam:
subnets = []
# Check if the IP's to add are OK
to_add = self._test_fixed_ips_for_port(
context, port['network_id'], changes.add,
port['device_owner'], subnets)
if port['device_owner'] not in constants.ROUTER_INTERFACE_OWNERS:
to_add += self._update_ips_for_pd_subnet(
context, subnets, changes.add, mac)
ipam_driver = driver.Pool.get_instance(None, context)
if changes.remove:
removed = self._ipam_deallocate_ips(context, ipam_driver, port,
changes.remove)
v6_stateless = self._classify_subnets(
context, subnets)[2]
handled_subnet_ids = [ip['subnet_id'] for ip in
to_add + changes.original + changes.remove]
to_add.extend(self._get_auto_address_ips(
v6_stateless, port, handled_subnet_ids))
if to_add:
added = self._ipam_allocate_ips(context, ipam_driver,
port, to_add)
return self.Changes(add=added,
original=changes.original,
remove=removed)
@db_api.CONTEXT_WRITER
def save_allocation_pools(self, context, subnet, allocation_pools):
for pool in allocation_pools:
first_ip = str(netaddr.IPAddress(pool.first, pool.version))
last_ip = str(netaddr.IPAddress(pool.last, pool.version))
obj_subnet.IPAllocationPool(
context, subnet_id=subnet['id'], start=first_ip,
end=last_ip).create()
def update_port_with_ips(self, context, host, db_port, new_port, new_mac):
changes = self.Changes(add=[], original=[], remove=[])
auto_assign_subnets = []
if new_mac:
original = self._make_port_dict(db_port, process_extensions=False)
if original.get('mac_address') != new_mac:
original_ips = original.get('fixed_ips', [])
# NOTE(hjensas): Only set the default for 'fixed_ips' in
# new_port if the original port or new_port actually have IPs.
# Setting the default to [] breaks deferred IP allocation.
# See Bug: https://bugs.launchpad.net/neutron/+bug/1811905
if original_ips or new_port.get('fixed_ips'):
new_ips = new_port.setdefault('fixed_ips', original_ips)
new_ips_subnets = [new_ip['subnet_id']
for new_ip in new_ips]
for orig_ip in original_ips:
if ipv6_utils.is_eui64_address(orig_ip.get('ip_address')):
subnet_to_delete = {}
subnet_to_delete['subnet_id'] = orig_ip['subnet_id']
subnet_to_delete['delete_subnet'] = True
auto_assign_subnets.append(subnet_to_delete)
try:
i = new_ips_subnets.index(orig_ip['subnet_id'])
new_ips[i] = subnet_to_delete
except ValueError:
new_ips.append(subnet_to_delete)
if 'fixed_ips' in new_port:
original = self._make_port_dict(db_port,
process_extensions=False)
changes = self._update_ips_for_port(context,
db_port,
host,
original["fixed_ips"],
new_port['fixed_ips'],
new_mac)
try:
# Expire the fixed_ips of db_port in current transaction, because
# it will be changed in the following operation and the latest
# data is expected.
context.session.expire(db_port, ['fixed_ips'])
# Check if the IPs need to be updated
network_id = db_port['network_id']
for ip in changes.remove:
self._delete_ip_allocation(context, network_id,
ip['subnet_id'], ip['ip_address'])
for ip in changes.add:
self._store_ip_allocation(
context, ip['ip_address'], network_id,
ip['subnet_id'], db_port.id)
self._update_db_port(context, db_port, new_port, network_id,
new_mac)
if auto_assign_subnets:
port_copy = copy.deepcopy(original)
port_copy.update(new_port)
port_copy['fixed_ips'] = auto_assign_subnets
self.allocate_ips_for_port_and_store(
context, {'port': port_copy}, port_copy['id'])
getattr(db_port, 'fixed_ips') # refresh relationship before return
except Exception:
with excutils.save_and_reraise_exception():
if 'fixed_ips' in new_port:
ipam_driver = driver.Pool.get_instance(None, context)
if not ipam_driver.needs_rollback():
return
LOG.debug("An exception occurred during port update.")
if changes.add:
LOG.debug("Reverting IP allocation.")
self._safe_rollback(self._ipam_deallocate_ips,
context,
ipam_driver,
db_port,
changes.add,
revert_on_fail=False)
if changes.remove:
LOG.debug("Reverting IP deallocation.")
self._safe_rollback(self._ipam_allocate_ips,
context,
ipam_driver,
db_port,
changes.remove,
revert_on_fail=False)
return changes
def delete_port(self, context, id):
# Get fixed_ips list before port deletion
port = self._get_port(context, id)
ipam_driver = driver.Pool.get_instance(None, context)
super(IpamPluggableBackend, self).delete_port(context, id)
# Deallocating ips via IPAM after port is deleted locally.
# So no need to do rollback actions on remote server
# in case of fail to delete port locally
self._ipam_deallocate_ips(context, ipam_driver, port,
port['fixed_ips'])
def update_db_subnet(self, context, id, s, old_pools):
subnet = obj_subnet.Subnet.get_object(context, id=id)
old_segment_id = subnet.segment_id if subnet else None
if 'segment_id' in s:
self._validate_segment(
context, s['network_id'], s['segment_id'], action='update',
old_segment_id=old_segment_id)
# 'allocation_pools' is removed from 's' in
# _update_subnet_allocation_pools (ipam_backend_mixin),
# so create unchanged copy for ipam driver
subnet_copy = copy.deepcopy(s)
subnet, changes = super(IpamPluggableBackend, self).update_db_subnet(
context, id, s, old_pools)
ipam_driver = driver.Pool.get_instance(None, context)
# Set old allocation pools if no new pools are provided by user.
# Passing old pools allows to call ipam driver on each subnet update
# even if allocation pools are not changed. So custom ipam drivers
# are able to track other fields changes on subnet update.
if 'allocation_pools' not in subnet_copy:
subnet_copy['allocation_pools'] = old_pools
self._ipam_update_allocation_pools(context, ipam_driver, subnet_copy)
return subnet, changes
def add_auto_addrs_on_network_ports(self, context, subnet, ipam_subnet):
"""For an auto-address subnet, add addrs for ports on the net."""
# TODO(ataraday): switched for writer when flush_on_subtransaction
# will be available for neutron
with context.session.begin(subtransactions=True):
network_id = subnet['network_id']
ports = port_obj.Port.get_objects(
context, network_id=network_id,
device_owner=obj_utils.NotIn(
constants.ROUTER_INTERFACE_OWNERS_SNAT))
updated_ports = []
ipam_driver = driver.Pool.get_instance(None, context)
factory = ipam_driver.get_address_request_factory()
for port in ports:
# Find candidate subnets based on host_id and existing
# fixed_ips. This will filter subnets on other segments. Only
# allocate if this subnet is a valid candidate.
p = self._make_port_dict(port)
fixed_configured = (p['fixed_ips'] is not
constants.ATTR_NOT_SPECIFIED)
subnet_candidates = obj_subnet.Subnet.find_candidate_subnets(
context,
network_id,
p.get(portbindings.HOST_ID),
p.get('device_owner'),
fixed_configured,
p.get('fixed_ips'))
if subnet['id'] not in [s['id'] for s in subnet_candidates]:
continue
ip = {'subnet_id': subnet['id'],
'subnet_cidr': subnet['cidr'],
'eui64_address': True,
'mac': port.mac_address}
ip_request = factory.get_request(context, port, ip)
try:
ip_address = ipam_subnet.allocate(ip_request)
allocated = port_obj.IPAllocation(
context, network_id=network_id, port_id=port.id,
ip_address=ip_address, subnet_id=subnet['id'])
# Do the insertion of each IP allocation entry within
# the context of a nested transaction, so that the entry
# is rolled back independently of other entries whenever
# the corresponding port has been deleted; since OVO
# already opens a nested transaction, we don't need to do
# it explicitly here.
allocated.create()
updated_ports.append(port.id)
except db_exc.DBReferenceError:
LOG.debug("Port %s was deleted while updating it with an "
"IPv6 auto-address. Ignoring.", port.id)
LOG.debug("Reverting IP allocation for %s", ip_address)
# Do not fail if reverting allocation was unsuccessful
try:
ipam_subnet.deallocate(ip_address)
except Exception:
LOG.debug("Reverting IP allocation failed for %s",
ip_address)
except ipam_exc.IpAddressAlreadyAllocated:
LOG.debug("Port %s got IPv6 auto-address in a concurrent "
"create or update port request. Ignoring.",
port.id)
return updated_ports
def allocate_subnet(self, context, network, subnet, subnetpool_id):
subnetpool = None
if subnetpool_id and not subnetpool_id == constants.IPV6_PD_POOL_ID:
subnetpool = self._get_subnetpool(context, id=subnetpool_id)
self._validate_ip_version_with_subnetpool(subnet, subnetpool)
# gateway_ip and allocation pools should be validated or generated
# only for specific request
if subnet['cidr'] is not constants.ATTR_NOT_SPECIFIED:
subnet['gateway_ip'] = self._gateway_ip_str(subnet,
subnet['cidr'])
subnet['allocation_pools'] = self._prepare_allocation_pools(
subnet['allocation_pools'],
subnet['cidr'],
subnet['gateway_ip'])
ipam_driver = driver.Pool.get_instance(subnetpool, context)
subnet_factory = ipam_driver.get_subnet_request_factory()
subnet_request = subnet_factory.get_request(context, subnet,
subnetpool)
ipam_subnet = ipam_driver.allocate_subnet(subnet_request)
# get updated details with actually allocated subnet
subnet_request = ipam_subnet.get_details()
try:
subnet = self._save_subnet(context,
network,
self._make_subnet_args(
subnet_request,
subnet,
subnetpool_id),
subnet['dns_nameservers'],
subnet['host_routes'],
subnet_request)
obj_subnet.NetworkSubnetLock.lock_subnet(context, network.id,
subnet.id)
except Exception:
# Note(pbondar): Third-party ipam servers can't rely
# on transaction rollback, so explicit rollback call needed.
# IPAM part rolled back in exception handling
# and subnet part is rolled back by transaction rollback.
with excutils.save_and_reraise_exception():
if not ipam_driver.needs_rollback():
return
LOG.debug("An exception occurred during subnet creation. "
"Reverting subnet allocation.")
self._safe_rollback(self.delete_subnet,
context,
subnet_request.subnet_id)
return subnet, ipam_subnet