From de3a3cda740dfa22152c8224c39aeb9e51642d93 Mon Sep 17 00:00:00 2001 From: John Davidge Date: Mon, 1 Aug 2016 14:04:08 +0100 Subject: [PATCH] IP allocation with Service Subnets This changes the way that IPAM decides which subnets to use when assigning IPs to newly created ports. If the port has a defined device_owner, this is used to filter available subnets to choose from only those with a matching service_type or no service_type at all. If the given network has no service subnets, then the existing behaviour is used. A new IPAM exception is introduced to handle the following scenarios: 1. A port is created with a device_owner and only non-matching service subnets exist. 2. A port is created without a device owner, and no subnets exist without a service_type. With this patch, service subnets are now usable. Implements: blueprint service-subnets APIImpact: subnet-create and subnet-update with service_types DocImpact: IPs assigned to new ports will now come from a service subnet matching the port device_owner, if one exists. Closes-Bug: 1544768 Change-Id: If3dd94a46bdee24c13d1f17c4f2e69af0cb8af63 --- neutron/db/ipam_backend_mixin.py | 48 ++++++- neutron/db/ipam_non_pluggable_backend.py | 5 +- neutron/db/ipam_pluggable_backend.py | 3 +- neutron/ipam/exceptions.py | 4 + .../extensions/test_subnet_service_types.py | 124 +++++++++++++++++- ...subnet-service-types-bc81f6df9834f96e.yaml | 15 +++ 6 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 releasenotes/notes/add-subnet-service-types-bc81f6df9834f96e.yaml diff --git a/neutron/db/ipam_backend_mixin.py b/neutron/db/ipam_backend_mixin.py index eb179d84508..812b68bc223 100644 --- a/neutron/db/ipam_backend_mixin.py +++ b/neutron/db/ipam_backend_mixin.py @@ -38,6 +38,7 @@ from neutron.db import segments_db from neutron.db import subnet_service_type_db_models as service_type_db from neutron.extensions import portbindings from neutron.extensions import segment +from neutron.ipam import exceptions as ipam_exceptions from neutron.ipam import utils as ipam_utils from neutron.objects import subnet as subnet_obj from neutron.services.segments import db as segment_svc_db @@ -564,18 +565,50 @@ class IpamBackendMixin(db_base_plugin_common.DbBasePluginCommon): fixed_ip_list.append({'subnet_id': subnet['id']}) return fixed_ip_list - def _ipam_get_subnets(self, context, network_id, host): + def _query_filter_service_subnets(self, query, service_type): + ServiceType = service_type_db.SubnetServiceType + query = query.add_entity(ServiceType) + query = query.outerjoin(ServiceType) + query = query.filter(or_(ServiceType.service_type.is_(None), + ServiceType.service_type == service_type)) + return query.from_self(models_v2.Subnet) + + def _check_service_subnets(self, query, service_type): + """Raise an exception if empty subnet list is caused by service type""" + if not query.limit(1).count(): + return + query = self._query_filter_service_subnets(query, service_type) + if query.limit(1).count(): + return + raise ipam_exceptions.IpAddressGenerationFailureNoMatchingSubnet() + + def _sort_service_subnets(self, subnets, query, service_type): + """Give priority to subnets with service_types""" + subnets = sorted(subnets, + key=lambda subnet: not subnet.get('service_types')) + if not subnets: + # If we have an empty subnet list, check if it's caused by + # the service type. + self._check_service_subnets(query, service_type) + return subnets + + def _ipam_get_subnets(self, context, network_id, host, service_type=None): Subnet = models_v2.Subnet SegmentHostMapping = segment_svc_db.SegmentHostMapping - query = self._get_collection_query(context, Subnet) - query = query.filter(Subnet.network_id == network_id) + unfiltered_query = self._get_collection_query(context, Subnet) + unfiltered_query = unfiltered_query.filter( + Subnet.network_id == network_id) + query = self._query_filter_service_subnets(unfiltered_query, + service_type) # Note: This seems redundant, but its not. It has to cover cases # where host is None, ATTR_NOT_SPECIFIED, or '' due to differences in # host binding implementations. if not validators.is_attr_set(host) or not host: query = query.filter(Subnet.segment_id.is_(None)) - return [self._make_subnet_dict(c, context=context) for c in query] + return self._sort_service_subnets( + [self._make_subnet_dict(c, context=context) + for c in query], unfiltered_query, service_type) # A host has been provided. Consider these two scenarios # 1. Not a routed network: subnets are not on segments @@ -602,6 +635,7 @@ class IpamBackendMixin(db_base_plugin_common.DbBasePluginCommon): query = query.filter(Subnet.network_id == network_id) query = query.filter(Subnet.segment_id.isnot(None)) if query.count() == 0: + self._check_service_subnets(unfiltered_query, service_type) return [] # It is a routed network but no subnets found for host raise segment_exc.HostNotConnectedToAnySegment( @@ -618,8 +652,10 @@ class IpamBackendMixin(db_base_plugin_common.DbBasePluginCommon): raise segment_exc.HostConnectedToMultipleSegments( host=host, network_id=network_id) - return [self._make_subnet_dict(subnet, context=context) - for subnet, _mapping in results] + return self._sort_service_subnets( + [self._make_subnet_dict(subnet, context=context) + for subnet, _mapping in results], + unfiltered_query, service_type) def _make_subnet_args(self, detail, subnet, subnetpool_id): args = super(IpamBackendMixin, self)._make_subnet_args( diff --git a/neutron/db/ipam_non_pluggable_backend.py b/neutron/db/ipam_non_pluggable_backend.py index ae4467e98cb..e2148641059 100644 --- a/neutron/db/ipam_non_pluggable_backend.py +++ b/neutron/db/ipam_non_pluggable_backend.py @@ -67,7 +67,7 @@ class IpamNonPluggableBackend(ipam_backend_mixin.IpamBackendMixin): subnet_ip_pools.add(netaddr.IPRange(ip_pool.first_ip, ip_pool.last_ip)) - for subnet_id in ip_pools: + for subnet_id in subnet_id_list: subnet_ip_pools = ip_pools[subnet_id] subnet_ip_allocs = ip_allocations[subnet_id] filter_set = netaddr.IPSet() @@ -282,7 +282,8 @@ class IpamNonPluggableBackend(ipam_backend_mixin.IpamBackendMixin): p = port['port'] subnets = self._ipam_get_subnets(context, network_id=p['network_id'], - host=p.get(portbindings.HOST_ID)) + host=p.get(portbindings.HOST_ID), + service_type=p.get('device_owner')) v4, v6_stateful, v6_stateless = self._classify_subnets( context, subnets) diff --git a/neutron/db/ipam_pluggable_backend.py b/neutron/db/ipam_pluggable_backend.py index ff600af0461..ad6e9cdcd6a 100644 --- a/neutron/db/ipam_pluggable_backend.py +++ b/neutron/db/ipam_pluggable_backend.py @@ -200,7 +200,8 @@ class IpamPluggableBackend(ipam_backend_mixin.IpamBackendMixin): p = port['port'] subnets = self._ipam_get_subnets(context, network_id=p['network_id'], - host=p.get(portbindings.HOST_ID)) + host=p.get(portbindings.HOST_ID), + service_type=p.get('device_owner')) v4, v6_stateful, v6_stateless = self._classify_subnets( context, subnets) diff --git a/neutron/ipam/exceptions.py b/neutron/ipam/exceptions.py index 2376c4bb0de..29a4a947cbb 100644 --- a/neutron/ipam/exceptions.py +++ b/neutron/ipam/exceptions.py @@ -68,6 +68,10 @@ class IpAddressGenerationFailureAllSubnets(IpAddressGenerationFailure): message = _("No more IP addresses available.") +class IpAddressGenerationFailureNoMatchingSubnet(IpAddressGenerationFailure): + message = _("No valid service subnet for the given device owner.") + + class IPAllocationFailed(exceptions.NeutronException): message = _("IP allocation failed. Try again later.") diff --git a/neutron/tests/unit/extensions/test_subnet_service_types.py b/neutron/tests/unit/extensions/test_subnet_service_types.py index c53d345e292..1a1f9e130fb 100644 --- a/neutron/tests/unit/extensions/test_subnet_service_types.py +++ b/neutron/tests/unit/extensions/test_subnet_service_types.py @@ -44,7 +44,7 @@ class SubnetServiceTypesExtensionTestCase( test_db_base_plugin_v2.NeutronDbPluginV2TestCase): """Test API extension subnet_service_types attributes. """ - CIDR = '10.0.0.0/8' + CIDRS = ['10.0.0.0/8', '20.0.0.0/8', '30.0.0.0/8'] IP_VERSION = 4 def setUp(self): @@ -54,14 +54,17 @@ class SubnetServiceTypesExtensionTestCase( super(SubnetServiceTypesExtensionTestCase, self).setUp(plugin=plugin, ext_mgr=ext_mgr) - def _create_service_subnet(self, service_types=None, network=None): + def _create_service_subnet(self, service_types=None, cidr=None, + network=None): if not network: with self.network() as network: pass network = network['network'] + if not cidr: + cidr = self.CIDRS[0] args = {'net_id': network['id'], 'tenant_id': network['tenant_id'], - 'cidr': self.CIDR, + 'cidr': cidr, 'ip_version': self.IP_VERSION} if service_types: args['service_types'] = service_types @@ -158,8 +161,121 @@ class SubnetServiceTypesExtensionTestCase( # Update it with an invalid service type self._test_update_subnet(subnet, service_types, expect_fail=True) + def _assert_port_res(self, port, service_type, subnet, fallback, + error='IpAddressGenerationFailureNoMatchingSubnet'): + res = self.deserialize('json', port) + if fallback: + port = res['port'] + self.assertEqual(1, len(port['fixed_ips'])) + self.assertEqual(service_type, port['device_owner']) + self.assertEqual(subnet['id'], port['fixed_ips'][0]['subnet_id']) + else: + self.assertEqual(error, res['NeutronError']['type']) + + def test_create_port_with_matching_service_type(self): + with self.network() as network: + pass + matching_type = 'network:foo' + non_matching_type = 'network:bar' + # Create a subnet with no service types + self._create_service_subnet(network=network) + # Create a subnet with a non-matching service type + self._create_service_subnet([non_matching_type], + cidr=self.CIDRS[2], + network=network) + # Create a subnet with a service type to match the port device owner + res = self._create_service_subnet([matching_type], + cidr=self.CIDRS[1], + network=network) + service_subnet = self.deserialize('json', res)['subnet'] + # Create a port with device owner matching the correct service subnet + network = network['network'] + port = self._create_port(self.fmt, + net_id=network['id'], + tenant_id=network['tenant_id'], + device_owner=matching_type) + self._assert_port_res(port, matching_type, service_subnet, True) + + def test_create_port_without_matching_service_type(self, fallback=True): + with self.network() as network: + pass + subnet = '' + matching_type = 'compute:foo' + non_matching_type = 'network:foo' + if fallback: + # Create a subnet with no service types + res = self._create_service_subnet(network=network) + subnet = self.deserialize('json', res)['subnet'] + # Create a subnet with a non-matching service type + self._create_service_subnet([non_matching_type], + cidr=self.CIDRS[1], + network=network) + # Create a port with device owner not matching the service subnet + network = network['network'] + port = self._create_port(self.fmt, + net_id=network['id'], + tenant_id=network['tenant_id'], + device_owner=matching_type) + self._assert_port_res(port, matching_type, subnet, fallback) + + def test_create_port_without_matching_service_type_no_fallback(self): + self.test_create_port_without_matching_service_type(fallback=False) + + def test_create_port_no_device_owner(self, fallback=True): + with self.network() as network: + pass + subnet = '' + service_type = 'compute:foo' + if fallback: + # Create a subnet with no service types + res = self._create_service_subnet(network=network) + subnet = self.deserialize('json', res)['subnet'] + # Create a subnet with a service_type + self._create_service_subnet([service_type], + cidr=self.CIDRS[1], + network=network) + # Create a port without a device owner + network = network['network'] + port = self._create_port(self.fmt, + net_id=network['id'], + tenant_id=network['tenant_id']) + self._assert_port_res(port, '', subnet, fallback) + + def test_create_port_no_device_owner_no_fallback(self): + self.test_create_port_no_device_owner(fallback=False) + + def test_create_port_exhausted_subnet(self, fallback=True): + with self.network() as network: + pass + subnet = '' + service_type = 'compute:foo' + if fallback: + # Create a subnet with no service types + res = self._create_service_subnet(network=network) + subnet = self.deserialize('json', res)['subnet'] + # Create a subnet with a service_type + res = self._create_service_subnet([service_type], + cidr=self.CIDRS[1], + network=network) + service_subnet = self.deserialize('json', res)['subnet'] + # Update the service subnet with empty allocation pools + data = {'subnet': {'allocation_pools': []}} + req = self.new_update_request('subnets', data, service_subnet['id']) + res = self.deserialize(self.fmt, req.get_response(self.api)) + # Create a port with a matching device owner + network = network['network'] + port = self._create_port(self.fmt, + net_id=network['id'], + tenant_id=network['tenant_id'], + device_owner=service_type) + self._assert_port_res(port, service_type, subnet, fallback, + error='IpAddressGenerationFailure') + + def test_create_port_exhausted_subnet_no_fallback(self): + self.test_create_port_exhausted_subnet(fallback=False) + class SubnetServiceTypesExtensionTestCasev6( SubnetServiceTypesExtensionTestCase): - CIDR = '2001:db8::/64' + CIDRS = ['2001:db8:2::/64', '2001:db8:3::/64', '2001:db8:4::/64'] IP_VERSION = 6 diff --git a/releasenotes/notes/add-subnet-service-types-bc81f6df9834f96e.yaml b/releasenotes/notes/add-subnet-service-types-bc81f6df9834f96e.yaml new file mode 100644 index 00000000000..6ffdf7945b5 --- /dev/null +++ b/releasenotes/notes/add-subnet-service-types-bc81f6df9834f96e.yaml @@ -0,0 +1,15 @@ +--- +features: + - Subnets now have a new property 'service_types'. + This is a list of port device owners, such that + only ports with a matching device owner will be + given an IP from this subnet. If no matching + service subnet exists for the given device owner, + or no service subnets have been defined on the + network, the port will be assigned an IP from a + subnet with no service-types. This preserves + backwards compatibility with older deployments. +upgrade: + - A new table 'subnet_service_types' has been added + to cater for this feature. It uses the ID field + from the 'subnets' table as a foreign key.