From 45f3bb810fcdded68fdb40645025fc5aee0aba72 Mon Sep 17 00:00:00 2001 From: Pavel Bondar Date: Mon, 6 Jul 2015 18:36:22 +0300 Subject: [PATCH] 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 --- neutron/db/ipam_pluggable_backend.py | 129 ++++++++++ neutron/ipam/requests.py | 19 +- .../unit/db/test_ipam_pluggable_backend.py | 235 ++++++++++++++++++ neutron/tests/unit/ipam/test_requests.py | 34 +-- 4 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 neutron/db/ipam_pluggable_backend.py create mode 100644 neutron/tests/unit/db/test_ipam_pluggable_backend.py diff --git a/neutron/db/ipam_pluggable_backend.py b/neutron/db/ipam_pluggable_backend.py new file mode 100644 index 00000000000..a93f66b4747 --- /dev/null +++ b/neutron/db/ipam_pluggable_backend.py @@ -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 diff --git a/neutron/ipam/requests.py b/neutron/ipam/requests.py index 7d45e235776..76a6860f1f4 100644 --- a/neutron/ipam/requests.py +++ b/neutron/ipam/requests.py @@ -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): diff --git a/neutron/tests/unit/db/test_ipam_pluggable_backend.py b/neutron/tests/unit/db/test_ipam_pluggable_backend.py new file mode 100644 index 00000000000..fd4e457d940 --- /dev/null +++ b/neutron/tests/unit/db/test_ipam_pluggable_backend.py @@ -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) diff --git a/neutron/tests/unit/ipam/test_requests.py b/neutron/tests/unit/ipam/test_requests.py index 243e8b70320..8fd014c0982 100644 --- a/neutron/tests/unit/ipam/test_requests.py +++ b/neutron/tests/unit/ipam/test_requests.py @@ -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)