diff --git a/etc/quantum.conf b/etc/quantum.conf index 86eeb9c69eb..262f9bf5d64 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 fb76e83d446..f16df2e936a 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 529af1bb3ea..7e3f6377d5d 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 00000000000..d8abada7540 --- /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 00000000000..474fa228b9b --- /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 f7dd55cb2b9..f6a74654af1 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 19b8f5b1167..f5098a4fa25 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 00000000000..7c5c6b45541 --- /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 3f819dc16ba..4a00804c33f 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 1ec015c75ca..92f2028e77b 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 00000000000..a375bee925b --- /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 c0728730e1a..065d73e0b3a 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) + 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() - response.headers['Content-Type'] = content_type - response.body = body + 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)