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)