From: Yong Sheng Gong Date: Sun, 29 Jul 2012 12:59:55 +0000 (+0800) Subject: Add quota per-tenant. X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=46cac19852b7879daa36962a8643727264a42ff5;p=openstack-build%2Fneutron-build.git Add quota per-tenant. blueprint quantum-api-quotas We implement it as an extension for linux bridge and ovs plugins. We also expose the /quotas/Xx url to client to operate the quota. We need admin role to show other tenant's quota, and to update quota data. Any user can show its own tenant's quota. An DB table is used to save the quota for each tenant. To use it, we have in quantum.conf: quota_driver = quantum.extensions._quotav2_driver.DbQuotaDriver The default quotas for each tenant are defined in quantum.conf too. In addition, modify extension framework to allow exposing a new resource and its controler. The extension can check the environment, such as configuration in global cfg.CONF to decide if it can be enabled. Also, we can define enabled extensions for each plugin in extensions.py New resources can be put into quota framework via quota_items in nova.conf Change-Id: I54d6107fdb2808cdae1a40b501ed8c7f379dedee --- diff --git a/etc/quantum.conf b/etc/quantum.conf index 86eeb9c69..262f9bf5d 100644 --- a/etc/quantum.conf +++ b/etc/quantum.conf @@ -125,13 +125,19 @@ control_exchange = quantum # rpc_zmq_bind_address = * [QUOTAS] -# number of networks allowed per tenant +# resource name(s) that are supported in quota features +# quota_items = network,subnet,port + +# default number of resource allowed per tenant, minus for unlimited +# default_quota = -1 + +# number of networks allowed per tenant, and minus means unlimited # quota_network = 10 -# number of subnets allowed per tenant +# number of subnets allowed per tenant, and minus means unlimited # quota_subnet = 10 -# number of ports allowed per tenant +# number of ports allowed per tenant, and minus means unlimited # quota_port = 50 # default driver to use for quota checks diff --git a/quantum/api/v2/base.py b/quantum/api/v2/base.py index fb76e83d4..f16df2e93 100644 --- a/quantum/api/v2/base.py +++ b/quantum/api/v2/base.py @@ -265,7 +265,9 @@ class Controller(object): self._resource + '.create.start', notifier_api.INFO, body) - body = self._prepare_request_body(request.context, body, True) + body = Controller.prepare_request_body(request.context, body, True, + self._resource, self._attr_info, + allow_bulk=self._allow_bulk) action = "create_%s" % self._resource # Check authz try: @@ -280,11 +282,20 @@ class Controller(object): action, item[self._resource], plugin=self._plugin) - 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) + try: + count = QUOTAS.count(request.context, self._resource, + self._plugin, self._collection, + item[self._resource]['tenant_id']) + kwargs = {self._resource: count + 1} + except exceptions.QuotaResourceUnknown as e: + # We don't want to quota this resource + LOG.debug(e) + except Exception: + raise + else: + QUOTAS.limit_check(request.context, + item[self._resource]['tenant_id'], + **kwargs) else: self._validate_network_tenant_ownership( request, @@ -294,11 +305,20 @@ class Controller(object): action, body[self._resource], plugin=self._plugin) - 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) + try: + count = QUOTAS.count(request.context, self._resource, + self._plugin, self._collection, + body[self._resource]['tenant_id']) + kwargs = {self._resource: count + 1} + except exceptions.QuotaResourceUnknown as e: + # We don't want to quota this resource + LOG.debug(e) + except Exception: + raise + else: + QUOTAS.limit_check(request.context, + body[self._resource]['tenant_id'], + **kwargs) except exceptions.PolicyNotAuthorized: LOG.exception("Create operation not authorized") raise webob.exc.HTTPForbidden() @@ -366,7 +386,9 @@ class Controller(object): self._resource + '.update.start', notifier_api.INFO, payload) - body = self._prepare_request_body(request.context, body, False) + body = Controller.prepare_request_body(request.context, body, False, + self._resource, self._attr_info, + allow_bulk=self._allow_bulk) action = "update_%s" % self._resource # Load object to check authz # but pass only attributes in the original body and required @@ -399,7 +421,8 @@ class Controller(object): result) return result - def _populate_tenant_id(self, context, res_dict, is_create): + @staticmethod + def _populate_tenant_id(context, res_dict, is_create): if (('tenant_id' in res_dict and res_dict['tenant_id'] != context.tenant_id and @@ -416,7 +439,9 @@ class Controller(object): " that tenant_id is specified") raise webob.exc.HTTPBadRequest(msg) - def _prepare_request_body(self, context, body, is_create): + @staticmethod + def prepare_request_body(context, body, is_create, resource, attr_info, + allow_bulk=False): """ verifies required attributes are in request body, and that an attribute is only specified if it is allowed for the given operation (create/update). @@ -425,35 +450,36 @@ class Controller(object): body argument must be the deserialized body """ + collection = resource + "s" if not body: raise webob.exc.HTTPBadRequest(_("Resource body required")) - body = body or {self._resource: {}} - if self._collection in body and self._allow_bulk: - bulk_body = [self._prepare_request_body(context, - {self._resource: b}, - is_create) - if self._resource not in b - else self._prepare_request_body(context, b, is_create) - for b in body[self._collection]] + body = body or {resource: {}} + if collection in body and allow_bulk: + bulk_body = [Controller.prepare_request_body( + context, {resource: b}, is_create, resource, attr_info, + allow_bulk) if resource not in b + else Controller.prepare_request_body( + context, b, is_create, resource, attr_info, allow_bulk) + for b in body[collection]] if not bulk_body: raise webob.exc.HTTPBadRequest(_("Resources required")) - return {self._collection: bulk_body} + return {collection: bulk_body} - elif self._collection in body and not self._allow_bulk: + elif collection in body and not allow_bulk: raise webob.exc.HTTPBadRequest("Bulk operation not supported") - res_dict = body.get(self._resource) + res_dict = body.get(resource) if res_dict is None: - msg = _("Unable to find '%s' in request body") % self._resource + msg = _("Unable to find '%s' in request body") % resource raise webob.exc.HTTPBadRequest(msg) - self._populate_tenant_id(context, res_dict, is_create) + Controller._populate_tenant_id(context, res_dict, is_create) if is_create: # POST - for attr, attr_vals in self._attr_info.iteritems(): + for attr, attr_vals in attr_info.iteritems(): is_required = ('default' not in attr_vals and attr_vals['allow_post']) if is_required and attr not in res_dict: @@ -469,12 +495,12 @@ class Controller(object): res_dict[attr] = res_dict.get(attr, attr_vals.get('default')) else: # PUT - for attr, attr_vals in self._attr_info.iteritems(): + for attr, attr_vals in attr_info.iteritems(): if attr in res_dict and not attr_vals['allow_put']: msg = _("Cannot update read-only attribute %s") % attr raise webob.exc.HTTPUnprocessableEntity(msg) - for attr, attr_vals in self._attr_info.iteritems(): + for attr, attr_vals in attr_info.iteritems(): # Convert values if necessary if ('convert_to' in attr_vals and attr in res_dict and diff --git a/quantum/common/exceptions.py b/quantum/common/exceptions.py index 529af1bb3..7e3f6377d 100644 --- a/quantum/common/exceptions.py +++ b/quantum/common/exceptions.py @@ -213,3 +213,7 @@ class InvalidQuotaValue(QuantumException): class InvalidSharedSetting(QuantumException): message = _("Unable to reconfigure sharing settings for network" "%(network). Multiple tenants are using it") + + +class InvalidExtenstionEnv(QuantumException): + message = _("Invalid extension environment: %(reason)s") diff --git a/quantum/extensions/_quotav2_driver.py b/quantum/extensions/_quotav2_driver.py new file mode 100644 index 000000000..d8abada75 --- /dev/null +++ b/quantum/extensions/_quotav2_driver.py @@ -0,0 +1,158 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +from quantum.common import exceptions +from quantum.extensions import _quotav2_model as quotav2_model + + +class DbQuotaDriver(object): + """ + Driver to perform necessary checks to enforce quotas and obtain + quota information. The default driver utilizes the local + database. + """ + + @staticmethod + def get_tenant_quotas(context, resources, tenant_id): + """ + Given a list of resources, retrieve the quotas for the given + tenant. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resource keys. + :param tenant_id: The ID of the tenant to return quotas for. + :return dict: from resource name to dict of name and limit + """ + + quotas = {} + tenant_quotas = context.session.query( + quotav2_model.Quota).filter_by(tenant_id=tenant_id).all() + tenant_quotas_dict = {} + for _quota in tenant_quotas: + tenant_quotas_dict[_quota['resource']] = _quota['limit'] + for key, resource in resources.items(): + quotas[key] = dict( + name=key, + limit=tenant_quotas_dict.get(key, resource.default)) + return quotas + + @staticmethod + def delete_tenant_quota(context, tenant_id): + """Delete the quota entries for a given tenant_id. + + Atfer deletion, this tenant will use default quota values in conf. + """ + with context.session.begin(): + tenant_quotas = context.session.query( + quotav2_model.Quota).filter_by(tenant_id=tenant_id).all() + for quota in tenant_quotas: + context.session.delete(quota) + + @staticmethod + def get_all_quotas(context, resources): + """ + Given a list of resources, retrieve the quotas for the all + tenants. + + :param context: The request context, for access checks. + :param resources: A dictionary of the registered resource keys. + :return quotas: list of dict of tenant_id:, resourcekey1: + resourcekey2: ... + """ + + _quotas = context.session.query(quotav2_model.Quota).all() + quotas = {} + tenant_quotas_dict = {} + for _quota in _quotas: + tenant_id = _quota['tenant_id'] + if tenant_id not in quotas: + quotas[tenant_id] = {'tenant_id': tenant_id} + tenant_quotas_dict = quotas[tenant_id] + tenant_quotas_dict[_quota['resource']] = _quota['limit'] + + # we complete the quotas according to input resources + for tenant_quotas_dict in quotas.itervalues(): + for key, resource in resources.items(): + tenant_quotas_dict[key] = tenant_quotas_dict.get( + key, resource.default) + return quotas.itervalues() + + def _get_quotas(self, context, tenant_id, 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 tenant_id: the tenant_id to check quota. + :param resources: A dictionary of the registered resources. + :param keys: A list of the desired quotas to retrieve. + + """ + + 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)) + + # Grab and return the quotas (without usages) + quotas = DbQuotaDriver.get_tenant_quotas( + context, sub_resources, context.tenant_id) + + return dict((k, v['limit']) for k, v in quotas.items()) + + def limit_check(self, context, tenant_id, 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 tenant_id: The tenant_id to check the quota. + :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, tenant_id, 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)) diff --git a/quantum/extensions/_quotav2_model.py b/quantum/extensions/_quotav2_model.py new file mode 100644 index 000000000..474fa228b --- /dev/null +++ b/quantum/extensions/_quotav2_model.py @@ -0,0 +1,30 @@ +# Copyright (c) 2012 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. + +import sqlalchemy as sa + +from quantum.db import model_base +from quantum.db import models_v2 + + +class Quota(model_base.BASEV2, models_v2.HasId): + """Represent a single quota override for a tenant. + + If there is no row for a given tenant id and resource, then the + default for the quota class is used. + """ + tenant_id = sa.Column(sa.String(255), index=True) + resource = sa.Column(sa.String(255)) + limit = sa.Column(sa.Integer) diff --git a/quantum/extensions/extensions.py b/quantum/extensions/extensions.py index f7dd55cb2..f6a74654a 100644 --- a/quantum/extensions/extensions.py +++ b/quantum/extensions/extensions.py @@ -30,11 +30,28 @@ from quantum.common import exceptions import quantum.extensions from quantum.manager import QuantumManager from quantum.openstack.common import cfg +from quantum.openstack.common import importutils from quantum import wsgi LOG = logging.getLogger('quantum.api.extensions') +# Besides the supported_extension_aliases in plugin class, +# we also support register enabled extensions here so that we +# can load some mandatory files (such as db models) before initialize plugin +ENABLED_EXTS = { + 'quantum.plugins.linuxbridge.lb_quantum_plugin.LinuxBridgePluginV2': + { + 'ext_alias': ["quotas"], + 'ext_db_models': ['quantum.extensions._quotav2_model.Quota'], + }, + 'quantum.plugins.openvswitch.ovs_quantum_plugin.OVSQuantumPluginV2': + { + 'ext_alias': ["quotas"], + 'ext_db_models': ['quantum.extensions._quotav2_model.Quota'], + }, +} + class PluginInterface(object): __metaclass__ = ABCMeta @@ -132,8 +149,8 @@ class ExtensionDescriptor(object): request_exts = [] return request_exts - def get_extended_attributes(self, version): - """Map describing extended attributes for core resources. + def get_extended_resources(self, version): + """retrieve extended resources or attributes for core resources. Extended attributes are implemented by a core plugin similarly to the attributes defined in the core, and can appear in @@ -143,6 +160,9 @@ class ExtensionDescriptor(object): map[][][] specifying the extended resource attribute properties required by that API version. + + Extension can add resources and their attr definitions too. + The returned map can be integrated into RESOURCE_ATTRIBUTE_MAP. """ return {} @@ -281,7 +301,6 @@ class ExtensionMiddleware(wsgi.Middleware): self._router = routes.middleware.RoutesMiddleware(self._dispatch, mapper) - super(ExtensionMiddleware, self).__init__(application) @classmethod @@ -411,12 +430,22 @@ class ExtensionManager(object): return request_exts def extend_resources(self, version, attr_map): - """Extend resources with additional attributes.""" + """Extend resources with additional resources or attributes. + + :param: attr_map, the existing mapping from resource name to + attrs definition. + + After this function, we will extend the attr_map if an extension + wants to extend this map. + """ for ext in self.extensions.itervalues(): try: - extended_attrs = ext.get_extended_attributes(version) + extended_attrs = ext.get_extended_resources(version) for resource, resource_attrs in extended_attrs.iteritems(): - attr_map[resource].update(resource_attrs) + if attr_map.get(resource, None): + attr_map[resource].update(resource_attrs) + else: + attr_map[resource] = resource_attrs except AttributeError: # Extensions aren't required to have extended # attributes @@ -433,6 +462,12 @@ class ExtensionManager(object): except AttributeError as ex: LOG.exception(_("Exception loading extension: %s"), unicode(ex)) return False + if hasattr(extension, 'check_env'): + try: + extension.check_env() + except exceptions.InvalidExtenstionEnv as ex: + LOG.warn(_("Exception loading extension: %s"), unicode(ex)) + return False return True def _load_all_extensions(self): @@ -511,6 +546,10 @@ class PluginAwareExtensionManager(ExtensionManager): supports_extension = (hasattr(self.plugin, "supported_extension_aliases") and alias in self.plugin.supported_extension_aliases) + plugin_provider = cfg.CONF.core_plugin + if not supports_extension and plugin_provider in ENABLED_EXTS: + supports_extension = (alias in + ENABLED_EXTS[plugin_provider]['ext_alias']) if not supports_extension: LOG.warn("extension %s not supported by plugin %s", alias, self.plugin) @@ -531,6 +570,11 @@ class PluginAwareExtensionManager(ExtensionManager): @classmethod def get_instance(cls): if cls._instance is None: + plugin_provider = cfg.CONF.core_plugin + if plugin_provider in ENABLED_EXTS: + for model in ENABLED_EXTS[plugin_provider]['ext_db_models']: + LOG.debug('loading model %s', model) + model_class = importutils.import_class(model) cls._instance = cls(get_extensions_path(), QuantumManager.get_plugin()) return cls._instance diff --git a/quantum/extensions/providernet.py b/quantum/extensions/providernet.py index 19b8f5b11..f5098a4fa 100644 --- a/quantum/extensions/providernet.py +++ b/quantum/extensions/providernet.py @@ -72,7 +72,7 @@ class Providernet(object): def get_updated(cls): return "2012-07-23T10:00:00-00:00" - def get_extended_attributes(self, version): + def get_extended_resources(self, version): if version == "2.0": return EXTENDED_ATTRIBUTES_2_0 else: diff --git a/quantum/extensions/quotasv2.py b/quantum/extensions/quotasv2.py new file mode 100644 index 000000000..7c5c6b455 --- /dev/null +++ b/quantum/extensions/quotasv2.py @@ -0,0 +1,171 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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 webob + +from quantum.api.v2 import base +from quantum.common import exceptions +from quantum.extensions import extensions +from quantum.extensions import _quotav2_driver as quotav2_driver +from quantum.extensions import _quotav2_model as quotav2_model +from quantum.manager import QuantumManager +from quantum.openstack.common import cfg +from quantum import quota +from quantum import wsgi + +RESOURCE_NAME = 'quota' +RESOURCE_COLLECTION = RESOURCE_NAME + "s" +QUOTAS = quota.QUOTAS +DB_QUOTA_DRIVER = 'quantum.extensions._quotav2_driver.DbQuotaDriver' +EXTENDED_ATTRIBUTES_2_0 = { + RESOURCE_COLLECTION: {} +} + +for quota_resource in QUOTAS.resources.iterkeys(): + attr_dict = EXTENDED_ATTRIBUTES_2_0[RESOURCE_COLLECTION] + attr_dict[quota_resource] = {'allow_post': False, + 'allow_put': True, + 'convert_to': int, + 'is_visible': True} + + +class QuotaSetsController(wsgi.Controller): + + def __init__(self, plugin): + self._resource_name = RESOURCE_NAME + self._plugin = plugin + + def _get_body(self, request): + body = self._deserialize(request.body, request.get_content_type()) + attr_info = EXTENDED_ATTRIBUTES_2_0[RESOURCE_COLLECTION] + req_body = base.Controller.prepare_request_body( + request.context, body, False, self._resource_name, attr_info) + return req_body + + def _get_quotas(self, request, tenant_id): + values = quotav2_driver.DbQuotaDriver.get_tenant_quotas( + request.context, QUOTAS.resources, tenant_id) + return dict((k, v['limit']) for k, v in values.items()) + + def create(self, request, body=None): + raise NotImplemented() + + def index(self, request): + context = request.context + if not context.is_admin: + raise webob.exc.HTTPForbidden() + return {self._resource_name + "s": + quotav2_driver.DbQuotaDriver.get_all_quotas( + context, QUOTAS.resources)} + + def tenant(self, request): + """Retrieve the tenant info in context.""" + context = request.context + if not context.tenant_id: + raise webob.exc.HTTPBadRequest('invalid tenant') + return {'tenant': {'tenant_id': context.tenant_id}} + + def show(self, request, id): + context = request.context + tenant_id = id + if not tenant_id: + raise webob.exc.HTTPBadRequest('invalid tenant') + if (tenant_id != context.tenant_id and + not context.is_admin): + raise webob.exc.HTTPForbidden() + return {self._resource_name: + self._get_quotas(request, tenant_id)} + + def _check_modification_delete_privilege(self, context, tenant_id): + if not tenant_id: + raise webob.exc.HTTPBadRequest('invalid tenant') + if (not context.is_admin): + raise webob.exc.HTTPForbidden() + return tenant_id + + def delete(self, request, id): + tenant_id = id + tenant_id = self._check_modification_delete_privilege(request.context, + tenant_id) + quotav2_driver.DbQuotaDriver.delete_tenant_quota(request.context, + tenant_id) + + def update(self, request, id): + tenant_id = id + tenant_id = self._check_modification_delete_privilege(request.context, + tenant_id) + req_body = self._get_body(request) + for key in req_body[self._resource_name].keys(): + if key in QUOTAS.resources: + value = int(req_body[self._resource_name][key]) + with request.context.session.begin(): + tenant_quotas = request.context.session.query( + quotav2_model.Quota).filter_by(tenant_id=tenant_id, + resource=key).all() + if not tenant_quotas: + quota = quotav2_model.Quota(tenant_id=tenant_id, + resource=key, + limit=value) + request.context.session.add(quota) + else: + quota = tenant_quotas[0] + quota.update({'limit': value}) + return {self._resource_name: self._get_quotas(request, tenant_id)} + + +class Quotasv2(object): + """Quotas management support""" + @classmethod + def get_name(cls): + return "Quotas for each tenant" + + @classmethod + def get_alias(cls): + return RESOURCE_COLLECTION + + @classmethod + def get_description(cls): + return ("Expose functions for cloud admin to update quotas" + "for each tenant") + + @classmethod + def get_namespace(cls): + return "http://docs.openstack.org/network/ext/quotas-sets/api/v2.0" + + @classmethod + def get_updated(cls): + return "2012-07-29T10:00:00-00:00" + + def get_extended_resources(self, version): + if version == "2.0": + return EXTENDED_ATTRIBUTES_2_0 + else: + return {} + + def check_env(self): + if cfg.CONF.QUOTAS.quota_driver != DB_QUOTA_DRIVER: + msg = _('quota driver %s is needed.') % DB_QUOTA_DRIVER + raise exceptions.InvalidExtenstionEnv(reason=msg) + + @classmethod + def get_resources(cls): + """ Returns Ext Resources """ + controller = QuotaSetsController(QuantumManager.get_plugin()) + return [extensions.ResourceExtension( + Quotasv2.get_alias(), + controller, + collection_actions={'tenant': 'GET'})] diff --git a/quantum/quota.py b/quantum/quota.py index 3f819dc16..4a00804c3 100644 --- a/quantum/quota.py +++ b/quantum/quota.py @@ -24,15 +24,24 @@ from quantum.openstack.common import importutils LOG = logging.getLogger(__name__) quota_opts = [ + cfg.ListOpt('quota_items', + default=['network', 'subnet', 'port'], + help='resource name(s) that are supported in quota features'), + cfg.IntOpt('default_quota', + default=-1, + help='default number of resource allowed per tenant, ' + 'minus for unlimited'), cfg.IntOpt('quota_network', default=10, - help='number of networks allowed per tenant, -1 for unlimited'), + help='number of networks allowed per tenant,' + 'minus for unlimited'), cfg.IntOpt('quota_subnet', default=10, - help='number of subnets allowed per tenant, -1 for unlimited'), + help='number of subnets allowed per tenant, ' + 'minus for unlimited'), cfg.IntOpt('quota_port', default=50, - help='number of ports allowed per tenant, -1 for unlimited'), + help='number of ports allowed per tenant, minus for unlimited'), cfg.StrOpt('quota_driver', default='quantum.quota.ConfDriver', help='default driver to use for quota checks'), @@ -73,7 +82,8 @@ class ConfDriver(object): quotas[resource.name] = resource.default return quotas - def limit_check(self, context, resources, values): + def limit_check(self, context, tenant_id, + resources, values): """Check simple quota limits. For limits--those quotas for which there is no usage @@ -90,6 +100,7 @@ class ConfDriver(object): nothing. :param context: The request context, for access checks. + :param tennant_id: The tenant_id to check quota. :param resources: A dictionary of the registered resources. :param values: A dictionary of the values to check against the quota. @@ -115,14 +126,12 @@ class ConfDriver(object): class BaseResource(object): """Describe a single resource for quota checking.""" - def __init__(self, name, flag=None): + def __init__(self, name, flag): """ 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 @@ -131,8 +140,10 @@ class BaseResource(object): @property def default(self): """Return the default value of the quota.""" - - return cfg.CONF.QUOTAS[self.flag] if self.flag else -1 + if hasattr(cfg.CONF.QUOTAS, self.flag): + return cfg.CONF.QUOTAS[self.flag] + else: + return cfg.CONF.QUOTAS.default_quota class CountableResource(BaseResource): @@ -186,9 +197,17 @@ class QuotaEngine(object): def register_resource(self, resource): """Register a resource.""" - + if resource.name in self._resources: + LOG.warn('%s is already registered.', resource.name) + return self._resources[resource.name] = resource + def register_resource_by_name(self, resourcename): + """Register a resource by name.""" + resource = CountableResource(resourcename, _count_resource, + 'quota_' + resourcename) + self.register_resource(resource) + def register_resources(self, resources): """Register a list of resources.""" @@ -214,7 +233,7 @@ class QuotaEngine(object): return res.count(context, *args, **kwargs) - def limit_check(self, context, **values): + def limit_check(self, context, tenant_id, **values): """Check simple quota limits. For limits--those quotas for which there is no usage @@ -236,11 +255,12 @@ class QuotaEngine(object): :param context: The request context, for access checks. """ - return self._driver.limit_check(context, self._resources, values) + return self._driver.limit_check(context, tenant_id, + self._resources, values) @property def resources(self): - return sorted(self._resources.keys()) + return self._resources QUOTAS = QuotaEngine() @@ -252,11 +272,9 @@ def _count_resource(context, plugin, resources, 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'), -] - +resources = [] +for resource_item in cfg.CONF.QUOTAS.quota_items: + resources.append(CountableResource(resource_item, _count_resource, + 'quota_' + resource_item)) QUOTAS.register_resources(resources) diff --git a/quantum/tests/unit/extensions/v2attributes.py b/quantum/tests/unit/extensions/v2attributes.py index 1ec015c75..92f2028e7 100644 --- a/quantum/tests/unit/extensions/v2attributes.py +++ b/quantum/tests/unit/extensions/v2attributes.py @@ -41,7 +41,7 @@ class V2attributes(object): def get_updated(self): return "2012-07-18T10:00:00-00:00" - def get_extended_attributes(self, version): + def get_extended_resources(self, version): if version == "2.0": return EXTENDED_ATTRIBUTES_2_0 else: diff --git a/quantum/tests/unit/test_quota_per_tenant_ext.py b/quantum/tests/unit/test_quota_per_tenant_ext.py new file mode 100644 index 000000000..a375bee92 --- /dev/null +++ b/quantum/tests/unit/test_quota_per_tenant_ext.py @@ -0,0 +1,190 @@ +import unittest +import webtest + +import mock + +from quantum.api.v2 import attributes +from quantum.common import config +from quantum import context +from quantum.db import api as db +from quantum.extensions import extensions +from quantum import manager +from quantum.openstack.common import cfg +from quantum.plugins.linuxbridge.db import l2network_db_v2 +from quantum import quota +from quantum.tests.unit import test_api_v2 +from quantum.tests.unit import test_extensions + + +TARGET_PLUGIN = ('quantum.plugins.linuxbridge.lb_quantum_plugin' + '.LinuxBridgePluginV2') + + +_get_path = test_api_v2._get_path + + +class QuotaExtensionTestCase(unittest.TestCase): + + def setUp(self): + if getattr(self, 'testflag', 1) == 1: + self._setUp1() + else: + self._setUp2() + + def _setUp1(self): + db._ENGINE = None + db._MAKER = None + # Ensure 'stale' patched copies of the plugin are never returned + manager.QuantumManager._instance = None + + # Ensure existing ExtensionManager is not used + extensions.PluginAwareExtensionManager._instance = None + + # Save the global RESOURCE_ATTRIBUTE_MAP + self.saved_attr_map = {} + for resource, attrs in attributes.RESOURCE_ATTRIBUTE_MAP.iteritems(): + self.saved_attr_map[resource] = attrs.copy() + + # Create the default configurations + args = ['--config-file', test_extensions.etcdir('quantum.conf.test')] + config.parse(args=args) + + # Update the plugin and extensions path + cfg.CONF.set_override('core_plugin', TARGET_PLUGIN) + cfg.CONF.set_override( + 'quota_driver', + 'quantum.extensions._quotav2_driver.DbQuotaDriver', + group='QUOTAS') + cfg.CONF.set_override( + 'quota_items', + ['network', 'subnet', 'port', 'extra1'], + group='QUOTAS') + + self._plugin_patcher = mock.patch(TARGET_PLUGIN, autospec=True) + self.plugin = self._plugin_patcher.start() + # QUOTAS will regester the items in conf when starting + # extra1 here is added later, so have to do it manually + quota.QUOTAS.register_resource_by_name('extra1') + ext_mgr = extensions.PluginAwareExtensionManager.get_instance() + l2network_db_v2.initialize() + app = config.load_paste_app('extensions_test_app') + ext_middleware = extensions.ExtensionMiddleware(app, ext_mgr=ext_mgr) + self.api = webtest.TestApp(ext_middleware) + + def _setUp2(self): + db._ENGINE = None + db._MAKER = None + # Ensure 'stale' patched copies of the plugin are never returned + manager.QuantumManager._instance = None + + # Ensure existing ExtensionManager is not used + extensions.PluginAwareExtensionManager._instance = None + + # Save the global RESOURCE_ATTRIBUTE_MAP + self.saved_attr_map = {} + for resource, attrs in attributes.RESOURCE_ATTRIBUTE_MAP.iteritems(): + self.saved_attr_map[resource] = attrs.copy() + + # Create the default configurations + args = ['--config-file', test_extensions.etcdir('quantum.conf.test')] + config.parse(args=args) + + # Update the plugin and extensions path + cfg.CONF.set_override('core_plugin', TARGET_PLUGIN) + self._plugin_patcher = mock.patch(TARGET_PLUGIN, autospec=True) + self.plugin = self._plugin_patcher.start() + ext_mgr = extensions.PluginAwareExtensionManager.get_instance() + l2network_db_v2.initialize() + app = config.load_paste_app('extensions_test_app') + ext_middleware = extensions.ExtensionMiddleware(app, ext_mgr=ext_mgr) + self.api = webtest.TestApp(ext_middleware) + + def tearDown(self): + self._plugin_patcher.stop() + self.api = None + self.plugin = None + db._ENGINE = None + db._MAKER = None + cfg.CONF.reset() + + # Restore the global RESOURCE_ATTRIBUTE_MAP + attributes.RESOURCE_ATTRIBUTE_MAP = self.saved_attr_map + + def test_quotas_loaded_right(self): + res = self.api.get(_get_path('quotas')) + self.assertEquals(200, res.status_int) + + def test_quotas_defaul_values(self): + tenant_id = 'tenant_id1' + env = {'quantum.context': context.Context('', tenant_id)} + res = self.api.get(_get_path('quotas', id=tenant_id), + extra_environ=env) + self.assertEquals(10, res.json['quota']['network']) + self.assertEquals(10, res.json['quota']['subnet']) + self.assertEquals(50, res.json['quota']['port']) + self.assertEquals(-1, res.json['quota']['extra1']) + + def test_show_quotas_with_admin(self): + tenant_id = 'tenant_id1' + env = {'quantum.context': context.Context('', tenant_id + '2', + is_admin=True)} + res = self.api.get(_get_path('quotas', id=tenant_id), + extra_environ=env) + self.assertEquals(200, res.status_int) + + def test_show_quotas_without_admin_forbidden(self): + tenant_id = 'tenant_id1' + env = {'quantum.context': context.Context('', tenant_id + '2', + is_admin=False)} + res = self.api.get(_get_path('quotas', id=tenant_id), + extra_environ=env, expect_errors=True) + self.assertEquals(403, res.status_int) + + def test_update_quotas_without_admin_forbidden(self): + tenant_id = 'tenant_id1' + env = {'quantum.context': context.Context('', tenant_id, + is_admin=False)} + quotas = {'quota': {'network': 100}} + res = self.api.put_json(_get_path('quotas', id=tenant_id, + fmt='json'), + quotas, extra_environ=env, + expect_errors=True) + self.assertEquals(403, res.status_int) + + def test_update_quotas_with_admin(self): + tenant_id = 'tenant_id1' + env = {'quantum.context': context.Context('', tenant_id + '2', + is_admin=True)} + quotas = {'quota': {'network': 100}} + res = self.api.put_json(_get_path('quotas', id=tenant_id, fmt='json'), + quotas, extra_environ=env) + self.assertEquals(200, res.status_int) + env2 = {'quantum.context': context.Context('', tenant_id)} + res = self.api.get(_get_path('quotas', id=tenant_id), + extra_environ=env2).json + self.assertEquals(100, res['quota']['network']) + + def test_delete_quotas_with_admin(self): + tenant_id = 'tenant_id1' + env = {'quantum.context': context.Context('', tenant_id + '2', + is_admin=True)} + res = self.api.delete(_get_path('quotas', id=tenant_id, fmt='json'), + extra_environ=env) + self.assertEquals(204, res.status_int) + + def test_delete_quotas_without_admin_forbidden(self): + tenant_id = 'tenant_id1' + env = {'quantum.context': context.Context('', tenant_id, + is_admin=False)} + res = self.api.delete(_get_path('quotas', id=tenant_id, fmt='json'), + extra_environ=env, expect_errors=True) + self.assertEquals(403, res.status_int) + + def test_quotas_loaded_bad(self): + self.testflag = 2 + try: + res = self.api.get(_get_path('quotas'), expect_errors=True) + self.assertEquals(404, res.status_int) + except Exception: + pass + self.testflag = 1 diff --git a/quantum/wsgi.py b/quantum/wsgi.py index c0728730e..065d73e0b 100644 --- a/quantum/wsgi.py +++ b/quantum/wsgi.py @@ -32,6 +32,7 @@ import webob.dec import webob.exc from quantum.common import exceptions as exception +from quantum import context from quantum.openstack.common import jsonutils @@ -180,6 +181,12 @@ class Request(webob.Request): return type return None + @property + def context(self): + if 'quantum.context' not in self.environ: + self.environ['quantum.context'] = context.get_admin_context() + return self.environ['quantum.context'] + class ActionDispatcher(object): """Maps method name to local methods through action name.""" @@ -894,14 +901,20 @@ class Controller(object): arg_dict['request'] = req result = method(**arg_dict) - if isinstance(result, dict): - content_type = req.best_match_content_type() - default_xmlns = self.get_default_xmlns(req) - body = self._serialize(result, content_type, default_xmlns) - - response = webob.Response() - response.headers['Content-Type'] = content_type - response.body = body + if isinstance(result, dict) or result is None: + if result is None: + status = 204 + content_type = '' + body = None + else: + status = 200 + content_type = req.best_match_content_type() + default_xmlns = self.get_default_xmlns(req) + body = self._serialize(result, content_type, default_xmlns) + + response = webob.Response(status=status, + content_type=content_type, + body=body) msg_dict = dict(url=req.url, status=response.status_int) msg = _("%(url)s returned with HTTP %(status)d") % msg_dict LOG.debug(msg)