]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Add quota per-tenant.
authorYong Sheng Gong <gongysh@cn.ibm.com>
Sun, 29 Jul 2012 12:59:55 +0000 (20:59 +0800)
committerYong Sheng Gong <gongysh@cn.ibm.com>
Tue, 14 Aug 2012 15:27:56 +0000 (23:27 +0800)
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

12 files changed:
etc/quantum.conf
quantum/api/v2/base.py
quantum/common/exceptions.py
quantum/extensions/_quotav2_driver.py [new file with mode: 0644]
quantum/extensions/_quotav2_model.py [new file with mode: 0644]
quantum/extensions/extensions.py
quantum/extensions/providernet.py
quantum/extensions/quotasv2.py [new file with mode: 0644]
quantum/quota.py
quantum/tests/unit/extensions/v2attributes.py
quantum/tests/unit/test_quota_per_tenant_ext.py [new file with mode: 0644]
quantum/wsgi.py

index 86eeb9c69eb70f8de95ed3eb401589afba4a64ae..262f9bf5d641208597611443c97f9d40b8056d03 100644 (file)
@@ -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
index fb76e83d446ec6e6523e12d0537c996cc9a33440..f16df2e936aeca372e63c07decf8532816fad17f 100644 (file)
@@ -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
index 529af1bb3eabcb4000e494b40e42925e59104fac..7e3f6377d5d52974440a166cd91755fa88248f88 100644 (file)
@@ -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 (file)
index 0000000..d8abada
--- /dev/null
@@ -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 (file)
index 0000000..474fa22
--- /dev/null
@@ -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)
index f7dd55cb2b92db6160c0e5584b42661d1fc914c5..f6a74654af168ff0869a579c605653dab13725c6 100644 (file)
@@ -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[<resource_name>][<attribute_name>][<attribute_property>]
         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
index 19b8f5b116710f38ff14d204685f851dedfe49ba..f5098a4fa2583574b855f2d74975c994309c4b3c 100644 (file)
@@ -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 (file)
index 0000000..7c5c6b4
--- /dev/null
@@ -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'})]
index 3f819dc16baf7f9499eb05c01bd94cc453b7fce6..4a00804c33f6e3dbc7dbf8648445cbb9455cc2a2 100644 (file)
@@ -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)
index 1ec015c75cac2eb215c553516ddfac30d8849437..92f2028e77b6d2b3f51cc23c25f1d442bcbb12de 100644 (file)
@@ -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 (file)
index 0000000..a375bee
--- /dev/null
@@ -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
index c0728730e1aa90944015e860e5f6f1cb6e9f2f10..065d73e0b3afdda2f0ed13df990495bb7549bacd 100644 (file)
@@ -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)