From b419cc56d5b154a6ffef2eaa6559dd95bced05c8 Mon Sep 17 00:00:00 2001 From: Carl Baldwin Date: Thu, 13 Nov 2014 12:27:27 -0700 Subject: [PATCH] Introduce External IPAM Interface This introduces an interface for an external IPAM driver. Neutron needs to be modified to make calls using it for its IPAM needs. Additionally, the default IPAM interface must be written to implement this interface. Partially-Implements: blueprint neutron-ipam Change-Id: Ieb565a2d2629ab8236a4be1173df464b7aa06f04 --- neutron/ipam/__init__.py | 205 +++++++++++++++++++++++++++ neutron/ipam/driver.py | 123 ++++++++++++++++ neutron/tests/unit/ipam/__init__.py | 0 neutron/tests/unit/ipam/test_ipam.py | 188 ++++++++++++++++++++++++ 4 files changed, 516 insertions(+) create mode 100644 neutron/ipam/__init__.py create mode 100644 neutron/ipam/driver.py create mode 100644 neutron/tests/unit/ipam/__init__.py create mode 100644 neutron/tests/unit/ipam/test_ipam.py diff --git a/neutron/ipam/__init__.py b/neutron/ipam/__init__.py new file mode 100644 index 00000000000..4f7d216ccc7 --- /dev/null +++ b/neutron/ipam/__init__.py @@ -0,0 +1,205 @@ +# 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 abc +import netaddr + +import six + +from neutron.common import constants + + +@six.add_metaclass(abc.ABCMeta) +class SubnetPool(object): + """Represents a pool of IPs available inside an address scope.""" + + +@six.add_metaclass(abc.ABCMeta) +class SubnetRequest(object): + """Carries the data needed to make a subnet request + + The data validated and carried by an instance of this class is the data + that is common to any type of request. This class shouldn't be + instantiated on its own. Rather, a subclass of this class should be used. + """ + def __init__(self, tenant_id, subnet_id, + gateway_ip=None, allocation_pools=None): + """Initialize and validate + + :param tenant_id: The tenant id who will own the subnet + :type tenant_id: str uuid + :param subnet_id: Neutron's subnet id + :type subnet_id: str uuid + :param gateway_ip: An IP to reserve for the subnet gateway. + :type gateway_ip: None or convertible to netaddr.IPAddress + :param allocation_pools: The pool from which IPAM should allocate + addresses. The allocator *may* allow allocating addresses outside + of this range if specifically requested. + :type allocation_pools: A list of netaddr.IPRange. None if not + specified. + """ + self._tenant_id = tenant_id + self._subnet_id = subnet_id + self._gateway_ip = None + self._allocation_pools = None + + if gateway_ip is not None: + self._gateway_ip = netaddr.IPAddress(gateway_ip) + + if allocation_pools is not None: + allocation_pools = sorted(allocation_pools) + previous = None + for pool in allocation_pools: + if not isinstance(pool, netaddr.ip.IPRange): + raise TypeError("Ranges must be netaddr.IPRange") + if previous and pool.first <= previous.last: + raise ValueError("Ranges must not overlap") + previous = pool + if 1 < len(allocation_pools): + # Checks that all the ranges are in the same IP version. + # IPRange sorts first by ip version so we can get by with just + # checking the first and the last range having sorted them + # above. + first_version = allocation_pools[0].version + last_version = allocation_pools[-1].version + if first_version != last_version: + raise ValueError("Ranges must be in the same IP version") + self._allocation_pools = allocation_pools + + if self.gateway_ip and self.allocation_pools: + if self.gateway_ip.version != self.allocation_pools[0].version: + raise ValueError("Gateway IP version inconsistent with " + "allocation pool version") + + @property + def tenant_id(self): + return self._tenant_id + + @property + def subnet_id(self): + return self._subnet_id + + @property + def gateway_ip(self): + return self._gateway_ip + + @property + def allocation_pools(self): + return self._allocation_pools + + def _validate_with_subnet(self, subnet): + if self.gateway_ip: + if self.gateway_ip not in subnet: + raise ValueError("gateway_ip is not in the subnet") + + if self.allocation_pools: + if subnet.version != self.allocation_pools[0].version: + raise ValueError("allocation_pools use the wrong ip version") + for pool in self.allocation_pools: + if pool not in subnet: + raise ValueError("allocation_pools are not in the subnet") + + +class AnySubnetRequest(SubnetRequest): + """A template for allocating an unspecified subnet from IPAM + + A driver may not implement this type of request. For example, The initial + reference implementation will not support this. The API has no way of + creating a subnet without a specific address until subnet-allocation is + implemented. + """ + WILDCARDS = {constants.IPv4: '0.0.0.0', + constants.IPv6: '::'} + + def __init__(self, tenant_id, subnet_id, version, prefixlen, + gateway_ip=None, allocation_pools=None): + """ + :param version: Either constants.IPv4 or constants.IPv6 + :param prefixlen: The prefix len requested. Must be within the min and + max allowed. + :type prefixlen: int + """ + super(AnySubnetRequest, self).__init__( + tenant_id=tenant_id, + subnet_id=subnet_id, + gateway_ip=gateway_ip, + allocation_pools=allocation_pools) + + net = netaddr.IPNetwork(self.WILDCARDS[version] + '/' + str(prefixlen)) + self._validate_with_subnet(net) + + self._prefixlen = prefixlen + + @property + def prefixlen(self): + return self._prefixlen + + +class SpecificSubnetRequest(SubnetRequest): + """A template for allocating a specified subnet from IPAM + + The initial reference implementation will probably just allow any + allocation, even overlapping ones. This can be expanded on by future + blueprints. + """ + def __init__(self, tenant_id, subnet_id, subnet, + gateway_ip=None, allocation_pools=None): + """ + :param subnet: The subnet requested. Can be IPv4 or IPv6. However, + when IPAM tries to fulfill this request, the IP version must match + the version of the address scope being used. + :type subnet: netaddr.IPNetwork or convertible to one + """ + super(SpecificSubnetRequest, self).__init__( + tenant_id=tenant_id, + subnet_id=subnet_id, + gateway_ip=gateway_ip, + allocation_pools=allocation_pools) + + self._subnet = netaddr.IPNetwork(subnet) + self._validate_with_subnet(self._subnet) + + @property + def subnet(self): + return self._subnet + + @property + def prefixlen(self): + return self._subnet.prefixlen + + +@six.add_metaclass(abc.ABCMeta) +class AddressRequest(object): + """Abstract base class for address requests""" + + +class SpecificAddressRequest(AddressRequest): + """For requesting a specified address from IPAM""" + def __init__(self, address): + """ + :param address: The address being requested + :type address: A netaddr.IPAddress or convertible to one. + """ + super(SpecificAddressRequest, self).__init__() + self._address = netaddr.IPAddress(address) + + @property + def address(self): + return self._address + + +class AnyAddressRequest(AddressRequest): + """Used to request any available address from the pool.""" + + +class RouterGatewayAddressRequest(AddressRequest): + """Used to request allocating the special router gateway address.""" diff --git a/neutron/ipam/driver.py b/neutron/ipam/driver.py new file mode 100644 index 00000000000..6968d313395 --- /dev/null +++ b/neutron/ipam/driver.py @@ -0,0 +1,123 @@ +# 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 abc + +import six + + +@six.add_metaclass(abc.ABCMeta) +class Pool(object): + """Interface definition for an IPAM driver. + + There should be an instance of the driver for every subnet pool. + """ + + def __init__(self, subnet_pool_id): + """Initialize pool + + :param subnet_pool_id: SubnetPool ID of the address space to use. + :type subnet_pool_id: str uuid + """ + self._subnet_pool_id = subnet_pool_id + + @classmethod + def get_instance(cls, subnet_pool_id): + """Returns an instance of the configured IPAM driver + + :param subnet_pool_id: Subnet pool ID of the address space to use. + :type subnet_pool_id: str uuid + :returns: An instance of Driver for the given subnet pool + """ + raise NotImplementedError + + @abc.abstractmethod + def allocate_subnet(self, request): + """Allocates a subnet based on the subnet request + + :param request: Describes the allocation requested. + :type request: An instance of a sub-class of SubnetRequest + :returns: An instance of Subnet + :raises: RequestNotSupported, IPAMAlreadyAllocated + """ + + @abc.abstractmethod + def get_subnet(self, subnet_id): + """Gets the matching subnet if it has been allocated + + :param subnet_id: the subnet identifier + :type subnet_id: str uuid + :returns: An instance of IPAM Subnet + :raises: IPAMAllocationNotFound + """ + + @abc.abstractmethod + def update_subnet(self, request): + """Updates an already allocated subnet + + This is used to notify the external IPAM system of updates to a subnet. + + :param request: Update the subnet to match this request + :type request: An instance of a sub-class of SpecificSubnetRequest + :returns: An instance of IPAM Subnet + :raises: RequestNotSupported, IPAMAllocationNotFound + """ + + @abc.abstractmethod + def remove_subnet(self, subnet_id): + """Removes an allocation + + The initial reference implementation will probably do nothing. + + :param subnet_id: the subnet identifier + :type subnet_id: str uuid + :raises: IPAMAllocationNotFound + """ + + +@six.add_metaclass(abc.ABCMeta) +class Subnet(object): + """Interface definition for an IPAM subnet + + A subnet would typically be associated with a network but may not be. It + could represent a dynamically routed IP address space in which case the + normal network and broadcast addresses would be useable. It should always + be a routable block of addresses and representable in CIDR notation. + """ + + @abc.abstractmethod + def allocate(self, address_request): + """Allocates an IP address based on the request passed in + + :param address_request: Specifies what to allocate. + :type address_request: An instance of a subclass of AddressRequest + :returns: A netaddr.IPAddress + :raises: AddressNotAvailable, AddressOutsideAllocationPool, + AddressOutsideSubnet + """ + + @abc.abstractmethod + def deallocate(self, address): + """Returns a previously allocated address to the pool + + :param address: The address to give back. + :type address: A netaddr.IPAddress or convertible to one. + :returns: None + :raises: IPAMAllocationNotFound + """ + + @abc.abstractmethod + def get_details(self): + """Returns the details of the subnet + + :returns: An instance of SpecificSubnetRequest with the subnet detail. + """ diff --git a/neutron/tests/unit/ipam/__init__.py b/neutron/tests/unit/ipam/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/neutron/tests/unit/ipam/test_ipam.py b/neutron/tests/unit/ipam/test_ipam.py new file mode 100644 index 00000000000..7d27f38f7f7 --- /dev/null +++ b/neutron/tests/unit/ipam/test_ipam.py @@ -0,0 +1,188 @@ +# 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 netaddr + +from neutron.common import constants +from neutron import ipam +from neutron.openstack.common import uuidutils +from neutron.tests import base + + +class IpamSubnetRequestTestCase(base.BaseTestCase): + + def setUp(self): + super(IpamSubnetRequestTestCase, self).setUp() + self.tenant_id = uuidutils.generate_uuid() + self.subnet_id = uuidutils.generate_uuid() + + +class TestIpamSubnetRequests(IpamSubnetRequestTestCase): + + def test_subnet_request(self): + pool = ipam.SubnetRequest(self.tenant_id, + self.subnet_id) + self.assertEqual(self.tenant_id, pool.tenant_id) + self.assertEqual(self.subnet_id, pool.subnet_id) + self.assertEqual(None, pool.gateway_ip) + self.assertEqual(None, pool.allocation_pools) + + def test_subnet_request_gateway(self): + request = ipam.SubnetRequest(self.tenant_id, + self.subnet_id, + gateway_ip='1.2.3.1') + self.assertEqual('1.2.3.1', str(request.gateway_ip)) + + def test_subnet_request_bad_gateway(self): + self.assertRaises(netaddr.core.AddrFormatError, + ipam.SubnetRequest, + self.tenant_id, + self.subnet_id, + gateway_ip='1.2.3.') + + def test_subnet_request_with_range(self): + allocation_pools = [netaddr.IPRange('1.2.3.4', '1.2.3.5'), + netaddr.IPRange('1.2.3.7', '1.2.3.9')] + request = ipam.SubnetRequest(self.tenant_id, + self.subnet_id, + allocation_pools=allocation_pools) + self.assertEqual(allocation_pools, request.allocation_pools) + + def test_subnet_request_range_not_list(self): + self.assertRaises(TypeError, + ipam.SubnetRequest, + self.tenant_id, + self.subnet_id, + allocation_pools=1) + + def test_subnet_request_bad_range(self): + self.assertRaises(TypeError, + ipam.SubnetRequest, + self.tenant_id, + self.subnet_id, + allocation_pools=['1.2.3.4']) + + def test_subnet_request_different_versions(self): + pools = [netaddr.IPRange('0.0.0.1', '0.0.0.2'), + netaddr.IPRange('::1', '::2')] + self.assertRaises(ValueError, + ipam.SubnetRequest, + self.tenant_id, + self.subnet_id, + allocation_pools=pools) + + def test_subnet_request_overlap(self): + pools = [netaddr.IPRange('0.0.0.10', '0.0.0.20'), + netaddr.IPRange('0.0.0.8', '0.0.0.10')] + self.assertRaises(ValueError, + ipam.SubnetRequest, + self.tenant_id, + self.subnet_id, + allocation_pools=pools) + + +class TestIpamAnySubnetRequest(IpamSubnetRequestTestCase): + + def test_subnet_request(self): + request = ipam.AnySubnetRequest(self.tenant_id, + self.subnet_id, + constants.IPv4, + 24, + gateway_ip='0.0.0.1') + self.assertEqual(24, request.prefixlen) + + def test_subnet_request_bad_prefix_type(self): + self.assertRaises(netaddr.core.AddrFormatError, + ipam.AnySubnetRequest, + self.tenant_id, + self.subnet_id, + constants.IPv4, + 'A') + + def test_subnet_request_bad_prefix(self): + self.assertRaises(netaddr.core.AddrFormatError, + ipam.AnySubnetRequest, + self.tenant_id, + self.subnet_id, + constants.IPv4, + 33) + self.assertRaises(netaddr.core.AddrFormatError, + ipam.AnySubnetRequest, + self.tenant_id, + self.subnet_id, + constants.IPv6, + 129) + + def test_subnet_request_bad_gateway(self): + self.assertRaises(ValueError, + ipam.AnySubnetRequest, + self.tenant_id, + self.subnet_id, + constants.IPv6, + 64, + gateway_ip='2000::1') + + def test_subnet_request_allocation_pool_wrong_version(self): + pools = [netaddr.IPRange('0.0.0.4', '0.0.0.5')] + self.assertRaises(ValueError, + ipam.AnySubnetRequest, + self.tenant_id, + self.subnet_id, + constants.IPv6, + 64, + allocation_pools=pools) + + def test_subnet_request_allocation_pool_not_in_net(self): + pools = [netaddr.IPRange('0.0.0.64', '0.0.0.128')] + self.assertRaises(ValueError, + ipam.AnySubnetRequest, + self.tenant_id, + self.subnet_id, + constants.IPv4, + 25, + allocation_pools=pools) + + +class TestIpamSpecificSubnetRequest(IpamSubnetRequestTestCase): + + def test_subnet_request(self): + request = ipam.SpecificSubnetRequest(self.tenant_id, + self.subnet_id, + '1.2.3.0/24', + gateway_ip='1.2.3.1') + self.assertEqual(24, request.prefixlen) + self.assertEqual(netaddr.IPAddress('1.2.3.1'), request.gateway_ip) + self.assertEqual(netaddr.IPNetwork('1.2.3.0/24'), request.subnet) + + def test_subnet_request_bad_gateway(self): + self.assertRaises(ValueError, + ipam.SpecificSubnetRequest, + self.tenant_id, + self.subnet_id, + '2001::1', + gateway_ip='2000::1') + + +class TestAddressRequest(base.BaseTestCase): + + # This class doesn't test much. At least running through all of the + # constructors may shake out some trivial bugs. + def test_specific_address_ipv6(self): + request = ipam.SpecificAddressRequest('2000::45') + self.assertEqual(netaddr.IPAddress('2000::45'), request.address) + + def test_specific_address_ipv4(self): + request = ipam.SpecificAddressRequest('1.2.3.32') + self.assertEqual(netaddr.IPAddress('1.2.3.32'), request.address) + + def test_any_address(self): + ipam.AnyAddressRequest()