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
This commit is contained in:
John Davidge 2016-08-01 14:04:08 +01:00
parent 96a371bf50
commit de3a3cda74
6 changed files with 186 additions and 13 deletions

View File

@ -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(

View File

@ -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)

View File

@ -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)

View File

@ -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.")

View File

@ -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

View File

@ -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.