From 2fa1fc4bb1a324e3878c68a74ca7bdb4bd545db1 Mon Sep 17 00:00:00 2001 From: Ryan Tidwell Date: Mon, 16 Mar 2015 11:02:13 -0700 Subject: [PATCH] Simple subnetpool allocation quotas Enables enforcement of allocation quotas on subnet pools. The quota is pool-wide, with the value of allocation_quota applied to every tenant who uses the pool. allocation_quota must be non-negative, and is an optional attribute. If not supplied, no quotas are enforced. Quotas are measured in prefix space allocated. For IPv4 subnet pools, the quota is measured in units of /32 ie each tenant can allocate up to X /32's from the pool. For IPv6 subnet pools, the quota is measured in units of /64 ie each tenant can allocate up to X /64's from the pool. For backward-compatibility, allocation quotas are not applied to the implicit (AKA null) pool. Standard subnet quotas will continue to be applied to all requests. ApiImpact Partially-Implements: blueprint subnet-allocation Change-Id: I7e4641f47790414c693c7cc9b7a44b1889087801 --- neutron/api/v2/attributes.py | 6 ++ neutron/common/exceptions.py | 4 ++ neutron/db/db_base_plugin_v2.py | 9 ++- .../28a09af858a8_subnetpool_quotas.py | 36 ++++++++++ .../alembic_migrations/versions/HEAD | 2 +- neutron/db/models_v2.py | 1 + neutron/ipam/subnet_alloc.py | 71 ++++++++++++++++--- neutron/tests/unit/ipam/test_subnet_alloc.py | 26 ++++++- neutron/tests/unit/test_db_plugin.py | 38 ++++++++++ 9 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 neutron/db/migration/alembic_migrations/versions/28a09af858a8_subnetpool_quotas.py diff --git a/neutron/api/v2/attributes.py b/neutron/api/v2/attributes.py index 1ccbf779d..d94a0a972 100644 --- a/neutron/api/v2/attributes.py +++ b/neutron/api/v2/attributes.py @@ -859,6 +859,12 @@ RESOURCE_ATTRIBUTE_MAP = { 'allow_put': True, 'validate': {'type:subnet_list': None}, 'is_visible': True}, + 'default_quota': {'allow_post': True, + 'allow_put': True, + 'validate': {'type:non_negative': None}, + 'convert_to': convert_to_int, + 'default': ATTR_NOT_SPECIFIED, + 'is_visible': True}, 'ip_version': {'allow_post': False, 'allow_put': False, 'is_visible': True}, diff --git a/neutron/common/exceptions.py b/neutron/common/exceptions.py index 521f9922d..d5b64c52e 100644 --- a/neutron/common/exceptions.py +++ b/neutron/common/exceptions.py @@ -449,3 +449,7 @@ class MaxPrefixSubnetAllocationError(BadRequest): class SubnetPoolDeleteError(BadRequest): message = _("Unable to delete subnet pool: %(reason)s") + + +class SubnetPoolQuotaExceeded(OverQuota): + message = _("Per-tenant subnet pool prefix quota exceeded") diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index ea3461b2a..1e5fc8936 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -884,7 +884,8 @@ class NeutronDbPluginV2(neutron_plugin_base_v2.NeutronPluginBaseV2, 'shared': subnetpool['shared'], 'prefixes': [prefix['cidr'] for prefix in subnetpool['prefixes']], - 'ip_version': subnetpool['ip_version']} + 'ip_version': subnetpool['ip_version'], + 'default_quota': subnetpool['default_quota']} return self._fields(res, fields) def _make_port_dict(self, port, fields=None, @@ -1512,7 +1513,8 @@ class NeutronDbPluginV2(neutron_plugin_base_v2.NeutronPluginBaseV2, sp_reader.default_prefixlen, 'min_prefixlen': sp_reader.min_prefixlen, 'max_prefixlen': sp_reader.max_prefixlen, - 'shared': sp_reader.shared} + 'shared': sp_reader.shared, + 'default_quota': sp_reader.default_quota} subnetpool = models_v2.SubnetPool(**pool_args) context.session.add(subnetpool) for prefix in sp_reader.prefixes: @@ -1548,7 +1550,8 @@ class NeutronDbPluginV2(neutron_plugin_base_v2.NeutronPluginBaseV2, updated['prefixes'] = orig_prefixes for key in ['id', 'name', 'ip_version', 'min_prefixlen', - 'max_prefixlen', 'default_prefixlen', 'shared']: + 'max_prefixlen', 'default_prefixlen', 'shared', + 'default_quota']: self._write_key(key, updated, model, new_pool) return updated diff --git a/neutron/db/migration/alembic_migrations/versions/28a09af858a8_subnetpool_quotas.py b/neutron/db/migration/alembic_migrations/versions/28a09af858a8_subnetpool_quotas.py new file mode 100644 index 000000000..64f3598f8 --- /dev/null +++ b/neutron/db/migration/alembic_migrations/versions/28a09af858a8_subnetpool_quotas.py @@ -0,0 +1,36 @@ +# Copyright 2015 OpenStack Foundation +# +# 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. +# + +"""Initial operations to support basic quotas on prefix space in a subnet pool + +Revision ID: 28a09af858a8 +Revises: 268fb5e99aa2 +Create Date: 2015-03-16 10:36:48.810741 + +""" + +# revision identifiers, used by Alembic. +revision = '28a09af858a8' +down_revision = '268fb5e99aa2' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('subnetpools', + sa.Column('default_quota', + sa.Integer(), + nullable=True)) diff --git a/neutron/db/migration/alembic_migrations/versions/HEAD b/neutron/db/migration/alembic_migrations/versions/HEAD index bcef6fb15..619b47345 100644 --- a/neutron/db/migration/alembic_migrations/versions/HEAD +++ b/neutron/db/migration/alembic_migrations/versions/HEAD @@ -1 +1 @@ -268fb5e99aa2 +28a09af858a8 diff --git a/neutron/db/models_v2.py b/neutron/db/models_v2.py index 5bf2541f3..eb77ede25 100644 --- a/neutron/db/models_v2.py +++ b/neutron/db/models_v2.py @@ -235,6 +235,7 @@ class SubnetPool(model_base.BASEV2, HasId, HasTenant): min_prefixlen = sa.Column(sa.Integer, nullable=False) max_prefixlen = sa.Column(sa.Integer, nullable=False) shared = sa.Column(sa.Boolean, nullable=False) + default_quota = sa.Column(sa.Integer, nullable=True) prefixes = orm.relationship(SubnetPoolPrefix, backref='subnetpools', cascade='all, delete, delete-orphan', diff --git a/neutron/ipam/subnet_alloc.py b/neutron/ipam/subnet_alloc.py index b366e241d..d4b933210 100644 --- a/neutron/ipam/subnet_alloc.py +++ b/neutron/ipam/subnet_alloc.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import math import operator import netaddr @@ -34,6 +35,7 @@ class SubnetAllocator(driver.Pool): def __init__(self, subnetpool): self._subnetpool = subnetpool + self._sp_helper = SubnetPoolHelper() def _get_allocated_cidrs(self, session): query = session.query( @@ -42,7 +44,7 @@ class SubnetAllocator(driver.Pool): return (x.cidr for x in subnets) def _get_available_prefix_list(self, session): - prefixes = (x.cidr for x in self._subnetpool['prefixes']) + prefixes = (x.cidr for x in self._subnetpool.prefixes) allocations = self._get_allocated_cidrs(session) prefix_set = netaddr.IPSet(iterable=prefixes) allocation_set = netaddr.IPSet(iterable=allocations) @@ -52,8 +54,42 @@ class SubnetAllocator(driver.Pool): key=operator.attrgetter('prefixlen'), reverse=True) + def _num_quota_units_in_prefixlen(self, prefixlen, quota_unit): + return math.pow(2, quota_unit - prefixlen) + + def _allocations_used_by_tenant(self, session, quota_unit): + subnetpool_id = self._subnetpool['id'] + tenant_id = self._subnetpool['tenant_id'] + with session.begin(subtransactions=True): + qry = session.query( + models_v2.Subnet).with_lockmode('update') + allocations = qry.filter_by(subnetpool_id=subnetpool_id, + tenant_id=tenant_id) + value = 0 + for allocation in allocations: + prefixlen = netaddr.IPNetwork(allocation.cidr).prefixlen + value += self._num_quota_units_in_prefixlen(prefixlen, + quota_unit) + return value + + def _check_subnetpool_tenant_quota(self, session, tenant_id, prefixlen): + quota_unit = self._sp_helper.ip_version_subnetpool_quota_unit( + self._subnetpool['ip_version']) + quota = self._subnetpool.get('default_quota') + + if quota: + used = self._allocations_used_by_tenant(session, quota_unit) + requested_units = self._num_quota_units_in_prefixlen(prefixlen, + quota_unit) + + if used + requested_units > quota: + raise n_exc.SubnetPoolQuotaExceeded() + def _allocate_any_subnet(self, session, request): with session.begin(subtransactions=True): + self._check_subnetpool_tenant_quota(session, + request.tenant_id, + request.prefixlen) prefix_pool = self._get_available_prefix_list(session) for prefix in prefix_pool: if request.prefixlen >= prefix.prefixlen: @@ -73,6 +109,9 @@ class SubnetAllocator(driver.Pool): def _allocate_specific_subnet(self, session, request): with session.begin(subtransactions=True): + self._check_subnetpool_tenant_quota(session, + request.tenant_id, + request.prefixlen) subnet = request.subnet available = self._get_available_prefix_list(session) matched = netaddr.all_matching_cidrs(subnet, available) @@ -152,7 +191,7 @@ class SubnetPoolReader(object): _sp_helper = None def __init__(self, subnetpool): - self._read_prefix_list(subnetpool) + self._read_prefix_info(subnetpool) self._sp_helper = SubnetPoolHelper() self._read_id(subnetpool) self._read_prefix_bounds(subnetpool) @@ -168,6 +207,7 @@ class SubnetPoolReader(object): 'max_prefixlen': self.max_prefixlen, 'default_prefix': self.default_prefix, 'default_prefixlen': self.default_prefixlen, + 'default_quota': self.default_quota, 'shared': self.shared} def _read_attrs(self, subnetpool, keys): @@ -225,7 +265,7 @@ class SubnetPoolReader(object): setattr(self, prefix_attr, prefix_cidr) setattr(self, prefixlen_attr, prefixlen) - def _read_prefix_list(self, subnetpool): + def _read_prefix_info(self, subnetpool): prefix_list = subnetpool['prefixes'] if not prefix_list: raise n_exc.EmptySubnetPoolPrefixList() @@ -236,6 +276,10 @@ class SubnetPoolReader(object): ip_version = netaddr.IPNetwork(prefix).version elif netaddr.IPNetwork(prefix).version != ip_version: raise n_exc.PrefixVersionMismatch() + self.default_quota = subnetpool.get('default_quota') + + if self.default_quota is attributes.ATTR_NOT_SPECIFIED: + self.default_quota = None self.ip_version = ip_version self.prefixes = self._compact_subnetpool_prefix_list(prefix_list) @@ -253,12 +297,16 @@ class SubnetPoolReader(object): class SubnetPoolHelper(object): - PREFIX_VERSION_INFO = {4: {'max_prefixlen': constants.IPv4_BITS, + _PREFIX_VERSION_INFO = {4: {'max_prefixlen': constants.IPv4_BITS, 'wildcard': '0.0.0.0', - 'default_min_prefixlen': 8}, + 'default_min_prefixlen': 8, + # IPv4 quota measured in units of /32 + 'quota_units': 32}, 6: {'max_prefixlen': constants.IPv6_BITS, 'wildcard': '::', - 'default_min_prefixlen': 64}} + 'default_min_prefixlen': 64, + # IPv6 quota measured in units of /64 + 'quota_units': 64}} def validate_min_prefixlen(self, min_prefixlen, max_prefixlen): if min_prefixlen < 0: @@ -272,7 +320,7 @@ class SubnetPoolHelper(object): base_prefixlen=max_prefixlen) def validate_max_prefixlen(self, prefixlen, ip_version): - max = self.PREFIX_VERSION_INFO[ip_version]['max_prefixlen'] + max = self._PREFIX_VERSION_INFO[ip_version]['max_prefixlen'] if prefixlen > max: raise n_exc.IllegalSubnetPoolPrefixBounds( prefix_type='max_prefixlen', @@ -298,10 +346,13 @@ class SubnetPoolHelper(object): base_prefixlen=max_prefixlen) def wildcard(self, ip_version): - return self.PREFIX_VERSION_INFO[ip_version]['wildcard'] + return self._PREFIX_VERSION_INFO[ip_version]['wildcard'] def default_max_prefixlen(self, ip_version): - return self.PREFIX_VERSION_INFO[ip_version]['max_prefixlen'] + return self._PREFIX_VERSION_INFO[ip_version]['max_prefixlen'] def default_min_prefixlen(self, ip_version): - return self.PREFIX_VERSION_INFO[ip_version]['default_min_prefixlen'] + return self._PREFIX_VERSION_INFO[ip_version]['default_min_prefixlen'] + + def ip_version_subnetpool_quota_unit(self, ip_version): + return self._PREFIX_VERSION_INFO[ip_version]['quota_units'] diff --git a/neutron/tests/unit/ipam/test_subnet_alloc.py b/neutron/tests/unit/ipam/test_subnet_alloc.py index 256d3b947..a0fc85d82 100644 --- a/neutron/tests/unit/ipam/test_subnet_alloc.py +++ b/neutron/tests/unit/ipam/test_subnet_alloc.py @@ -42,6 +42,7 @@ class TestSubnetAllocation(testlib_api.SqlTestCase): min_prefixlen, ip_version, max_prefixlen=attributes.ATTR_NOT_SPECIFIED, default_prefixlen=attributes.ATTR_NOT_SPECIFIED, + default_quota=attributes.ATTR_NOT_SPECIFIED, shared=False): subnetpool = {'subnetpool': {'name': name, 'tenant_id': self._tenant_id, @@ -49,7 +50,8 @@ class TestSubnetAllocation(testlib_api.SqlTestCase): 'min_prefixlen': min_prefixlen, 'max_prefixlen': max_prefixlen, 'default_prefixlen': default_prefixlen, - 'shared': shared}} + 'shared': shared, + 'default_quota': default_quota}} return plugin.create_subnetpool(ctx, subnetpool) def _get_subnetpool(self, ctx, plugin, id): @@ -142,3 +144,25 @@ class TestSubnetAllocation(testlib_api.SqlTestCase): detail = res.get_details() self.assertEqual(detail.gateway_ip, netaddr.IPAddress('10.1.2.254')) + + def test__allocation_value_for_tenant_no_allocations(self): + sp = self._create_subnet_pool(self.plugin, self.ctx, 'test-sp', + ['10.1.0.0/16', '192.168.1.0/24'], + 21, 4) + sa = subnet_alloc.SubnetAllocator(sp) + value = sa._allocations_used_by_tenant(self.ctx.session, 32) + self.assertEqual(value, 0) + + def test_subnetpool_default_quota_exceeded(self): + sp = self._create_subnet_pool(self.plugin, self.ctx, 'test-sp', + ['fe80::/48'], + 48, 6, default_quota=1) + sp = self.plugin._get_subnetpool(self.ctx, sp['id']) + sa = subnet_alloc.SubnetAllocator(sp) + req = ipam.SpecificSubnetRequest(self._tenant_id, + uuidutils.generate_uuid(), + 'fe80::/63') + self.assertRaises(n_exc.SubnetPoolQuotaExceeded, + sa.allocate_subnet, + self.ctx.session, + req) diff --git a/neutron/tests/unit/test_db_plugin.py b/neutron/tests/unit/test_db_plugin.py index 077f4e90f..4b87ef452 100644 --- a/neutron/tests/unit/test_db_plugin.py +++ b/neutron/tests/unit/test_db_plugin.py @@ -4815,6 +4815,21 @@ class TestSubnetPoolsV2(NeutronDbPluginV2TestCase): res = req.get_response(self.api) self.assertEqual(res.status_int, 400) + def test_update_subnetpool_default_quota(self): + initial_subnetpool = self._test_create_subnetpool(['10.10.10.0/24'], + tenant_id=self._tenant_id, + name=self._POOL_NAME, + min_prefixlen='24', + default_quota=10) + + self.assertEqual(initial_subnetpool['subnetpool']['default_quota'], + 10) + data = {'subnetpool': {'default_quota': '1'}} + req = self.new_update_request('subnetpools', data, + initial_subnetpool['subnetpool']['id']) + res = self.deserialize(self.fmt, req.get_response(self.api)) + self.assertEqual(res['subnetpool']['default_quota'], 1) + def test_allocate_any_subnet_with_prefixlen(self): with self.network() as network: sp = self._test_create_subnetpool(['10.10.0.0/16'], @@ -5067,6 +5082,29 @@ class TestSubnetPoolsV2(NeutronDbPluginV2TestCase): res = req.get_response(self.api) self.assertEqual(res.status_int, 400) + def test_allocate_subnet_over_quota(self): + with self.network() as network: + sp = self._test_create_subnetpool(['10.10.0.0/16'], + tenant_id=self._tenant_id, + name=self._POOL_NAME, + min_prefixlen='21', + default_quota=2048) + + # Request a specific subnet allocation + data = {'subnet': {'network_id': network['network']['id'], + 'subnetpool_id': sp['subnetpool']['id'], + 'ip_version': 4, + 'prefixlen': 21, + 'tenant_id': network['network']['tenant_id']}} + req = self.new_create_request('subnets', data) + # Allocate a subnet to fill the quota + res = req.get_response(self.api) + self.assertEqual(res.status_int, 201) + # Attempt to allocate a /21 again + res = req.get_response(self.api) + # Assert error + self.assertEqual(res.status_int, 409) + class DbModelTestCase(base.BaseTestCase): """DB model tests.""" -- 2.45.2