Add Pluggable IPAM Backend Part 1

Add methods for allocating/deallocating ips using IPAM driver.
Methods are covered by unit tests and currently used only by them.

For pluggable IPAM case ipam driver may execute calls to third-party servers.
It means we can't rely on database transaction rollback in case of failure.
So if any bulk ip allocation/deallocation fails rollback should be done
on third-party servers as well.
Any completed ip allocation should be explicitly deallocated in case of
failure, and vise versa for failure on deallocation.
Try-except block is used to do manual rollback actions.
After rollback actions are done, exception is reraised and local db
transaction rollback occurs.

Pluggable IPAM was divided into two parts to keep review size small.
Following patches are expected to use these methods for ip address
allocation.

Partially-Implements: blueprint neutron-ipam

Change-Id: I8bb836c9883e189b065698ae0a862b2d909d5cbf
This commit is contained in:
Pavel Bondar 2015-07-06 18:36:22 +03:00
parent 310e1e0553
commit 45f3bb810f
4 changed files with 399 additions and 18 deletions

View File

@ -0,0 +1,129 @@
# 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.
from oslo_log import log as logging
from oslo_utils import excutils
from neutron.common import exceptions as n_exc
from neutron.db import ipam_backend_mixin
from neutron.i18n import _LE
from neutron.ipam import exceptions as ipam_exc
LOG = logging.getLogger(__name__)
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 _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():
LOG.debug("An exception occurred during IP deallocation.")
if revert_on_fail and deallocated:
LOG.debug("Reverting deallocation")
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(_LE("IP deallocation failed on "
"external system for %s"), addresses)
return deallocated
def _ipam_try_allocate_ip(self, context, ipam_driver, port, ip_dict):
factory = ipam_driver.get_address_request_factory()
ip_request = factory.get_request(context, port, ip_dict)
ipam_subnet = ipam_driver.get_subnet(ip_dict['subnet_id'])
return ipam_subnet.allocate(ip_request)
def _ipam_allocate_single_ip(self, context, ipam_driver, port, subnets):
"""Allocates single ip from set of subnets
Raises n_exc.IpAddressGenerationFailure if allocation failed for
all subnets.
"""
for subnet in subnets:
try:
return [self._ipam_try_allocate_ip(context, ipam_driver,
port, subnet),
subnet]
except ipam_exc.IpAddressGenerationFailure:
continue
raise n_exc.IpAddressGenerationFailure(
net_id=port['network_id'])
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 = []
# 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 (i.e. first successful ip allocation
# is returned)
ip_list = [ip] if isinstance(ip, dict) else ip
ip_address, ip_subnet = self._ipam_allocate_single_ip(
context, ipam_driver, port, ip_list)
allocated.append({'ip_address': ip_address,
'subnet_cidr': ip_subnet['subnet_cidr'],
'subnet_id': ip_subnet['subnet_id']})
except Exception:
with excutils.save_and_reraise_exception():
LOG.debug("An exception occurred during IP allocation.")
if revert_on_fail and allocated:
LOG.debug("Reverting allocation")
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(_LE("IP allocation failed on "
"external system for %s"), addresses)
return allocated

View File

@ -255,11 +255,22 @@ class AddressRequestFactory(object):
"""
@classmethod
def get_request(cls, context, port, ip):
if not ip:
return AnyAddressRequest()
def get_request(cls, context, port, ip_dict):
"""
:param context: context (not used here, but can be used in sub-classes)
:param port: port dict (not used here, but can be used in sub-classes)
:param ip_dict: dict that can contain 'ip_address', 'mac' and
'subnet_cidr' keys. Request to generate is selected depending on
this ip_dict keys.
:return: returns prepared AddressRequest (specific or any)
"""
if ip_dict.get('ip_address'):
return SpecificAddressRequest(ip_dict['ip_address'])
elif ip_dict.get('eui64_address'):
return AutomaticAddressRequest(prefix=ip_dict['subnet_cidr'],
mac=ip_dict['mac'])
else:
return SpecificAddressRequest(ip)
return AnyAddressRequest()
class SubnetRequestFactory(object):

View File

@ -0,0 +1,235 @@
# 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 mock
import netaddr
from oslo_utils import uuidutils
from neutron.common import exceptions as n_exc
from neutron.common import ipv6_utils
from neutron.db import ipam_pluggable_backend
from neutron.ipam import requests as ipam_req
from neutron.tests.unit.db import test_db_base_plugin_v2 as test_db_base
class TestDbBasePluginIpam(test_db_base.NeutronDbPluginV2TestCase):
def setUp(self):
super(TestDbBasePluginIpam, self).setUp()
self.tenant_id = uuidutils.generate_uuid()
self.subnet_id = uuidutils.generate_uuid()
def _prepare_mocks(self):
mocks = {
'driver': mock.Mock(),
'subnet': mock.Mock(),
'subnet_request': ipam_req.SpecificSubnetRequest(
self.tenant_id,
self.subnet_id,
'10.0.0.0/24',
'10.0.0.1',
[netaddr.IPRange('10.0.0.2', '10.0.0.254')]),
}
mocks['driver'].get_subnet.return_value = mocks['subnet']
mocks['driver'].allocate_subnet.return_value = mocks['subnet']
mocks['driver'].get_subnet_request_factory = (
ipam_req.SubnetRequestFactory)
mocks['driver'].get_address_request_factory = (
ipam_req.AddressRequestFactory)
mocks['subnet'].get_details.return_value = mocks['subnet_request']
return mocks
def _prepare_ipam(self):
mocks = self._prepare_mocks()
mocks['ipam'] = ipam_pluggable_backend.IpamPluggableBackend()
return mocks
def _get_allocate_mock(self, auto_ip='10.0.0.2',
fail_ip='127.0.0.1',
error_message='SomeError'):
def allocate_mock(request):
if type(request) == ipam_req.SpecificAddressRequest:
if request.address == netaddr.IPAddress(fail_ip):
raise n_exc.InvalidInput(error_message=error_message)
else:
return str(request.address)
else:
return auto_ip
return allocate_mock
def _validate_allocate_calls(self, expected_calls, mocks):
assert mocks['subnet'].allocate.called
actual_calls = mocks['subnet'].allocate.call_args_list
self.assertEqual(len(expected_calls), len(actual_calls))
i = 0
for call in expected_calls:
if call['ip_address']:
self.assertEqual(ipam_req.SpecificAddressRequest,
type(actual_calls[i][0][0]))
self.assertEqual(netaddr.IPAddress(call['ip_address']),
actual_calls[i][0][0].address)
else:
self.assertEqual(ipam_req.AnyAddressRequest,
type(actual_calls[i][0][0]))
i += 1
def _convert_to_ips(self, data):
ips = [{'ip_address': ip,
'subnet_id': data[ip][1],
'subnet_cidr': data[ip][0]} for ip in data]
return sorted(ips, key=lambda t: t['subnet_cidr'])
def _gen_subnet_id(self):
return uuidutils.generate_uuid()
def test_deallocate_single_ip(self):
mocks = self._prepare_ipam()
ip = '192.168.12.45'
data = {ip: ['192.168.12.0/24', self._gen_subnet_id()]}
ips = self._convert_to_ips(data)
mocks['ipam']._ipam_deallocate_ips(mock.ANY, mocks['driver'],
mock.ANY, ips)
mocks['driver'].get_subnet.assert_called_once_with(data[ip][1])
mocks['subnet'].deallocate.assert_called_once_with(ip)
def test_deallocate_multiple_ips(self):
mocks = self._prepare_ipam()
data = {'192.168.43.15': ['192.168.43.0/24', self._gen_subnet_id()],
'172.23.158.84': ['172.23.128.0/17', self._gen_subnet_id()],
'8.8.8.8': ['8.0.0.0/8', self._gen_subnet_id()]}
ips = self._convert_to_ips(data)
mocks['ipam']._ipam_deallocate_ips(mock.ANY, mocks['driver'],
mock.ANY, ips)
get_calls = [mock.call(data[ip][1]) for ip in data]
mocks['driver'].get_subnet.assert_has_calls(get_calls, any_order=True)
ip_calls = [mock.call(ip) for ip in data]
mocks['subnet'].deallocate.assert_has_calls(ip_calls, any_order=True)
def _single_ip_allocate_helper(self, mocks, ip, network, subnet):
ips = [{'subnet_cidr': network,
'subnet_id': subnet}]
if ip:
ips[0]['ip_address'] = ip
allocated_ips = mocks['ipam']._ipam_allocate_ips(
mock.ANY, mocks['driver'], mock.ANY, ips)
mocks['driver'].get_subnet.assert_called_once_with(subnet)
assert mocks['subnet'].allocate.called
request = mocks['subnet'].allocate.call_args[0][0]
return {'ips': allocated_ips,
'request': request}
def test_allocate_single_fixed_ip(self):
mocks = self._prepare_ipam()
ip = '192.168.15.123'
mocks['subnet'].allocate.return_value = ip
results = self._single_ip_allocate_helper(mocks,
ip,
'192.168.15.0/24',
self._gen_subnet_id())
self.assertEqual(ipam_req.SpecificAddressRequest,
type(results['request']))
self.assertEqual(netaddr.IPAddress(ip), results['request'].address)
self.assertEqual(ip, results['ips'][0]['ip_address'],
'Should allocate the same ip as passed')
def test_allocate_single_any_ip(self):
mocks = self._prepare_ipam()
network = '192.168.15.0/24'
ip = '192.168.15.83'
mocks['subnet'].allocate.return_value = ip
results = self._single_ip_allocate_helper(mocks, '', network,
self._gen_subnet_id())
self.assertEqual(ipam_req.AnyAddressRequest, type(results['request']))
self.assertEqual(ip, results['ips'][0]['ip_address'])
def test_allocate_eui64_ip(self):
mocks = self._prepare_ipam()
ip = {'subnet_id': self._gen_subnet_id(),
'subnet_cidr': '2001:470:abcd::/64',
'mac': '6c:62:6d:de:cf:49',
'eui64_address': True}
eui64_ip = ipv6_utils.get_ipv6_addr_by_EUI64(ip['subnet_cidr'],
ip['mac'])
mocks['ipam']._ipam_allocate_ips(mock.ANY, mocks['driver'],
mock.ANY, [ip])
request = mocks['subnet'].allocate.call_args[0][0]
self.assertEqual(ipam_req.AutomaticAddressRequest, type(request))
self.assertEqual(eui64_ip, request.address)
def test_allocate_multiple_ips(self):
mocks = self._prepare_ipam()
data = {'': ['172.23.128.0/17', self._gen_subnet_id()],
'192.168.43.15': ['192.168.43.0/24', self._gen_subnet_id()],
'8.8.8.8': ['8.0.0.0/8', self._gen_subnet_id()]}
ips = self._convert_to_ips(data)
mocks['subnet'].allocate.side_effect = self._get_allocate_mock(
auto_ip='172.23.128.94')
mocks['ipam']._ipam_allocate_ips(
mock.ANY, mocks['driver'], mock.ANY, ips)
get_calls = [mock.call(data[ip][1]) for ip in data]
mocks['driver'].get_subnet.assert_has_calls(get_calls, any_order=True)
self._validate_allocate_calls(ips, mocks)
def test_allocate_multiple_ips_with_exception(self):
mocks = self._prepare_ipam()
auto_ip = '172.23.128.94'
fail_ip = '192.168.43.15'
data = {'': ['172.23.128.0/17', self._gen_subnet_id()],
fail_ip: ['192.168.43.0/24', self._gen_subnet_id()],
'8.8.8.8': ['8.0.0.0/8', self._gen_subnet_id()]}
ips = self._convert_to_ips(data)
mocks['subnet'].allocate.side_effect = self._get_allocate_mock(
auto_ip=auto_ip, fail_ip=fail_ip)
# Exception should be raised on attempt to allocate second ip.
# Revert action should be performed for the already allocated ips,
# In this test case only one ip should be deallocated
# and original error should be reraised
self.assertRaises(n_exc.InvalidInput,
mocks['ipam']._ipam_allocate_ips,
mock.ANY,
mocks['driver'],
mock.ANY,
ips)
# get_subnet should be called only for the first two networks
get_calls = [mock.call(data[ip][1]) for ip in ['', fail_ip]]
mocks['driver'].get_subnet.assert_has_calls(get_calls, any_order=True)
# Allocate should be called for the first two ips only
self._validate_allocate_calls(ips[:-1], mocks)
# Deallocate should be called for the first ip only
mocks['subnet'].deallocate.assert_called_once_with(auto_ip)

View File

@ -291,20 +291,26 @@ class TestAddressRequestFactory(base.BaseTestCase):
def test_specific_address_request_is_loaded(self):
for address in ('10.12.0.15', 'fffe::1'):
ip = {'ip_address': address}
self.assertIsInstance(
ipam_req.AddressRequestFactory.get_request(None,
None,
address),
ipam_req.AddressRequestFactory.get_request(None, None, ip),
ipam_req.SpecificAddressRequest)
def test_any_address_request_is_loaded(self):
for addr in [None, '']:
ip = {'ip_address': addr}
self.assertIsInstance(
ipam_req.AddressRequestFactory.get_request(None,
None,
addr),
ipam_req.AddressRequestFactory.get_request(None, None, ip),
ipam_req.AnyAddressRequest)
def test_automatic_address_request_is_loaded(self):
ip = {'mac': '6c:62:6d:de:cf:49',
'subnet_cidr': '2001:470:abcd::/64',
'eui64_address': True}
self.assertIsInstance(
ipam_req.AddressRequestFactory.get_request(None, None, ip),
ipam_req.AutomaticAddressRequest)
class TestSubnetRequestFactory(IpamSubnetRequestTestCase):
@ -331,31 +337,31 @@ class TestSubnetRequestFactory(IpamSubnetRequestTestCase):
subnet, subnetpool = self._build_subnet_dict(cidr=address)
self.assertIsInstance(
ipam_req.SubnetRequestFactory.get_request(None,
subnet,
subnetpool),
subnet,
subnetpool),
ipam_req.SpecificSubnetRequest)
def test_any_address_request_is_loaded_for_ipv4(self):
subnet, subnetpool = self._build_subnet_dict(cidr=None, ip_version=4)
self.assertIsInstance(
ipam_req.SubnetRequestFactory.get_request(None,
subnet,
subnetpool),
subnet,
subnetpool),
ipam_req.AnySubnetRequest)
def test_any_address_request_is_loaded_for_ipv6(self):
subnet, subnetpool = self._build_subnet_dict(cidr=None, ip_version=6)
self.assertIsInstance(
ipam_req.SubnetRequestFactory.get_request(None,
subnet,
subnetpool),
subnet,
subnetpool),
ipam_req.AnySubnetRequest)
def test_args_are_passed_to_specific_request(self):
subnet, subnetpool = self._build_subnet_dict()
request = ipam_req.SubnetRequestFactory.get_request(None,
subnet,
subnetpool)
subnet,
subnetpool)
self.assertIsInstance(request,
ipam_req.SpecificSubnetRequest)
self.assertEqual(self.tenant_id, request.tenant_id)