diff --git a/etc/quantum.conf b/etc/quantum.conf index 2b3c47bdd1..90f75a5607 100644 --- a/etc/quantum.conf +++ b/etc/quantum.conf @@ -30,3 +30,16 @@ api_paste_config = api-paste.ini # Maximum amount of retries to generate a unique MAC address # mac_generation_retries = 16 + +[QUOTAS] +# number of networks allowed per tenant +# quota_network = 10 + +# number of subnets allowed per tenant +# quota_subnet = 10 + +# number of ports allowed per tenant +# quota_port = 50 + +# default driver to use for quota checks +# quota_driver = quantum.quota.ConfDriver diff --git a/quantum/api/v2/base.py b/quantum/api/v2/base.py index e1819c9a5b..056e8fb899 100644 --- a/quantum/api/v2/base.py +++ b/quantum/api/v2/base.py @@ -22,6 +22,7 @@ from quantum.api.v2 import views from quantum.common import exceptions from quantum.common import utils from quantum import policy +from quantum import quota LOG = logging.getLogger(__name__) XML_NS_V20 = 'http://openstack.org/quantum/api/v2.0' @@ -37,6 +38,8 @@ FAULT_MAP = {exceptions.NotFound: webob.exc.HTTPNotFound, exceptions.InvalidAllocationPool: webob.exc.HTTPBadRequest, } +QUOTAS = quota.QUOTAS + def fields(request): """ @@ -176,10 +179,8 @@ class Controller(object): def create(self, request, body=None): """Creates a new instance of the requested entity""" - body = self._prepare_request_body(request.context, body, True, allow_bulk=True) - action = "create_%s" % self._resource # Check authz @@ -196,12 +197,22 @@ class Controller(object): action, item[self._resource], ) + count = QUOTAS.count(request.context, self._resource, + self._plugin, self._collection, + item[self._resource]['tenant_id']) + kwargs = {self._resource: count + 1} + QUOTAS.limit_check(request.context, **kwargs) else: self._validate_network_tenant_ownership( request, body[self._resource] ) policy.enforce(request.context, action, body[self._resource]) + count = QUOTAS.count(request.context, self._resource, + self._plugin, self._collection, + body[self._resource]['tenant_id']) + kwargs = {self._resource: count + 1} + QUOTAS.limit_check(request.context, **kwargs) except exceptions.PolicyNotAuthorized: raise webob.exc.HTTPForbidden() diff --git a/quantum/common/exceptions.py b/quantum/common/exceptions.py index a00cf8ee08..88e548927a 100644 --- a/quantum/common/exceptions.py +++ b/quantum/common/exceptions.py @@ -168,3 +168,16 @@ class PreexistingDeviceFailure(QuantumException): class SudoRequired(QuantumException): message = _("Sudo priviledge is required to run this command.") + + +class QuotaResourceUnknown(QuantumException): + message = _("Unknown quota resources %(unknown)s.") + + +class OverQuota(QuantumException): + message = _("Quota exceeded for resources: %(overs)s") + + +class InvalidQuotaValue(QuantumException): + message = _("Change would make usage less than 0 for the following " + "resources: %(unders)s") diff --git a/quantum/quota.py b/quantum/quota.py new file mode 100644 index 0000000000..3f819dc16b --- /dev/null +++ b/quantum/quota.py @@ -0,0 +1,262 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC +# +# 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. + +"""Quotas for instances, volumes, and floating ips.""" + +import logging + +from quantum.common import exceptions +from quantum.openstack.common import cfg +from quantum.openstack.common import importutils + +LOG = logging.getLogger(__name__) +quota_opts = [ + cfg.IntOpt('quota_network', + default=10, + help='number of networks allowed per tenant, -1 for unlimited'), + cfg.IntOpt('quota_subnet', + default=10, + help='number of subnets allowed per tenant, -1 for unlimited'), + cfg.IntOpt('quota_port', + default=50, + help='number of ports allowed per tenant, -1 for unlimited'), + cfg.StrOpt('quota_driver', + default='quantum.quota.ConfDriver', + help='default driver to use for quota checks'), +] +# Register the configuration options +cfg.CONF.register_opts(quota_opts, 'QUOTAS') + + +class ConfDriver(object): + """ + Driver to perform necessary checks to enforce quotas and obtain + quota information. The default driver utilizes the default values + in quantum.conf. + """ + + def _get_quotas(self, context, resources, keys): + """ + A helper method which retrieves the quotas for the specific + resources identified by keys, and which apply to the current + context. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + :param keys: A list of the desired quotas to retrieve. + """ + + # Filter resources + desired = set(keys) + sub_resources = dict((k, v) for k, v in resources.items() + if k in desired) + + # Make sure we accounted for all of them... + if len(keys) != len(sub_resources): + unknown = desired - set(sub_resources.keys()) + raise exceptions.QuotaResourceUnknown(unknown=sorted(unknown)) + quotas = {} + for resource in sub_resources.values(): + quotas[resource.name] = resource.default + return quotas + + def limit_check(self, context, resources, values): + """Check simple quota limits. + + For limits--those quotas for which there is no usage + synchronization function--this method checks that a set of + proposed values are permitted by the limit restriction. + + This method will raise a QuotaResourceUnknown exception if a + given resource is unknown or if it is not a simple limit + resource. + + If any of the proposed values is over the defined quota, an + OverQuota exception will be raised with the sorted list of the + resources which are too high. Otherwise, the method returns + nothing. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resources. + :param values: A dictionary of the values to check against the + quota. + """ + + # Ensure no value is less than zero + unders = [key for key, val in values.items() if val < 0] + if unders: + raise exceptions.InvalidQuotaValue(unders=sorted(unders)) + + # Get the applicable quotas + quotas = self._get_quotas(context, resources, values.keys()) + + # Check the quotas and construct a list of the resources that + # would be put over limit by the desired values + overs = [key for key, val in values.items() + if quotas[key] >= 0 and quotas[key] < val] + if overs: + raise exceptions.OverQuota(overs=sorted(overs), quotas=quotas, + usages={}) + + +class BaseResource(object): + """Describe a single resource for quota checking.""" + + def __init__(self, name, flag=None): + """ + Initializes a Resource. + + :param name: The name of the resource, i.e., "instances". + :param flag: The name of the flag or configuration option + which specifies the default value of the quota + for this resource. + """ + + self.name = name + self.flag = flag + + @property + def default(self): + """Return the default value of the quota.""" + + return cfg.CONF.QUOTAS[self.flag] if self.flag else -1 + + +class CountableResource(BaseResource): + """Describe a resource where the counts are determined by a function. + """ + + def __init__(self, name, count, flag=None): + """Initializes a CountableResource. + + Countable resources are those resources which directly + correspond to objects in the database, i.e., netowk, subnet, + etc.,. A CountableResource must be constructed with a counting + function, which will be called to determine the current counts + of the resource. + + The counting function will be passed the context, along with + the extra positional and keyword arguments that are passed to + Quota.count(). It should return an integer specifying the + count. + + :param name: The name of the resource, i.e., "instances". + :param count: A callable which returns the count of the + resource. The arguments passed are as described + above. + :param flag: The name of the flag or configuration option + which specifies the default value of the quota + for this resource. + """ + + super(CountableResource, self).__init__(name, flag=flag) + self.count = count + + +class QuotaEngine(object): + """Represent the set of recognized quotas.""" + + def __init__(self, quota_driver_class=None): + """Initialize a Quota object.""" + + if not quota_driver_class: + quota_driver_class = cfg.CONF.QUOTAS.quota_driver + + if isinstance(quota_driver_class, basestring): + quota_driver_class = importutils.import_object(quota_driver_class) + + self._resources = {} + self._driver = quota_driver_class + + def __contains__(self, resource): + return resource in self._resources + + def register_resource(self, resource): + """Register a resource.""" + + self._resources[resource.name] = resource + + def register_resources(self, resources): + """Register a list of resources.""" + + for resource in resources: + self.register_resource(resource) + + def count(self, context, resource, *args, **kwargs): + """Count a resource. + + For countable resources, invokes the count() function and + returns its result. Arguments following the context and + resource are passed directly to the count function declared by + the resource. + + :param context: The request context, for access checks. + :param resource: The name of the resource, as a string. + """ + + # Get the resource + res = self._resources.get(resource) + if not res or not hasattr(res, 'count'): + raise exceptions.QuotaResourceUnknown(unknown=[resource]) + + return res.count(context, *args, **kwargs) + + def limit_check(self, context, **values): + """Check simple quota limits. + + For limits--those quotas for which there is no usage + synchronization function--this method checks that a set of + proposed values are permitted by the limit restriction. The + values to check are given as keyword arguments, where the key + identifies the specific quota limit to check, and the value is + the proposed value. + + This method will raise a QuotaResourceUnknown exception if a + given resource is unknown or if it is not a simple limit + resource. + + If any of the proposed values is over the defined quota, an + OverQuota exception will be raised with the sorted list of the + resources which are too high. Otherwise, the method returns + nothing. + + :param context: The request context, for access checks. + """ + + return self._driver.limit_check(context, self._resources, values) + + @property + def resources(self): + return sorted(self._resources.keys()) + + +QUOTAS = QuotaEngine() + + +def _count_resource(context, plugin, resources, tenant_id): + obj_getter = getattr(plugin, "get_%s" % resources) + obj_list = obj_getter(context, filters={'tenant_id': [tenant_id]}) + return len(obj_list) if obj_list else 0 + + +resources = [ + CountableResource('network', _count_resource, 'quota_network'), + CountableResource('subnet', _count_resource, 'quota_subnet'), + CountableResource('port', _count_resource, 'quota_port'), +] + + +QUOTAS.register_resources(resources) diff --git a/quantum/tests/unit/test_api_v2.py b/quantum/tests/unit/test_api_v2.py index b068108cd1..a7972defeb 100644 --- a/quantum/tests/unit/test_api_v2.py +++ b/quantum/tests/unit/test_api_v2.py @@ -128,11 +128,7 @@ class ResourceIndexTestCase(unittest.TestCase): self.assertTrue(link['rel'] == 'self') -class APIv2TestCase(unittest.TestCase): - # NOTE(jkoelker) This potentially leaks the mock object if the setUp - # raises without being caught. Using unittest2 - # or dropping 2.6 support so we can use addCleanup - # will get around this. +class APIv2TestBase(unittest.TestCase): def setUp(self): plugin = 'quantum.quantum_plugin_base_v2.QuantumPluginBaseV2' # Ensure 'stale' patched copies of the plugin are never returned @@ -155,6 +151,12 @@ class APIv2TestCase(unittest.TestCase): self.plugin = None cfg.CONF.reset() + +class APIv2TestCase(APIv2TestBase): + # NOTE(jkoelker) This potentially leaks the mock object if the setUp + # raises without being caught. Using unittest2 + # or dropping 2.6 support so we can use addCleanup + # will get around this. def test_verbose_attr(self): instance = self.plugin.return_value instance.get_networks.return_value = [] @@ -387,7 +389,7 @@ class APIv2TestCase(unittest.TestCase): # Note: since all resources use the same controller and validation # logic, we actually get really good coverage from testing just networks. -class JSONV2TestCase(APIv2TestCase): +class JSONV2TestCase(APIv2TestBase): def _test_list(self, req_tenant_id, real_tenant_id): env = {} @@ -729,3 +731,42 @@ class V2Views(unittest.TestCase): keys = ('id', 'network_id', 'tenant_id', 'gateway_ip', 'ip_version', 'cidr') self._view(keys, views.subnet) + + +class QuotaTest(APIv2TestBase): + def test_create_network_quota(self): + cfg.CONF.set_override('quota_network', 1, group='QUOTAS') + net_id = _uuid() + initial_input = {'network': {'name': 'net1', 'tenant_id': _uuid()}} + full_input = {'network': {'admin_state_up': True, 'subnets': []}} + full_input['network'].update(initial_input['network']) + + return_value = {'id': net_id, 'status': "ACTIVE"} + return_value.update(full_input['network']) + return_networks = {'networks': [return_value]} + instance = self.plugin.return_value + instance.get_networks.return_value = return_networks + res = self.api.post_json( + _get_path('networks'), initial_input, expect_errors=True) + instance.get_networks.assert_called_with(mock.ANY, + filters=mock.ANY) + self.assertTrue("Quota exceeded for resources" in + res.json['QuantumError']) + + def test_create_network_quota_without_limit(self): + cfg.CONF.set_override('quota_network', -1, group='QUOTAS') + net_id = _uuid() + initial_input = {'network': {'name': 'net1', 'tenant_id': _uuid()}} + full_input = {'network': {'admin_state_up': True, 'subnets': []}} + full_input['network'].update(initial_input['network']) + return_networks = [] + for i in xrange(0, 3): + return_value = {'id': net_id + str(i), 'status': "ACTIVE"} + return_value.update(full_input['network']) + return_networks.append(return_value) + self.assertEquals(3, len(return_networks)) + instance = self.plugin.return_value + instance.get_networks.return_value = return_networks + res = self.api.post_json( + _get_path('networks'), initial_input) + self.assertEqual(res.status_int, exc.HTTPCreated.code)